From 355d4f44e37c4e375a5bfb6bbd4650243b0a0d8a Mon Sep 17 00:00:00 2001 From: Niranjan Jayakar Date: Fri, 5 Jun 2020 17:39:10 +0100 Subject: [PATCH] feat(cognito): user pool - identity provider attribute mapping --- packages/@aws-cdk/aws-cognito/README.md | 25 ++- .../aws-cognito/lib/private/attr-names.ts | 19 ++ .../aws-cognito/lib/user-pool-idps/amazon.ts | 1 + .../aws-cognito/lib/user-pool-idps/base.ts | 190 +++++++++++++++++- .../lib/user-pool-idps/facebook.ts | 1 + .../@aws-cdk/aws-cognito/lib/user-pool.ts | 21 +- .../test/integ.user-pool-idp.expected.json | 5 + .../aws-cognito/test/integ.user-pool-idp.ts | 9 +- .../test/user-pool-idps/base.test.ts | 94 +++++++++ 9 files changed, 339 insertions(+), 26 deletions(-) create mode 100644 packages/@aws-cdk/aws-cognito/lib/private/attr-names.ts create mode 100644 packages/@aws-cdk/aws-cognito/test/user-pool-idps/base.test.ts diff --git a/packages/@aws-cdk/aws-cognito/README.md b/packages/@aws-cdk/aws-cognito/README.md index 2cca87a32e726..bd45b0d9a7ee8 100644 --- a/packages/@aws-cdk/aws-cognito/README.md +++ b/packages/@aws-cdk/aws-cognito/README.md @@ -367,9 +367,26 @@ const provider = new UserPoolIdentityProviderAmazon(stack, 'Amazon', { }); ``` -In order to allow users to sign in with a third-party identity provider, the app client that faces the user should be -configured to use the identity provider. See [App Clients](#app-clients) section to know more about App Clients. -The identity providers should be configured on `identityProviders` property available on the `UserPoolClient` construct. +Attribute mapping allows mapping attributes provided by the third-party identity providers to [standard and custom +attributes](#Attributes) of the user pool. Learn more about [Specifying Identity Provider Attribute Mappings for Your +User Pool](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-specifying-attribute-mapping.html). + +The following code shows how different attributes provided by 'Login With Amazon' can be mapped to standard and custom +user pool attributes. + +```ts +new UserPoolIdentityProviderAmazon(stack, 'Amazon', { + // ... + attributeMapping: { + email: ProviderAttribute.AMAZON_EMAIL, + website: ProviderAttribute.custom('url'), // use custom() when an attribute is not pre-defined in the CDK + custom: { + // custom user pool attributes go here + uniqueId: ProviderAttribute.AMAZON_USER_ID, + } + } +}); +``` ### App Clients @@ -456,7 +473,7 @@ pool.addClient('app-client', { All identity providers created in the CDK app are automatically registered into the corresponding user pool. All app clients created in the CDK have all of the identity providers enabled by default. The 'Cognito' identity provider, -that allows users to register and sign in directly with the Cognito user pool, is also enabled by default. +that allows users to register and sign in directly with the Cognito user pool, is also enabled by default. Alternatively, the list of supported identity providers for a client can be explicitly specified - ```ts diff --git a/packages/@aws-cdk/aws-cognito/lib/private/attr-names.ts b/packages/@aws-cdk/aws-cognito/lib/private/attr-names.ts new file mode 100644 index 0000000000000..1f0891cec1704 --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/lib/private/attr-names.ts @@ -0,0 +1,19 @@ +export const StandardAttributeNames = { + address: 'address', + birthdate: 'birthdate', + email: 'email', + familyName: 'family_name', + gender: 'gender', + givenName: 'given_name', + locale: 'locale', + middleName: 'middle_name', + fullname: 'name', + nickname: 'nickname', + phoneNumber: 'phone_number', + profilePicture: 'picture', + preferredUsername: 'preferred_username', + profilePage: 'profile', + timezone: 'zoneinfo', + lastUpdateTime: 'updated_at', + website: 'website', +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/amazon.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/amazon.ts index d5f4fd5402609..04d5098b7f83a 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/amazon.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/amazon.ts @@ -45,6 +45,7 @@ export class UserPoolIdentityProviderAmazon extends UserPoolIdentityProviderBase client_secret: props.clientSecret, authorize_scopes: scopes.join(' '), }, + attributeMapping: super.configureAttributeMapping(), }); this.providerName = super.getResourceNameAttribute(resource.ref); diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/base.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/base.ts index b95ffd106a285..db099195b8cac 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/base.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/base.ts @@ -1,7 +1,169 @@ import { Construct, Resource } from '@aws-cdk/core'; +import { StandardAttributeNames } from '../private/attr-names'; import { IUserPool } from '../user-pool'; import { IUserPoolIdentityProvider } from '../user-pool-idp'; +/** + * An attribute available from a third party identity provider. + */ +export class ProviderAttribute { + /** The user id attribute provided by Amazon */ + public static readonly AMAZON_USER_ID = new ProviderAttribute('user_id'); + /** The email attribute provided by Amazon */ + public static readonly AMAZON_EMAIL = new ProviderAttribute('email'); + /** The name attribute provided by Amazon */ + public static readonly AMAZON_NAME = new ProviderAttribute('name'); + /** The postal code attribute provided by Amazon */ + public static readonly AMAZON_POSTAL_CODE = new ProviderAttribute('postal_code'); + + /** The user id attribute provided by Facebook */ + public static readonly FACEBOOK_ID = new ProviderAttribute('id'); + /** The birthday attribute provided by Facebook */ + public static readonly FACEBOOK_BIRTHDAY = new ProviderAttribute('birthday'); + /** The email attribute provided by Facebook */ + public static readonly FACEBOOK_EMAIL = new ProviderAttribute('email'); + /** The name attribute provided by Facebook */ + public static readonly FACEBOOK_NAME = new ProviderAttribute('name'); + /** The first name attribute provided by Facebook */ + public static readonly FACEBOOK_FIRST_NAME = new ProviderAttribute('first_name'); + /** The last name attribute provided by Facebook */ + public static readonly FACEBOOK_LAST_NAME = new ProviderAttribute('last_name'); + /** The middle name attribute provided by Facebook */ + public static readonly FACEBOOK_MIDDLE_NAME = new ProviderAttribute('middle_name'); + /** The gender attribute provided by Facebook */ + public static readonly FACEBOOK_GENDER = new ProviderAttribute('gender'); + /** The locale attribute provided by Facebook */ + public static readonly FACEBOOK_LOCALE = new ProviderAttribute('locale'); + + /** + * Use this to specify an attribute from the identity provider that is not pre-defined in the CDK. + * @param attributeName the attribute value string as recognized by the provider + */ + public static custom(attributeName: string): ProviderAttribute { + return new ProviderAttribute(attributeName); + } + + /** The attribute value string as recognized by the provider. */ + public readonly attributeName: string; + + private constructor(attributeName: string) { + this.attributeName = attributeName; + } +} + +/** + * The mapping of user pool attributes to the attributes provided by the identity providers. + */ +export interface AttributeMapping { + /** + * The user's postal address is a required attribute. + * @default - not mapped + */ + readonly address?: ProviderAttribute; + + /** + * The user's birthday. + * @default - not mapped + */ + readonly birthdate?: ProviderAttribute; + + /** + * The user's e-mail address. + * @default - not mapped + */ + readonly email?: ProviderAttribute; + + /** + * The surname or last name of user. + * @default - not mapped + */ + readonly familyName?: ProviderAttribute; + + /** + * The user's gender. + * @default - not mapped + */ + readonly gender?: ProviderAttribute; + + /** + * The user's first name or give name. + * @default - not mapped + */ + readonly givenName?: ProviderAttribute; + + /** + * The user's locale. + * @default - not mapped + */ + readonly locale?: ProviderAttribute; + + /** + * The user's middle name. + * @default - not mapped + */ + readonly middleName?: ProviderAttribute; + + /** + * The user's full name in displayable form. + * @default - not mapped + */ + readonly fullname?: ProviderAttribute; + + /** + * The user's nickname or casual name. + * @default - not mapped + */ + readonly nickname?: ProviderAttribute; + + /** + * The user's telephone number. + * @default - not mapped + */ + readonly phoneNumber?: ProviderAttribute; + + /** + * The URL to the user's profile picture. + * @default - not mapped + */ + readonly profilePicture?: ProviderAttribute; + + /** + * The user's preferred username. + * @default - not mapped + */ + readonly preferredUsername?: ProviderAttribute; + + /** + * The URL to the user's profile page. + * @default - not mapped + */ + readonly profilePage?: ProviderAttribute; + + /** + * The user's time zone. + * @default - not mapped + */ + readonly timezone?: ProviderAttribute; + + /** + * Time, the user's information was last updated. + * @default - not mapped + */ + readonly lastUpdateTime?: ProviderAttribute; + + /** + * The URL to the user's web page or blog. + * @default - not mapped + */ + readonly website?: ProviderAttribute; + + /** + * Specify custom attribute mapping here and mapping for any standard attributes not supported yet. + * @default - no custom attribute mapping + */ + readonly custom?: { [key: string]: ProviderAttribute }; +} + /** * Properties to create a new instance of UserPoolIdentityProvider */ @@ -10,6 +172,12 @@ export interface UserPoolIdentityProviderProps { * The user pool to which this construct provides identities. */ readonly userPool: IUserPool; + + /** + * Mapping attributes from the identity provider to standard and custom attributes of the user pool. + * @default - no attribute mapping + */ + readonly attributeMapping?: AttributeMapping; } /** @@ -18,8 +186,28 @@ export interface UserPoolIdentityProviderProps { export abstract class UserPoolIdentityProviderBase extends Resource implements IUserPoolIdentityProvider { public abstract readonly providerName: string; - public constructor(scope: Construct, id: string, props: UserPoolIdentityProviderProps) { + public constructor(scope: Construct, id: string, private readonly props: UserPoolIdentityProviderProps) { super(scope, id); props.userPool.registerIdentityProvider(this); } + + protected configureAttributeMapping(): any { + if (!this.props.attributeMapping) { + return undefined; + } + type SansCustom = Omit; + let mapping: { [key: string]: string } = {}; + mapping = Object.entries(this.props.attributeMapping) + .filter(([k, _]) => k !== 'custom') // 'custom' handled later separately + .reduce((agg, [k, v]) => { + return { ...agg, [StandardAttributeNames[k as keyof SansCustom]]: v.attributeName }; + }, mapping); + if (this.props.attributeMapping.custom) { + mapping = Object.entries(this.props.attributeMapping.custom).reduce((agg, [k, v]) => { + return { ...agg, [k]: v.attributeName }; + }, mapping); + } + if (Object.keys(mapping).length === 0) { return undefined; } + return mapping; + } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/facebook.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/facebook.ts index d404c40965575..fee333011ffe6 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/facebook.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-idps/facebook.ts @@ -50,6 +50,7 @@ export class UserPoolIdentityProviderFacebook extends UserPoolIdentityProviderBa authorize_scopes: scopes.join(','), api_version: props.apiVersion, }, + attributeMapping: super.configureAttributeMapping(), }); this.providerName = super.getResourceNameAttribute(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 4f7b29a22e325..bfd38a9bd2b36 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts @@ -2,6 +2,7 @@ import { IRole, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from ' import * as lambda from '@aws-cdk/aws-lambda'; import { Construct, Duration, IResource, Lazy, Resource, Stack, Token } from '@aws-cdk/core'; import { CfnUserPool } from './cognito.generated'; +import { StandardAttributeNames } from './private/attr-names'; import { ICustomAttribute, StandardAttribute, StandardAttributes } from './user-pool-attr'; import { UserPoolClient, UserPoolClientOptions } from './user-pool-client'; import { UserPoolDomain, UserPoolDomainOptions } from './user-pool-domain'; @@ -909,26 +910,6 @@ export class UserPool extends UserPoolBase { } } -const StandardAttributeNames: Record = { - address: 'address', - birthdate: 'birthdate', - email: 'email', - familyName: 'family_name', - gender: 'gender', - givenName: 'given_name', - locale: 'locale', - middleName: 'middle_name', - fullname: 'name', - nickname: 'nickname', - phoneNumber: 'phone_number', - profilePicture: 'picture', - preferredUsername: 'preferred_username', - profilePage: 'profile', - timezone: 'zoneinfo', - lastUpdateTime: 'updated_at', - website: 'website', -}; - function undefinedIfNoKeys(struct: object): object | undefined { const allUndefined = Object.values(struct).reduce((acc, v) => acc && (v === undefined), true); return allUndefined ? undefined : struct; diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-idp.expected.json b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-idp.expected.json index bbed1eca96f4c..c826a9380e222 100644 --- a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-idp.expected.json +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-idp.expected.json @@ -108,6 +108,11 @@ "UserPoolId": { "Ref": "pool056F3F7E" }, + "AttributeMapping": { + "given_name": "name", + "email": "email", + "userId": "user_id" + }, "ProviderDetails": { "client_id": "amzn-client-id", "client_secret": "amzn-client-secret", diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-idp.ts b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-idp.ts index e22b504cf8ad7..31804f1ce95e8 100644 --- a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-idp.ts +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-idp.ts @@ -1,5 +1,5 @@ import { App, CfnOutput, Stack } from '@aws-cdk/core'; -import { UserPool, UserPoolIdentityProviderAmazon } from '../lib'; +import { ProviderAttribute, UserPool, UserPoolIdentityProviderAmazon } from '../lib'; /* * Stack verification steps @@ -15,6 +15,13 @@ new UserPoolIdentityProviderAmazon(stack, 'amazon', { userPool: userpool, clientId: 'amzn-client-id', clientSecret: 'amzn-client-secret', + attributeMapping: { + givenName: ProviderAttribute.AMAZON_NAME, + email: ProviderAttribute.AMAZON_EMAIL, + custom: { + userId: ProviderAttribute.AMAZON_USER_ID, + }, + }, }); const client = userpool.addClient('client'); diff --git a/packages/@aws-cdk/aws-cognito/test/user-pool-idps/base.test.ts b/packages/@aws-cdk/aws-cognito/test/user-pool-idps/base.test.ts new file mode 100644 index 0000000000000..4f5be6f6dfc21 --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/test/user-pool-idps/base.test.ts @@ -0,0 +1,94 @@ +import '@aws-cdk/assert/jest'; +import { Stack } from '@aws-cdk/core'; +import { ProviderAttribute, UserPool, UserPoolIdentityProviderBase } from '../../lib'; + +class MyIdp extends UserPoolIdentityProviderBase { + public readonly providerName = 'MyProvider'; + public readonly mapping = this.configureAttributeMapping(); +} + +describe('UserPoolIdentityProvider', () => { + describe('attribute mapping', () => { + test('absent or empty', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'UserPool'); + + // WHEN + const idp1 = new MyIdp(stack, 'MyIdp1', { + userPool: pool, + }); + const idp2 = new MyIdp(stack, 'MyIdp2', { + userPool: pool, + attributeMapping: {}, + }); + + // THEN + expect(idp1.mapping).toBeUndefined(); + expect(idp2.mapping).toBeUndefined(); + }); + + test('standard attributes', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'UserPool'); + + // WHEN + const idp = new MyIdp(stack, 'MyIdp1', { + userPool: pool, + attributeMapping: { + givenName: ProviderAttribute.FACEBOOK_NAME, + birthdate: ProviderAttribute.FACEBOOK_BIRTHDAY, + }, + }); + + // THEN + expect(idp.mapping).toStrictEqual({ + given_name: 'name', + birthdate: 'birthday', + }); + }); + + test('custom', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'UserPool'); + + // WHEN + const idp = new MyIdp(stack, 'MyIdp1', { + userPool: pool, + attributeMapping: { + custom: { + 'custom-attr-1': ProviderAttribute.AMAZON_EMAIL, + 'custom-attr-2': ProviderAttribute.AMAZON_NAME, + }, + }, + }); + + // THEN + expect(idp.mapping).toStrictEqual({ + 'custom-attr-1': 'email', + 'custom-attr-2': 'name', + }); + }); + + test('custom provider attribute', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'UserPool'); + + // WHEN + const idp = new MyIdp(stack, 'MyIdp1', { + userPool: pool, + attributeMapping: { + address: ProviderAttribute.custom('custom-provider-attr'), + }, + }); + + // THEN + expect(idp.mapping).toStrictEqual({ + address: 'custom-provider-attr', + }); + }); + }); +}); \ No newline at end of file