diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md b/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md index d1aebb7477b82..e2d9f13198711 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md @@ -22,9 +22,11 @@ - [HTTP APIs](#http-apis) - [Default Authorization](#default-authorization) - [Route Authorization](#route-authorization) -- [JWT Authorizers](#jwt-authorizers) - - [User Pool Authorizer](#user-pool-authorizer) -- [Lambda Authorizers](#lambda-authorizers) + - [JWT Authorizers](#jwt-authorizers) + - [User Pool Authorizer](#user-pool-authorizer) + - [Lambda Authorizers](#lambda-authorizers) +- [WebSocket APIs](#websocket-apis) + - [Lambda Authorizer](#lambda-authorizer) ## Introduction @@ -37,7 +39,7 @@ API](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-acces Access control for Http Apis is managed by restricting which routes can be invoked via. -Authorizers, and scopes can either be applied to the api, or specifically for each route. +Authorizers and scopes can either be applied to the api, or specifically for each route. ### Default Authorization @@ -110,7 +112,7 @@ api.addRoutes({ }); ``` -## JWT Authorizers +### JWT Authorizers JWT authorizers allow the use of JSON Web Tokens (JWTs) as part of [OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html) and [OAuth 2.0](https://oauth.net/2/) frameworks to allow and restrict clients from accessing HTTP APIs. @@ -144,7 +146,7 @@ api.addRoutes({ }); ``` -### User Pool Authorizer +#### User Pool Authorizer User Pool Authorizer is a type of JWT Authorizer that uses a Cognito user pool and app client to control who can access your Api. After a successful authorization from the app client, the generated access token will be used as the JWT. @@ -170,7 +172,7 @@ api.addRoutes({ }); ``` -## Lambda Authorizers +### 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. @@ -196,3 +198,36 @@ api.addRoutes({ authorizer, }); ``` + +## WebSocket APIs + +You can set an authorizer to your WebSocket API's `$connect` route to control access to your API. + +### Lambda Authorizer + +Lambda authorizers use a Lambda function to control access to your WebSocket API. When a client connects to your API, API Gateway invokes your Lambda function and uses the response to determine whether the client can access your API. + +```ts +import { WebSocketLambdaAuthorizer } from '@aws-cdk/aws-apigatewayv2-authorizers'; +import { WebSocketLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations'; + +// This function handles your auth logic +declare const authHandler: lambda.Function; + +// This function handles your WebSocket requests +declare const handler: lambda.Function; + +const authorizer = new WebSocketLambdaAuthorizer('Authorizer', authHandler); + +const integration = new WebSocketLambdaIntegration( + 'Integration', + handler, +); + +new apigwv2.WebSocketApi(this, 'WebSocketApi', { + connectRouteOptions: { + integration, + authorizer, + }, +}); +``` diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/index.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/index.ts index c202386ae710e..fd16aff655ff2 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/index.ts @@ -1 +1,2 @@ export * from './http'; +export * from './websocket'; diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/websocket/index.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/websocket/index.ts new file mode 100644 index 0000000000000..04a64da0c7540 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/websocket/index.ts @@ -0,0 +1 @@ +export * from './lambda'; diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/websocket/lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/websocket/lambda.ts new file mode 100644 index 0000000000000..2e60cbdd7b547 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/websocket/lambda.ts @@ -0,0 +1,89 @@ +import { + WebSocketAuthorizer, + WebSocketAuthorizerType, + WebSocketRouteAuthorizerBindOptions, + WebSocketRouteAuthorizerConfig, + IWebSocketRouteAuthorizer, + IWebSocketApi, +} from '@aws-cdk/aws-apigatewayv2'; +import { ServicePrincipal } from '@aws-cdk/aws-iam'; +import { IFunction } from '@aws-cdk/aws-lambda'; +import { Stack, 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'; + +/** + * Properties to initialize WebSocketTokenAuthorizer. + */ +export interface WebSocketLambdaAuthorizerProps { + + /** + * The name of the authorizer + * @default - same value as `id` passed in the constructor. + */ + readonly authorizerName?: string; + + /** + * The identity source for which authorization is requested. + * + * @default ['$request.header.Authorization'] + */ + readonly identitySource?: string[]; +} + +/** + * Authorize WebSocket Api routes via a lambda function + */ +export class WebSocketLambdaAuthorizer implements IWebSocketRouteAuthorizer { + private authorizer?: WebSocketAuthorizer; + private webSocketApi?: IWebSocketApi; + + constructor( + private readonly id: string, + private readonly handler: IFunction, + private readonly props: WebSocketLambdaAuthorizerProps = {}) { + } + + public bind(options: WebSocketRouteAuthorizerBindOptions): WebSocketRouteAuthorizerConfig { + if (this.webSocketApi && (this.webSocketApi.apiId !== options.route.webSocketApi.apiId)) { + throw new Error('Cannot attach the same authorizer to multiple Apis'); + } + + if (!this.authorizer) { + this.webSocketApi = options.route.webSocketApi; + this.authorizer = new WebSocketAuthorizer(options.scope, this.id, { + webSocketApi: options.route.webSocketApi, + identitySource: this.props.identitySource ?? [ + '$request.header.Authorization', + ], + type: WebSocketAuthorizerType.LAMBDA, + authorizerName: this.props.authorizerName ?? this.id, + authorizerUri: lambdaAuthorizerArn(this.handler), + }); + + this.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.webSocketApi.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/test/websocket/lambda.test.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/websocket/lambda.test.ts new file mode 100644 index 0000000000000..c171247801911 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/websocket/lambda.test.ts @@ -0,0 +1,46 @@ +import { Template } from '@aws-cdk/assertions'; +import { WebSocketApi } from '@aws-cdk/aws-apigatewayv2'; +import { WebSocketLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations'; +import { Code, Function, Runtime } from '@aws-cdk/aws-lambda'; +import { Stack } from '@aws-cdk/core'; +import { WebSocketLambdaAuthorizer } from '../../lib'; + +describe('WebSocketLambdaAuthorizer', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + const integration = new WebSocketLambdaIntegration( + 'Integration', + handler, + ); + + const authorizer = new WebSocketLambdaAuthorizer('default-authorizer', handler); + + // WHEN + new WebSocketApi(stack, 'WebSocketApi', { + connectRouteOptions: { + integration, + authorizer, + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Authorizer', { + Name: 'default-authorizer', + AuthorizerType: 'REQUEST', + IdentitySource: [ + '$request.header.Authorization', + ], + }); + + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Route', { + AuthorizationType: 'CUSTOM', + }); + }); +}); diff --git a/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json index 6b344f8f1688f..a26b35f5ccfb9 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json +++ b/packages/@aws-cdk/aws-apigatewayv2-integrations/test/websocket/integ.lambda.expected.json @@ -284,6 +284,7 @@ "Ref": "mywsapi32E6CE11" }, "RouteKey": "$connect", + "AuthorizationType": "NONE", "Target": { "Fn::Join": [ "", @@ -373,6 +374,7 @@ "Ref": "mywsapi32E6CE11" }, "RouteKey": "$disconnect", + "AuthorizationType": "NONE", "Target": { "Fn::Join": [ "", @@ -462,6 +464,7 @@ "Ref": "mywsapi32E6CE11" }, "RouteKey": "$default", + "AuthorizationType": "NONE", "Target": { "Fn::Join": [ "", @@ -551,6 +554,7 @@ "Ref": "mywsapi32E6CE11" }, "RouteKey": "sendmessage", + "AuthorizationType": "NONE", "Target": { "Fn::Join": [ "", diff --git a/packages/@aws-cdk/aws-apigatewayv2/README.md b/packages/@aws-cdk/aws-apigatewayv2/README.md index 6c0697287c08d..a09d015dc87a2 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2/README.md @@ -35,12 +35,13 @@ Higher level constructs for Websocket APIs | ![Experimental](https://img.shields - [Publishing HTTP APIs](#publishing-http-apis) - [Custom Domain](#custom-domain) - [Mutual TLS](#mutual-tls-mtls) - - [Managing access](#managing-access) + - [Managing access to HTTP APIs](#managing-access-to-http-apis) - [Metrics](#metrics) - [VPC Link](#vpc-link) - [Private Integration](#private-integration) - [WebSocket API](#websocket-api) - [Manage Connections Permission](#manage-connections-permission) + - [Managing access to WebSocket APIs](#managing-access-to-websocket-apis) ## Introduction @@ -254,7 +255,7 @@ declare const apiDemo: apigwv2.HttpApi; const demoDomainUrl = apiDemo.defaultStage?.domainUrl; // returns "https://example.com/demo" ``` -## Mutual TLS (mTLS) +### 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. @@ -277,7 +278,7 @@ new DomainName(stack, 'DomainName', { Instructions for configuring your trust store can be found [here](https://aws.amazon.com/blogs/compute/introducing-mutual-tls-authentication-for-amazon-api-gateway/) -### Managing access +### Managing access to HTTP APIs API Gateway supports multiple mechanisms for [controlling and managing access to your HTTP API](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-access-control.html) through authorizers. @@ -419,3 +420,9 @@ stage.grantManageConnections(lambda); // for all the stages permission webSocketApi.grantManageConnections(lambda); ``` + +### Managing access to WebSocket APIs + +API Gateway supports multiple mechanisms for [controlling and managing access to a WebSocket API](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-control-access.html) through authorizers. + +These authorizers can be found in the [APIGatewayV2-Authorizers](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-apigatewayv2-authorizers-readme.html) constructs library. diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts index 08936ecf36d8f..d4a7cf21b4ac4 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts @@ -166,7 +166,7 @@ export class HttpAuthorizer extends Resource implements IHttpAuthorizer { } /** - * This check is required because Cloudformation will fail stack creation is this property + * This check is required because Cloudformation will fail stack creation if this property * is set for the JWT authorizer. AuthorizerPayloadFormatVersion can only be set for REQUEST authorizer */ if (props.type === HttpAuthorizerType.LAMBDA && typeof authorizerPayloadFormatVersion === 'undefined') { diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/authorizer.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/authorizer.ts new file mode 100644 index 0000000000000..5abb420c80bad --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/authorizer.ts @@ -0,0 +1,176 @@ +import { Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnAuthorizer } from '../apigatewayv2.generated'; + +import { IAuthorizer } from '../common'; +import { IWebSocketApi } from './api'; +import { IWebSocketRoute } from './route'; + +/** + * Supported Authorizer types + */ +export enum WebSocketAuthorizerType { + /** Lambda Authorizer */ + LAMBDA = 'REQUEST', +} + +/** + * Properties to initialize an instance of `WebSocketAuthorizer`. + */ +export interface WebSocketAuthorizerProps { + /** + * Name of the authorizer + * @default - id of the WebSocketAuthorizer construct. + */ + readonly authorizerName?: string + + /** + * WebSocket Api to attach the authorizer to + */ + readonly webSocketApi: IWebSocketApi + + /** + * The type of authorizer + */ + readonly type: WebSocketAuthorizerType; + + /** + * The identity source for which authorization is requested. + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigatewayv2-authorizer.html#cfn-apigatewayv2-authorizer-identitysource + */ + readonly identitySource: string[]; + + /** + * 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; +} + +/** + * An authorizer for WebSocket APIs + */ +export interface IWebSocketAuthorizer extends IAuthorizer { +} + +/** + * Reference to an WebSocket authorizer + */ +export interface WebSocketAuthorizerAttributes { + /** + * Id of the Authorizer + */ + readonly authorizerId: string + + /** + * Type of authorizer + * + * Possible values are: + * - CUSTOM - Lambda Authorizer + * - NONE - No Authorization + */ + readonly authorizerType: string +} + +/** + * An authorizer for WebSocket Apis + * @resource AWS::ApiGatewayV2::Authorizer + */ +export class WebSocketAuthorizer extends Resource implements IWebSocketAuthorizer { + /** + * Import an existing WebSocket Authorizer into this CDK app. + */ + public static fromWebSocketAuthorizerAttributes(scope: Construct, id: string, attrs: WebSocketAuthorizerAttributes): IWebSocketRouteAuthorizer { + class Import extends Resource implements IWebSocketRouteAuthorizer { + public readonly authorizerId = attrs.authorizerId; + public readonly authorizerType = attrs.authorizerType; + + public bind(): WebSocketRouteAuthorizerConfig { + return { + authorizerId: attrs.authorizerId, + authorizationType: attrs.authorizerType, + }; + } + } + return new Import(scope, id); + } + + public readonly authorizerId: string; + + constructor(scope: Construct, id: string, props: WebSocketAuthorizerProps) { + super(scope, id); + + if (props.type === WebSocketAuthorizerType.LAMBDA && !props.authorizerUri) { + throw new Error('authorizerUri is mandatory for Lambda authorizers'); + } + + const resource = new CfnAuthorizer(this, 'Resource', { + name: props.authorizerName ?? id, + apiId: props.webSocketApi.apiId, + authorizerType: props.type, + identitySource: props.identitySource, + authorizerUri: props.authorizerUri, + }); + + this.authorizerId = resource.ref; + } +} + +/** + * Input to the bind() operation, that binds an authorizer to a route. + */ +export interface WebSocketRouteAuthorizerBindOptions { + /** + * The route to which the authorizer is being bound. + */ + readonly route: IWebSocketRoute; + /** + * The scope for any constructs created as part of the bind. + */ + readonly scope: Construct; +} + +/** + * Results of binding an authorizer to an WebSocket route. + */ +export interface WebSocketRouteAuthorizerConfig { + /** + * The authorizer id + * + * @default - No authorizer id (useful for AWS_IAM route authorizer) + */ + readonly authorizerId?: string; + + /** + * The type of authorization + * + * Possible values are: + * - CUSTOM - Lambda Authorizer + * - NONE - No Authorization + */ + readonly authorizationType: string; +} + +/** + * An authorizer that can attach to an WebSocket Route. + */ +export interface IWebSocketRouteAuthorizer { + /** + * Bind this authorizer to a specified WebSocket route. + */ + bind(options: WebSocketRouteAuthorizerBindOptions): WebSocketRouteAuthorizerConfig; +} + +/** + * Explicitly configure no authorizers on specific WebSocket API routes. + */ +export class WebSocketNoneAuthorizer implements IWebSocketRouteAuthorizer { + public bind(_: WebSocketRouteAuthorizerBindOptions): WebSocketRouteAuthorizerConfig { + return { + authorizationType: 'NONE', + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts index b0ce6a8a91419..4fe65943cbb8b 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/index.ts @@ -2,3 +2,4 @@ export * from './api'; export * from './route'; export * from './stage'; export * from './integration'; +export * from './authorizer'; diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts index 38316c6449c13..0aaa93587015c 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts @@ -3,6 +3,7 @@ import { Construct } from 'constructs'; import { CfnRoute } from '../apigatewayv2.generated'; import { IRoute } from '../common'; import { IWebSocketApi } from './api'; +import { IWebSocketRouteAuthorizer, WebSocketNoneAuthorizer } from './authorizer'; import { WebSocketRouteIntegration } from './integration'; /** @@ -29,15 +30,21 @@ export interface WebSocketRouteOptions { * The integration to be configured on this route. */ readonly integration: WebSocketRouteIntegration; -} + /** + * The authorize to this route. You can only set authorizer to a $connect route. + * + * @default - No Authorizer + */ + readonly authorizer?: IWebSocketRouteAuthorizer; +} /** * Properties to initialize a new Route */ export interface WebSocketRouteProps extends WebSocketRouteOptions { /** - * the API the route is associated with + * The API the route is associated with. */ readonly webSocketApi: IWebSocketApi; @@ -64,6 +71,10 @@ export class WebSocketRoute extends Resource implements IWebSocketRoute { constructor(scope: Construct, id: string, props: WebSocketRouteProps) { super(scope, id); + if (props.routeKey != '$connect' && props.authorizer) { + throw new Error('You can only set a WebSocket authorizer to a $connect route.'); + } + this.webSocketApi = props.webSocketApi; this.routeKey = props.routeKey; @@ -72,10 +83,18 @@ export class WebSocketRoute extends Resource implements IWebSocketRoute { scope: this, }); + const authorizer = props.authorizer ?? new WebSocketNoneAuthorizer(); // must be explicitly NONE (not undefined) for stack updates to work correctly + const authBindResult = authorizer.bind({ + route: this, + scope: this.webSocketApi instanceof Construct ? this.webSocketApi : this, // scope under the API if it's not imported + }); + const route = new CfnRoute(this, 'Resource', { apiId: props.webSocketApi.apiId, routeKey: props.routeKey, target: `integrations/${config.integrationId}`, + authorizerId: authBindResult.authorizerId, + authorizationType: authBindResult.authorizationType, }); this.routeId = route.ref; } diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/authorizer.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/authorizer.test.ts new file mode 100644 index 0000000000000..105b7f168e74e --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/authorizer.test.ts @@ -0,0 +1,26 @@ +import { Template } from '@aws-cdk/assertions'; +import { Stack } from '@aws-cdk/core'; +import { + WebSocketApi, WebSocketAuthorizer, WebSocketAuthorizerType, +} from '../../lib'; + +describe('WebSocketAuthorizer', () => { + describe('lambda', () => { + it('default', () => { + const stack = new Stack(); + const webSocketApi = new WebSocketApi(stack, 'WebSocketApi'); + + new WebSocketAuthorizer(stack, 'WebSocketAuthorizer', { + webSocketApi, + identitySource: ['identitysource.1', 'identitysource.2'], + type: WebSocketAuthorizerType.LAMBDA, + authorizerUri: 'arn:cool-lambda-arn', + }); + + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Authorizer', { + AuthorizerType: 'REQUEST', + AuthorizerUri: 'arn:cool-lambda-arn', + }); + }); + }); +});