feat(AWS HTTP API): JWT authorizers support

This commit is contained in:
Mariusz Nowak 2020-02-17 18:38:45 +13:00 committed by Mariusz Nowak
parent 1dcc53be4c
commit fbf99fa2ab
5 changed files with 253 additions and 15 deletions

View File

@ -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
```

View File

@ -593,4 +593,7 @@ module.exports = {
getHttpApiRouteLogicalId(routeKey) {
return `HttpApiRoute${this.normalizePath(routeKey)}`;
},
getHttpApiAuthorizerLogicalId(authorizerName) {
return `HttpApiAuthorizer${this.getNormalizedFunctionName(authorizerName)}`;
},
};

View File

@ -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) {

View File

@ -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']);
});
});
});

View File

@ -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', () => {