From 24f8f74ebec023f5e3f5bd2bdfc89575a53b38f3 Mon Sep 17 00:00:00 2001 From: Kyle Robertson Date: Tue, 11 Jan 2022 11:02:08 -0500 Subject: [PATCH] feat(apigatewayv2): websocket api: api keys (#16636) ---- This PR adds support for requiring an API Key on Websocket API routes. Specifically, it does the following: * Exposes `apiKeyRequired` on route object (defaults to false) * Exposes `apiKeySelectionExpression` on api object In addition, the following has been added: * Logic to ensure `apiKeySelectionExpression` falls within the two currently supported values * Created a few basic integration tests for the api and route objects for websockets *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-apigatewayv2/README.md | 14 +++++++ .../aws-apigatewayv2/lib/websocket/api.ts | 30 ++++++++++++++ .../aws-apigatewayv2/lib/websocket/route.ts | 7 ++++ .../test/websocket/api.test.ts | 29 +++++++++++++- .../websocket/integ.api-apikey.expected.json | 13 +++++++ .../test/websocket/integ.api-apikey.ts | 14 +++++++ .../test/websocket/route.test.ts | 39 +++++++++++++++++++ 7 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 packages/@aws-cdk/aws-apigatewayv2/test/websocket/integ.api-apikey.expected.json create mode 100644 packages/@aws-cdk/aws-apigatewayv2/test/websocket/integ.api-apikey.ts diff --git a/packages/@aws-cdk/aws-apigatewayv2/README.md b/packages/@aws-cdk/aws-apigatewayv2/README.md index a09d015dc87a2..ccecf1546466f 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2/README.md @@ -426,3 +426,17 @@ webSocketApi.grantManageConnections(lambda); 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. + +### API Keys + +Websocket APIs also support usage of API Keys. An API Key is a key that is used to grant access to an API. These are useful for controlling and tracking access to an API, when used together with [usage plans](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-usage-plans.html). These together allow you to configure controls around API access such as quotas and throttling, along with per-API Key metrics on usage. + +To require an API Key when accessing the Websocket API: + +```ts +const webSocketApi = new WebSocketApi(stack, 'mywsapi',{ + apiKeySelectionExpression: WebSocketApiKeySelectionExpression.HEADER_X_API_KEY, + }); +... +``` + diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts index d78d16842e295..19bede1303437 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/api.ts @@ -12,6 +12,29 @@ import { WebSocketRoute, WebSocketRouteOptions } from './route'; export interface IWebSocketApi extends IApi { } +/** + * Represents the currently available API Key Selection Expressions + */ +export class WebSocketApiKeySelectionExpression { + + /** + * The API will extract the key value from the `x-api-key` header in the user request. + */ + public static readonly HEADER_X_API_KEY = new WebSocketApiKeySelectionExpression('$request.header.x-api-key'); + + /** + * The API will extract the key value from the `usageIdentifierKey` attribute in the `context` map, + * returned by the Lambda Authorizer. + * See https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-output.html + */ + public static readonly AUTHORIZER_USAGE_IDENTIFIER_KEY = new WebSocketApiKeySelectionExpression('$context.authorizer.usageIdentifierKey'); + + /** + * @param customApiKeySelector The expression used by API Gateway + */ + public constructor(public readonly customApiKeySelector: string) {} +} + /** * Props for WebSocket API */ @@ -22,6 +45,12 @@ export interface WebSocketApiProps { */ readonly apiName?: string; + /** + * An API key selection expression. Providing this option will require an API Key be provided to access the API. + * @default - Key is not required to access these APIs + */ + readonly apiKeySelectionExpression?: WebSocketApiKeySelectionExpression + /** * The description of the API. * @default - none @@ -76,6 +105,7 @@ export class WebSocketApi extends ApiBase implements IWebSocketApi { const resource = new CfnApi(this, 'Resource', { name: this.webSocketApiName, + apiKeySelectionExpression: props?.apiKeySelectionExpression?.customApiKeySelector, protocolType: 'WEBSOCKET', description: props?.description, routeSelectionExpression: props?.routeSelectionExpression ?? '$request.body.action', diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts index 0aaa93587015c..923311a2b524e 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/websocket/route.ts @@ -52,6 +52,12 @@ export interface WebSocketRouteProps extends WebSocketRouteOptions { * The key to this route. */ readonly routeKey: string; + + /** + * Whether the route requires an API Key to be provided + * @default false + */ + readonly apiKeyRequired?: boolean; } /** @@ -91,6 +97,7 @@ export class WebSocketRoute extends Resource implements IWebSocketRoute { const route = new CfnRoute(this, 'Resource', { apiId: props.webSocketApi.apiId, + apiKeyRequired: props.apiKeyRequired, routeKey: props.routeKey, target: `integrations/${config.integrationId}`, authorizerId: authBindResult.authorizerId, diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts index 50e973d445731..ba687a79a9afe 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/api.test.ts @@ -2,8 +2,12 @@ import { Match, Template } from '@aws-cdk/assertions'; import { User } from '@aws-cdk/aws-iam'; import { Stack } from '@aws-cdk/core'; import { - WebSocketRouteIntegration, WebSocketApi, WebSocketIntegrationType, - WebSocketRouteIntegrationBindOptions, WebSocketRouteIntegrationConfig, + WebSocketRouteIntegration, + WebSocketApi, + WebSocketApiKeySelectionExpression, + WebSocketIntegrationType, + WebSocketRouteIntegrationBindOptions, + WebSocketRouteIntegrationConfig, } from '../../lib'; describe('WebSocketApi', () => { @@ -25,6 +29,27 @@ describe('WebSocketApi', () => { Template.fromStack(stack).resourceCountIs('AWS::ApiGatewayV2::Integration', 0); }); + test('apiKeySelectionExpression: given a value', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new WebSocketApi(stack, 'api', { + apiKeySelectionExpression: WebSocketApiKeySelectionExpression.AUTHORIZER_USAGE_IDENTIFIER_KEY, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Api', { + ApiKeySelectionExpression: '$context.authorizer.usageIdentifierKey', + Name: 'api', + ProtocolType: 'WEBSOCKET', + }); + + Template.fromStack(stack).resourceCountIs('AWS::ApiGatewayV2::Stage', 0); + Template.fromStack(stack).resourceCountIs('AWS::ApiGatewayV2::Route', 0); + Template.fromStack(stack).resourceCountIs('AWS::ApiGatewayV2::Integration', 0); + }); + test('addRoute: adds a route with passed key', () => { // GIVEN const stack = new Stack(); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/integ.api-apikey.expected.json b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/integ.api-apikey.expected.json new file mode 100644 index 0000000000000..bc0b6f740acc8 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/integ.api-apikey.expected.json @@ -0,0 +1,13 @@ +{ + "Resources": { + "MyWebsocketApiEBAC53DF": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "ApiKeySelectionExpression": "$request.header.x-api-key", + "Name": "MyWebsocketApi", + "ProtocolType": "WEBSOCKET", + "RouteSelectionExpression": "$request.body.action" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/integ.api-apikey.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/integ.api-apikey.ts new file mode 100644 index 0000000000000..1c5482bd3848e --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/integ.api-apikey.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import * as cdk from '@aws-cdk/core'; +import * as apigw from '../../lib'; +import { WebSocketApiKeySelectionExpression } from '../../lib'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-aws-apigatewayv2-websockets'); + +new apigw.WebSocketApi(stack, 'MyWebsocketApi', { + apiKeySelectionExpression: WebSocketApiKeySelectionExpression.HEADER_X_API_KEY, +}); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts index 655390f31165f..94c4e969a08b6 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/websocket/route.test.ts @@ -42,6 +42,45 @@ describe('WebSocketRoute', () => { }); }); + test('Api Key is required for route when apiKeyIsRequired is true', () => { + // GIVEN + const stack = new Stack(); + const webSocketApi = new WebSocketApi(stack, 'Api'); + + // WHEN + new WebSocketRoute(stack, 'Route', { + webSocketApi, + integration: new DummyIntegration(), + routeKey: 'message', + apiKeyRequired: true, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Route', { + ApiId: stack.resolve(webSocketApi.apiId), + ApiKeyRequired: true, + RouteKey: 'message', + Target: { + 'Fn::Join': [ + '', + [ + 'integrations/', + { + Ref: 'RouteDummyIntegrationE40E82B4', + }, + ], + ], + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Integration', { + ApiId: stack.resolve(webSocketApi.apiId), + IntegrationType: 'AWS_PROXY', + IntegrationUri: 'some-uri', + }); + }); + + test('integration cannot be used across WebSocketApis', () => { // GIVEN const integration = new DummyIntegration();