From fbf99fa2abf9ce3bc13fc4a6c8439a650d3eaa4e Mon Sep 17 00:00:00 2001 From: Mariusz Nowak Date: Mon, 17 Feb 2020 18:38:45 +1300 Subject: [PATCH] feat(AWS HTTP API): JWT authorizers support --- docs/providers/aws/events/http-api.md | 39 ++++++++ lib/plugins/aws/lib/naming.js | 3 + .../package/compile/events/httpApi/index.js | 69 ++++++++++++-- .../compile/events/httpApi/index.test.js | 68 ++++++++++++++ tests/integration-all/http-api/tests.js | 89 ++++++++++++++++--- 5 files changed, 253 insertions(+), 15 deletions(-) diff --git a/docs/providers/aws/events/http-api.md b/docs/providers/aws/events/http-api.md index 4729f1497..e75c16b5b 100644 --- a/docs/providers/aws/events/http-api.md +++ b/docs/providers/aws/events/http-api.md @@ -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 +``` diff --git a/lib/plugins/aws/lib/naming.js b/lib/plugins/aws/lib/naming.js index d585e4e94..dda3c62f2 100644 --- a/lib/plugins/aws/lib/naming.js +++ b/lib/plugins/aws/lib/naming.js @@ -593,4 +593,7 @@ module.exports = { getHttpApiRouteLogicalId(routeKey) { return `HttpApiRoute${this.normalizePath(routeKey)}`; }, + getHttpApiAuthorizerLogicalId(authorizerName) { + return `HttpApiAuthorizer${this.getNormalizedFunctionName(authorizerName)}`; + }, }; diff --git a/lib/plugins/aws/package/compile/events/httpApi/index.js b/lib/plugins/aws/package/compile/events/httpApi/index.js index 9b556456a..354f4b2ec 100644 --- a/lib/plugins/aws/package/compile/events/httpApi/index.js +++ b/lib/plugins/aws/package/compile/events/httpApi/index.js @@ -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) { diff --git a/lib/plugins/aws/package/compile/events/httpApi/index.test.js b/lib/plugins/aws/package/compile/events/httpApi/index.test.js index e05d135cd..0535b6c35 100644 --- a/lib/plugins/aws/package/compile/events/httpApi/index.test.js +++ b/lib/plugins/aws/package/compile/events/httpApi/index.test.js @@ -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']); + }); + }); }); diff --git a/tests/integration-all/http-api/tests.js b/tests/integration-all/http-api/tests.js index 57cd68ae3..267c8a94d 100644 --- a/tests/integration-all/http-api/tests.js +++ b/tests/integration-all/http-api/tests.js @@ -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', () => {