From ff1e5b3c580119c107fe26c67fe3cc220f9ee7c9 Mon Sep 17 00:00:00 2001 From: Ayush Goyal Date: Wed, 3 Feb 2021 19:38:17 +0530 Subject: [PATCH] feat(apigateway): cognito user pool authorizer (#12786) feat(apigateway): add support for cognito user pool authorizer closes #5618 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-apigateway/README.md | 20 ++ .../aws-apigateway/lib/authorizers/cognito.ts | 115 +++++++++++ .../aws-apigateway/lib/authorizers/index.ts | 1 + packages/@aws-cdk/aws-apigateway/package.json | 2 + .../test/authorizers/cognito.test.ts | 66 ++++++ .../integ.cognito-authorizer.expected.json | 191 ++++++++++++++++++ .../authorizers/integ.cognito-authorizer.ts | 43 ++++ 7 files changed, 438 insertions(+) create mode 100644 packages/@aws-cdk/aws-apigateway/lib/authorizers/cognito.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/authorizers/cognito.test.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.expected.json create mode 100644 packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.ts diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index 60d2cdcfa3654..4d4d1a79eb797 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -32,6 +32,7 @@ running on AWS Lambda, or any web application. - [IAM-based authorizer](#iam-based-authorizer) - [Lambda-based token authorizer](#lambda-based-token-authorizer) - [Lambda-based request authorizer](#lambda-based-request-authorizer) + - [Cognito User Pools authorizer](#cognito-user-pools-authorizer) - [Mutual TLS](#mutal-tls-mtls) - [Deployments](#deployments) - [Deep dive: Invalidation of deployments](#deep-dive-invalidation-of-deployments) @@ -580,6 +581,25 @@ Authorizers can also be passed via the `defaultMethodOptions` property within th explicitly overridden, the specified defaults will be applied across all `Method`s across the `RestApi` or across all `Resource`s, depending on where the defaults were specified. +### Cognito User Pools authorizer + +API Gateway also allows [Amazon Cognito user pools as authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html) + +The following snippet configures a Cognito user pool as an authorizer: + +```ts +const userPool = new cognito.UserPool(stack, 'UserPool'); + +const auth = new apigateway.CognitoUserPoolsAuthorizer(this, 'booksAuthorizer', { + cognitoUserPools: [userPool] +}); + +books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), { + authorizer: auth, + authorizationType: apigateway.AuthorizationType.COGNITO, +}); +``` + ## Mutual TLS (mTLS) Mutual TLS can be configured to limit access to your API based by using client certificates instead of (or as an extension of) using authorization headers. diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/cognito.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/cognito.ts new file mode 100644 index 0000000000000..a1d000189354c --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/cognito.ts @@ -0,0 +1,115 @@ +import * as cognito from '@aws-cdk/aws-cognito'; +import { Duration, Lazy, Names, Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnAuthorizer } from '../apigateway.generated'; +import { Authorizer, IAuthorizer } from '../authorizer'; +import { AuthorizationType } from '../method'; +import { IRestApi } from '../restapi'; + +/** + * Properties for CognitoUserPoolsAuthorizer + */ +export interface CognitoUserPoolsAuthorizerProps { + /** + * An optional human friendly name for the authorizer. Note that, this is not the primary identifier of the authorizer. + * + * @default - the unique construct ID + */ + readonly authorizerName?: string; + + /** + * The user pools to associate with this authorizer. + */ + readonly cognitoUserPools: cognito.IUserPool[]; + + /** + * How long APIGateway should cache the results. Max 1 hour. + * Disable caching by setting this to 0. + * + * @default Duration.minutes(5) + */ + readonly resultsCacheTtl?: Duration; + + /** + * The request header mapping expression for the bearer token. This is typically passed as part of the header, in which case + * this should be `method.request.header.Authorizer` where Authorizer is the header containing the bearer token. + * @see https://docs.aws.amazon.com/apigateway/api-reference/link-relation/authorizer-create/#identitySource + * @default `IdentitySource.header('Authorization')` + */ + readonly identitySource?: string; +} + +/** + * Cognito user pools based custom authorizer + * + * @resource AWS::ApiGateway::Authorizer + */ +export class CognitoUserPoolsAuthorizer extends Authorizer implements IAuthorizer { + /** + * The id of the authorizer. + * @attribute + */ + public readonly authorizerId: string; + + /** + * The ARN of the authorizer to be used in permission policies, such as IAM and resource-based grants. + * @attribute + */ + public readonly authorizerArn: string; + + /** + * The authorization type of this authorizer. + */ + public readonly authorizationType?: AuthorizationType; + + private restApiId?: string; + + constructor(scope: Construct, id: string, props: CognitoUserPoolsAuthorizerProps) { + super(scope, id); + + const restApiId = this.lazyRestApiId(); + const resource = new CfnAuthorizer(this, 'Resource', { + name: props.authorizerName ?? Names.uniqueId(this), + restApiId, + type: 'COGNITO_USER_POOLS', + providerArns: props.cognitoUserPools.map(userPool => userPool.userPoolArn), + authorizerResultTtlInSeconds: props.resultsCacheTtl?.toSeconds(), + identitySource: props.identitySource || 'method.request.header.Authorization', + }); + + this.authorizerId = resource.ref; + this.authorizerArn = Stack.of(this).formatArn({ + service: 'execute-api', + resource: restApiId, + resourceName: `authorizers/${this.authorizerId}`, + }); + this.authorizationType = AuthorizationType.COGNITO; + } + + /** + * Attaches this authorizer to a specific REST API. + * @internal + */ + public _attachToApi(restApi: IRestApi): void { + if (this.restApiId && this.restApiId !== restApi.restApiId) { + throw new Error('Cannot attach authorizer to two different rest APIs'); + } + + this.restApiId = restApi.restApiId; + } + + /** + * Returns a token that resolves to the Rest Api Id at the time of synthesis. + * Throws an error, during token resolution, if no RestApi is attached to this authorizer. + */ + private lazyRestApiId() { + return Lazy.string({ + produce: () => { + if (!this.restApiId) { + throw new Error(`Authorizer (${this.node.path}) must be attached to a RestApi`); + } + return this.restApiId; + }, + }); + } +} diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts index 57289c931f760..fd93db036fefe 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts @@ -1,2 +1,3 @@ export * from './lambda'; export * from './identity-source'; +export * from './cognito'; diff --git a/packages/@aws-cdk/aws-apigateway/package.json b/packages/@aws-cdk/aws-apigateway/package.json index c9650deeb9a4b..3a0641e8b1162 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -80,6 +80,7 @@ "dependencies": { "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-cognito": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", @@ -95,6 +96,7 @@ "peerDependencies": { "@aws-cdk/aws-certificatemanager": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-cognito": "0.0.0", "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-elasticloadbalancingv2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/cognito.test.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/cognito.test.ts new file mode 100644 index 0000000000000..e59339177d5d4 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/cognito.test.ts @@ -0,0 +1,66 @@ +import '@aws-cdk/assert/jest'; +import * as cognito from '@aws-cdk/aws-cognito'; +import { Duration, Stack } from '@aws-cdk/core'; +import { AuthorizationType, CognitoUserPoolsAuthorizer, RestApi } from '../../lib'; + +describe('Cognito Authorizer', () => { + test('default cognito authorizer', () => { + // GIVEN + const stack = new Stack(); + const userPool = new cognito.UserPool(stack, 'UserPool'); + + // WHEN + const authorizer = new CognitoUserPoolsAuthorizer(stack, 'myauthorizer', { + cognitoUserPools: [userPool], + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer, + authorizationType: AuthorizationType.COGNITO, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGateway::Authorizer', { + Type: 'COGNITO_USER_POOLS', + RestApiId: stack.resolve(restApi.restApiId), + IdentitySource: 'method.request.header.Authorization', + ProviderARNs: [stack.resolve(userPool.userPoolArn)], + }); + + expect(authorizer.authorizerArn.endsWith(`/authorizers/${authorizer.authorizerId}`)).toBeTruthy(); + }); + + test('cognito authorizer with all parameters specified', () => { + // GIVEN + const stack = new Stack(); + const userPool1 = new cognito.UserPool(stack, 'UserPool1'); + const userPool2 = new cognito.UserPool(stack, 'UserPool2'); + + // WHEN + const authorizer = new CognitoUserPoolsAuthorizer(stack, 'myauthorizer', { + cognitoUserPools: [userPool1, userPool2], + identitySource: 'method.request.header.whoami', + authorizerName: 'myauthorizer', + resultsCacheTtl: Duration.minutes(1), + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer, + authorizationType: AuthorizationType.COGNITO, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGateway::Authorizer', { + Type: 'COGNITO_USER_POOLS', + Name: 'myauthorizer', + RestApiId: stack.resolve(restApi.restApiId), + IdentitySource: 'method.request.header.whoami', + AuthorizerResultTtlInSeconds: 60, + ProviderARNs: [stack.resolve(userPool1.userPoolArn), stack.resolve(userPool2.userPoolArn)], + }); + + expect(authorizer.authorizerArn.endsWith(`/authorizers/${authorizer.authorizerId}`)).toBeTruthy(); + }); +}); diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.expected.json b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.expected.json new file mode 100644 index 0000000000000..990619cb495d4 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.expected.json @@ -0,0 +1,191 @@ +{ + "Resources": { + "UserPool6BA7E5F2": { + "Type": "AWS::Cognito::UserPool", + "Properties": { + "AccountRecoverySetting": { + "RecoveryMechanisms": [ + { + "Name": "verified_phone_number", + "Priority": 1 + }, + { + "Name": "verified_email", + "Priority": 2 + } + ] + }, + "AdminCreateUserConfig": { + "AllowAdminCreateUserOnly": true + }, + "EmailVerificationMessage": "The verification code to your new account is {####}", + "EmailVerificationSubject": "Verify your new account", + "SmsVerificationMessage": "The verification code to your new account is {####}", + "VerificationMessageTemplate": { + "DefaultEmailOption": "CONFIRM_WITH_CODE", + "EmailMessage": "The verification code to your new account is {####}", + "EmailSubject": "Verify your new account", + "SmsMessage": "The verification code to your new account is {####}" + } + } + }, + "myauthorizer23CB99DD": { + "Type": "AWS::ApiGateway::Authorizer", + "Properties": { + "RestApiId": { + "Ref": "myrestapi551C8392" + }, + "Type": "COGNITO_USER_POOLS", + "IdentitySource": "method.request.header.Authorization", + "Name": "CognitoUserPoolsAuthorizerIntegmyauthorizer10C804C1", + "ProviderARNs": [ + { + "Fn::GetAtt": [ + "UserPool6BA7E5F2", + "Arn" + ] + } + ] + } + }, + "myrestapi551C8392": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "myrestapi" + } + }, + "myrestapiCloudWatchRoleC48DA1DD": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + } + }, + "myrestapiAccountA49A05BE": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "myrestapiCloudWatchRoleC48DA1DD", + "Arn" + ] + } + }, + "DependsOn": [ + "myrestapi551C8392" + ] + }, + "myrestapiDeployment419B1464b903292b53d7532ca4296973bcb95b1a": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "myrestapi551C8392" + }, + "Description": "Automatically created by the RestApi construct" + }, + "DependsOn": [ + "myrestapiANY94B0497F" + ] + }, + "myrestapiDeploymentStageprodA9250EA4": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "myrestapi551C8392" + }, + "DeploymentId": { + "Ref": "myrestapiDeployment419B1464b903292b53d7532ca4296973bcb95b1a" + }, + "StageName": "prod" + } + }, + "myrestapiANY94B0497F": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "myrestapi551C8392", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "myrestapi551C8392" + }, + "AuthorizationType": "COGNITO_USER_POOLS", + "AuthorizerId": { + "Ref": "myauthorizer23CB99DD" + }, + "Integration": { + "IntegrationResponses": [ + { + "StatusCode": "200" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": "{ \"statusCode\": 200 }" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "StatusCode": "200" + } + ] + } + } + }, + "Outputs": { + "myrestapiEndpointE06F9D98": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "myrestapi551C8392" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "myrestapiDeploymentStageprodA9250EA4" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.ts new file mode 100644 index 0000000000000..4830dc83ae29f --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.cognito-authorizer.ts @@ -0,0 +1,43 @@ +import * as cognito from '@aws-cdk/aws-cognito'; +import { App, Stack } from '@aws-cdk/core'; +import { AuthorizationType, CognitoUserPoolsAuthorizer, MockIntegration, PassthroughBehavior, RestApi } from '../../lib'; + +/* + * Stack verification steps: + * * 1. Get the IdToken for the created pool by adding user/app-client and using aws cognito-idp: + * * a. aws cognito-idp create-user-pool-client --user-pool-id --client-name --no-generate-secret + * * b. aws cognito-idp admin-create-user --user-pool-id --username --temporary-password + * * c. aws cognito-idp initiate-auth --client-id --auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME=,PASSWORD= + * * d. aws cognito-idp respond-to-auth-challenge --client-id --challenge-name --session + * * + * * 2. Verify the stack using above obtained token: + * * a. `curl -s -o /dev/null -w "%{http_code}" ` should return 401 + * * b. `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: ' ` should return 403 + * * c. `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: ' ` should return 200 + */ + +const app = new App(); +const stack = new Stack(app, 'CognitoUserPoolsAuthorizerInteg'); + +const userPool = new cognito.UserPool(stack, 'UserPool'); + +const authorizer = new CognitoUserPoolsAuthorizer(stack, 'myauthorizer', { + cognitoUserPools: [userPool], +}); + +const restApi = new RestApi(stack, 'myrestapi'); +restApi.root.addMethod('ANY', new MockIntegration({ + integrationResponses: [ + { statusCode: '200' }, + ], + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: { + 'application/json': '{ "statusCode": 200 }', + }, +}), { + methodResponses: [ + { statusCode: '200' }, + ], + authorizer, + authorizationType: AuthorizationType.COGNITO, +});