diff --git a/examples/simple-authenticated-api/README.md b/examples/simple-authenticated-api/README.md index 8d1a023f..3dba858a 100644 --- a/examples/simple-authenticated-api/README.md +++ b/examples/simple-authenticated-api/README.md @@ -7,6 +7,7 @@ The AuthenticatedApi: - creates an ApiGateway (v2) - creates an authorisation lambda which authenticates tokens against persona - creates any number of routes, each with a lambda to handle requests. +- creates any number of routes redirecting to a given url(s). - allows authentication to be configured either on all routes or on individual routes - triggers an alarm if the response time on any route in the api exceeds a configurable duration - triggers an alarm if the duration of a lambda dealing with the api requests exceeds a configurable duration diff --git a/examples/simple-authenticated-api/lib/simple-authenticated-api-stack.ts b/examples/simple-authenticated-api/lib/simple-authenticated-api-stack.ts index 04c83c1f..9ce31919 100644 --- a/examples/simple-authenticated-api/lib/simple-authenticated-api-stack.ts +++ b/examples/simple-authenticated-api/lib/simple-authenticated-api-stack.ts @@ -1,5 +1,6 @@ import * as apigatewayv2 from "@aws-cdk/aws-apigatewayv2"; import * as cdk from "@aws-cdk/core"; +import * as s3 from "@aws-cdk/aws-s3"; import * as ec2 from "@aws-cdk/aws-ec2"; import * as sns from "@aws-cdk/aws-sns"; @@ -84,7 +85,7 @@ export class SimpleAuthenticatedApiStack extends cdk.Stack { } ); - /* const api = */ new AuthenticatedApi( + const api = new AuthenticatedApi( this, `${prefix}simple-authenticated-api`, { @@ -111,17 +112,17 @@ export class SimpleAuthenticatedApiStack extends cdk.Stack { oauth_route: "/oauth/tokens/", }, - routes: [ + lambdaRoutes: [ { name: "route1", - paths: ["/1/route1"], + path: "/1/route1", method: apigatewayv2.HttpMethod.GET, lambda: route1Handler, requiredScope: "analytics:admin", }, { name: "route2", - paths: ["/1/route2"], + path: "/1/route2", method: apigatewayv2.HttpMethod.GET, lambda: route2Handler, isPublic: true, @@ -129,5 +130,29 @@ export class SimpleAuthenticatedApiStack extends cdk.Stack { ], } ); + + // It's common to want to route to static content, for example api documentation. + // This example is creating a bucket which will host documentation as a website. + // A route is then added to the api to point to the bucket. + // Note: The construct does not add the content to the bucket - you must do this yourself. + const documentationBucket = new s3.Bucket( + this, + `${prefix}simple-authenticated-api-docs`, + { + bucketName: `${prefix}simple-authenticated-api-docs`, + publicReadAccess: true, + removalPolicy: cdk.RemovalPolicy.DESTROY, + websiteIndexDocument: "index.html", + } + ); + + // Url Routes can be added in the initial props of the api construct, but they can also be + // added using the following method + api.addUrlRoute({ + name: "simple authenticated api docs", + baseUrl: `${documentationBucket.bucketWebsiteUrl}/api-documentation/index.html`, + path: "/api-documentation", + method: apigatewayv2.HttpMethod.GET, + }); } } diff --git a/lib/authenticated-api/authenticated-api-props.ts b/lib/authenticated-api/authenticated-api-props.ts index 84e25553..cc5c2aaf 100644 --- a/lib/authenticated-api/authenticated-api-props.ts +++ b/lib/authenticated-api/authenticated-api-props.ts @@ -3,13 +3,15 @@ import * as ec2 from "@aws-cdk/aws-ec2"; import * as sns from "@aws-cdk/aws-sns"; import { RouteLambdaProps } from "./route-lambda-props"; +import { RouteUrlProps } from "./route-url-props"; export interface AuthenticatedApiProps { prefix: string; name: string; description: string; stageName: string; - routes: Array; + lambdaRoutes?: Array; + urlRoutes?: Array; securityGroups?: Array; vpc?: ec2.IVpc; vpcSubnets?: ec2.SubnetSelection; diff --git a/lib/authenticated-api/authenticated-api.ts b/lib/authenticated-api/authenticated-api.ts index 984aad1d..04379421 100644 --- a/lib/authenticated-api/authenticated-api.ts +++ b/lib/authenticated-api/authenticated-api.ts @@ -1,5 +1,6 @@ import * as acm from "@aws-cdk/aws-certificatemanager"; import * as apigatewayv2 from "@aws-cdk/aws-apigatewayv2"; +import * as apigateway2Integrations from "@aws-cdk/aws-apigatewayv2-integrations"; import * as authorizers from "@aws-cdk/aws-apigatewayv2-authorizers"; import * as cdk from "@aws-cdk/core"; import * as cloudwatch from "@aws-cdk/aws-cloudwatch"; @@ -10,6 +11,8 @@ import * as lambdaNodeJs from "@aws-cdk/aws-lambda-nodejs"; import * as path from "path"; import { AuthenticatedApiProps } from "./authenticated-api-props"; +import { RouteUrlProps } from "./route-url-props"; +import { IAlarmAction } from "@aws-cdk/aws-cloudwatch"; const DEFAULT_API_LATENCY_THRESHOLD = cdk.Duration.minutes(1); const DEFAULT_LAMBDA_DURATION_THRESHOLD = cdk.Duration.minutes(1); @@ -18,6 +21,10 @@ export class AuthenticatedApi extends cdk.Construct { readonly apiId: string; readonly httpApiId: string; + private httpApi: apigatewayv2.HttpApi; + private authorizer: apigatewayv2.IHttpRouteAuthorizer; + private alarmAction: IAlarmAction; + constructor(scope: cdk.Construct, id: string, props: AuthenticatedApiProps) { super(scope, id); @@ -50,29 +57,29 @@ export class AuthenticatedApi extends cdk.Construct { }), }; - const httpApi = new apigatewayv2.HttpApi( + this.httpApi = new apigatewayv2.HttpApi( this, `${props.prefix}${props.name}`, apiGatewayProps ); - this.apiId = httpApi.apiId; - this.httpApiId = httpApi.httpApiId; + this.apiId = this.httpApi.apiId; + this.httpApiId = this.httpApi.httpApiId; new cdk.CfnOutput(this, "apiGatewayEndpoint", { exportName: `${props.prefix}${props.name}-endpoint`, - value: httpApi.apiEndpoint, + value: this.httpApi.apiEndpoint, }); - const alarmAction = new cloudwatchActions.SnsAction(props.alarmTopic); + this.alarmAction = new cloudwatchActions.SnsAction(props.alarmTopic); // Routes may contain required scopes. These scopes need to be in the config // of the authorization lambda. Create this config ahead of creating the authorization lambda const scopeConfig: { [k: string]: string } = {}; - for (const routeProps of props.routes) { - if (routeProps.requiredScope) { - for (const path of routeProps.paths) { - scopeConfig[`^${path}$`] = routeProps.requiredScope; + if (props.lambdaRoutes) { + for (const routeProps of props.lambdaRoutes) { + if (routeProps.requiredScope) { + scopeConfig[`^${routeProps.path}$`] = routeProps.requiredScope; } } } @@ -119,7 +126,7 @@ export class AuthenticatedApi extends cdk.Construct { } ); - const authorizer = new authorizers.HttpLambdaAuthorizer( + this.authorizer = new authorizers.HttpLambdaAuthorizer( "lambda-authorizer", authLambda, { @@ -128,92 +135,98 @@ export class AuthenticatedApi extends cdk.Construct { } ); - for (const routeProps of props.routes) { - const integration = new integrations.HttpLambdaIntegration( - "http-lambda-integration", - routeProps.lambda - ); + if (props.urlRoutes) { + for (const routeProps of props.urlRoutes) { + this.addUrlRoute(routeProps); + } + } + + if (props.lambdaRoutes) { + for (const routeProps of props.lambdaRoutes) { + const integration = new integrations.HttpLambdaIntegration( + "http-lambda-integration", + routeProps.lambda + ); - for (const path of routeProps.paths) { if (routeProps.isPublic === true) { - httpApi.addRoutes({ - path: path, + this.httpApi.addRoutes({ + path: routeProps.path, methods: [routeProps.method], integration, }); } else { - httpApi.addRoutes({ - path: path, + this.httpApi.addRoutes({ + path: routeProps.path, methods: [routeProps.method], integration, - authorizer, + authorizer: this.authorizer, }); } - } - // Add Cloudwatch alarms for this route + // Add Cloudwatch alarms for this route - // Add an alarm on the duration of the lambda dealing with the HTTP Request - const durationThreshold = routeProps.lamdaDurationAlarmThreshold - ? routeProps.lamdaDurationAlarmThreshold - : DEFAULT_LAMBDA_DURATION_THRESHOLD; - const durationMetric = routeProps.lambda - .metric("Duration") - .with({ period: cdk.Duration.minutes(1), statistic: "sum" }); - const durationAlarm = new cloudwatch.Alarm( - this, - `${props.prefix}${props.name}-${routeProps.name}-duration-alarm`, - { - alarmName: `${props.prefix}${props.name}-${routeProps.name}-duration-alarm`, - alarmDescription: `Alarm if duration of lambda for route ${ - props.prefix - }${props.name}-${ - routeProps.name - } exceeds duration ${durationThreshold.toMilliseconds()} milliseconds`, - actionsEnabled: true, - metric: durationMetric, - evaluationPeriods: 1, - threshold: durationThreshold.toMilliseconds(), - comparisonOperator: - cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - // Set treatMissingData to IGNORE - // Stops alarms with minimal data having false alarms when they transition to this state - treatMissingData: cloudwatch.TreatMissingData.IGNORE, - } - ); - durationAlarm.addAlarmAction(alarmAction); - durationAlarm.addOkAction(alarmAction); + // Add an alarm on the duration of the lambda dealing with the HTTP Request + const durationThreshold = routeProps.lamdaDurationAlarmThreshold + ? routeProps.lamdaDurationAlarmThreshold + : DEFAULT_LAMBDA_DURATION_THRESHOLD; + const durationMetric = routeProps.lambda + .metric("Duration") + .with({ period: cdk.Duration.minutes(1), statistic: "sum" }); + const durationAlarm = new cloudwatch.Alarm( + this, + `${props.prefix}${props.name}-${routeProps.name}-duration-alarm`, + { + alarmName: `${props.prefix}${props.name}-${routeProps.name}-duration-alarm`, + alarmDescription: `Alarm if duration of lambda for route ${ + props.prefix + }${props.name}-${ + routeProps.name + } exceeds duration ${durationThreshold.toMilliseconds()} milliseconds`, + actionsEnabled: true, + metric: durationMetric, + evaluationPeriods: 1, + threshold: durationThreshold.toMilliseconds(), + comparisonOperator: + cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + // Set treatMissingData to IGNORE + // Stops alarms with minimal data having false alarms when they transition to this state + treatMissingData: cloudwatch.TreatMissingData.IGNORE, + } + ); + durationAlarm.addAlarmAction(this.alarmAction); + durationAlarm.addOkAction(this.alarmAction); - const errorsMetric = routeProps.lambda - .metric("Errors") - .with({ period: cdk.Duration.minutes(1), statistic: "sum" }); + const errorsMetric = routeProps.lambda + .metric("Errors") + .with({ period: cdk.Duration.minutes(1), statistic: "sum" }); - const errorsAlarm = new cloudwatch.Alarm( - this, - `${props.prefix}${props.name}-${routeProps.name}-errors-alarm`, - { - alarmName: `${props.prefix}${props.name}-${routeProps.name}-errors-alarm`, - alarmDescription: `Alarm if errors on api ${props.prefix}${props.name}-${routeProps.name}`, - actionsEnabled: true, - metric: errorsMetric, - evaluationPeriods: 1, - threshold: 1, - comparisonOperator: - cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - // Set treatMissingData to IGNORE - // Stops alarms with minimal data having false alarms when they transition to this state - treatMissingData: cloudwatch.TreatMissingData.IGNORE, - } - ); - errorsAlarm.addAlarmAction(alarmAction); - errorsAlarm.addOkAction(alarmAction); + const errorsAlarm = new cloudwatch.Alarm( + this, + `${props.prefix}${props.name}-${routeProps.name}-errors-alarm`, + { + alarmName: `${props.prefix}${props.name}-${routeProps.name}-errors-alarm`, + alarmDescription: `Alarm if errors on api ${props.prefix}${props.name}-${routeProps.name}`, + actionsEnabled: true, + metric: errorsMetric, + evaluationPeriods: 1, + threshold: 1, + comparisonOperator: + cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + // Set treatMissingData to IGNORE + // Stops alarms with minimal data having false alarms when they transition to this state + treatMissingData: cloudwatch.TreatMissingData.IGNORE, + } + ); + errorsAlarm.addAlarmAction(this.alarmAction); + errorsAlarm.addOkAction(this.alarmAction); + } } // Add a cloudwatch alarm for the latency of the api - this is all routes within the api const latencyThreshold = props.apiLatencyAlarmThreshold ? props.apiLatencyAlarmThreshold : DEFAULT_API_LATENCY_THRESHOLD; - const metricLatency = httpApi + const metricLatency = this.httpApi .metricLatency() .with({ statistic: "average", period: cdk.Duration.minutes(1) }); @@ -236,7 +249,21 @@ export class AuthenticatedApi extends cdk.Construct { treatMissingData: cloudwatch.TreatMissingData.IGNORE, } ); - routeLatencyAlarm.addAlarmAction(alarmAction); - routeLatencyAlarm.addOkAction(alarmAction); + routeLatencyAlarm.addAlarmAction(this.alarmAction); + routeLatencyAlarm.addOkAction(this.alarmAction); + } + + addUrlRoute(routeProps: RouteUrlProps) { + this.httpApi.addRoutes({ + path: routeProps.path, + methods: [routeProps.method], + integration: new apigateway2Integrations.HttpUrlIntegration( + routeProps.name, + routeProps.baseUrl, + { + method: routeProps.method, + } + ), + }); } } diff --git a/lib/authenticated-api/route-lambda-props.ts b/lib/authenticated-api/route-lambda-props.ts index 26bb505a..211e3c57 100644 --- a/lib/authenticated-api/route-lambda-props.ts +++ b/lib/authenticated-api/route-lambda-props.ts @@ -5,7 +5,7 @@ import { AuthenticatedApiFunction } from "./authenticated-api-function"; export interface RouteLambdaProps { name: string; - paths: Array; + path: string; method: apigatewayv2.HttpMethod; isPublic?: boolean; // Defaults to false requiredScope?: string; diff --git a/lib/authenticated-api/route-url-props.ts b/lib/authenticated-api/route-url-props.ts new file mode 100644 index 00000000..12db5217 --- /dev/null +++ b/lib/authenticated-api/route-url-props.ts @@ -0,0 +1,10 @@ +import * as apigatewayv2 from "@aws-cdk/aws-apigatewayv2"; + +export interface RouteUrlProps { + name: string; + baseUrl: string; + path: string; + method: apigatewayv2.HttpMethod; + isPublic?: boolean; // Defaults to false + requiredScope?: string; +} diff --git a/test/infra/authenticated-api/authenticated-api.test.ts b/test/infra/authenticated-api/authenticated-api.test.ts index 4a04d01c..f8d6cf3a 100644 --- a/test/infra/authenticated-api/authenticated-api.test.ts +++ b/test/infra/authenticated-api/authenticated-api.test.ts @@ -13,7 +13,7 @@ import * as sns from "@aws-cdk/aws-sns"; import { AuthenticatedApi, AuthenticatedApiFunction } from "../../../lib"; describe("AuthenticatedApi", () => { - describe("with only required props", () => { + describe("with lambda routes", () => { let stack: cdk.Stack; beforeAll(() => { @@ -78,16 +78,16 @@ describe("AuthenticatedApi", () => { oauth_route: "/oauth/tokens/", }, - routes: [ + lambdaRoutes: [ { name: "route1", - paths: ["/1/test-route-1"], + path: "/1/test-route-1", method: apigatewayv2.HttpMethod.GET, lambda: route1Handler, }, { name: "route2", - paths: ["/1/test-route-2"], + path: "/1/test-route-2", method: apigatewayv2.HttpMethod.GET, lambda: route2Handler, isPublic: true, @@ -300,4 +300,74 @@ describe("AuthenticatedApi", () => { ); }); }); + describe("with url routes", () => { + let stack: cdk.Stack; + + beforeAll(() => { + const app = new cdk.App(); + stack = new cdk.Stack(app, "TestStack"); + const alarmTopic = new sns.Topic(stack, "TestAlarm", { + topicName: "TestAlarm", + }); + const vpc = new ec2.Vpc(stack, "TheVPC", { + cidr: "10.0.0.0/16", + }); + + new AuthenticatedApi(stack, "MyTestAuthenticatedApi", { + prefix: `test-`, + name: "MyTestAuthenticatedApiWithUrlRoutes", + description: "A simple example API", + stageName: "development", // This should be development / staging / production as appropriate + alarmTopic, + vpc, + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_NAT }, + domainName: `test-simple-authenticated-api.talis.com`, + certificateArn: + "arn:aws:acm:eu-west-1:302477901552:certificate/46e0fb43-bba8-4aa7-bf98-a3b2038cf760", + corsDomain: [ + "http://localhost:4200", + `https://test-simple-authenticated-api.talis.com`, + ], + + persona: { + host: "staging-users.talis.com", + scheme: "https", + port: "443", + oauth_route: "/oauth/tokens/", + }, + + urlRoutes: [ + { + name: "route1", + baseUrl: "https://www.example.com", + path: "/api/index.html", + method: apigatewayv2.HttpMethod.GET, + }, + { + name: "route2", + baseUrl: "https://www.example.com", + path: "/docs/index.html", + method: apigatewayv2.HttpMethod.GET, + }, + ], + }); + }); + + test("provisions routes", () => { + expectCDK(stack).to(countResources("AWS::ApiGatewayV2::Route", 2)); + + expectCDK(stack).to( + haveResource("AWS::ApiGatewayV2::Route", { + RouteKey: "GET /api/index.html", + AuthorizationType: "NONE", + }) + ); + expectCDK(stack).to( + haveResource("AWS::ApiGatewayV2::Route", { + RouteKey: "GET /docs/index.html", + AuthorizationType: "NONE", + }) + ); + }); + }); }); diff --git a/test/integration/authenticated-api/authenticated-api.test.ts b/test/integration/authenticated-api/authenticated-api.test.ts index dab2362c..384587b8 100644 --- a/test/integration/authenticated-api/authenticated-api.test.ts +++ b/test/integration/authenticated-api/authenticated-api.test.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { ApiGatewayV2 } from "aws-sdk"; +import { ApiGatewayV2, S3 } from "aws-sdk"; const api = new ApiGatewayV2(); @@ -12,6 +12,8 @@ const TALIS_CDK_AUTH_API_VALID_CLIENT = const TALIS_CDK_AUTH_API_VALID_SECRET = process.env.TALIS_CDK_AUTH_API_VALID_SECRET ?? ""; +const s3 = new S3(); + describe("AuthenticatedApi", () => { // Increase the timeout We are making http calls which might have to spin up a cold lambda jest.setTimeout(30000); @@ -66,8 +68,32 @@ describe("AuthenticatedApi", () => { return response.data.access_token; } + async function uploadExampleDocumentation() { + await s3 + .putObject({ + Bucket: `${process.env.AWS_PREFIX}simple-authenticated-api-docs`, + Key: `api-documentation/index.html`, + Body: "Simple Authenticated Api Documentation", + }) + .promise(); + } + + async function deleteExampleDocumentation() { + await s3 + .deleteObject({ + Bucket: `${process.env.AWS_PREFIX}simple-authenticated-api-docs`, + Key: `api-documentation/index.html`, + }) + .promise(); + } + beforeAll(async () => { apiGatewayId = await findApiGatewayId(); + await uploadExampleDocumentation(); + }); + + afterAll(async () => { + await deleteExampleDocumentation(); }); test("returns 200 for unauthenticated route", async () => { @@ -121,4 +147,13 @@ describe("AuthenticatedApi", () => { expect(response.status).toBe(200); expect(response.data).toBe("route 1"); }); + + test("returns 200 when routing to a url", async () => { + const axiosAuthInstance = axios.create({ + baseURL: `https://${apiGatewayId}.execute-api.eu-west-1.amazonaws.com/`, + }); + const response = await axiosAuthInstance.get("api-documentation"); + expect(response.status).toBe(200); + expect(response.data).toBe("Simple Authenticated Api Documentation"); + }); });