diff --git a/packages/@aws-cdk/aws-appsync/README.md b/packages/@aws-cdk/aws-appsync/README.md index 0145beae621f6..a191b51a86483 100644 --- a/packages/@aws-cdk/aws-appsync/README.md +++ b/packages/@aws-cdk/aws-appsync/README.md @@ -28,7 +28,7 @@ APIs that use GraphQL. ### DynamoDB -Example of a GraphQL API with `AWS_IAM` authorization resolving into a DynamoDb +Example of a GraphQL API with `AWS_IAM` [authorization](#authorization) resolving into a DynamoDb backend data source. GraphQL schema file `schema.graphql`: @@ -345,6 +345,40 @@ If you don't specify `graphqlArn` in `fromXxxAttributes`, CDK will autogenerate the expected `arn` for the imported api, given the `apiId`. For creating data sources and resolvers, an `apiId` is sufficient. +## Authorization + +There are multiple authorization types available for GraphQL API to cater to different +access use cases. They are: + +- API Keys (`AuthorizationType.API_KEY`) +- Amazon Cognito User Pools (`AuthorizationType.USER_POOL`) +- OpenID Connect (`AuthorizationType.OPENID_CONNECT`) +- AWS Identity and Access Management (`AuthorizationType.AWS_IAM`) +- AWS Lambda (`AuthorizationType.AWS_LAMBDA`) + +These types can be used simultaneously in a single API, allowing different types of clients to +access data. When you specify an authorization type, you can also specify the corresponding +authorization mode to finish defining your authorization. For example, this is a GraphQL API +with AWS Lambda Authorization. + +```ts +authFunction = new lambda.Function(stack, 'auth-function', {}); + +new appsync.GraphqlApi(stack, 'api', { + name: 'api', + schema: appsync.Schema.fromAsset(path.join(__dirname, 'appsync.test.graphql')), + authorizationConfig: { + defaultAuthorization: { + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: authFunction, + // can also specify `resultsCacheTtl` and `validationRegex`. + }, + }, + }, +}); +``` + ## Permissions When using `AWS_IAM` as the authorization type for GraphQL API, an IAM Role diff --git a/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts b/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts index 8b0252bed9eb4..93c9078e32358 100644 --- a/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts +++ b/packages/@aws-cdk/aws-appsync/lib/graphqlapi.ts @@ -1,5 +1,6 @@ import { IUserPool } from '@aws-cdk/aws-cognito'; import { ManagedPolicy, Role, IRole, ServicePrincipal, Grant, IGrantable } from '@aws-cdk/aws-iam'; +import { IFunction } from '@aws-cdk/aws-lambda'; import { CfnResource, Duration, Expiration, IResolvable, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnApiKey, CfnGraphQLApi, CfnGraphQLSchema } from './appsync.generated'; @@ -29,6 +30,10 @@ export enum AuthorizationType { * OpenID Connect authorization type */ OIDC = 'OPENID_CONNECT', + /** + * Lambda authorization type + */ + LAMBDA = 'AWS_LAMBDA', } /** @@ -58,6 +63,11 @@ export interface AuthorizationMode { * @default - none */ readonly openIdConnectConfig?: OpenIdConnectConfig; + /** + * If authorizationType is `AuthorizationType.LAMBDA`, this option is required. + * @default - none + */ + readonly lambdaAuthorizerConfig?: LambdaAuthorizerConfig; } /** @@ -150,6 +160,38 @@ export interface OpenIdConnectConfig { readonly oidcProvider: string; } +/** + * Configuration for Lambda authorization in AppSync. Note that you can only have a single AWS Lambda function configured to authorize your API. + */ +export interface LambdaAuthorizerConfig { + /** + * The authorizer lambda function. + * Note: This Lambda function must have the following resource-based policy assigned to it. + * When configuring Lambda authorizers in the console, this is done for you. + * To do so with the AWS CLI, run the following: + * + * `aws lambda add-permission --function-name "arn:aws:lambda:us-east-2:111122223333:function:my-function" --statement-id "appsync" --principal appsync.amazonaws.com --action lambda:InvokeFunction` + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-appsync-graphqlapi-lambdaauthorizerconfig.html + */ + readonly handler: IFunction; + + /** + * How long the results are cached. + * Disable caching by setting this to 0. + * + * @default Duration.minutes(5) + */ + readonly resultsCacheTtl?: Duration; + + /** + * A regular expression for validation of tokens before the Lambda function is called. + * + * @default - no regex filter will be applied. + */ + readonly validationRegex?: string; +} + /** * Configuration of the API authorization modes. */ @@ -418,6 +460,7 @@ export class GraphqlApi extends GraphqlApiBase { logConfig: this.setupLogConfig(props.logConfig), openIdConnectConfig: this.setupOpenIdConnectConfig(defaultMode.openIdConnectConfig), userPoolConfig: this.setupUserPoolConfig(defaultMode.userPoolConfig), + lambdaAuthorizerConfig: this.setupLambdaAuthorizerConfig(defaultMode.lambdaAuthorizerConfig), additionalAuthenticationProviders: this.setupAdditionalAuthorizationModes(additionalModes), xrayEnabled: props.xrayEnabled, }); @@ -490,6 +533,9 @@ export class GraphqlApi extends GraphqlApiBase { } private validateAuthorizationProps(modes: AuthorizationMode[]) { + if (modes.filter((mode) => mode.authorizationType === AuthorizationType.LAMBDA).length > 1) { + throw new Error('You can only have a single AWS Lambda function configured to authorize your API.'); + } modes.map((mode) => { if (mode.authorizationType === AuthorizationType.OIDC && !mode.openIdConnectConfig) { throw new Error('Missing OIDC Configuration'); @@ -497,6 +543,9 @@ export class GraphqlApi extends GraphqlApiBase { if (mode.authorizationType === AuthorizationType.USER_POOL && !mode.userPoolConfig) { throw new Error('Missing User Pool Configuration'); } + if (mode.authorizationType === AuthorizationType.LAMBDA && !mode.lambdaAuthorizerConfig) { + throw new Error('Missing Lambda Configuration'); + } }); if (modes.filter((mode) => mode.authorizationType === AuthorizationType.API_KEY).length > 1) { throw new Error('You can\'t duplicate API_KEY configuration. See https://docs.aws.amazon.com/appsync/latest/devguide/security.html'); @@ -551,6 +600,15 @@ export class GraphqlApi extends GraphqlApiBase { }; } + private setupLambdaAuthorizerConfig(config?: LambdaAuthorizerConfig) { + if (!config) return undefined; + return { + authorizerResultTtlInSeconds: config.resultsCacheTtl?.toSeconds(), + authorizerUri: config.handler.functionArn, + identityValidationExpression: config.validationRegex, + }; + } + private setupAdditionalAuthorizationModes(modes?: AuthorizationMode[]) { if (!modes || modes.length === 0) return undefined; return modes.reduce((acc, mode) => [ @@ -558,6 +616,7 @@ export class GraphqlApi extends GraphqlApiBase { authenticationType: mode.authorizationType, userPoolConfig: this.setupUserPoolConfig(mode.userPoolConfig), openIdConnectConfig: this.setupOpenIdConnectConfig(mode.openIdConnectConfig), + lambdaAuthorizerConfig: this.setupLambdaAuthorizerConfig(mode.lambdaAuthorizerConfig), }, ], []); } diff --git a/packages/@aws-cdk/aws-appsync/test/appsync-auth.test.ts b/packages/@aws-cdk/aws-appsync/test/appsync-auth.test.ts index a137e54b0423f..67f12ae4bc13a 100644 --- a/packages/@aws-cdk/aws-appsync/test/appsync-auth.test.ts +++ b/packages/@aws-cdk/aws-appsync/test/appsync-auth.test.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import { Template } from '@aws-cdk/assertions'; import * as cognito from '@aws-cdk/aws-cognito'; +import * as lambda from '@aws-cdk/aws-lambda'; import * as cdk from '@aws-cdk/core'; import * as appsync from '../lib'; @@ -630,3 +631,206 @@ describe('AppSync OIDC Authorization', () => { }); }); }); + +describe('AppSync Lambda Authorization', () => { + let fn: lambda.Function; + beforeEach(() => { + fn = new lambda.Function(stack, 'auth-function', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: lambda.Code.fromInline('/* lambda authentication code here.*/'), + }); + }); + + test('Lambda authorization configurable in default authorization has default configuration', () => { + // WHEN + new appsync.GraphqlApi(stack, 'api', { + name: 'api', + schema: appsync.Schema.fromAsset(path.join(__dirname, 'appsync.test.graphql')), + authorizationConfig: { + defaultAuthorization: { + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: fn, + }, + }, + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::AppSync::GraphQLApi', { + AuthenticationType: 'AWS_LAMBDA', + LambdaAuthorizerConfig: { + AuthorizerUri: { + 'Fn::GetAtt': [ + 'authfunction96361832', + 'Arn', + ], + }, + }, + }); + }); + + test('Lambda authorization configurable in default authorization', () => { + // WHEN + new appsync.GraphqlApi(stack, 'api', { + name: 'api', + schema: appsync.Schema.fromAsset(path.join(__dirname, 'appsync.test.graphql')), + authorizationConfig: { + defaultAuthorization: { + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: fn, + resultsCacheTtl: cdk.Duration.seconds(300), + validationRegex: 'custom-.*', + }, + }, + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::AppSync::GraphQLApi', { + AuthenticationType: 'AWS_LAMBDA', + LambdaAuthorizerConfig: { + AuthorizerUri: { + 'Fn::GetAtt': [ + 'authfunction96361832', + 'Arn', + ], + }, + AuthorizerResultTtlInSeconds: 300, + IdentityValidationExpression: 'custom-.*', + }, + }); + }); + + test('Lambda authorization configurable in additional authorization has default configuration', () => { + // WHEN + new appsync.GraphqlApi(stack, 'api', { + name: 'api', + schema: appsync.Schema.fromAsset(path.join(__dirname, 'appsync.test.graphql')), + authorizationConfig: { + additionalAuthorizationModes: [{ + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: fn, + }, + }], + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::AppSync::GraphQLApi', { + AdditionalAuthenticationProviders: [{ + AuthenticationType: 'AWS_LAMBDA', + LambdaAuthorizerConfig: { + AuthorizerUri: { + 'Fn::GetAtt': [ + 'authfunction96361832', + 'Arn', + ], + }, + }, + }], + }); + }); + + test('Lambda authorization configurable in additional authorization', () => { + // WHEN + new appsync.GraphqlApi(stack, 'api', { + name: 'api', + schema: appsync.Schema.fromAsset(path.join(__dirname, 'appsync.test.graphql')), + authorizationConfig: { + additionalAuthorizationModes: [{ + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: fn, + resultsCacheTtl: cdk.Duration.seconds(300), + validationRegex: 'custom-.*', + }, + }], + }, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::AppSync::GraphQLApi', { + AdditionalAuthenticationProviders: [{ + AuthenticationType: 'AWS_LAMBDA', + LambdaAuthorizerConfig: { + AuthorizerUri: { + 'Fn::GetAtt': [ + 'authfunction96361832', + 'Arn', + ], + }, + AuthorizerResultTtlInSeconds: 300, + IdentityValidationExpression: 'custom-.*', + }, + }], + }); + }); + + test('Lambda authorization throws with multiple lambda authorization', () => { + expect(() => new appsync.GraphqlApi(stack, 'api', { + name: 'api', + schema: appsync.Schema.fromAsset(path.join(__dirname, 'appsync.test.graphql')), + authorizationConfig: { + defaultAuthorization: { + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: fn, + }, + }, + additionalAuthorizationModes: [ + { + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: fn, + resultsCacheTtl: cdk.Duration.seconds(300), + validationRegex: 'custom-.*', + }, + }, + ], + }, + })).toThrow('You can only have a single AWS Lambda function configured to authorize your API.'); + + expect(() => new appsync.GraphqlApi(stack, 'api2', { + name: 'api', + schema: appsync.Schema.fromAsset(path.join(__dirname, 'appsync.test.graphql')), + authorizationConfig: { + defaultAuthorization: { authorizationType: appsync.AuthorizationType.IAM }, + additionalAuthorizationModes: [ + { + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: fn, + resultsCacheTtl: cdk.Duration.seconds(300), + validationRegex: 'custom-.*', + }, + }, + { + authorizationType: appsync.AuthorizationType.LAMBDA, + lambdaAuthorizerConfig: { + handler: fn, + resultsCacheTtl: cdk.Duration.seconds(300), + validationRegex: 'custom-.*', + }, + }, + ], + }, + })).toThrow('You can only have a single AWS Lambda function configured to authorize your API.'); + }); + + test('throws if authorization type and mode do not match', () => { + expect(() => new appsync.GraphqlApi(stack, 'api', { + name: 'api', + schema: appsync.Schema.fromAsset(path.join(__dirname, 'appsync.test.graphql')), + authorizationConfig: { + defaultAuthorization: { + authorizationType: appsync.AuthorizationType.LAMBDA, + openIdConnectConfig: { oidcProvider: 'test' }, + }, + }, + })).toThrow('Missing Lambda Configuration'); + }); +}); \ No newline at end of file