mirror of
https://github.com/serverless/serverless.git
synced 2026-01-18 14:58:43 +00:00
feat(AWS HTTP API): JWT authorizers support
This commit is contained in:
parent
1dcc53be4c
commit
fbf99fa2ab
@ -103,3 +103,42 @@ provider:
|
||||
- Special-Response-Header
|
||||
maxAge: 6000 # In seconds
|
||||
```
|
||||
|
||||
## JWT Authorizers
|
||||
|
||||
Currently the only way to restrict access to configured HTTP API endpoints is by setting up an JWT Authorizers.
|
||||
|
||||
_For deep details on that follow [AWS documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-jwt-authorizer.html)_
|
||||
|
||||
To ensure endpoints (as configured in `serverless.yml`) are backed with autorizers, follow below steps.
|
||||
|
||||
### 1. Configure authorizers on `provider.httpApi.authorizers`
|
||||
|
||||
```yaml
|
||||
provider:
|
||||
httpApi:
|
||||
authorizers:
|
||||
someJwtAuthorizer:
|
||||
identitySource: $request.header.Authorization
|
||||
issuerUrl: https://cognito-idp.${region}.amazonaws.com/${cognitoPoolId}
|
||||
audience:
|
||||
- ${client1Id}
|
||||
- ${client2Id}
|
||||
```
|
||||
|
||||
### 2. Configure endpoints which are expected to have restricted access:
|
||||
|
||||
```yaml
|
||||
functions:
|
||||
someFunction:
|
||||
handler: index.handler
|
||||
events:
|
||||
- httpApi:
|
||||
method: POST
|
||||
path: /some-post
|
||||
authorizer:
|
||||
name: someJwtAuthorizer
|
||||
scopes: # Optional
|
||||
- user.id
|
||||
- user.email
|
||||
```
|
||||
|
||||
@ -593,4 +593,7 @@ module.exports = {
|
||||
getHttpApiRouteLogicalId(routeKey) {
|
||||
return `HttpApiRoute${this.normalizePath(routeKey)}`;
|
||||
},
|
||||
getHttpApiAuthorizerLogicalId(authorizerName) {
|
||||
return `HttpApiAuthorizer${this.getNormalizedFunctionName(authorizerName)}`;
|
||||
},
|
||||
};
|
||||
|
||||
@ -41,6 +41,7 @@ class HttpApiEvents {
|
||||
this.cfTemplate = this.serverless.service.provider.compiledCloudFormationTemplate;
|
||||
this.compileApi();
|
||||
this.compileStage();
|
||||
this.compileAuthorizers();
|
||||
this.compileEndpoints();
|
||||
},
|
||||
};
|
||||
@ -98,12 +99,33 @@ class HttpApiEvents {
|
||||
},
|
||||
};
|
||||
}
|
||||
compileAuthorizers() {
|
||||
for (const authorizer of this.config.authorizers.values()) {
|
||||
this.cfTemplate.Resources[
|
||||
this.provider.naming.getHttpApiAuthorizerLogicalId(authorizer.name)
|
||||
] = {
|
||||
Type: 'AWS::ApiGatewayV2::Authorizer',
|
||||
Properties: {
|
||||
ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() },
|
||||
AuthorizerType: 'JWT',
|
||||
IdentitySource: [authorizer.identitySource],
|
||||
JwtConfiguration: {
|
||||
Audience: Array.from(authorizer.audience),
|
||||
Issuer: authorizer.issuerUrl,
|
||||
},
|
||||
Name: authorizer.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
compileEndpoints() {
|
||||
for (const [routeKey, { targetData }] of this.config.routes) {
|
||||
for (const [routeKey, { targetData, authorizer, authorizationScopes }] of this.config.routes) {
|
||||
this.compileLambdaPermissions(targetData);
|
||||
if (routeKey === '*') continue;
|
||||
this.compileIntegration(targetData);
|
||||
this.cfTemplate.Resources[this.provider.naming.getHttpApiRouteLogicalId(routeKey)] = {
|
||||
const resource = (this.cfTemplate.Resources[
|
||||
this.provider.naming.getHttpApiRouteLogicalId(routeKey)
|
||||
] = {
|
||||
Type: 'AWS::ApiGatewayV2::Route',
|
||||
Properties: {
|
||||
ApiId: { Ref: this.provider.naming.getHttpApiLogicalId() },
|
||||
@ -121,7 +143,16 @@ class HttpApiEvents {
|
||||
},
|
||||
},
|
||||
DependsOn: this.provider.naming.getHttpApiIntegrationLogicalId(targetData.functionName),
|
||||
};
|
||||
});
|
||||
if (authorizer) {
|
||||
Object.assign(resource.Properties, {
|
||||
AuthorizationType: 'JWT',
|
||||
AuthorizerId: {
|
||||
Ref: this.provider.naming.getHttpApiAuthorizerLogicalId(authorizer.name),
|
||||
},
|
||||
AuthorizationScopes: authorizationScopes && Array.from(authorizationScopes),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -159,6 +190,18 @@ Object.defineProperties(
|
||||
if (shouldFillCorsMethods) cors.allowedMethods = new Set(['OPTIONS']);
|
||||
}
|
||||
|
||||
const userAuthorizers = userConfig.authorizers;
|
||||
const authorizers = (this.config.authorizers = new Map());
|
||||
if (userAuthorizers) {
|
||||
for (const [name, authorizerConfig] of _.entries(userAuthorizers)) {
|
||||
authorizers.set(name, {
|
||||
name: authorizerConfig.name || name,
|
||||
identitySource: authorizerConfig.identitySource,
|
||||
issuerUrl: authorizerConfig.issuerUrl,
|
||||
audience: toSet(authorizerConfig.audience),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [functionName, functionData] of _.entries(this.serverless.service.functions)) {
|
||||
const routeTargetData = {
|
||||
functionName,
|
||||
@ -169,8 +212,9 @@ Object.defineProperties(
|
||||
if (!event.httpApi) continue;
|
||||
let method;
|
||||
let path;
|
||||
let authorizer;
|
||||
if (_.isObject(event.httpApi)) {
|
||||
({ method, path } = event.httpApi);
|
||||
({ method, path, authorizer } = event.httpApi);
|
||||
} else {
|
||||
const methodPath = String(event.httpApi);
|
||||
if (methodPath === '*') {
|
||||
@ -249,7 +293,22 @@ Object.defineProperties(
|
||||
);
|
||||
}
|
||||
}
|
||||
routes.set(routeKey, { targetData: routeTargetData });
|
||||
const routeConfig = { targetData: routeTargetData };
|
||||
if (authorizer) {
|
||||
const { name, scopes } = (() => {
|
||||
if (_.isObject(authorizer)) return authorizer;
|
||||
return { name: authorizer };
|
||||
})();
|
||||
if (!authorizers.has(name)) {
|
||||
throw new this.serverless.classes.Error(
|
||||
`Event references not configured authorizer '${name}'`,
|
||||
'UNRECOGNIZED_HTTP_API_AUTHORIZER'
|
||||
);
|
||||
}
|
||||
routeConfig.authorizer = authorizers.get(name);
|
||||
if (scopes) routeConfig.authorizationScopes = toSet(scopes);
|
||||
}
|
||||
routes.set(routeKey, routeConfig);
|
||||
if (shouldFillCorsMethods) {
|
||||
if (event.resolvedMethod === 'ANY') {
|
||||
for (const allowedMethod of allowedMethods) {
|
||||
|
||||
@ -287,4 +287,72 @@ describe('HttpApiEvents', () => {
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorizers: JWT', () => {
|
||||
let cfResources;
|
||||
let naming;
|
||||
before(() =>
|
||||
fixtures
|
||||
.extend('httpApi', {
|
||||
provider: {
|
||||
httpApi: {
|
||||
authorizers: {
|
||||
someAuthorizer: {
|
||||
identitySource: '$request.header.Authorization',
|
||||
issuerUrl: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxx',
|
||||
audience: 'audiencexxx',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
functions: {
|
||||
foo: {
|
||||
events: [
|
||||
{
|
||||
httpApi: {
|
||||
authorizer: {
|
||||
name: 'someAuthorizer',
|
||||
scopes: 'foo',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(fixturePath =>
|
||||
runServerless({
|
||||
cwd: fixturePath,
|
||||
cliArgs: ['package'],
|
||||
}).then(serverless => {
|
||||
cfResources = serverless.service.provider.compiledCloudFormationTemplate.Resources;
|
||||
naming = serverless.getProvider('aws').naming;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
it('Should configure authorizer resource', () => {
|
||||
expect(cfResources[naming.getHttpApiAuthorizerLogicalId('someAuthorizer')]).to.deep.equal({
|
||||
Type: 'AWS::ApiGatewayV2::Authorizer',
|
||||
Properties: {
|
||||
ApiId: { Ref: naming.getHttpApiLogicalId() },
|
||||
AuthorizerType: 'JWT',
|
||||
IdentitySource: ['$request.header.Authorization'],
|
||||
JwtConfiguration: {
|
||||
Audience: ['audiencexxx'],
|
||||
Issuer: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxx',
|
||||
},
|
||||
Name: 'someAuthorizer',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should setup authorizer properties on an endpoint', () => {
|
||||
const routeResourceProps =
|
||||
cfResources[naming.getHttpApiRouteLogicalId('GET /foo')].Properties;
|
||||
|
||||
expect(routeResourceProps.AuthorizationType).to.equal('JWT');
|
||||
expect(routeResourceProps.AuthorizationScopes).to.deep.equal(['foo']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,12 +30,64 @@ describe('HTTP API Integration Test', function() {
|
||||
};
|
||||
|
||||
describe('Specific endpoints', () => {
|
||||
let poolId;
|
||||
let clientId;
|
||||
const userName = 'test-http-api';
|
||||
const userPassword = 'razDwa3!';
|
||||
|
||||
before(async () => {
|
||||
tmpDirPath = getTmpDirPath();
|
||||
log.debug('temporary path %s', tmpDirPath);
|
||||
poolId = (
|
||||
await awsRequest('CognitoIdentityServiceProvider', 'createUserPool', {
|
||||
PoolName: `test-http-api-${process.hrtime()[1]}`,
|
||||
})
|
||||
).UserPool.Id;
|
||||
[clientId] = await Promise.all([
|
||||
awsRequest('CognitoIdentityServiceProvider', 'createUserPoolClient', {
|
||||
ClientName: 'test-http-api',
|
||||
UserPoolId: poolId,
|
||||
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
|
||||
PreventUserExistenceErrors: 'ENABLED',
|
||||
}).then(result => result.UserPoolClient.ClientId),
|
||||
awsRequest('CognitoIdentityServiceProvider', 'adminCreateUser', {
|
||||
UserPoolId: poolId,
|
||||
Username: userName,
|
||||
}).then(() =>
|
||||
awsRequest('CognitoIdentityServiceProvider', 'adminSetUserPassword', {
|
||||
UserPoolId: poolId,
|
||||
Username: userName,
|
||||
Password: userPassword,
|
||||
Permanent: true,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
const serverlessConfig = await createTestService(tmpDirPath, {
|
||||
templateDir: await fixtures.extend('httpApi', {
|
||||
provider: { httpApi: { cors: { exposedResponseHeaders: 'X-foo' } } },
|
||||
provider: {
|
||||
httpApi: {
|
||||
cors: { exposedResponseHeaders: 'X-foo' },
|
||||
authorizers: {
|
||||
someAuthorizer: {
|
||||
identitySource: '$request.header.Authorization',
|
||||
issuerUrl: `https://cognito-idp.us-east-1.amazonaws.com/${poolId}`,
|
||||
audience: clientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
functions: {
|
||||
foo: {
|
||||
events: [
|
||||
{
|
||||
httpApi: {
|
||||
authorizer: 'someAuthorizer',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
serviceName = serverlessConfig.service;
|
||||
@ -46,18 +98,12 @@ describe('HTTP API Integration Test', function() {
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await awsRequest('CognitoIdentityServiceProvider', 'deleteUserPool', { UserPoolId: poolId });
|
||||
if (!serviceName) return;
|
||||
log.notice('Removing service...');
|
||||
await removeService(tmpDirPath);
|
||||
});
|
||||
|
||||
it('should expose an accessible GET HTTP endpoint', async () => {
|
||||
const testEndpoint = `${endpoint}/foo`;
|
||||
|
||||
const response = await fetch(testEndpoint, { method: 'GET' });
|
||||
const json = await response.json();
|
||||
expect(json).to.deep.equal({ method: 'GET', path: '/foo' });
|
||||
});
|
||||
|
||||
it('should expose an accessible POST HTTP endpoint', async () => {
|
||||
const testEndpoint = `${endpoint}/some-post`;
|
||||
|
||||
@ -89,7 +135,7 @@ describe('HTTP API Integration Test', function() {
|
||||
});
|
||||
|
||||
it('should support CORS when indicated', async () => {
|
||||
const testEndpoint = `${endpoint}/foo`;
|
||||
const testEndpoint = `${endpoint}/bar/whatever`;
|
||||
|
||||
const response = await fetch(testEndpoint, {
|
||||
method: 'GET',
|
||||
@ -98,6 +144,29 @@ describe('HTTP API Integration Test', function() {
|
||||
expect(response.headers.get('access-control-allow-origin')).to.equal('*');
|
||||
expect(response.headers.get('access-control-expose-headers')).to.equal('x-foo');
|
||||
});
|
||||
|
||||
it('should expose a GET HTTP endpoint backed by JWT authorization', async () => {
|
||||
const testEndpoint = `${endpoint}/foo`;
|
||||
|
||||
const responseUnauthorized = await fetch(testEndpoint, {
|
||||
method: 'GET',
|
||||
});
|
||||
expect(responseUnauthorized.status).to.equal(401);
|
||||
|
||||
const token = (
|
||||
await awsRequest('CognitoIdentityServiceProvider', 'initiateAuth', {
|
||||
AuthFlow: 'USER_PASSWORD_AUTH',
|
||||
AuthParameters: { USERNAME: userName, PASSWORD: userPassword },
|
||||
ClientId: clientId,
|
||||
})
|
||||
).AuthenticationResult.IdToken;
|
||||
const responseAuthorized = await fetch(testEndpoint, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: token },
|
||||
});
|
||||
const json = await responseAuthorized.json();
|
||||
expect(json).to.deep.equal({ method: 'GET', path: '/foo' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Catch-all endpoints', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user