diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index bc6361164c7e1..a04bc7f4adff6 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -332,6 +332,71 @@ const proxy = resource.addProxy({ }); ``` +### Authorizers + +API Gateway [supports several different authorization types](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-control-access-to-api.html) +that can be used for controlling access to your REST APIs. + +#### IAM-based authorizer + +The following CDK code provides 'excecute-api' permission to an IAM user, via IAM policies, for the 'GET' method on the `books` resource: + +```ts +const getBooks = books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), { + authorizationType: apigateway.AuthorizationType.IAM +}); + +iamUser.attachInlinePolicy(new iam.Policy(this, 'AllowBooks', { + statements: [ + new iam.PolicyStatement({ + actions: [ 'execute-api:Invoke' ], + effect: iam.Effect.Allow, + resources: [ getBooks.methodArn() ] + }) + ] +})) +``` + +#### Lambda-based token authorizer + +API Gateway also allows [lambda functions to be used as authorizers](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html). + +This module provides support for token-based Lambda authorizers. When a client makes a request to an API's methods configured with such +an authorizer, API Gateway calls the Lambda authorizer, which takes the caller's identity as input and returns an IAM policy as output. +A token-based Lambda authorizer (also called a token authorizer) receives the caller's identity in a bearer token, such as +a JSON Web Token (JWT) or an OAuth token. + +API Gateway interacts with the authorizer Lambda function handler by passing input and expecting the output in a specific format. +The event object that the handler is called with contains the `authorizationToken` and the `methodArn` from the request to the +API Gateway endpoint. The handler is expected to return the `principalId` (i.e. the client identifier) and a `policyDocument` stating +what the client is authorizer to perform. +See https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html for a detailed specification on +inputs and outputs of the lambda handler. + +The following code attaches a token-based Lambda authorizer to the 'GET' Method of the Book resource: + +```ts +const authFn = new lambda.Function(this, 'booksAuthorizerLambda', { + // ... + // ... +}); + +const auth = new lambda.TokenAuthorizer(this, 'booksAuthorizer', { + function: authFn +}); + +books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), { + authorizer: auth +}); +``` + +By default, the `TokenAuthorizer` looks for the authorization token in the request header with the key 'Authorization'. This can, +however, be modified by changing the `identitySource` property. + +Authorizers can also be passed via the `defaultMethodOptions` property within the `RestApi` construct or the `Method` construct. Unless +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. + ### Deployments By default, the `RestApi` construct will automatically create an API Gateway diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizer.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizer.ts index 0e9f6e0bb1a86..3a2f3698a554d 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/authorizer.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizer.ts @@ -1,9 +1,33 @@ +import { Resource } from '@aws-cdk/core'; +import { AuthorizationType } from './method'; +import { RestApi } from './restapi'; + +/** + * Base class for all custom authorizers + */ +export abstract class Authorizer extends Resource implements IAuthorizer { + public readonly abstract authorizerId: string; + public readonly authorizationType?: AuthorizationType = AuthorizationType.CUSTOM; + + /** + * Called when the authorizer is used from a specific REST API. + * @internal + */ + public abstract _attachToApi(restApi: RestApi): void; +} + /** * Represents an API Gateway authorizer. */ export interface IAuthorizer { /** * The authorizer ID. + * @attribute */ readonly authorizerId: string; -} \ No newline at end of file + + /** + * The authorization type of this authorizer. + */ + readonly authorizationType?: AuthorizationType; +} diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts new file mode 100644 index 0000000000000..338a17e47cf8c --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts @@ -0,0 +1 @@ +export * from './lambda'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts new file mode 100644 index 0000000000000..ad222b627593e --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts @@ -0,0 +1,141 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { Construct, Duration, Lazy, Stack } from '@aws-cdk/core'; +import { CfnAuthorizer } from '../apigateway.generated'; +import { Authorizer, IAuthorizer } from '../authorizer'; +import { RestApi } from '../restapi'; + +/** + * Properties for TokenAuthorizer + */ +export interface TokenAuthorizerProps { + + /** + * An optional human friendly name for the authorizer. Note that, this is not the primary identifier of the authorizer. + * + * @default - none + */ + readonly authorizerName?: string; + + /** + * The handler for the authorizer lambda function. + * + * The handler must follow a very specific protocol on the input it receives and the output it needs to produce. + * API Gateway has documented the handler's input specification + * {@link https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-input.html | here} and output specification + * {@link https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-output.html | here}. + */ + readonly handler: lambda.IFunction; + + /** + * 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 'method.request.header.Authorization' + */ + readonly identitySource?: string; + + /** + * How long APIGateway should cache the results. Max 1 hour. + * Disable caching by setting this to 0. + * + * @default Duration.minutes(5) + */ + readonly resultsCacheTtl?: Duration; + + /** + * An optional regex to be matched against the authorization token. When matched the authorizer lambda is invoked, + * otherwise a 401 Unauthorized is returned to the client. + * + * @default - no regex filter will be applied. + */ + readonly validationRegex?: string; + + /** + * An optional IAM role for APIGateway to assume before calling the Lambda-based authorizer. The IAM role must be + * assumable by 'apigateway.amazonaws.com'. + * + * @default - A resource policy is added to the Lambda function allowing apigateway.amazonaws.com to invoke the function. + */ + readonly assumeRole?: iam.IRole; +} + +/** + * Token based lambda authorizer that recognizes the caller's identity as a bearer token, + * such as a JSON Web Token (JWT) or an OAuth token. + * Based on the token, authorization is performed by a lambda function. + * + * @resource AWS::ApiGateway::Authorizer + */ +export class TokenAuthorizer 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. + */ + public readonly authorizerArn: string; + + private restApiId?: string; + + constructor(scope: Construct, id: string, props: TokenAuthorizerProps) { + super(scope, id); + + if (props.resultsCacheTtl && props.resultsCacheTtl.toSeconds() > 3600) { + throw new Error(`Lambda authorizer property 'resultsCacheTtl' must not be greater than 3600 seconds (1 hour)`); + } + + const restApiId = Lazy.stringValue({ produce: () => this.restApiId }); + + const resource = new CfnAuthorizer(this, 'Resource', { + name: props.authorizerName, + restApiId, + type: 'TOKEN', + authorizerUri: `arn:aws:apigateway:${Stack.of(this).region}:lambda:path/2015-03-31/functions/${props.handler.functionArn}/invocations`, + authorizerCredentials: props.assumeRole ? props.assumeRole.roleArn : undefined, + authorizerResultTtlInSeconds: props.resultsCacheTtl && props.resultsCacheTtl.toSeconds(), + identitySource: props.identitySource || 'method.request.header.Authorization', + identityValidationExpression: props.validationRegex, + }); + + this.authorizerId = resource.ref; + + this.authorizerArn = Stack.of(this).formatArn({ + service: 'execute-api', + resource: restApiId, + resourceName: `authorizers/${this.authorizerId}` + }); + + if (!props.assumeRole) { + props.handler.addPermission(`${this.node.uniqueId}:Permissions`, { + principal: new iam.ServicePrincipal('apigateway.amazonaws.com'), + sourceArn: this.authorizerArn + }); + } else if (props.assumeRole instanceof iam.Role) { // i.e., not imported + props.assumeRole.attachInlinePolicy(new iam.Policy(this, 'authorizerInvokePolicy', { + statements: [ + new iam.PolicyStatement({ + resources: [ props.handler.functionArn ], + actions: [ 'lambda:InvokeFunction' ], + }) + ] + })); + } + } + + /** + * Attaches this authorizer to a specific REST API. + * @internal + */ + public _attachToApi(restApi: RestApi) { + if (this.restApiId && this.restApiId !== restApi.restApiId) { + throw new Error(`Cannot attach authorizer to two different rest APIs`); + } + + this.restApiId = restApi.restApiId; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/lib/index.ts b/packages/@aws-cdk/aws-apigateway/lib/index.ts index f19a847f30556..9b17ddf169fcb 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/index.ts @@ -17,6 +17,7 @@ export * from './json-schema'; export * from './domain-name'; export * from './base-path-mapping'; export * from './cors'; +export * from './authorizers'; // AWS::ApiGateway CloudFormation Resources: export * from './apigateway.generated'; diff --git a/packages/@aws-cdk/aws-apigateway/lib/method.ts b/packages/@aws-cdk/aws-apigateway/lib/method.ts index 5720648436191..438a2d1601e8f 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/method.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/method.ts @@ -1,6 +1,6 @@ import { Construct, Resource, Stack } from '@aws-cdk/core'; import { CfnMethod, CfnMethodProps } from './apigateway.generated'; -import { IAuthorizer } from './authorizer'; +import { Authorizer, IAuthorizer } from './authorizer'; import { ConnectionType, Integration } from './integration'; import { MockIntegration } from './integrations/mock'; import { MethodResponse } from './methodresponse'; @@ -19,13 +19,21 @@ export interface MethodOptions { /** * Method authorization. - * @default None open access + * If the value is set of `Custom`, an `authorizer` must also be specified. + * + * If you're using one of the authorizers that are available via the {@link Authorizer} class, such as {@link Authorizer#token()}, + * it is recommended that this option not be specified. The authorizer will take care of setting the correct authorization type. + * However, specifying an authorization type using this property that conflicts with what is expected by the {@link Authorizer} + * will result in an error. + * + * @default - open access unless `authorizer` is specified */ readonly authorizationType?: AuthorizationType; /** * If `authorizationType` is `Custom`, this specifies the ID of the method * authorizer resource. + * If specified, the value of `authorizationType` must be set to `Custom` */ readonly authorizer?: IAuthorizer; @@ -117,6 +125,20 @@ export class Method extends Resource { const defaultMethodOptions = props.resource.defaultMethodOptions || {}; const authorizer = options.authorizer || defaultMethodOptions.authorizer; + const authorizerId = authorizer?.authorizerId; + + const authorizationTypeOption = options.authorizationType || defaultMethodOptions.authorizationType; + const authorizationType = authorizer?.authorizationType || authorizationTypeOption || AuthorizationType.NONE; + + // if the authorizer defines an authorization type and we also have an explicit option set, check that they are the same + if (authorizer?.authorizationType && authorizationTypeOption && authorizer?.authorizationType !== authorizationTypeOption) { + throw new Error(`${this.resource}/${this.httpMethod} - Authorization type is set to ${authorizationTypeOption} ` + + `which is different from what is required by the authorizer [${authorizer.authorizationType}]`); + } + + if (authorizer instanceof Authorizer) { + authorizer._attachToApi(this.restApi); + } const methodProps: CfnMethodProps = { resourceId: props.resource.resourceId, @@ -124,8 +146,8 @@ export class Method extends Resource { httpMethod: this.httpMethod, operationName: options.operationName || defaultMethodOptions.operationName, apiKeyRequired: options.apiKeyRequired || defaultMethodOptions.apiKeyRequired, - authorizationType: options.authorizationType || defaultMethodOptions.authorizationType || AuthorizationType.NONE, - authorizerId: authorizer && authorizer.authorizerId, + authorizationType, + authorizerId, requestParameters: options.requestParameters || defaultMethodOptions.requestParameters, integration: this.renderIntegration(props.integration), methodResponses: this.renderMethodResponses(options.methodResponses), diff --git a/packages/@aws-cdk/aws-apigateway/package.json b/packages/@aws-cdk/aws-apigateway/package.json index a18b6caeb2647..baf2be01515fe 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -285,7 +285,8 @@ "props-default-doc:@aws-cdk/aws-apigateway.MethodOptions.operationName", "props-default-doc:@aws-cdk/aws-apigateway.MethodOptions.requestModels", "props-default-doc:@aws-cdk/aws-apigateway.MethodOptions.requestValidator", - "docs-public-apis:@aws-cdk/aws-apigateway.ResourceBase.url" + "docs-public-apis:@aws-cdk/aws-apigateway.ResourceBase.url", + "attribute-tag:@aws-cdk/aws-apigateway.TokenAuthorizer.authorizerArn" ] }, "stability": "stable" diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.expected.json b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.expected.json new file mode 100644 index 0000000000000..702856a6922b3 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.expected.json @@ -0,0 +1,320 @@ +{ + "Resources": { + "MyAuthorizerFunctionServiceRole8A34C19E": { + "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" + ] + ] + } + ] + } + }, + "MyAuthorizerFunction70F1223E": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3Bucket115F9EA4" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "MyAuthorizerFunctionServiceRole8A34C19E", + "Arn" + ] + }, + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "MyAuthorizerFunctionServiceRole8A34C19E" + ] + }, + "authorizerRole06E70703": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyAuthorizer6575980E": { + "Type": "AWS::ApiGateway::Authorizer", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "Type": "TOKEN", + "AuthorizerCredentials": { + "Fn::GetAtt": [ + "authorizerRole06E70703", + "Arn" + ] + }, + "AuthorizerUri": { + "Fn::Join": [ + "", + [ + "arn:aws:apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "MyAuthorizerFunction70F1223E", + "Arn" + ] + }, + "/invocations" + ] + ] + }, + "IdentitySource": "method.request.header.Authorization" + } + }, + "MyAuthorizerauthorizerInvokePolicy0F88B8E1": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "MyAuthorizerFunction70F1223E", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyAuthorizerauthorizerInvokePolicy0F88B8E1", + "Roles": [ + { + "Ref": "authorizerRole06E70703" + } + ] + } + }, + "MyRestApi2D1F47A9": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "MyRestApi" + } + }, + "MyRestApiDeploymentB555B5828fad37a0e56bbac79ae37ae990881dca": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "Description": "Automatically created by the RestApi construct" + }, + "DependsOn": [ + "MyRestApiANY05143F93" + ] + }, + "MyRestApiDeploymentStageprodC33B8E5F": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "DeploymentId": { + "Ref": "MyRestApiDeploymentB555B5828fad37a0e56bbac79ae37ae990881dca" + }, + "StageName": "prod" + } + }, + "MyRestApiCloudWatchRoleD4042E8E": { + "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" + ] + ] + } + ] + } + }, + "MyRestApiAccount2FB6DB7A": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "MyRestApiCloudWatchRoleD4042E8E", + "Arn" + ] + } + }, + "DependsOn": [ + "MyRestApi2D1F47A9" + ] + }, + "MyRestApiANY05143F93": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "MyRestApi2D1F47A9", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "AuthorizationType": "CUSTOM", + "AuthorizerId": { + "Ref": "MyAuthorizer6575980E" + }, + "Integration": { + "IntegrationResponses": [ + { + "StatusCode": "200" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": "{ \"statusCode\": 200 }" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "StatusCode": "200" + } + ] + } + } + }, + "Parameters": { + "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3Bucket115F9EA4": { + "Type": "String", + "Description": "S3 bucket for asset \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + }, + "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E": { + "Type": "String", + "Description": "S3 key for asset version \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + }, + "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aArtifactHash1A0BBA4E": { + "Type": "String", + "Description": "Artifact hash for asset \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + } + }, + "Outputs": { + "MyRestApiEndpoint4C55E4CB": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "MyRestApi2D1F47A9" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyRestApiDeploymentStageprodC33B8E5F" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.ts new file mode 100644 index 0000000000000..f64cceb3453c8 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.ts @@ -0,0 +1,46 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, Stack } from '@aws-cdk/core'; +import * as path from 'path'; +import { AuthorizationType, MockIntegration, PassthroughBehavior, RestApi, TokenAuthorizer } from '../../lib'; + +// Against the RestApi endpoint from the stack output, run +// `curl -s -o /dev/null -w "%{http_code}" ` should return 401 +// `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: deny' ` should return 403 +// `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: allow' ` should return 200 + +const app = new App(); +const stack = new Stack(app, 'TokenAuthorizerIAMRoleInteg'); + +const authorizerFn = new lambda.Function(stack, 'MyAuthorizerFunction', { + runtime: lambda.Runtime.NODEJS_10_X, + handler: 'index.handler', + code: lambda.AssetCode.fromAsset(path.join(__dirname, 'integ.token-authorizer.handler')) +}); + +const role = new iam.Role(stack, 'authorizerRole', { + assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com') +}); + +const authorizer = new TokenAuthorizer(stack, 'MyAuthorizer', { + handler: authorizerFn, + assumeRole: role, +}); + +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.CUSTOM +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.expected.json b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.expected.json new file mode 100644 index 0000000000000..bfad9880b7040 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.expected.json @@ -0,0 +1,311 @@ +{ + "Resources": { + "MyAuthorizerFunctionServiceRole8A34C19E": { + "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" + ] + ] + } + ] + } + }, + "MyAuthorizerFunction70F1223E": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3Bucket115F9EA4" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "MyAuthorizerFunctionServiceRole8A34C19E", + "Arn" + ] + }, + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "MyAuthorizerFunctionServiceRole8A34C19E" + ] + }, + "MyRestApi2D1F47A9": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "MyRestApi" + } + }, + "MyRestApiDeploymentB555B5828fad37a0e56bbac79ae37ae990881dca": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "Description": "Automatically created by the RestApi construct" + }, + "DependsOn": [ + "MyRestApiANY05143F93" + ] + }, + "MyRestApiDeploymentStageprodC33B8E5F": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "DeploymentId": { + "Ref": "MyRestApiDeploymentB555B5828fad37a0e56bbac79ae37ae990881dca" + }, + "StageName": "prod" + } + }, + "MyRestApiCloudWatchRoleD4042E8E": { + "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" + ] + ] + } + ] + } + }, + "MyRestApiAccount2FB6DB7A": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "MyRestApiCloudWatchRoleD4042E8E", + "Arn" + ] + } + }, + "DependsOn": [ + "MyRestApi2D1F47A9" + ] + }, + "MyRestApiANY05143F93": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "MyRestApi2D1F47A9", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "AuthorizationType": "CUSTOM", + "AuthorizerId": { + "Ref": "MyAuthorizer6575980E" + }, + "Integration": { + "IntegrationResponses": [ + { + "StatusCode": "200" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": "{ \"statusCode\": 200 }" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "StatusCode": "200" + } + ] + } + }, + "MyAuthorizer6575980E": { + "Type": "AWS::ApiGateway::Authorizer", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "Type": "TOKEN", + "AuthorizerUri": { + "Fn::Join": [ + "", + [ + "arn:aws:apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "MyAuthorizerFunction70F1223E", + "Arn" + ] + }, + "/invocations" + ] + ] + }, + "IdentitySource": "method.request.header.Authorization" + } + }, + "MyAuthorizerFunctionTokenAuthorizerIntegMyAuthorizer793B1D5FPermissions7557AE26": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "MyAuthorizerFunction70F1223E", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyRestApi2D1F47A9" + }, + "/authorizers/", + { + "Ref": "MyAuthorizer6575980E" + } + ] + ] + } + } + } + }, + "Parameters": { + "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3Bucket115F9EA4": { + "Type": "String", + "Description": "S3 bucket for asset \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + }, + "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E": { + "Type": "String", + "Description": "S3 key for asset version \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + }, + "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aArtifactHash1A0BBA4E": { + "Type": "String", + "Description": "Artifact hash for asset \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + } + }, + "Outputs": { + "MyRestApiEndpoint4C55E4CB": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "MyRestApi2D1F47A9" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyRestApiDeploymentStageprodC33B8E5F" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.handler/index.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.handler/index.ts new file mode 100644 index 0000000000000..f1ad21bc7b09a --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.handler/index.ts @@ -0,0 +1,23 @@ +// tslint:disable:no-console + +export const handler = async (event: any, _context: any = {}): Promise => { + const authToken: string = event.authorizationToken; + console.log(`event.authorizationToken = ${authToken}`); + if (authToken === 'allow' || authToken === 'deny') { + return { + principalId: 'user', + policyDocument: { + Version: "2012-10-17", + Statement: [ + { + Action: "execute-api:Invoke", + Effect: authToken, + Resource: event.methodArn + } + ] + } + }; + } else { + throw new Error('Unauthorized'); + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.ts new file mode 100644 index 0000000000000..579741f5890e7 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.ts @@ -0,0 +1,39 @@ +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, Stack } from '@aws-cdk/core'; +import * as path from 'path'; +import { MockIntegration, PassthroughBehavior, RestApi, TokenAuthorizer } from '../../lib'; + +// Against the RestApi endpoint from the stack output, run +// `curl -s -o /dev/null -w "%{http_code}" ` should return 401 +// `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: deny' ` should return 403 +// `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: allow' ` should return 200 + +const app = new App(); +const stack = new Stack(app, 'TokenAuthorizerInteg'); + +const authorizerFn = new lambda.Function(stack, 'MyAuthorizerFunction', { + runtime: lambda.Runtime.NODEJS_10_X, + handler: 'index.handler', + code: lambda.AssetCode.fromAsset(path.join(__dirname, 'integ.token-authorizer.handler')) +}); + +const restapi = new RestApi(stack, 'MyRestApi'); + +const authorizer = new TokenAuthorizer(stack, 'MyAuthorizer', { + handler: authorizerFn, +}); + +restapi.root.addMethod('ANY', new MockIntegration({ + integrationResponses: [ + { statusCode: '200' } + ], + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: { + 'application/json': '{ "statusCode": 200 }', + }, +}), { + methodResponses: [ + { statusCode: '200' } + ], + authorizer +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/test.lambda.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/test.lambda.ts new file mode 100644 index 0000000000000..a2bbb570ceede --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/test.lambda.ts @@ -0,0 +1,130 @@ +import { expect, haveResource, ResourcePart } from '@aws-cdk/assert'; +import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { Duration, Stack } from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import { AuthorizationType, RestApi, TokenAuthorizer } from '../../lib'; + +export = { + 'default token authorizer'(test: Test) { + const stack = new Stack(); + + const func = new lambda.Function(stack, 'myfunction', { + handler: 'handler', + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_8_10, + }); + + const auth = new TokenAuthorizer(stack, 'myauthorizer', { + handler: func + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer: auth, + authorizationType: AuthorizationType.CUSTOM + }); + + expect(stack).to(haveResource('AWS::ApiGateway::Authorizer', { + Type: 'TOKEN', + RestApiId: stack.resolve(restApi.restApiId), + IdentitySource: 'method.request.header.Authorization' + })); + + expect(stack).to(haveResource('AWS::Lambda::Permission', { + Action: 'lambda:InvokeFunction', + Principal: 'apigateway.amazonaws.com', + })); + + test.ok(auth.authorizerArn.endsWith(`/authorizers/${auth.authorizerId}`), 'Malformed authorizer ARN'); + + test.done(); + }, + + 'token authorizer with all parameters specified'(test: Test) { + const stack = new Stack(); + + const func = new lambda.Function(stack, 'myfunction', { + handler: 'handler', + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_8_10, + }); + + const auth = new TokenAuthorizer(stack, 'myauthorizer', { + handler: func, + identitySource: 'method.request.header.whoami', + validationRegex: 'a-hacker', + authorizerName: 'myauthorizer', + resultsCacheTtl: Duration.minutes(1), + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer: auth, + authorizationType: AuthorizationType.CUSTOM + }); + + expect(stack).to(haveResource('AWS::ApiGateway::Authorizer', { + Type: 'TOKEN', + RestApiId: stack.resolve(restApi.restApiId), + IdentitySource: 'method.request.header.whoami', + IdentityValidationExpression: 'a-hacker', + Name: 'myauthorizer', + AuthorizerResultTtlInSeconds: 60 + })); + + test.done(); + }, + + 'token authorizer with assume role'(test: Test) { + const stack = new Stack(); + + const func = new lambda.Function(stack, 'myfunction', { + handler: 'handler', + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_8_10, + }); + + const role = new iam.Role(stack, 'authorizerassumerole', { + assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), + roleName: 'authorizerassumerole' + }); + + const auth = new TokenAuthorizer(stack, 'myauthorizer', { + handler: func, + assumeRole: role + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer: auth, + authorizationType: AuthorizationType.CUSTOM + }); + + expect(stack).to(haveResource('AWS::ApiGateway::Authorizer', { + Type: 'TOKEN', + RestApiId: stack.resolve(restApi.restApiId), + })); + + expect(stack).to(haveResource('AWS::IAM::Role')); + + expect(stack).to(haveResource('AWS::IAM::Policy', { + Roles: [ + stack.resolve(role.roleName) + ], + PolicyDocument: { + Statement: [ + { + Resource: stack.resolve(func.functionArn), + Action: 'lambda:InvokeFunction', + Effect: 'Allow', + } + ], + } + }, ResourcePart.Properties, true)); + + expect(stack).notTo(haveResource('AWS::Lambda::Permission')); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-apigateway/test/test.method.ts b/packages/@aws-cdk/aws-apigateway/test/test.method.ts index 8202c9b462d93..612e10c7d3144 100644 --- a/packages/@aws-cdk/aws-apigateway/test/test.method.ts +++ b/packages/@aws-cdk/aws-apigateway/test/test.method.ts @@ -2,10 +2,16 @@ import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as elbv2 from '@aws-cdk/aws-elasticloadbalancingv2'; import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; import * as apigw from '../lib'; +const DUMMY_AUTHORIZER: apigw.IAuthorizer = { + authorizerId: 'dummyauthorizer', + authorizationType: apigw.AuthorizationType.CUSTOM +}; + export = { 'default setup'(test: Test) { // GIVEN @@ -609,4 +615,84 @@ export = { test.done(); }, + + 'authorizer is bound correctly'(test: Test) { + const stack = new cdk.Stack(); + + const restApi = new apigw.RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer: DUMMY_AUTHORIZER + }); + + expect(stack).to(haveResource('AWS::ApiGateway::Method', { + HttpMethod: 'ANY', + AuthorizationType: 'CUSTOM', + AuthorizerId: DUMMY_AUTHORIZER.authorizerId, + })); + + test.done(); + }, + + 'authorizer via default method options'(test: Test) { + const stack = new cdk.Stack(); + + const func = new lambda.Function(stack, 'myfunction', { + handler: 'handler', + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_8_10, + }); + + const auth = new apigw.TokenAuthorizer(stack, 'myauthorizer1', { + authorizerName: 'myauthorizer1', + handler: func + }); + + const restApi = new apigw.RestApi(stack, 'myrestapi', { + defaultMethodOptions: { + authorizer: auth + } + }); + restApi.root.addMethod('ANY'); + + expect(stack).to(haveResource('AWS::ApiGateway::Authorizer', { + Name: 'myauthorizer1', + Type: 'TOKEN', + RestApiId: stack.resolve(restApi.restApiId) + })); + + test.done(); + }, + + 'fails when authorization type does not match the authorizer'(test: Test) { + const stack = new cdk.Stack(); + + const restApi = new apigw.RestApi(stack, 'myrestapi'); + + test.throws(() => { + restApi.root.addMethod('ANY', undefined, { + authorizationType: apigw.AuthorizationType.IAM, + authorizer: DUMMY_AUTHORIZER + }); + }, /Authorization type is set to AWS_IAM which is different from what is required by the authorizer/); + + test.done(); + }, + + 'fails when authorization type does not match the authorizer in default method options'(test: Test) { + const stack = new cdk.Stack(); + + const restApi = new apigw.RestApi(stack, 'myrestapi', { + defaultMethodOptions: { + authorizer: DUMMY_AUTHORIZER + } + }); + + test.throws(() => { + restApi.root.addMethod('ANY', undefined, { + authorizationType: apigw.AuthorizationType.NONE, + }); + }, /Authorization type is set to NONE which is different from what is required by the authorizer/); + + test.done(); + } };