diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md b/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md index ae44e5e5d97fc..3e888e57c9a9c 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md @@ -24,6 +24,7 @@ - [Route Authorization](#route-authorization) - [JWT Authorizers](#jwt-authorizers) - [User Pool Authorizer](#user-pool-authorizer) +- [Lambda Authorizers](#lambda-authorizers) ## Introduction @@ -162,3 +163,32 @@ api.addRoutes({ authorizer, }); ``` + +## Lambda Authorizers + +Lambda authorizers use a Lambda function to control access to your HTTP API. When a client calls your API, API Gateway invokes your Lambda function and uses the response to determine whether the client can access your API. + +Lambda authorizers depending on their response, fall into either two types - Simple or IAM. You can learn about differences [here](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html#http-api-lambda-authorizer.payload-format-response). + + +```ts +// This function handles your auth logic +const authHandler = new Function(this, 'auth-function', { + //... +}); + +const authorizer = new HttpLambdaAuthorizer({ + responseTypes: [HttpLambdaAuthorizerType.SIMPLE] // Define if returns simple and/or iam response + handler: authHandler, +}); + +const api = new HttpApi(stack, 'HttpApi'); + +api.addRoutes({ + integration: new HttpProxyIntegration({ + url: 'https://get-books-proxy.myproxy.internal', + }), + path: '/books', + authorizer, +}); +``` diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts index 9f9ad94c6a4b7..410cc8aa09f2e 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts @@ -1,2 +1,3 @@ export * from './user-pool'; -export * from './jwt'; \ No newline at end of file +export * from './jwt'; +export * from './lambda'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/jwt.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/jwt.ts index afb5f10ac07f8..184d02f3382b6 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/jwt.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/jwt.ts @@ -64,7 +64,7 @@ export class HttpJwtAuthorizer implements IHttpRouteAuthorizer { return { authorizerId: this.authorizer.authorizerId, - authorizationType: HttpAuthorizerType.JWT, + authorizationType: 'JWT', }; } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/lambda.ts new file mode 100644 index 0000000000000..fcb4f327c08a2 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/lambda.ts @@ -0,0 +1,130 @@ +import { + HttpAuthorizer, + HttpAuthorizerType, + HttpRouteAuthorizerBindOptions, + HttpRouteAuthorizerConfig, + IHttpRouteAuthorizer, + AuthorizerPayloadVersion, + IHttpApi, +} from '@aws-cdk/aws-apigatewayv2'; +import { ServicePrincipal } from '@aws-cdk/aws-iam'; +import { IFunction } from '@aws-cdk/aws-lambda'; +import { Stack, Duration, Names } from '@aws-cdk/core'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Specifies the type responses the lambda returns + */ +export enum HttpLambdaResponseType { + /** Returns simple boolean response */ + SIMPLE, + + /** Returns an IAM Policy */ + IAM, +} + +/** + * Properties to initialize HttpTokenAuthorizer. + */ +export interface HttpLambdaAuthorizerProps { + + /** + * The name of the authorizer + */ + readonly authorizerName: string; + + /** + * The identity source for which authorization is requested. + * + * @default ['$request.header.Authorization'] + */ + readonly identitySource?: string[]; + + /** + * The lambda function used for authorization + */ + readonly handler: IFunction; + + /** + * How long APIGateway should cache the results. Max 1 hour. + * Disable caching by setting this to `Duration.seconds(0)`. + * + * @default Duration.minutes(5) + */ + readonly resultsCacheTtl?: Duration; + + /** + * The types of responses the lambda can return + * + * If HttpLambdaResponseType.SIMPLE is included then + * response format 2.0 will be used. + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html#http-api-lambda-authorizer.payload-format-response + * + * @default [HttpLambdaResponseType.IAM] + */ + readonly responseTypes?: HttpLambdaResponseType[]; +} + +/** + * Authorize Http Api routes via a lambda function + */ +export class HttpLambdaAuthorizer implements IHttpRouteAuthorizer { + private authorizer?: HttpAuthorizer; + private httpApi?: IHttpApi; + + constructor(private readonly props: HttpLambdaAuthorizerProps) { + } + + public bind(options: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { + if (this.httpApi && (this.httpApi.apiId !== options.route.httpApi.apiId)) { + throw new Error('Cannot attach the same authorizer to multiple Apis'); + } + + if (!this.authorizer) { + const id = this.props.authorizerName; + + const responseTypes = this.props.responseTypes ?? [HttpLambdaResponseType.IAM]; + const enableSimpleResponses = responseTypes.includes(HttpLambdaResponseType.SIMPLE) || undefined; + + this.httpApi = options.route.httpApi; + this.authorizer = new HttpAuthorizer(options.scope, id, { + httpApi: options.route.httpApi, + identitySource: this.props.identitySource ?? [ + '$request.header.Authorization', + ], + type: HttpAuthorizerType.LAMBDA, + authorizerName: this.props.authorizerName, + enableSimpleResponses, + payloadFormatVersion: enableSimpleResponses ? AuthorizerPayloadVersion.VERSION_2_0 : AuthorizerPayloadVersion.VERSION_1_0, + authorizerUri: lambdaAuthorizerArn(this.props.handler), + resultsCacheTtl: this.props.resultsCacheTtl ?? Duration.minutes(5), + }); + + this.props.handler.addPermission(`${Names.nodeUniqueId(this.authorizer.node)}-Permission`, { + scope: options.scope as CoreConstruct, + principal: new ServicePrincipal('apigateway.amazonaws.com'), + sourceArn: Stack.of(options.route).formatArn({ + service: 'execute-api', + resource: options.route.httpApi.apiId, + resourceName: `authorizers/${this.authorizer.authorizerId}`, + }), + }); + } + + return { + authorizerId: this.authorizer.authorizerId, + authorizationType: 'CUSTOM', + }; + } +} + +/** + * constructs the authorizerURIArn. + */ +function lambdaAuthorizerArn(handler: IFunction) { + return `arn:${Stack.of(handler).partition}:apigateway:${Stack.of(handler).region}:lambda:path/2015-03-31/functions/${handler.functionArn}/invocations`; +} diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/user-pool.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/user-pool.ts index 4a251b8eb7406..702a3a05576ec 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/user-pool.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/user-pool.ts @@ -63,7 +63,7 @@ export class HttpUserPoolAuthorizer implements IHttpRouteAuthorizer { return { authorizerId: this.authorizer.authorizerId, - authorizationType: HttpAuthorizerType.JWT, + authorizationType: 'JWT', }; } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/package.json b/packages/@aws-cdk/aws-apigatewayv2-authorizers/package.json index c70e7a5dd8115..58de08da038e4 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/package.json +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/package.json @@ -82,12 +82,16 @@ "dependencies": { "@aws-cdk/aws-apigatewayv2": "0.0.0", "@aws-cdk/aws-cognito": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, "peerDependencies": { "@aws-cdk/aws-apigatewayv2": "0.0.0", "@aws-cdk/aws-cognito": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/auth-handler/index.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/auth-handler/index.ts new file mode 100644 index 0000000000000..f08c1bdb1b42a --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/auth-handler/index.ts @@ -0,0 +1,9 @@ +/* eslint-disable no-console */ + +export const handler = async (event: AWSLambda.APIGatewayProxyEventV2) => { + const key = event.headers['x-api-key']; + + return { + isAuthorized: key === '123', + }; +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.expected.json b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.expected.json new file mode 100644 index 0000000000000..69c407f274908 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.expected.json @@ -0,0 +1,397 @@ +{ + "Resources": { + "MyHttpApi8AEAAC21": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "Name": "MyHttpApi", + "ProtocolType": "HTTP" + } + }, + "MyHttpApiDefaultStageDCB9BC49": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "StageName": "$default", + "AutoDeploy": true + } + }, + "MyHttpApiGETAuthorizerIntegMyHttpApiGET16D02385PermissionBB02EBFE": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "lambda8B5974B5", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyHttpApi8AEAAC21" + }, + "/*/*/" + ] + ] + } + } + }, + "MyHttpApiGETHttpIntegration6f095b8469365f72e33fa33d9711b140516EBE31": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "lambda8B5974B5", + "Arn" + ] + }, + "PayloadFormatVersion": "2.0" + } + }, + "MyHttpApiGETE0EFC6F8": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "RouteKey": "GET /", + "AuthorizationType": "CUSTOM", + "AuthorizerId": { + "Ref": "MyHttpApimysimpleauthorizer98398C16" + }, + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "MyHttpApiGETHttpIntegration6f095b8469365f72e33fa33d9711b140516EBE31" + } + ] + ] + } + } + }, + "MyHttpApimysimpleauthorizer98398C16": { + "Type": "AWS::ApiGatewayV2::Authorizer", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "AuthorizerType": "REQUEST", + "IdentitySource": [ + "$request.header.X-API-Key" + ], + "Name": "my-simple-authorizer", + "AuthorizerPayloadFormatVersion": "2.0", + "AuthorizerResultTtlInSeconds": 300, + "AuthorizerUri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "authfunction96361832", + "Arn" + ] + }, + "/invocations" + ] + ] + }, + "EnableSimpleResponses": true + } + }, + "MyHttpApiAuthorizerIntegMyHttpApimysimpleauthorizer0F14A472PermissionF37EF5C8": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "authfunction96361832", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyHttpApi8AEAAC21" + }, + "/authorizers/", + { + "Ref": "MyHttpApimysimpleauthorizer98398C16" + } + ] + ] + } + } + }, + "authfunctionServiceRoleFCB72198": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "authfunction96361832": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3BucketC7E46972" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3VersionKeyA8ECA032" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3VersionKeyA8ECA032" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "authfunctionServiceRoleFCB72198", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs14.x" + }, + "DependsOn": [ + "authfunctionServiceRoleFCB72198" + ] + }, + "lambdaServiceRole494E4CA6": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "lambda8B5974B5": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3Bucket2E6D85D3" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3VersionKey22B8E7C6" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3VersionKey22B8E7C6" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "lambdaServiceRole494E4CA6", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "lambdaServiceRole494E4CA6" + ] + } + }, + "Parameters": { + "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3BucketC7E46972": { + "Type": "String", + "Description": "S3 bucket for asset \"7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043\"" + }, + "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3VersionKeyA8ECA032": { + "Type": "String", + "Description": "S3 key for asset version \"7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043\"" + }, + "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043ArtifactHashE679D99A": { + "Type": "String", + "Description": "Artifact hash for asset \"7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043\"" + }, + "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3Bucket2E6D85D3": { + "Type": "String", + "Description": "S3 bucket for asset \"1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cda\"" + }, + "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3VersionKey22B8E7C6": { + "Type": "String", + "Description": "S3 key for asset version \"1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cda\"" + }, + "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaArtifactHash82A279EA": { + "Type": "String", + "Description": "Artifact hash for asset \"1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cda\"" + } + }, + "Outputs": { + "URL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "MyHttpApi8AEAAC21" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.ts new file mode 100644 index 0000000000000..264da5f4bf510 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.ts @@ -0,0 +1,49 @@ +import * as path from 'path'; +import { HttpApi, HttpMethod } from '@aws-cdk/aws-apigatewayv2'; +import { LambdaProxyIntegration } from '@aws-cdk/aws-apigatewayv2-integrations'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, Stack, CfnOutput } from '@aws-cdk/core'; +import { HttpLambdaAuthorizer, HttpLambdaResponseType } from '../../lib'; + +/* + * Stack verification steps: + * * `curl -H 'X-API-Key: 123' ` should return 200 + * * `curl ` should return 401 + * * `curl -H 'X-API-Key: 1234' ` should return 403 + */ + +const app = new App(); +const stack = new Stack(app, 'AuthorizerInteg'); + +const httpApi = new HttpApi(stack, 'MyHttpApi'); + +const authHandler = new lambda.Function(stack, 'auth-function', { + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + code: lambda.Code.fromAsset(path.join(__dirname, '../auth-handler')), +}); + + +const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-simple-authorizer', + identitySource: ['$request.header.X-API-Key'], + handler: authHandler, + responseTypes: [HttpLambdaResponseType.SIMPLE], +}); + +const handler = new lambda.Function(stack, 'lambda', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: lambda.AssetCode.fromAsset(path.join(__dirname, '../integ.lambda.handler')), +}); + +httpApi.addRoutes({ + path: '/', + methods: [HttpMethod.GET], + integration: new LambdaProxyIntegration({ handler }), + authorizer, +}); + +new CfnOutput(stack, 'URL', { + value: httpApi.url!, +}); diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/lambda.test.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/lambda.test.ts new file mode 100644 index 0000000000000..a9efd500e6bf1 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/lambda.test.ts @@ -0,0 +1,182 @@ +import '@aws-cdk/assert-internal/jest'; +import { ABSENT } from '@aws-cdk/assert-internal'; +import { HttpApi, HttpIntegrationType, HttpRouteIntegrationBindOptions, IHttpRouteIntegration, PayloadFormatVersion } from '@aws-cdk/aws-apigatewayv2'; +import { Code, Function, Runtime } from '@aws-cdk/aws-lambda'; +import { Duration, Stack } from '@aws-cdk/core'; +import { HttpLambdaAuthorizer, HttpLambdaResponseType } from '../../lib'; + +describe('HttpLambdaAuthorizer', () => { + + test('default', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'default-authorizer', + handler, + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + Name: 'default-authorizer', + AuthorizerType: 'REQUEST', + AuthorizerResultTtlInSeconds: 300, + AuthorizerPayloadFormatVersion: '1.0', + IdentitySource: [ + '$request.header.Authorization', + ], + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + AuthorizationType: 'CUSTOM', + }); + }); + + test('should use format 2.0 and simple responses when simple response type is requested', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-simple-authorizer', + responseTypes: [HttpLambdaResponseType.SIMPLE], + handler, + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerPayloadFormatVersion: '2.0', + EnableSimpleResponses: true, + }); + }); + + test('should use format 1.0 when only IAM response type is requested', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-iam-authorizer', + responseTypes: [HttpLambdaResponseType.IAM], + handler, + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerPayloadFormatVersion: '1.0', + EnableSimpleResponses: ABSENT, + }); + }); + + test('should use format 2.0 and simple responses when both response types are requested', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-simple-iam-authorizer', + responseTypes: [HttpLambdaResponseType.IAM, HttpLambdaResponseType.SIMPLE], + handler, + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerPayloadFormatVersion: '2.0', + EnableSimpleResponses: true, + }); + }); + + test('can override cache ttl', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-functon', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-simple-authorizer', + responseTypes: [HttpLambdaResponseType.SIMPLE], + handler, + resultsCacheTtl: Duration.minutes(10), + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerResultTtlInSeconds: 600, + }); + }); +}); + +class DummyRouteIntegration implements IHttpRouteIntegration { + public bind(_: HttpRouteIntegrationBindOptions) { + return { + payloadFormatVersion: PayloadFormatVersion.VERSION_2_0, + type: HttpIntegrationType.HTTP_PROXY, + uri: 'some-uri', + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/integ.lambda.handler/index.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/integ.lambda.handler/index.ts new file mode 100644 index 0000000000000..def194e303e1e --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/integ.lambda.handler/index.ts @@ -0,0 +1,9 @@ +export const handler = async () => { + return { + statusCode: 200, + body: JSON.stringify({ message: 'Hello from authenticated lambda' }), + headers: { + 'Content-Type': 'application/json', + }, + }; +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts index 297abf12e78e2..08936ecf36d8f 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts @@ -1,4 +1,4 @@ -import { Resource } from '@aws-cdk/core'; +import { Duration, Resource } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnAuthorizer } from '../apigatewayv2.generated'; @@ -15,9 +15,18 @@ export enum HttpAuthorizerType { /** Lambda Authorizer */ LAMBDA = 'REQUEST', +} + +/** + * Payload format version for lambda authorizers + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html + */ +export enum AuthorizerPayloadVersion { + /** Version 1.0 */ + VERSION_1_0 = '1.0', - /** No authorizer */ - NONE = 'NONE' + /** Version 2.0 */ + VERSION_2_0 = '2.0' } /** @@ -58,6 +67,38 @@ export interface HttpAuthorizerProps { * @default - required for JWT authorizer types. */ readonly jwtIssuer?: string; + + /** + * Specifies whether a Lambda authorizer returns a response in a simple format. + * + * If enabled, the Lambda authorizer can return a boolean value instead of an IAM policy. + * + * @default - The lambda authorizer must return an IAM policy as its response + */ + readonly enableSimpleResponses?: boolean; + + /** + * Specifies the format of the payload sent to an HTTP API Lambda authorizer. + * + * @default AuthorizerPayloadVersion.VERSION_2_0 if the authorizer type is HttpAuthorizerType.LAMBDA + */ + readonly payloadFormatVersion?: AuthorizerPayloadVersion; + + /** + * The authorizer's Uniform Resource Identifier (URI). + * + * For REQUEST authorizers, this must be a well-formed Lambda function URI. + * + * @default - required for Request authorizer types + */ + readonly authorizerUri?: string; + + /** + * How long APIGateway should cache the results. Max 1 hour. + * + * @default - API Gateway will not cache authorizer responses + */ + readonly resultsCacheTtl?: Duration; } /** @@ -77,8 +118,13 @@ export interface HttpAuthorizerAttributes { /** * Type of authorizer + * + * Possible values are: + * - JWT - JSON Web Token Authorizer + * - CUSTOM - Lambda Authorizer + * - NONE - No Authorization */ - readonly authorizerType: HttpAuthorizerType + readonly authorizerType: string } /** @@ -109,10 +155,24 @@ export class HttpAuthorizer extends Resource implements IHttpAuthorizer { constructor(scope: Construct, id: string, props: HttpAuthorizerProps) { super(scope, id); + let authorizerPayloadFormatVersion = props.payloadFormatVersion; + if (props.type === HttpAuthorizerType.JWT && (!props.jwtAudience || props.jwtAudience.length === 0 || !props.jwtIssuer)) { throw new Error('jwtAudience and jwtIssuer are mandatory for JWT authorizers'); } + if (props.type === HttpAuthorizerType.LAMBDA && !props.authorizerUri) { + throw new Error('authorizerUri is mandatory for Lambda authorizers'); + } + + /** + * This check is required because Cloudformation will fail stack creation is this property + * is set for the JWT authorizer. AuthorizerPayloadFormatVersion can only be set for REQUEST authorizer + */ + if (props.type === HttpAuthorizerType.LAMBDA && typeof authorizerPayloadFormatVersion === 'undefined') { + authorizerPayloadFormatVersion = AuthorizerPayloadVersion.VERSION_2_0; + } + const resource = new CfnAuthorizer(this, 'Resource', { name: props.authorizerName ?? id, apiId: props.httpApi.apiId, @@ -122,6 +182,10 @@ export class HttpAuthorizer extends Resource implements IHttpAuthorizer { audience: props.jwtAudience, issuer: props.jwtIssuer, }), + enableSimpleResponses: props.enableSimpleResponses, + authorizerPayloadFormatVersion, + authorizerUri: props.authorizerUri, + authorizerResultTtlInSeconds: props.resultsCacheTtl?.toSeconds(), }); this.authorizerId = resource.ref; @@ -152,10 +216,17 @@ export interface HttpRouteAuthorizerConfig { * @default - No authorizer id (useful for AWS_IAM route authorizer) */ readonly authorizerId?: string; + /** * The type of authorization + * + * Possible values are: + * - JWT - JSON Web Token Authorizer + * - CUSTOM - Lambda Authorizer + * - NONE - No Authorization */ - readonly authorizationType: HttpAuthorizerType; + readonly authorizationType: string; + /** * The list of OIDC scopes to include in the authorization. * @default - no authorization scopes @@ -184,7 +255,7 @@ function undefinedIfNoKeys(obj: A): A | undefined { export class HttpNoneAuthorizer implements IHttpRouteAuthorizer { public bind(_: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { return { - authorizationType: HttpAuthorizerType.NONE, + authorizationType: 'NONE', }; } } diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts index 5178281d08953..a88aaae0b3416 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts @@ -3,7 +3,7 @@ import { Construct } from 'constructs'; import { CfnRoute, CfnRouteProps } from '../apigatewayv2.generated'; import { IRoute } from '../common'; import { IHttpApi } from './api'; -import { HttpAuthorizerType, IHttpRouteAuthorizer } from './authorizer'; +import { IHttpRouteAuthorizer } from './authorizer'; import { IHttpRouteIntegration } from './integration'; /** @@ -120,6 +120,20 @@ export interface HttpRouteProps extends BatchHttpRouteOptions { readonly authorizationScopes?: string[]; } +/** + * Supported Route Authorizer types + */ +enum HttpRouteAuthorizationType { + /** JSON Web Tokens */ + JWT = 'JWT', + + /** Lambda Authorizer */ + CUSTOM = 'CUSTOM', + + /** No authorizer */ + NONE = 'NONE' +} + /** * Route class that creates the Route for API Gateway HTTP API * @resource AWS::ApiGatewayV2::Route @@ -147,6 +161,10 @@ export class HttpRoute extends Resource implements IHttpRoute { scope: this.httpApi instanceof Construct ? this.httpApi : this, // scope under the API if it's not imported }) : undefined; + if (authBindResult && !(authBindResult.authorizationType in HttpRouteAuthorizationType)) { + throw new Error('authorizationType should either be JWT, CUSTOM, or NONE'); + } + let authorizationScopes = authBindResult?.authorizationScopes; if (authBindResult && props.authorizationScopes) { @@ -165,7 +183,7 @@ export class HttpRoute extends Resource implements IHttpRoute { routeKey: props.routeKey.key, target: `integrations/${integration.integrationId}`, authorizerId: authBindResult?.authorizerId, - authorizationType: authBindResult?.authorizationType ?? HttpAuthorizerType.NONE, // must be explicitly NONE (not undefined) for stack updates to work correctly + authorizationType: authBindResult?.authorizationType ?? 'NONE', // must be explicitly NONE (not undefined) for stack updates to work correctly authorizationScopes, }; diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts index 12d2c68aa0ecb..3b07593676c11 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts @@ -5,7 +5,7 @@ import * as ec2 from '@aws-cdk/aws-ec2'; import { Duration, Stack } from '@aws-cdk/core'; import { CorsHttpMethod, - HttpApi, HttpAuthorizer, HttpAuthorizerType, HttpIntegrationType, HttpMethod, HttpRouteAuthorizerBindOptions, HttpRouteAuthorizerConfig, + HttpApi, HttpAuthorizer, HttpIntegrationType, HttpMethod, HttpRouteAuthorizerBindOptions, HttpRouteAuthorizerConfig, HttpRouteIntegrationBindOptions, HttpRouteIntegrationConfig, IHttpRouteAuthorizer, IHttpRouteIntegration, HttpNoneAuthorizer, PayloadFormatVersion, } from '../../lib'; @@ -310,7 +310,7 @@ describe('HttpApi', () => { const authorizer = HttpAuthorizer.fromHttpAuthorizerAttributes(stack, 'auth', { authorizerId: '12345', - authorizerType: HttpAuthorizerType.JWT, + authorizerType: 'JWT', }); // WHEN @@ -506,7 +506,7 @@ class DummyAuthorizer implements IHttpRouteAuthorizer { public bind(_: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { return { authorizerId: 'auth-1234', - authorizationType: HttpAuthorizerType.JWT, + authorizationType: 'JWT', }; } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts index 9f99ccb9f7691..92c0ee0422c17 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts @@ -64,4 +64,24 @@ describe('HttpAuthorizer', () => { }); }); }); + + describe('lambda', () => { + it('default', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + + new HttpAuthorizer(stack, 'HttpAuthorizer', { + httpApi, + identitySource: ['identitysource.1', 'identitysource.2'], + type: HttpAuthorizerType.LAMBDA, + authorizerUri: 'arn:cool-lambda-arn', + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerType: 'REQUEST', + AuthorizerPayloadFormatVersion: '2.0', + AuthorizerUri: 'arn:cool-lambda-arn', + }); + }); + }); }); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts index 8de7d2ae7f1d6..f30bdaba9205e 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts @@ -242,6 +242,20 @@ describe('HttpRoute', () => { AuthorizationScopes: ['read:books'], }); }); + + test('should fail when unsupported authorization type is used', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + + const authorizer = new InvalidTypeAuthorizer(); + + expect(() => new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books', HttpMethod.GET), + authorizer, + })).toThrowError('authorizationType should either be JWT, CUSTOM, or NONE'); + }); }); class DummyIntegration implements IHttpRouteIntegration { @@ -272,7 +286,29 @@ class DummyAuthorizer implements IHttpRouteAuthorizer { return { authorizerId: this.authorizer.authorizerId, - authorizationType: HttpAuthorizerType.JWT, + authorizationType: 'JWT', }; } } + +class InvalidTypeAuthorizer implements IHttpRouteAuthorizer { + private authorizer?: HttpAuthorizer; + + public bind(options: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { + if (!this.authorizer) { + + this.authorizer = new HttpAuthorizer(options.scope, 'auth-1234', { + httpApi: options.route.httpApi, + identitySource: ['identitysource.1', 'identitysource.2'], + type: HttpAuthorizerType.JWT, + jwtAudience: ['audience.1', 'audience.2'], + jwtIssuer: 'issuer', + }); + } + + return { + authorizerId: this.authorizer.authorizerId, + authorizationType: 'Random', + }; + } +} \ No newline at end of file