diff --git a/packages/@aws-cdk/aws-cognito/README.md b/packages/@aws-cdk/aws-cognito/README.md index 749ad38300d40..881db02148556 100644 --- a/packages/@aws-cdk/aws-cognito/README.md +++ b/packages/@aws-cdk/aws-cognito/README.md @@ -42,6 +42,7 @@ This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aw - [Import](#importing-user-pools) - [Identity Providers](#identity-providers) - [App Clients](#app-clients) + - [Resource Servers](#resource-servers) - [Domains](#domains) ## User Pools @@ -549,6 +550,46 @@ pool.addClient('app-client', { }); ``` +### Resource Servers + +A resource server is a server for access-protected resources. It handles authenticated requests from an app that has an +access token. See [Defining Resource +Servers](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html) +for more information. + +An application may choose to model custom permissions via OAuth. Resource Servers provide this capability via custom scopes +that are attached to an app client. The following example sets up a resource server for the 'users' resource for two different +app clients and configures the clients to use these scopes. + +```ts +const pool = new cognito.UserPool(this, 'Pool'); + +const readOnlyScope = new ResourceServerScope({ scopeName: 'read', scopeDescription: 'Read-only access' }); +const fullAccessScope = new ResourceServerScope({ scopeName: '*', scopeDescription: 'Full access' }); + +const userServer = pool.addResourceServer('ResourceServer', { + identifier: 'users', + scopes: [ readOnlyScope, fullAccessScope ], +}); + +const readOnlyClient = pool.addClient('read-only-client', { + // ... + oAuth: { + // ... + scopes: [ OAuthScope.resourceServer(userServer, readOnlyScope) ], + }, +}); + +const fullAccessClient = pool.addClient('full-access-client', { + // ... + oAuth: { + // ... + scopes: [ OAuthScope.resourceServer(userServer, fullAccessScope) ], + }, +}); +``` + + ### Domains After setting up an [app client](#app-clients), the address for the user pool's sign-up and sign-in webpages can be diff --git a/packages/@aws-cdk/aws-cognito/lib/index.ts b/packages/@aws-cdk/aws-cognito/lib/index.ts index 2da1e6121b69b..cab56671c2b9e 100644 --- a/packages/@aws-cdk/aws-cognito/lib/index.ts +++ b/packages/@aws-cdk/aws-cognito/lib/index.ts @@ -5,4 +5,5 @@ export * from './user-pool-attr'; export * from './user-pool-client'; export * from './user-pool-domain'; export * from './user-pool-idp'; -export * from './user-pool-idps'; \ No newline at end of file +export * from './user-pool-idps'; +export * from './user-pool-resource-server'; diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-client.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-client.ts index 630000df485ba..34e6f13f75cae 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool-client.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-client.ts @@ -2,6 +2,7 @@ import { IResource, Resource } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnUserPoolClient } from './cognito.generated'; import { IUserPool } from './user-pool'; +import { IUserPoolResourceServer, ResourceServerScope } from './user-pool-resource-server'; /** * Types of authentication flow @@ -133,6 +134,13 @@ export class OAuthScope { return new OAuthScope(name); } + /** + * Adds a custom scope that's tied to a resource server in your stack + */ + public static resourceServer(server: IUserPoolResourceServer, scope: ResourceServerScope) { + return new OAuthScope(`${server.userPoolResourceServerId}/${scope.scopeName}`); + } + /** * The name of this scope as recognized by CloudFormation. * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cognito-userpoolclient.html#cfn-cognito-userpoolclient-allowedoauthscopes diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-resource-server.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-resource-server.ts new file mode 100644 index 0000000000000..d78d61e956587 --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-resource-server.ts @@ -0,0 +1,116 @@ +import { IResource, Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnUserPoolResourceServer } from './cognito.generated'; +import { IUserPool } from './user-pool'; + +/** + * Represents a Cognito user pool resource server + */ +export interface IUserPoolResourceServer extends IResource { + /** + * Resource server id + * @attribute + */ + readonly userPoolResourceServerId: string; +} + +/** + * Props to initialize ResourceServerScope + */ +export interface ResourceServerScopeProps { + /** + * The name of the scope + */ + readonly scopeName: string; + + /** + * A description of the scope. + */ + readonly scopeDescription: string; +} + +/** + * A scope for ResourceServer + */ +export class ResourceServerScope { + /** + * The name of the scope + */ + public readonly scopeName: string; + + /** + * A description of the scope. + */ + public readonly scopeDescription: string; + + constructor(props: ResourceServerScopeProps) { + this.scopeName = props.scopeName; + this.scopeDescription = props.scopeDescription; + } +} + + +/** + * Options to create a UserPoolResourceServer + */ +export interface UserPoolResourceServerOptions { + /** + * A unique resource server identifier for the resource server. + */ + readonly identifier: string; + + /** + * A friendly name for the resource server. + * @default - same as `identifier` + */ + readonly userPoolResourceServerName?: string; + + /** + * Oauth scopes + * @default - No scopes will be added + */ + readonly scopes?: ResourceServerScope[]; +} + +/** + * Properties for the UserPoolResourceServer construct + */ +export interface UserPoolResourceServerProps extends UserPoolResourceServerOptions { + /** + * The user pool to add this resource server to + */ + readonly userPool: IUserPool; +} + +/** + * Defines a User Pool OAuth2.0 Resource Server + */ +export class UserPoolResourceServer extends Resource implements IUserPoolResourceServer { + /** + * Import a user pool resource client given its id. + */ + public static fromUserPoolResourceServerId(scope: Construct, id: string, userPoolResourceServerId: string): IUserPoolResourceServer { + class Import extends Resource implements IUserPoolResourceServer { + public readonly userPoolResourceServerId = userPoolResourceServerId; + } + + return new Import(scope, id); + } + + public readonly userPoolResourceServerId: string; + + constructor(scope: Construct, id: string, props: UserPoolResourceServerProps) { + super(scope, id, { + physicalName: props.identifier, + }); + + const resource = new CfnUserPoolResourceServer(this, 'Resource', { + identifier: this.physicalName, + name: props.userPoolResourceServerName ?? this.physicalName, + scopes: props.scopes, + userPoolId: props.userPool.userPoolId, + }); + + this.userPoolResourceServerId = resource.ref; + } +} diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts index 8091c3ae4bc1d..0e652fb2c0b76 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts @@ -9,6 +9,7 @@ import { ICustomAttribute, StandardAttribute, StandardAttributes } from './user- import { UserPoolClient, UserPoolClientOptions } from './user-pool-client'; import { UserPoolDomain, UserPoolDomainOptions } from './user-pool-domain'; import { IUserPoolIdentityProvider } from './user-pool-idp'; +import { UserPoolResourceServer, UserPoolResourceServerOptions } from './user-pool-resource-server'; /** * The different ways in which users of this pool can sign up or sign in. @@ -601,6 +602,12 @@ export interface IUserPool extends IResource { */ addDomain(id: string, options: UserPoolDomainOptions): UserPoolDomain; + /** + * Add a new resource server to this user pool. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-resource-servers.html + */ + addResourceServer(id: string, options: UserPoolResourceServerOptions): UserPoolResourceServer; + /** * Register an identity provider with this user pool. */ @@ -626,6 +633,13 @@ abstract class UserPoolBase extends Resource implements IUserPool { }); } + public addResourceServer(id: string, options: UserPoolResourceServerOptions): UserPoolResourceServer { + return new UserPoolResourceServer(this, id, { + userPool: this, + ...options, + }); + } + public registerIdentityProvider(provider: IUserPoolIdentityProvider) { this.identityProviders.push(provider); } diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-resource-server.expected.json b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-resource-server.expected.json new file mode 100644 index 0000000000000..ffc765b879357 --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-resource-server.expected.json @@ -0,0 +1,92 @@ +{ + "Resources": { + "myuserpool01998219": { + "Type": "AWS::Cognito::UserPool", + "Properties": { + "AccountRecoverySetting": { + "RecoveryMechanisms": [ + { + "Name": "verified_phone_number", + "Priority": 1 + }, + { + "Name": "verified_email", + "Priority": 2 + } + ] + }, + "AdminCreateUserConfig": { + "AllowAdminCreateUserOnly": true + }, + "EmailVerificationMessage": "The verification code to your new account is {####}", + "EmailVerificationSubject": "Verify your new account", + "SmsVerificationMessage": "The verification code to your new account is {####}", + "UserPoolName": "MyUserPool", + "VerificationMessageTemplate": { + "DefaultEmailOption": "CONFIRM_WITH_CODE", + "EmailMessage": "The verification code to your new account is {####}", + "EmailSubject": "Verify your new account", + "SmsMessage": "The verification code to your new account is {####}" + } + } + }, + "myuserpoolmyserver50C4D8E9": { + "Type": "AWS::Cognito::UserPoolResourceServer", + "Properties": { + "Identifier": "users", + "Name": "users", + "UserPoolId": { + "Ref": "myuserpool01998219" + }, + "Scopes": [ + { + "ScopeDescription": "read only", + "ScopeName": "read" + } + ] + } + }, + "myuserpoolclientC5FA41EC": { + "Type": "AWS::Cognito::UserPoolClient", + "Properties": { + "UserPoolId": { + "Ref": "myuserpool01998219" + }, + "AllowedOAuthFlows": [ + "client_credentials" + ], + "AllowedOAuthFlowsUserPoolClient": true, + "AllowedOAuthScopes": [ + { + "Fn::Join": [ + "", + [ + { + "Ref": "myuserpoolmyserver50C4D8E9" + }, + "/read" + ] + ] + } + ], + "ClientName": "users-app", + "GenerateSecret": true, + "SupportedIdentityProviders": [ + "COGNITO" + ] + } + } + }, + "Outputs": { + "poolid": { + "Value": { + "Ref": "myuserpool01998219" + } + }, + "clientid": { + "Value": { + "Ref": "myuserpoolclientC5FA41EC" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-resource-server.ts b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-resource-server.ts new file mode 100644 index 0000000000000..8610d7a3e1296 --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-resource-server.ts @@ -0,0 +1,44 @@ +import { App, CfnOutput, Stack } from '@aws-cdk/core'; +import { OAuthScope, ResourceServerScope, UserPool } from '../lib'; + +const app = new App(); +const stack = new Stack(app, 'integ-user-pool-resource-server'); + +/* + * Stack verification steps: + * Cognito will only allow you to add a custom scope on a user pool client that is defined by a resource server. + * Checking the app client scopes will verify if the resource server is configured correctly. + * The exports userPoolId and userPoolClientId are exported here to test + * + * * `aws cognito-idp describe-user-pool-client --user-pool-id $userPoolId --client-id $userPoolClientId` should return "users/read" in "AllowedOAuthScopes" + */ +const userPool = new UserPool(stack, 'myuserpool', { + userPoolName: 'MyUserPool', +}); + +const readScope = new ResourceServerScope({ scopeName: 'read', scopeDescription: 'read only' }); +const userServer = userPool.addResourceServer('myserver', { + identifier: 'users', + scopes: [readScope], +}); + +const client = userPool.addClient('client', { + userPoolClientName: 'users-app', + generateSecret: true, + oAuth: { + flows: { + clientCredentials: true, + }, + scopes: [ + OAuthScope.resourceServer(userServer, readScope), + ], + }, +}); + +new CfnOutput(stack, 'pool-id', { + value: userPool.userPoolId, +}); + +new CfnOutput(stack, 'client-id', { + value: client.userPoolClientId, +}); diff --git a/packages/@aws-cdk/aws-cognito/test/user-pool-client.test.ts b/packages/@aws-cdk/aws-cognito/test/user-pool-client.test.ts index eeddb55bc1ff1..93c8937dba68b 100644 --- a/packages/@aws-cdk/aws-cognito/test/user-pool-client.test.ts +++ b/packages/@aws-cdk/aws-cognito/test/user-pool-client.test.ts @@ -1,7 +1,7 @@ import { ABSENT } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; import { Stack } from '@aws-cdk/core'; -import { OAuthScope, UserPool, UserPoolClient, UserPoolClientIdentityProvider, UserPoolIdentityProvider } from '../lib'; +import { OAuthScope, ResourceServerScope, UserPool, UserPoolClient, UserPoolClientIdentityProvider, UserPoolIdentityProvider } from '../lib'; describe('User Pool Client', () => { test('default setup', () => { @@ -310,6 +310,41 @@ describe('User Pool Client', () => { }); }); + test('OAuth scopes - resource server', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + const scope = new ResourceServerScope({ scopeName: 'scope-name', scopeDescription: 'scope-desc' }); + const resourceServer = pool.addResourceServer('ResourceServer', { + identifier: 'resource-server', + scopes: [scope], + }); + + // WHEN + pool.addClient('Client', { + oAuth: { + flows: { clientCredentials: true }, + scopes: [ + OAuthScope.resourceServer(resourceServer, scope), + ], + }, + }); + + // THEN + expect(stack).toHaveResource('AWS::Cognito::UserPoolClient', { + AllowedOAuthScopes: [ + { + 'Fn::Join': [ + '', [ + stack.resolve(resourceServer.userPoolResourceServerId), + '/scope-name', + ], + ], + }, + ], + }); + }); + test('OAuthScope - openid is included when email or phone is specified', () => { // GIVEN const stack = new Stack(); diff --git a/packages/@aws-cdk/aws-cognito/test/user-pool-resource-server.test.ts b/packages/@aws-cdk/aws-cognito/test/user-pool-resource-server.test.ts new file mode 100644 index 0000000000000..17c2045230ca5 --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/test/user-pool-resource-server.test.ts @@ -0,0 +1,71 @@ +import '@aws-cdk/assert/jest'; +import { Stack } from '@aws-cdk/core'; +import { UserPool, UserPoolResourceServer } from '../lib'; + +describe('User Pool Resource Server', () => { + test('default setup', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + + // WHEN + new UserPoolResourceServer(stack, 'Server', { + userPool: pool, + identifier: 'users', + }); + + //THEN + expect(stack).toHaveResource('AWS::Cognito::UserPoolResourceServer', { + Identifier: 'users', + Name: 'users', + UserPoolId: stack.resolve(pool.userPoolId), + }); + }); + + test('can assign a custom name', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + + // WHEN + new UserPoolResourceServer(stack, 'Server', { + userPoolResourceServerName: 'internal-users', + userPool: pool, + identifier: 'users', + }); + + //THEN + expect(stack).toHaveResource('AWS::Cognito::UserPoolResourceServer', { + Identifier: 'users', + Name: 'internal-users', + }); + }); + + test('can assign scopes', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + + // WHEN + new UserPoolResourceServer(stack, 'Server', { + userPool: pool, + identifier: 'users', + scopes: [ + { + scopeName: 'read', + scopeDescription: 'read only access', + }, + ], + }); + + //THEN + expect(stack).toHaveResource('AWS::Cognito::UserPoolResourceServer', { + Scopes: [ + { + ScopeDescription: 'read only access', + ScopeName: 'read', + }, + ], + }); + }); +}); diff --git a/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts b/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts index 45d27521a92ff..a2199eecce800 100644 --- a/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts +++ b/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts @@ -883,6 +883,36 @@ describe('User Pool', () => { }); }); + test('addResourceServer', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const userpool = new UserPool(stack, 'Pool'); + userpool.addResourceServer('ResourceServer', { + identifier: 'users', + scopes: [ + { + scopeName: 'read', + scopeDescription: 'Read-only access', + }, + ], + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Cognito::UserPoolResourceServer', { + Identifier: 'users', + Name: 'users', + UserPoolId: stack.resolve(userpool.userPoolId), + Scopes: [ + { + ScopeDescription: 'Read-only access', + ScopeName: 'read', + }, + ], + }); + }); + test('addDomain', () => { // GIVEN const stack = new Stack();