diff --git a/packages/@aws-cdk/aws-cognito/README.md b/packages/@aws-cdk/aws-cognito/README.md index f139f9e7b55bc..5067ae8f44b63 100644 --- a/packages/@aws-cdk/aws-cognito/README.md +++ b/packages/@aws-cdk/aws-cognito/README.md @@ -572,6 +572,30 @@ pool.addClient('app-client', { }); ``` +Clients can (and should) be allowed to read and write relevant user attributes only. Usually every client can be allowed to read the `given_name` +attribute but not every client should be allowed to set the `email_verified` attribute. +The same criteria applies for both standard and custom attributes, more info is available at +[Attribute Permissions and Scopes](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html#user-pool-settings-attribute-permissions-and-scopes). +The default behaviour is to allow read and write permissions on all attributes. The following code shows how this can be configured for a client. + +```ts +const pool = new cognito.UserPool(this, 'Pool'); + +const clientWriteAttributes = (new ClientAttributes()) + .withStandardAttributes({name: true, email: true}) + .withCustomAttributes(['favouritePizza']); + +const clientReadAttributes = clientWriteAttributes + .withStandardAttributes({emailVerified: true}) + .withCustomAttributes(['pointsEarned']); + +pool.addClient('app-client', { + // ... + readAttributes: clientReadAttributes, + writeAttributes: clientWriteAttributes, +}); +``` + ### Resource Servers A resource server is a server for access-protected resources. It handles authenticated requests from an app that has an diff --git a/packages/@aws-cdk/aws-cognito/lib/private/attr-names.ts b/packages/@aws-cdk/aws-cognito/lib/private/attr-names.ts index 1f0891cec1704..c3b07ddbb7742 100644 --- a/packages/@aws-cdk/aws-cognito/lib/private/attr-names.ts +++ b/packages/@aws-cdk/aws-cognito/lib/private/attr-names.ts @@ -16,4 +16,6 @@ export const StandardAttributeNames = { timezone: 'zoneinfo', lastUpdateTime: 'updated_at', website: 'website', + emailVerified: 'email_verified', + phoneNumberVerified: 'phone_number_verified', }; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-attr.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-attr.ts index e9881bd8b3a14..0687a0cb123ba 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool-attr.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-attr.ts @@ -1,4 +1,5 @@ import { Token } from '@aws-cdk/core'; +import { StandardAttributeNames } from './private/attr-names'; /** * The set of standard attributes that can be marked as required or mutable. @@ -107,6 +108,18 @@ export interface StandardAttributes { * @default - see the defaults under `StandardAttribute` */ readonly website?: StandardAttribute; + + /** + * Whether the email address has been verified. + * @default - see the defaults under `StandardAttribute` + */ + readonly emailVerified?: StandardAttribute; + + /** + * Whether the phone number has been verified. + * @default - see the defaults under `StandardAttribute` + */ + readonly phoneNumberVerified?: StandardAttribute; } /** @@ -341,3 +354,190 @@ export class DateTimeAttribute implements ICustomAttribute { }; } } + +/** + * This interface contains all standard attributes recognized by Cognito + * from https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html + * including `email_verified` and `phone_number_verified` + */ +export interface StandardAttributesMask { + /** + * The user's postal address. + * @default false + */ + readonly address?: boolean; + + /** + * The user's birthday, represented as an ISO 8601:2004 format. + * @default false + */ + readonly birthdate?: boolean; + + /** + * The user's e-mail address, represented as an RFC 5322 [RFC5322] addr-spec. + * @default false + */ + readonly email?: boolean; + + /** + * The surname or last name of the user. + * @default false + */ + readonly familyName?: boolean; + + /** + * The user's gender. + * @default false + */ + readonly gender?: boolean; + + /** + * The user's first name or give name. + * @default false + */ + readonly givenName?: boolean; + + /** + * The user's locale, represented as a BCP47 [RFC5646] language tag. + * @default false + */ + readonly locale?: boolean; + + /** + * The user's middle name. + * @default false + */ + readonly middleName?: boolean; + + /** + * The user's full name in displayable form, including all name parts, titles and suffixes. + * @default false + */ + readonly fullname?: boolean; + + /** + * The user's nickname or casual name. + * @default false + */ + readonly nickname?: boolean; + + /** + * The user's telephone number. + * @default false + */ + readonly phoneNumber?: boolean; + + /** + * The URL to the user's profile picture. + * @default false + */ + readonly profilePicture?: boolean; + + /** + * The user's preffered username, different from the immutable user name. + * @default false + */ + readonly preferredUsername?: boolean; + + /** + * The URL to the user's profile page. + * @default false + */ + readonly profilePage?: boolean; + + /** + * The user's time zone. + * @default false + */ + readonly timezone?: boolean; + + /** + * The time, the user's information was last updated. + * @default false + */ + readonly lastUpdateTime?: boolean; + + /** + * The URL to the user's web page or blog. + * @default false + */ + readonly website?: boolean; + + /** + * Whether the email address has been verified. + * @default false + */ + readonly emailVerified?: boolean; + + /** + * Whether the phone number has been verified. + * @default false + */ + readonly phoneNumberVerified?: boolean; +} + + +/** + * A set of attributes, useful to set Read and Write attributes + */ +export class ClientAttributes { + + /** + * The set of attributes + */ + private attributesSet: Set; + + /** + * Creates a ClientAttributes with the specified attributes + * + * @default - a ClientAttributes object without any attributes + */ + constructor() { + this.attributesSet = new Set(); + } + + /** + * Creates a custom ClientAttributes with the specified attributes + * @param attributes a list of standard attributes to add to the set + */ + public withStandardAttributes(attributes: StandardAttributesMask): ClientAttributes { + let attributesSet = new Set(this.attributesSet); + // iterate through key-values in the `StandardAttributeNames` constant + // to get the value for all attributes + for (const attributeKey in StandardAttributeNames) { + if ((attributes as any)[attributeKey] === true) { + const attributeName = (StandardAttributeNames as any)[attributeKey]; + attributesSet.add(attributeName); + } + } + let aux = new ClientAttributes(); + aux.attributesSet = attributesSet; + return aux; + } + + /** + * Creates a custom ClientAttributes with the specified attributes + * @param attributes a list of custom attributes to add to the set + */ + public withCustomAttributes(...attributes: string[]): ClientAttributes { + let attributesSet: Set = new Set(this.attributesSet); + for (let attribute of attributes) { + // custom attributes MUST begin with `custom:`, so add the string if not present + if (!attribute.startsWith('custom:')) { + attribute = 'custom:' + attribute; + } + attributesSet.add(attribute); + } + let aux = new ClientAttributes(); + aux.attributesSet = attributesSet; + return aux; + } + + /** + * The list of attributes represented by this ClientAttributes + */ + public attributes(): string[] { + // sorting is unnecessary but it simplify testing + return Array.from(this.attributesSet).sort(); + } +} 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 d8c8ae2d946bf..ea5693f45d1c4 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, Duration } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnUserPoolClient } from './cognito.generated'; import { IUserPool } from './user-pool'; +import { ClientAttributes } from './user-pool-attr'; import { IUserPoolResourceServer, ResourceServerScope } from './user-pool-resource-server'; /** @@ -272,6 +273,20 @@ export interface UserPoolClientOptions { * @default Duration.minutes(60) */ readonly accessTokenValidity?: Duration; + + /** + * The set of attributes this client will be able to read. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html#user-pool-settings-attribute-permissions-and-scopes + * @default - all standard and custom attributes + */ + readonly readAttributes?: ClientAttributes; + + /** + * The set of attributes this client will be able to write. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html#user-pool-settings-attribute-permissions-and-scopes + * @default - all standard and custom attributes + */ + readonly writeAttributes?: ClientAttributes; } /** @@ -358,6 +373,8 @@ export class UserPoolClient extends Resource implements IUserPoolClient { allowedOAuthFlowsUserPoolClient: !props.disableOAuth, preventUserExistenceErrors: this.configurePreventUserExistenceErrors(props.preventUserExistenceErrors), supportedIdentityProviders: this.configureIdentityProviders(props), + readAttributes: props.readAttributes?.attributes(), + writeAttributes: props.writeAttributes?.attributes(), }); this.configureTokenValidity(resource, props); diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.expected.json b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.expected.json index be2e268a29eac..6d118b0cf046c 100644 --- a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.expected.json +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.expected.json @@ -62,6 +62,11 @@ "PreventUserExistenceErrors": "ENABLED", "SupportedIdentityProviders": [ "COGNITO" + ], + "WriteAttributes": [ + "address", "birthdate", "custom:attribute_one", "custom:attribute_two", "email", + "family_name", "gender", "given_name", "locale", "middle_name", "name", "nickname", "phone_number", + "picture", "preferred_username", "profile", "updated_at", "website", "zoneinfo" ] } } diff --git a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.ts b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.ts index dbad2591fc1bc..2cd4557cdb48a 100644 --- a/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.ts +++ b/packages/@aws-cdk/aws-cognito/test/integ.user-pool-client-explicit-props.ts @@ -1,5 +1,5 @@ import { App, Stack } from '@aws-cdk/core'; -import { OAuthScope, UserPool } from '../lib'; +import { OAuthScope, UserPool, ClientAttributes } from '../lib'; const app = new App(); const stack = new Stack(app, 'integ-user-pool-client-explicit-props'); @@ -30,4 +30,24 @@ userpool.addClient('myuserpoolclient', { callbackUrls: ['https://redirect-here.myapp.com'], }, preventUserExistenceErrors: true, + writeAttributes: (new ClientAttributes()).withStandardAttributes( + { + address: true, + birthdate: true, + email: true, + familyName: true, + fullname: true, + gender: true, + givenName: true, + lastUpdateTime: true, + locale: true, + middleName: true, + nickname: true, + phoneNumber: true, + preferredUsername: true, + profilePage: true, + profilePicture: true, + timezone: true, + website: true, + }).withCustomAttributes('attribute_one', 'attribute_two'), }); diff --git a/packages/@aws-cdk/aws-cognito/test/user-pool-attr.test.ts b/packages/@aws-cdk/aws-cognito/test/user-pool-attr.test.ts index 43ef1a48d5dd1..1d5b5a7bd5d5f 100644 --- a/packages/@aws-cdk/aws-cognito/test/user-pool-attr.test.ts +++ b/packages/@aws-cdk/aws-cognito/test/user-pool-attr.test.ts @@ -1,6 +1,7 @@ import '@aws-cdk/assert/jest'; import { CfnParameter, Stack } from '@aws-cdk/core'; -import { BooleanAttribute, CustomAttributeConfig, DateTimeAttribute, ICustomAttribute, NumberAttribute, StringAttribute } from '../lib'; +import { BooleanAttribute, CustomAttributeConfig, DateTimeAttribute, ICustomAttribute, NumberAttribute, StringAttribute, ClientAttributes } from '../lib'; +import { StandardAttributeNames } from '../lib/private/attr-names'; describe('User Pool Attributes', () => { @@ -178,4 +179,113 @@ describe('User Pool Attributes', () => { expect(bound.numberConstraints).toBeUndefined(); }); }); + + describe('ClientAttributes', () => { + test('create empty ClientAttributes', () => { + // WHEN + const clientAttributes = (new ClientAttributes()); + + // THEN + expect(clientAttributes.attributes()).toStrictEqual([]); + }); + + test('create ClientAttributes with all standard attributes', () => { + // GIVEN + const customAttributes = ['custom:my_attribute']; + + // WHEN + const clientAttributes = (new ClientAttributes()).withStandardAttributes({ + address: true, + birthdate: true, + email: true, + emailVerified: true, + familyName: true, + fullname: true, + gender: true, + givenName: true, + lastUpdateTime: true, + locale: true, + middleName: true, + nickname: true, + phoneNumber: true, + phoneNumberVerified: true, + preferredUsername: true, + profilePage: true, + profilePicture: true, + timezone: true, + website: true, + }).withCustomAttributes(...customAttributes); + const attributes = clientAttributes.attributes(); + + // THEN + expect(attributes.length).toEqual(20); + expect(attributes).toContain('preferred_username'); + expect(attributes).toContain('email_verified'); + expect(attributes).toContain('phone_number_verified'); + expect(attributes).toContain('custom:my_attribute'); + }); + + test('create ClientAttributes copying another one', () => { + // GIVEN + const original = (new ClientAttributes()) + .withStandardAttributes({ email: true }) + .withCustomAttributes('custom1'); + const copied = original + .withStandardAttributes({ emailVerified: true }) + .withCustomAttributes('custom2'); + + // WHEN + const originalAttributes = original.attributes(); + const copiedAttributes = copied.attributes(); + + // THEN + expect(originalAttributes.length).toEqual(2); + expect(copiedAttributes.length).toEqual(4); + // originals MUST NOT contain the added ones + expect(originalAttributes).toContain('email'); + expect(originalAttributes).toContain('custom:custom1'); + expect(originalAttributes).not.toContain('email_verified'); + expect(originalAttributes).not.toContain('custom:custom2'); + // copied MUST contain all attributes + expect(copiedAttributes).toContain('email'); + expect(copiedAttributes).toContain('custom:custom1'); + expect(copiedAttributes).toContain('email_verified'); + expect(copiedAttributes).toContain('custom:custom2'); + }); + + test('create ClientAttributes with custom attributes only', () => { + // GIVEN + const customAttributes = ['custom:my_first', 'my_second']; + + // WHEN + const clientAttributes = (new ClientAttributes()).withCustomAttributes(...customAttributes); + const attributes = clientAttributes.attributes(); + + // EXPECT + expect(attributes.length).toEqual(2); + expect(attributes).toContain('custom:my_first'); + expect(attributes).toContain('custom:my_second'); + }); + + test('create ClientAttributes with all StandardAttributeNames', () => { + // this test is intended to check if changes in the `StandardAttributeNames` constant + // does not reflect as changes in the `StandardAttributesMask` + // GIVEN + let allStandardAttributes = {} as any; + let standardAttributeNamesCount = 0; // the count of StandardAttributeNames + // iterate through the standard attribute names + for (const attributeKey in StandardAttributeNames) { + standardAttributeNamesCount += 1; + expect(StandardAttributeNames).toHaveProperty(attributeKey); + // add the standard attribute + allStandardAttributes[attributeKey] = true; + } + + // WHEN + const attributes = (new ClientAttributes()).withStandardAttributes(allStandardAttributes).attributes(); + + // EXPECT + expect(attributes.length).toEqual(standardAttributeNamesCount); + }); + }); }); 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 1f961616b095b..3a056cd02dda7 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 { ABSENT, arrayWith } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; import { Stack, Duration } from '@aws-cdk/core'; -import { OAuthScope, ResourceServerScope, UserPool, UserPoolClient, UserPoolClientIdentityProvider, UserPoolIdentityProvider } from '../lib'; +import { OAuthScope, ResourceServerScope, UserPool, UserPoolClient, UserPoolClientIdentityProvider, UserPoolIdentityProvider, ClientAttributes } from '../lib'; describe('User Pool Client', () => { test('default setup', () => { @@ -827,4 +827,61 @@ describe('User Pool Client', () => { }); }); }); + + describe('read and write attributes', () => { + test('undefined by default', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + + // WHEN + pool.addClient('Client', {}); + + // EXPECT + expect(stack).toHaveResourceLike('AWS::Cognito::UserPoolClient', { + ReadAttributes: ABSENT, + WriteAttributes: ABSENT, + }); + }); + + test('set attributes', () => { + // GIVEN + const stack = new Stack(); + const pool = new UserPool(stack, 'Pool'); + const writeAttributes = (new ClientAttributes()).withCustomAttributes('my_first').withStandardAttributes({ givenName: true, familyName: true }); + const readAttributes = (new ClientAttributes()).withStandardAttributes({ + address: true, + birthdate: true, + email: true, + emailVerified: true, + familyName: true, + fullname: true, + gender: true, + givenName: true, + lastUpdateTime: true, + locale: true, + middleName: true, + nickname: true, + phoneNumber: true, + phoneNumberVerified: true, + preferredUsername: true, + profilePage: true, + profilePicture: true, + timezone: true, + website: true, + }); + + // WHEN + pool.addClient('Client', { + readAttributes, + writeAttributes, + }); + + // EXPECT + expect(stack).toHaveResourceLike('AWS::Cognito::UserPoolClient', { + ReadAttributes: arrayWith('name', 'given_name', 'family_name', 'middle_name', 'nickname', 'preferred_username', 'profile', 'picture', 'website', 'email', 'email_verified', 'gender', 'birthdate', 'zoneinfo', 'locale', 'phone_number', 'phone_number_verified', 'address', 'updated_at'), + WriteAttributes: arrayWith('given_name', 'family_name', 'custom:my_first'), + }); + }); + }); }); \ No newline at end of file