From 90a7c499ea4175d64f4bdc182fd2d1f2fff4a420 Mon Sep 17 00:00:00 2001 From: awsluja <110861985+awsluja@users.noreply.github.com> Date: Mon, 11 Nov 2024 13:11:45 -0800 Subject: [PATCH] Reference Auth (#2118) * feat: reference auth basic setup * chore: factory basic tests * chore: update api * chore: add ref auth package as dependency to backend-auth * chore: lint * chore: add tests for construct * chore: cleanup tsconfig * chore: add changeset * chore: fix tests inputs * chore: update tests * fix: update resource provider types * chore: update api * feat: reference auth outputs * chore: add tests * chore: fix test * chore: cleanup reused variables * chore: changeset * chore: cleanup changeset * chore: cleanup * chore: cleanup changesets, lockfile, and api * chore: fix mismatched output structure * chore: refactor and add tests * chore: add more tests for identity pool errors * chore: cleanup * chore: fix test * chore: add role tests * chore: add tests for user pool client * chore: cleanup * chore: refactor * chore: fix api * chore: undo changes to concurrent workspace script * chore: add missing roles permission * chore: update expected IAM policy permissions for identity pool * fix: make sure to throw on errors when using Provider framework * chore: refactor * chore: cleanup * chore: more cleanup * chore: check for alias attributes and fix tests * chore: add support for validating group roles exist for user pool * chore: update package-lock file * chore: add checks for oauth validation * chore: fix typo * chore: eliminate forcing updates on any change * chore: remove commented out code * chore: merge factory count into single count for all auth factories * chore: move sample data and npmignore it * chore: cleanup * chore: fix path * chore: update package lock * chore: update package-lock * chore: move construct into backend-auth * chore: update api * chore: update changeset * chore: cleanup * chore: move props type to factory * chore: add working setup for e2e resources in ref auth (#2122) * chore: add working setup for e2e resources in ref auth * feed pr base sha and ref into envs before scripts (#2168) * feed pr base sha and ref into envs before scripts * removing empty file * chore: update names to use test prefix * chore: remove extra hyphen * chore: fix cleanup and add sandbox test * chore: make sure to throw if error describing stack is unknown --------- Co-authored-by: Roshane Pascual * chore: add bsd-3-clause-clear license to allow list * chore: cleanup * chore: make lambda deps dev dependencies * chore: revert license changes * chore: remove tag mechanism as not needed for cleanup --------- Co-authored-by: Roshane Pascual --- .changeset/good-pugs-rescue.md | 9 + .changeset/spicy-rules-speak.md | 2 + CONTRIBUTING.md | 2 + package-lock.json | 7 +- packages/auth-construct/src/construct.ts | 1 + packages/backend-auth/.npmignore | 1 + packages/backend-auth/API.md | 27 + packages/backend-auth/package.json | 7 +- packages/backend-auth/src/factory.test.ts | 4 +- packages/backend-auth/src/factory.ts | 4 +- packages/backend-auth/src/index.ts | 6 + .../backend-auth/src/lambda/.eslintrc.json | 6 + .../lambda/reference_auth_initializer.test.ts | 558 ++++++++++++++++++ .../src/lambda/reference_auth_initializer.ts | 544 +++++++++++++++++ .../src/reference_construct.test.ts | 184 ++++++ .../backend-auth/src/reference_construct.ts | 224 +++++++ .../src/reference_factory.test.ts | 279 +++++++++ .../backend-auth/src/reference_factory.ts | 239 ++++++++ .../src/test-resources/sample_data.ts | 448 ++++++++++++++ packages/backend-auth/tsconfig.json | 1 + .../src/convert_authorization_modes.test.ts | 1 + .../src/convert_authorization_modes.ts | 9 +- packages/backend-data/src/factory.test.ts | 1 + packages/backend-data/src/factory.ts | 7 +- packages/backend/API.md | 3 + packages/backend/src/index.ts | 2 +- .../auth_resource_creator.ts | 372 ++++++++++++ .../reference_auth_project.deployment.test.ts | 4 + .../reference_auth_project.sandbox.test.ts | 4 + .../reference_auth_project.ts | 340 +++++++++++ .../test-project-setup/test_project_base.ts | 69 ++- .../reference-auth/amplify/auth/resource.ts | 14 + .../reference-auth/amplify/backend.ts | 10 + .../amplify/data/add-user-to-group/handler.ts | 3 + .../data/add-user-to-group/resource.ts | 5 + .../reference-auth/amplify/data/resource.ts | 24 + .../amplify/storage/resource.ts | 15 + .../test-types/env/add-user-to-group.ts | 10 + packages/plugin-types/API.md | 15 + packages/plugin-types/src/auth_resources.ts | 41 ++ 40 files changed, 3485 insertions(+), 17 deletions(-) create mode 100644 .changeset/good-pugs-rescue.md create mode 100644 .changeset/spicy-rules-speak.md create mode 100644 packages/backend-auth/src/lambda/.eslintrc.json create mode 100644 packages/backend-auth/src/lambda/reference_auth_initializer.test.ts create mode 100644 packages/backend-auth/src/lambda/reference_auth_initializer.ts create mode 100644 packages/backend-auth/src/reference_construct.test.ts create mode 100644 packages/backend-auth/src/reference_construct.ts create mode 100644 packages/backend-auth/src/reference_factory.test.ts create mode 100644 packages/backend-auth/src/reference_factory.ts create mode 100644 packages/backend-auth/src/test-resources/sample_data.ts create mode 100644 packages/integration-tests/src/resource-creation/auth_resource_creator.ts create mode 100644 packages/integration-tests/src/test-e2e/deployment/reference_auth_project.deployment.test.ts create mode 100644 packages/integration-tests/src/test-e2e/sandbox/reference_auth_project.sandbox.test.ts create mode 100644 packages/integration-tests/src/test-project-setup/reference_auth_project.ts create mode 100644 packages/integration-tests/src/test-projects/reference-auth/amplify/auth/resource.ts create mode 100644 packages/integration-tests/src/test-projects/reference-auth/amplify/backend.ts create mode 100644 packages/integration-tests/src/test-projects/reference-auth/amplify/data/add-user-to-group/handler.ts create mode 100644 packages/integration-tests/src/test-projects/reference-auth/amplify/data/add-user-to-group/resource.ts create mode 100644 packages/integration-tests/src/test-projects/reference-auth/amplify/data/resource.ts create mode 100644 packages/integration-tests/src/test-projects/reference-auth/amplify/storage/resource.ts create mode 100644 packages/integration-tests/src/test-projects/reference-auth/test-types/env/add-user-to-group.ts diff --git a/.changeset/good-pugs-rescue.md b/.changeset/good-pugs-rescue.md new file mode 100644 index 0000000000..7f4a210605 --- /dev/null +++ b/.changeset/good-pugs-rescue.md @@ -0,0 +1,9 @@ +--- +'@aws-amplify/auth-construct': minor +'@aws-amplify/backend-auth': minor +'@aws-amplify/backend-data': minor +'@aws-amplify/plugin-types': minor +'@aws-amplify/backend': minor +--- + +Add support for referenceAuth. diff --git a/.changeset/spicy-rules-speak.md b/.changeset/spicy-rules-speak.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/spicy-rules-speak.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 424876cb2f..d76eea6cdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,6 +82,8 @@ For local testing we recommend writing unit tests that exercise the code you are npm run test:dir packages//lib/.test.ts ``` +> Note: If your test depends on \_\_dirname or import.meta.url paths, you may see errors resolving paths if you specify the entire path to the test file. You should specify just the `packages/` portion of the test you are running. + > Note: You must rebuild using `npm run build` for tests to pick up your changes. Sometimes it's nice to have a test project to use as a testing environment for local changes. You can create test projects in the `local-testing` directory using diff --git a/package-lock.json b/package-lock.json index 672a2ec6f5..1ce770cf49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31905,12 +31905,17 @@ "license": "Apache-2.0", "dependencies": { "@aws-amplify/auth-construct": "^1.4.0", + "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", "@aws-amplify/plugin-types": "^1.3.1" }, "devDependencies": { "@aws-amplify/backend-platform-test-stubs": "^0.3.6", - "@aws-amplify/platform-core": "^1.0.6" + "@aws-amplify/platform-core": "^1.0.6", + "@aws-sdk/client-cognito-identity": "^3.624.0", + "@aws-sdk/client-cognito-identity-provider": "^3.624.0", + "@types/aws-lambda": "^8.10.119", + "aws-lambda": "^1.0.7" }, "peerDependencies": { "aws-cdk-lib": "^2.158.0", diff --git a/packages/auth-construct/src/construct.ts b/packages/auth-construct/src/construct.ts index 84f9455d7b..7c94f9ad00 100644 --- a/packages/auth-construct/src/construct.ts +++ b/packages/auth-construct/src/construct.ts @@ -223,6 +223,7 @@ export class AmplifyAuth userPoolClient, authenticatedUserIamRole: auth, unauthenticatedUserIamRole: unAuth, + identityPoolId: identityPool.ref, cfnResources: { cfnUserPool, cfnUserPoolClient, diff --git a/packages/backend-auth/.npmignore b/packages/backend-auth/.npmignore index dbde1fb5db..78143c7113 100644 --- a/packages/backend-auth/.npmignore +++ b/packages/backend-auth/.npmignore @@ -10,5 +10,6 @@ # Then ignore test js and ts declaration files *.test.js *.test.d.ts +**/test-resources/** # This leaves us with including only js and ts declaration files of functional code diff --git a/packages/backend-auth/API.md b/packages/backend-auth/API.md index a1c07703d7..6663c93e78 100644 --- a/packages/backend-auth/API.md +++ b/packages/backend-auth/API.md @@ -7,9 +7,11 @@ import { AmazonProviderProps } from '@aws-amplify/auth-construct'; import { AmplifyFunction } from '@aws-amplify/plugin-types'; import { AppleProviderProps } from '@aws-amplify/auth-construct'; +import { AuthOutput } from '@aws-amplify/backend-output-schemas'; import { AuthProps } from '@aws-amplify/auth-construct'; import { AuthResources } from '@aws-amplify/plugin-types'; import { AuthRoleName } from '@aws-amplify/plugin-types'; +import { BackendOutputStorageStrategy } from '@aws-amplify/plugin-types'; import { BackendSecret } from '@aws-amplify/plugin-types'; import { ConstructFactory } from '@aws-amplify/plugin-types'; import { ConstructFactoryGetInstanceProps } from '@aws-amplify/plugin-types'; @@ -19,6 +21,7 @@ import { FunctionResources } from '@aws-amplify/plugin-types'; import { GoogleProviderProps } from '@aws-amplify/auth-construct'; import { IFunction } from 'aws-cdk-lib/aws-lambda'; import { OidcProviderProps } from '@aws-amplify/auth-construct'; +import { ReferenceAuthResources } from '@aws-amplify/plugin-types'; import { ResourceAccessAcceptor } from '@aws-amplify/plugin-types'; import { ResourceAccessAcceptorFactory } from '@aws-amplify/plugin-types'; import { ResourceProvider } from '@aws-amplify/plugin-types'; @@ -48,6 +51,11 @@ export type AmplifyAuthProps = Expand; +// @public (undocumented) +export type AmplifyReferenceAuthProps = Expand & { + access?: AuthAccessGenerator; +}>; + // @public export type AppleProviderFactoryProps = Omit & { clientId: BackendSecret; @@ -86,6 +94,9 @@ export type AuthLoginWithFactoryProps = Omit & ResourceAccessAcceptorFactory & StackProvider; +// @public (undocumented) +export type BackendReferenceAuth = ResourceProvider & ResourceAccessAcceptorFactory & StackProvider; + // @public export type CustomEmailSender = { handler: ConstructFactory | IFunction; @@ -130,6 +141,22 @@ export type OidcProviderFactoryProps = Omit ConstructFactory; + +// @public (undocumented) +export type ReferenceAuthProps = { + outputStorageStrategy?: BackendOutputStorageStrategy; + userPoolId: string; + identityPoolId: string; + userPoolClientId: string; + authRoleArn: string; + unauthRoleArn: string; + groups?: { + [groupName: string]: string; + }; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-auth/package.json b/packages/backend-auth/package.json index dd5dab4e33..4c3a4aed8a 100644 --- a/packages/backend-auth/package.json +++ b/packages/backend-auth/package.json @@ -20,12 +20,17 @@ "license": "Apache-2.0", "dependencies": { "@aws-amplify/auth-construct": "^1.4.0", + "@aws-amplify/backend-output-schemas": "^1.4.0", "@aws-amplify/backend-output-storage": "^1.1.3", "@aws-amplify/plugin-types": "^1.3.1" }, "devDependencies": { "@aws-amplify/backend-platform-test-stubs": "^0.3.6", - "@aws-amplify/platform-core": "^1.0.6" + "@aws-amplify/platform-core": "^1.0.6", + "@aws-sdk/client-cognito-identity-provider": "^3.624.0", + "@aws-sdk/client-cognito-identity": "^3.624.0", + "@types/aws-lambda": "^8.10.119", + "aws-lambda": "^1.0.7" }, "peerDependencies": { "aws-cdk-lib": "^2.158.0", diff --git a/packages/backend-auth/src/factory.test.ts b/packages/backend-auth/src/factory.test.ts index a01c5f5935..eb7ed5336e 100644 --- a/packages/backend-auth/src/factory.test.ts +++ b/packages/backend-auth/src/factory.test.ts @@ -153,8 +153,8 @@ void describe('AmplifyAuthFactory', () => { }, new AmplifyUserError('MultipleSingletonResourcesError', { message: - 'Multiple `defineAuth` calls are not allowed within an Amplify backend', - resolution: 'Remove all but one `defineAuth` call', + 'Multiple `defineAuth` or `referenceAuth` calls are not allowed within an Amplify backend', + resolution: 'Remove all but one `defineAuth` or `referenceAuth` call', }) ); }); diff --git a/packages/backend-auth/src/factory.ts b/packages/backend-auth/src/factory.ts index 48d411f07d..767e08430f 100644 --- a/packages/backend-auth/src/factory.ts +++ b/packages/backend-auth/src/factory.ts @@ -103,8 +103,8 @@ export class AmplifyAuthFactory implements ConstructFactory { if (AmplifyAuthFactory.factoryCount > 0) { throw new AmplifyUserError('MultipleSingletonResourcesError', { message: - 'Multiple `defineAuth` calls are not allowed within an Amplify backend', - resolution: 'Remove all but one `defineAuth` call', + 'Multiple `defineAuth` or `referenceAuth` calls are not allowed within an Amplify backend', + resolution: 'Remove all but one `defineAuth` or `referenceAuth` call', }); } AmplifyAuthFactory.factoryCount++; diff --git a/packages/backend-auth/src/index.ts b/packages/backend-auth/src/index.ts index 90ce00ddc3..8a53f0225d 100644 --- a/packages/backend-auth/src/index.ts +++ b/packages/backend-auth/src/index.ts @@ -1,2 +1,8 @@ export { BackendAuth, AmplifyAuthProps, defineAuth } from './factory.js'; +export { + BackendReferenceAuth, + AmplifyReferenceAuthProps, + referenceAuth, + ReferenceAuthProps, +} from './reference_factory.js'; export * from './types.js'; diff --git a/packages/backend-auth/src/lambda/.eslintrc.json b/packages/backend-auth/src/lambda/.eslintrc.json new file mode 100644 index 0000000000..fa0db4e422 --- /dev/null +++ b/packages/backend-auth/src/lambda/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "rules": { + "no-console": "off", + "amplify-backend-rules/prefer-amplify-errors": "off" + } +} diff --git a/packages/backend-auth/src/lambda/reference_auth_initializer.test.ts b/packages/backend-auth/src/lambda/reference_auth_initializer.test.ts new file mode 100644 index 0000000000..81c50484aa --- /dev/null +++ b/packages/backend-auth/src/lambda/reference_auth_initializer.test.ts @@ -0,0 +1,558 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import { ReferenceAuthInitializer } from './reference_auth_initializer.js'; +import { CloudFormationCustomResourceEvent } from 'aws-lambda'; +import assert from 'node:assert'; +import { + CognitoIdentityProviderClient, + DescribeUserPoolClientCommand, + DescribeUserPoolClientCommandOutput, + DescribeUserPoolCommand, + DescribeUserPoolCommandOutput, + GetUserPoolMfaConfigCommand, + GetUserPoolMfaConfigCommandOutput, + ListGroupsCommand, + ListGroupsCommandOutput, + ListIdentityProvidersCommand, + ListIdentityProvidersCommandOutput, +} from '@aws-sdk/client-cognito-identity-provider'; +import { + CognitoIdentityClient, + DescribeIdentityPoolCommand, + DescribeIdentityPoolCommandOutput, + GetIdentityPoolRolesCommand, + GetIdentityPoolRolesCommandOutput, +} from '@aws-sdk/client-cognito-identity'; +import { + IdentityPool, + IdentityPoolRoles, + IdentityProviders, + MFAResponse, + SampleInputProperties, + UserPool, + UserPoolClient, + UserPoolGroups, +} from '../test-resources/sample_data.js'; + +const customResourceEventCommon: Omit< + CloudFormationCustomResourceEvent, + 'RequestType' +> = { + ServiceToken: 'mockServiceToken', + ResponseURL: 'mockPreSignedS3Url', + StackId: 'mockStackId', + RequestId: '123', + LogicalResourceId: 'logicalId', + ResourceType: 'AWS::CloudFormation::CustomResource', + ResourceProperties: { + ...SampleInputProperties, + ServiceToken: 'token', + }, +}; +const createCfnEvent: CloudFormationCustomResourceEvent = { + RequestType: 'Create', + ...customResourceEventCommon, +}; + +const updateCfnEvent: CloudFormationCustomResourceEvent = { + RequestType: 'Update', + PhysicalResourceId: 'physicalId', + OldResourceProperties: { + ...SampleInputProperties, + ServiceToken: 'token', + }, + ...customResourceEventCommon, +}; + +const deleteCfnEvent: CloudFormationCustomResourceEvent = { + RequestType: 'Delete', + PhysicalResourceId: 'physicalId', + ...customResourceEventCommon, +}; +const httpError = { + $metadata: { + httpStatusCode: 500, + }, +}; +const httpSuccess = { + $metadata: { + httpStatusCode: 200, + }, +}; +const groupName = 'ADMINS'; +const groupRoleARN = 'arn:aws:iam::000000000000:role/sample-group-role'; +const groupRoleARNNotOnUserPool = + 'arn:aws:iam::000000000000:role/sample-bad-group-role'; +// aws sdk will throw with error message for any non 200 status so we don't need to re-package it +const awsSDKErrorMessageMock = new Error('this message comes from the aws sdk'); +const uuidMock = () => '00000000-0000-0000-0000-000000000000'; +const identityProviderClient = new CognitoIdentityProviderClient(); +const identityClient = new CognitoIdentityClient(); +const expectedData = { + userPoolId: SampleInputProperties.userPoolId, + webClientId: SampleInputProperties.userPoolClientId, + identityPoolId: SampleInputProperties.identityPoolId, + signupAttributes: '["sub","email"]', + usernameAttributes: '["email"]', + verificationMechanisms: '["email"]', + passwordPolicyMinLength: '10', + passwordPolicyRequirements: + '["REQUIRES_NUMBERS","REQUIRES_LOWERCASE","REQUIRES_UPPERCASE"]', + mfaConfiguration: 'ON', + mfaTypes: '["TOTP"]', + socialProviders: '["FACEBOOK","GOOGLE","LOGIN_WITH_AMAZON"]', + oauthCognitoDomain: 'ref-auth-userpool-1.auth.us-east-1.amazoncognito.com', + allowUnauthenticatedIdentities: 'true', + oauthScope: '["email","openid","phone"]', + oauthRedirectSignIn: 'https://redirect.com,https://redirect2.com', + oauthRedirectSignOut: 'https://anotherlogouturl.com,https://logouturl.com', + oauthResponseType: 'code', + oauthClientId: SampleInputProperties.userPoolClientId, +}; + +void describe('ReferenceAuthInitializer', () => { + let handler: ReferenceAuthInitializer; + let describeUserPoolResponse: DescribeUserPoolCommandOutput; + let getUserPoolMfaConfigResponse: GetUserPoolMfaConfigCommandOutput; + let listIdentityProvidersResponse: ListIdentityProvidersCommandOutput; + let describeUserPoolClientResponse: DescribeUserPoolClientCommandOutput; + let describeIdentityPoolResponse: DescribeIdentityPoolCommandOutput; + let getIdentityPoolRolesResponse: GetIdentityPoolRolesCommandOutput; + let listGroupsResponse: ListGroupsCommandOutput; + const rejectsAndMatchError = async ( + fn: Promise, + expectedErrorMessage: string + ): Promise => { + await assert.rejects(fn, (error: Error) => { + assert.strictEqual(error.message, expectedErrorMessage); + return true; + }); + }; + beforeEach(() => { + handler = new ReferenceAuthInitializer( + identityClient, + identityProviderClient, + uuidMock + ); + describeUserPoolResponse = { + ...httpSuccess, + UserPool: UserPool, + }; + getUserPoolMfaConfigResponse = { + ...httpSuccess, + ...MFAResponse, + }; + listIdentityProvidersResponse = { + ...httpSuccess, + Providers: [...IdentityProviders], + }; + describeUserPoolClientResponse = { + ...httpSuccess, + UserPoolClient: UserPoolClient, + }; + describeIdentityPoolResponse = { + ...httpSuccess, + ...IdentityPool, + }; + getIdentityPoolRolesResponse = { + ...httpSuccess, + ...IdentityPoolRoles, + }; + listGroupsResponse = { + ...httpSuccess, + ...UserPoolGroups, + }; + mock.method( + identityProviderClient, + 'send', + async ( + request: + | DescribeUserPoolCommand + | GetUserPoolMfaConfigCommand + | ListIdentityProvidersCommand + | DescribeUserPoolClientCommand + | ListGroupsCommand + ) => { + if (request instanceof DescribeUserPoolCommand) { + if (describeUserPoolResponse.$metadata.httpStatusCode !== 200) { + throw awsSDKErrorMessageMock; + } + return describeUserPoolResponse; + } + if (request instanceof GetUserPoolMfaConfigCommand) { + if (getUserPoolMfaConfigResponse.$metadata.httpStatusCode !== 200) { + throw awsSDKErrorMessageMock; + } + return getUserPoolMfaConfigResponse; + } + if (request instanceof ListIdentityProvidersCommand) { + if (listIdentityProvidersResponse.$metadata.httpStatusCode !== 200) { + throw awsSDKErrorMessageMock; + } + return listIdentityProvidersResponse; + } + if (request instanceof DescribeUserPoolClientCommand) { + if (describeUserPoolClientResponse.$metadata.httpStatusCode !== 200) { + throw awsSDKErrorMessageMock; + } + return describeUserPoolClientResponse; + } + if (request instanceof ListGroupsCommand) { + if (listGroupsResponse.$metadata.httpStatusCode !== 200) { + throw awsSDKErrorMessageMock; + } + return listGroupsResponse; + } + return undefined; + } + ); + mock.method( + identityClient, + 'send', + async ( + request: DescribeIdentityPoolCommand | GetIdentityPoolRolesCommand + ) => { + if (request instanceof DescribeIdentityPoolCommand) { + if (describeIdentityPoolResponse.$metadata.httpStatusCode !== 200) { + throw awsSDKErrorMessageMock; + } + return describeIdentityPoolResponse; + } + if (request instanceof GetIdentityPoolRolesCommand) { + if (getIdentityPoolRolesResponse.$metadata.httpStatusCode !== 200) { + throw awsSDKErrorMessageMock; + } + return getIdentityPoolRolesResponse; + } + return undefined; + } + ); + }); + void it('handles create events', async () => { + const result = await handler.handleEvent(createCfnEvent); + assert.deepEqual(result.Status, 'SUCCESS'); + assert.equal( + result.PhysicalResourceId, + '00000000-0000-0000-0000-000000000000' + ); + assert.deepEqual(result.Data, expectedData); + }); + + void it('handles update events', async () => { + const result = await handler.handleEvent(updateCfnEvent); + assert.deepEqual(result.Status, 'SUCCESS'); + assert.deepEqual(result.Data, expectedData); + }); + + void it('handles delete events', async () => { + const result = await handler.handleEvent(deleteCfnEvent); + assert.deepEqual(result.Status, 'SUCCESS'); + }); + + void it('throws if fetching user pool fails', async () => { + describeUserPoolResponse = httpError; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + awsSDKErrorMessageMock.message + ); + }); + + void it('throws if fetching user pool fails', async () => { + describeUserPoolResponse = { + ...httpSuccess, + UserPool: undefined, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'Failed to retrieve the specified UserPool.' + ); + }); + + void it('throws if user pool has no password policy', async () => { + describeUserPoolResponse = { + ...httpSuccess, + UserPool: { + ...UserPool, + Policies: undefined, + }, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'Failed to retrieve password policy.' + ); + }); + + void it('throws if user pool uses alias attributes', async () => { + describeUserPoolResponse = { + ...httpSuccess, + UserPool: { + ...UserPool, + UsernameAttributes: [], + AliasAttributes: ['email', 'phone_number'], + }, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'The specified user pool is configured with alias attributes which are not currently supported.' + ); + }); + + void it('throws if user pool does not have a domain configured and external login providers are enabled', async () => { + describeUserPoolResponse = { + ...httpSuccess, + UserPool: { + ...UserPool, + Domain: undefined, + CustomDomain: undefined, + }, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'You must configure a domain for your UserPool if external login providers are enabled.' + ); + }); + + void it('throws if user pool group is not found', async () => { + listGroupsResponse = { + ...httpSuccess, + Groups: [ + { + GroupName: 'OTHERGROUP', + RoleArn: groupRoleARNNotOnUserPool, + }, + ], + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + `The group '${groupName}' with role '${groupRoleARN}' does not match any group for the specified user pool.` + ); + }); + + void it('throws if user pool groups request fails', async () => { + listGroupsResponse = { + ...httpError, + Groups: undefined, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + awsSDKErrorMessageMock.message + ); + }); + + void it('throws if user pool groups response is undefined', async () => { + listGroupsResponse = { + ...httpSuccess, + Groups: undefined, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'An error occurred while retrieving the groups for the user pool.' + ); + }); + + void it('throws if fetching user pool MFA config fails', async () => { + getUserPoolMfaConfigResponse = httpError; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + awsSDKErrorMessageMock.message + ); + }); + + void it('throws if fetching user pool providers fails', async () => { + listIdentityProvidersResponse = { + $metadata: { + httpStatusCode: 500, + }, + Providers: [], + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + awsSDKErrorMessageMock.message + ); + }); + + void it('throws if fetching user pool providers returns undefined', async () => { + listIdentityProvidersResponse = { + ...httpSuccess, + Providers: undefined, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'An error occurred while retrieving identity providers for the user pool.' + ); + }); + + void it('throws if fetching user pool client fails', async () => { + describeUserPoolClientResponse = httpError; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + awsSDKErrorMessageMock.message + ); + }); + void it('throws if fetching user pool client returns undefined', async () => { + describeUserPoolClientResponse = { + ...httpSuccess, + UserPoolClient: undefined, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'An error occurred while retrieving the user pool client details.' + ); + }); + void it('throws if user pool client does not have sign-out / logout URLs configured and external login providers are enabled', async () => { + describeUserPoolClientResponse = { + ...httpSuccess, + UserPoolClient: { + ...UserPoolClient, + LogoutURLs: [], + }, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'Your UserPool client must have "Allowed sign-out URLs" configured if external login providers are enabled.' + ); + }); + void it('throws if user pool client does not have callback URLs configured and external login providers are enabled', async () => { + describeUserPoolClientResponse = { + ...httpSuccess, + UserPoolClient: { + ...UserPoolClient, + CallbackURLs: [], + }, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'Your UserPool client must have "Allowed callback URLs" configured if external login providers are enabled.' + ); + }); + + void it('throws if fetching identity pool fails', async () => { + describeIdentityPoolResponse = { + $metadata: { + httpStatusCode: 500, + }, + IdentityPoolId: undefined, + IdentityPoolName: undefined, + AllowUnauthenticatedIdentities: undefined, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + awsSDKErrorMessageMock.message + ); + }); + void it('throws if fetching identity pool returns undefined', async () => { + describeIdentityPoolResponse = { + ...httpSuccess, + IdentityPoolId: undefined, + IdentityPoolName: undefined, + AllowUnauthenticatedIdentities: undefined, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'An error occurred while retrieving the identity pool details.' + ); + }); + + void it('throws if fetching identity pool roles fails', async () => { + getIdentityPoolRolesResponse = httpError; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + awsSDKErrorMessageMock.message + ); + }); + void it('throws if fetching identity pool roles return undefined', async () => { + getIdentityPoolRolesResponse = { + ...httpSuccess, + Roles: undefined, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'An error occurred while retrieving the roles for the identity pool.' + ); + }); + // throws if userPool or client doesn't match identity pool + void it('throws there is not matching userPool for the identity pool', async () => { + describeIdentityPoolResponse = { + ...describeIdentityPoolResponse, + CognitoIdentityProviders: [ + { + ProviderName: + 'cognito-idp.us-east-1.amazonaws.com/us-east-1_wrongUserPool', + ClientId: 'sampleUserPoolClientId', + ServerSideTokenCheck: false, + }, + ], + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'The user pool and user pool client pair do not match any cognito identity providers for the specified identity pool.' + ); + }); + void it('throws if identity pool does not have cognito identity providers configured', async () => { + describeIdentityPoolResponse = { + ...describeIdentityPoolResponse, + CognitoIdentityProviders: [], + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'The specified identity pool does not have any cognito identity providers.' + ); + }); + void it('throws if the client id does not match any cognito provider on the identity pool', async () => { + describeIdentityPoolResponse = { + ...describeIdentityPoolResponse, + CognitoIdentityProviders: [ + { + ProviderName: + 'cognito-idp.us-east-1.amazonaws.com/us-east-1_userpoolTest', + ClientId: 'wrongClientId', + ServerSideTokenCheck: false, + }, + ], + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'The user pool and user pool client pair do not match any cognito identity providers for the specified identity pool.' + ); + }); + void it('throws if auth role ARN does not match', async () => { + getIdentityPoolRolesResponse = { + ...httpSuccess, + IdentityPoolId: SampleInputProperties.identityPoolId, + Roles: { + authenticated: 'wrongAuthRole', + unauthenticated: SampleInputProperties.unauthRoleArn, + }, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'The provided authRoleArn does not match the authenticated role for the specified identity pool.' + ); + }); + void it('throws if unauth role ARN does not match', async () => { + getIdentityPoolRolesResponse = { + ...httpSuccess, + IdentityPoolId: SampleInputProperties.identityPoolId, + Roles: { + authenticated: SampleInputProperties.authRoleArn, + unauthenticated: 'wrongUnauthRole', + }, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'The provided unauthRoleArn does not match the unauthenticated role for the specified identity pool.' + ); + }); + void it('throws if user pool client is not a web client', async () => { + describeUserPoolClientResponse = { + ...httpSuccess, + UserPoolClient: { + ...UserPoolClient, + ClientSecret: 'sample', + }, + }; + await rejectsAndMatchError( + handler.handleEvent(createCfnEvent), + 'The specified user pool client is not configured as a web client.' + ); + }); +}); diff --git a/packages/backend-auth/src/lambda/reference_auth_initializer.ts b/packages/backend-auth/src/lambda/reference_auth_initializer.ts new file mode 100644 index 0000000000..9f31a7302b --- /dev/null +++ b/packages/backend-auth/src/lambda/reference_auth_initializer.ts @@ -0,0 +1,544 @@ +import { + CloudFormationCustomResourceEvent, + CloudFormationCustomResourceResponse, + CloudFormationCustomResourceSuccessResponse, +} from 'aws-lambda'; +import { + CognitoIdentityProviderClient, + DescribeUserPoolClientCommand, + DescribeUserPoolCommand, + GetUserPoolMfaConfigCommand, + GetUserPoolMfaConfigCommandOutput, + GroupType, + ListGroupsCommand, + ListIdentityProvidersCommand, + PasswordPolicyType, + ProviderDescription, + UserPoolClientType, + UserPoolType, +} from '@aws-sdk/client-cognito-identity-provider'; +import { + CognitoIdentityClient, + DescribeIdentityPoolCommand, + DescribeIdentityPoolCommandOutput, + GetIdentityPoolRolesCommand, +} from '@aws-sdk/client-cognito-identity'; +import { randomUUID } from 'node:crypto'; +import { AuthOutput } from '@aws-amplify/backend-output-schemas'; +export type ReferenceAuthInitializerProps = { + userPoolId: string; + identityPoolId: string; + authRoleArn: string; + unauthRoleArn: string; + userPoolClientId: string; + groups: Record; + region: string; +}; + +/** + * Initializer that fetches and process auth resources. + */ +export class ReferenceAuthInitializer { + /** + * Create a new initializer + * @param cognitoIdentityClient identity client + * @param cognitoIdentityProviderClient identity provider client + */ + constructor( + private cognitoIdentityClient: CognitoIdentityClient, + private cognitoIdentityProviderClient: CognitoIdentityProviderClient, + private uuidGenerator: () => string + ) {} + + /** + * Handles custom resource events + * @param event event to process + * @returns custom resource response + */ + public handleEvent = async (event: CloudFormationCustomResourceEvent) => { + console.info(`Received '${event.RequestType}' event`); + // physical id is only generated on create, otherwise it must stay the same + const physicalId = + event.RequestType === 'Create' + ? this.uuidGenerator() + : event.PhysicalResourceId; + + // on delete, just respond with success since we don't need to do anything + if (event.RequestType === 'Delete') { + return { + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + PhysicalResourceId: physicalId, + StackId: event.StackId, + NoEcho: true, + Status: 'SUCCESS', + } as CloudFormationCustomResourceSuccessResponse; + } + // for create or update events, we will fetch and validate resource properties + const props = + event.ResourceProperties as unknown as ReferenceAuthInitializerProps; + const { + userPool, + userPoolPasswordPolicy, + userPoolMFA, + userPoolGroups, + userPoolProviders, + userPoolClient, + identityPool, + roles, + } = await this.getResourceDetails( + props.userPoolId, + props.identityPoolId, + props.userPoolClientId + ); + + this.validateResources( + userPool, + userPoolProviders, + userPoolGroups, + userPoolClient, + identityPool, + roles, + props + ); + + const userPoolOutputs = await this.getUserPoolOutputs( + userPool, + userPoolPasswordPolicy, + userPoolProviders, + userPoolMFA, + props.region + ); + const identityPoolOutputs = await this.getIdentityPoolOutputs(identityPool); + const userPoolClientOutputs = await this.getUserPoolClientOutputs( + userPoolClient + ); + const data: Omit = { + userPoolId: props.userPoolId, + webClientId: props.userPoolClientId, + identityPoolId: props.identityPoolId, + ...userPoolOutputs, + ...identityPoolOutputs, + ...userPoolClientOutputs, + }; + return { + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + PhysicalResourceId: physicalId, + StackId: event.StackId, + NoEcho: true, + Data: data, + Status: 'SUCCESS', + } as CloudFormationCustomResourceSuccessResponse; + }; + + private getUserPool = async (userPoolId: string) => { + const userPoolCommand = new DescribeUserPoolCommand({ + UserPoolId: userPoolId, + }); + const userPoolResponse = await this.cognitoIdentityProviderClient.send( + userPoolCommand + ); + if (!userPoolResponse.UserPool) { + throw new Error('Failed to retrieve the specified UserPool.'); + } + const userPool = userPoolResponse.UserPool; + const policy = userPool.Policies?.PasswordPolicy; + if (!policy) { + throw new Error('Failed to retrieve password policy.'); + } + return { + userPool: userPoolResponse.UserPool, + userPoolPasswordPolicy: policy, + }; + }; + + private getUserPoolMFASettings = async (userPoolId: string) => { + // mfa types + const mfaCommand = new GetUserPoolMfaConfigCommand({ + UserPoolId: userPoolId, + }); + const mfaResponse = await this.cognitoIdentityProviderClient.send( + mfaCommand + ); + return mfaResponse; + }; + + private getUserPoolGroups = async (userPoolId: string) => { + let nextToken: string | undefined; + const groups: GroupType[] = []; + do { + const listGroupsResponse = await this.cognitoIdentityProviderClient.send( + new ListGroupsCommand({ + UserPoolId: userPoolId, + NextToken: nextToken, + }) + ); + if (!listGroupsResponse.Groups) { + throw new Error( + 'An error occurred while retrieving the groups for the user pool.' + ); + } + groups.push(...listGroupsResponse.Groups); + nextToken = listGroupsResponse.NextToken; + } while (nextToken); + return groups; + }; + + private getUserPoolProviders = async (userPoolId: string) => { + const providers: ProviderDescription[] = []; + let nextToken: string | undefined; + do { + const providersResponse = await this.cognitoIdentityProviderClient.send( + new ListIdentityProvidersCommand({ + UserPoolId: userPoolId, + NextToken: nextToken, + }) + ); + if (providersResponse.Providers === undefined) { + throw new Error( + 'An error occurred while retrieving identity providers for the user pool.' + ); + } + providers.push(...providersResponse.Providers); + nextToken = providersResponse.NextToken; + } while (nextToken); + return providers; + }; + + private getIdentityPool = async (identityPoolId: string) => { + const idpResponse = await this.cognitoIdentityClient.send( + new DescribeIdentityPoolCommand({ + IdentityPoolId: identityPoolId, + }) + ); + if (!idpResponse.IdentityPoolId) { + throw new Error( + 'An error occurred while retrieving the identity pool details.' + ); + } + return idpResponse; + }; + + private getIdentityPoolRoles = async (identityPoolId: string) => { + const rolesCommand = new GetIdentityPoolRolesCommand({ + IdentityPoolId: identityPoolId, + }); + const rolesResponse = await this.cognitoIdentityClient.send(rolesCommand); + if (!rolesResponse.Roles) { + throw new Error( + 'An error occurred while retrieving the roles for the identity pool.' + ); + } + return rolesResponse.Roles; + }; + + private getUserPoolClient = async ( + userPoolId: string, + userPoolClientId: string + ) => { + const userPoolClientCommand = new DescribeUserPoolClientCommand({ + UserPoolId: userPoolId, + ClientId: userPoolClientId, + }); + const userPoolClientResponse = + await this.cognitoIdentityProviderClient.send(userPoolClientCommand); + if (!userPoolClientResponse.UserPoolClient) { + throw new Error( + 'An error occurred while retrieving the user pool client details.' + ); + } + return userPoolClientResponse.UserPoolClient; + }; + + /** + * Retrieves all of the resource data that is necessary for validation and output generation. + * @param userPoolId userPoolId + * @param identityPoolId identityPoolId + * @param userPoolClientId userPoolClientId + * @returns all necessary resource data + */ + private getResourceDetails = async ( + userPoolId: string, + identityPoolId: string, + userPoolClientId: string + ) => { + const { userPool, userPoolPasswordPolicy } = await this.getUserPool( + userPoolId + ); + const userPoolMFA = await this.getUserPoolMFASettings(userPoolId); + const userPoolProviders = await this.getUserPoolProviders(userPoolId); + const userPoolGroups = await this.getUserPoolGroups(userPoolId); + const userPoolClient = await this.getUserPoolClient( + userPoolId, + userPoolClientId + ); + const identityPool = await this.getIdentityPool(identityPoolId); + const roles = await this.getIdentityPoolRoles(identityPoolId); + return { + userPool, + userPoolPasswordPolicy, + userPoolMFA, + userPoolProviders, + userPoolGroups, + userPoolClient, + identityPool, + roles, + }; + }; + + /** + * Validate the resource associations. + * 1. make sure the user pool & user pool client pair are a cognito provider for the identity pool + * 2. make sure the provided auth/unauth role ARNs match the roles for the identity pool + * 3. make sure the user pool client is a web client + * @param userPool userPool + * @param userPoolProviders the user pool providers + * @param userPoolGroups the existing groups for the userPool + * @param userPoolClient userPoolClient + * @param identityPool identityPool + * @param identityPoolRoles identityPool roles + * @param props props that include the roles which we compare with the actual roles for the identity pool + */ + private validateResources = ( + userPool: UserPoolType, + userPoolProviders: ProviderDescription[], + userPoolGroups: GroupType[], + userPoolClient: UserPoolClientType, + identityPool: DescribeIdentityPoolCommandOutput, + identityPoolRoles: Record, + props: ReferenceAuthInitializerProps + ) => { + // verify the user pool is a cognito provider for this identity pool + if ( + !identityPool.CognitoIdentityProviders || + identityPool.CognitoIdentityProviders.length === 0 + ) { + throw new Error( + 'The specified identity pool does not have any cognito identity providers.' + ); + } + // check for alias attributes, since we don't support this yet + if (userPool.AliasAttributes && userPool.AliasAttributes.length > 0) { + throw new Error( + 'The specified user pool is configured with alias attributes which are not currently supported.' + ); + } + + // check OAuth settings + if (userPoolProviders.length > 0) { + // validate user pool + const domainSpecified = userPool.Domain || userPool.CustomDomain; + if (!domainSpecified) { + throw new Error( + 'You must configure a domain for your UserPool if external login providers are enabled.' + ); + } + + // validate user pool client + const hasLogoutUrls = + userPoolClient.LogoutURLs && userPoolClient.LogoutURLs.length > 0; + const hasCallbackUrls = + userPoolClient.CallbackURLs && userPoolClient.CallbackURLs.length > 0; + if (!hasLogoutUrls) { + throw new Error( + 'Your UserPool client must have "Allowed sign-out URLs" configured if external login providers are enabled.' + ); + } + if (!hasCallbackUrls) { + throw new Error( + 'Your UserPool client must have "Allowed callback URLs" configured if external login providers are enabled.' + ); + } + } + + // make sure props groups Roles actually exist for the user pool + const groupEntries = Object.entries(props.groups); + for (const [groupName, groupRoleARN] of groupEntries) { + const match = userPoolGroups.find((g) => g.RoleArn === groupRoleARN); + if (match === undefined) { + throw new Error( + `The group '${groupName}' with role '${groupRoleARN}' does not match any group for the specified user pool.` + ); + } + } + // verify that the user pool + user pool client pair are configured with the identity pool + const matchingProvider = identityPool.CognitoIdentityProviders.find((p) => { + const matchingUserPool: boolean = + p.ProviderName === + `cognito-idp.${props.region}.amazonaws.com/${userPool.Id}`; + const matchingUserPoolClient: boolean = + p.ClientId === userPoolClient.ClientId; + return matchingUserPool && matchingUserPoolClient; + }); + if (!matchingProvider) { + throw new Error( + 'The user pool and user pool client pair do not match any cognito identity providers for the specified identity pool.' + ); + } + // verify the auth / unauth roles from the props match the identity pool roles that we retrieved + const authRoleArn = identityPoolRoles['authenticated']; + const unauthRoleArn = identityPoolRoles['unauthenticated']; + if (authRoleArn !== props.authRoleArn) { + throw new Error( + 'The provided authRoleArn does not match the authenticated role for the specified identity pool.' + ); + } + if (unauthRoleArn !== props.unauthRoleArn) { + throw new Error( + 'The provided unauthRoleArn does not match the unauthenticated role for the specified identity pool.' + ); + } + + // make sure the client is a web client here (web clients shouldn't have client secrets) + if (userPoolClient?.ClientSecret) { + throw new Error( + 'The specified user pool client is not configured as a web client.' + ); + } + }; + + /** + * Transform the userPool data into outputs. + * @param userPool user pool + * @param userPoolPasswordPolicy user pool password policy + * @param userPoolProviders user pool providers + * @param userPoolMFA user pool MFA settings + * @returns formatted outputs + */ + private getUserPoolOutputs = ( + userPool: UserPoolType, + userPoolPasswordPolicy: PasswordPolicyType, + userPoolProviders: ProviderDescription[], + userPoolMFA: GetUserPoolMfaConfigCommandOutput, + region: string + ) => { + // password policy requirements + const requirements: string[] = []; + userPoolPasswordPolicy.RequireNumbers && + requirements.push('REQUIRES_NUMBERS'); + userPoolPasswordPolicy.RequireLowercase && + requirements.push('REQUIRES_LOWERCASE'); + userPoolPasswordPolicy.RequireUppercase && + requirements.push('REQUIRES_UPPERCASE'); + userPoolPasswordPolicy.RequireSymbols && + requirements.push('REQUIRES_SYMBOLS'); + // mfa types + const mfaTypes: string[] = []; + if ( + userPoolMFA.SmsMfaConfiguration && + userPoolMFA.SmsMfaConfiguration.SmsConfiguration + ) { + mfaTypes.push('SMS_MFA'); + } + if (userPoolMFA.SoftwareTokenMfaConfiguration?.Enabled) { + mfaTypes.push('TOTP'); + } + // social providers + const socialProviders: string[] = []; + if (userPoolProviders) { + for (const provider of userPoolProviders) { + const providerType = provider.ProviderType; + const providerName = provider.ProviderName; + if (providerType === 'Google') { + socialProviders.push('GOOGLE'); + } + if (providerType === 'Facebook') { + socialProviders.push('FACEBOOK'); + } + if (providerType === 'SignInWithApple') { + socialProviders.push('SIGN_IN_WITH_APPLE'); + } + if (providerType === 'LoginWithAmazon') { + socialProviders.push('LOGIN_WITH_AMAZON'); + } + if (providerType === 'OIDC' && providerName) { + socialProviders.push(providerName); + } + if (providerType === 'SAML' && providerName) { + socialProviders.push(providerName); + } + } + } + + // domain + const oauthDomain = userPool.CustomDomain ?? userPool.Domain ?? ''; + const fullDomainPath = `${oauthDomain}.auth.${region}.amazoncognito.com`; + const data = { + signupAttributes: JSON.stringify( + userPool.SchemaAttributes?.filter( + (attribute) => attribute.Required && attribute.Name + ).map((attribute) => attribute.Name?.toLowerCase()) || [] + ), + usernameAttributes: JSON.stringify( + userPool.UsernameAttributes?.map((attribute) => + attribute.toLowerCase() + ) || [] + ), + verificationMechanisms: JSON.stringify( + userPool.AutoVerifiedAttributes ?? [] + ), + passwordPolicyMinLength: + userPoolPasswordPolicy.MinimumLength === undefined + ? '' + : userPoolPasswordPolicy.MinimumLength.toString(), + passwordPolicyRequirements: JSON.stringify(requirements), + mfaConfiguration: userPool.MfaConfiguration ?? 'OFF', + mfaTypes: JSON.stringify(mfaTypes), + socialProviders: JSON.stringify(socialProviders), + oauthCognitoDomain: fullDomainPath, + }; + return data; + }; + + /** + * Transforms identityPool info into outputs. + * @param identityPool identity pool data + * @returns formatted outputs + */ + private getIdentityPoolOutputs = ( + identityPool: DescribeIdentityPoolCommandOutput + ) => { + const data = { + allowUnauthenticatedIdentities: + identityPool.AllowUnauthenticatedIdentities === true ? 'true' : 'false', + }; + return data; + }; + + /** + * Transforms userPoolClient info into outputs. + * @param userPoolClient userPoolClient data + * @returns formatted outputs + */ + private getUserPoolClientOutputs = (userPoolClient: UserPoolClientType) => { + const data = { + oauthScope: JSON.stringify(userPoolClient.AllowedOAuthScopes ?? []), + oauthRedirectSignIn: userPoolClient.CallbackURLs + ? userPoolClient.CallbackURLs.join(',') + : '', + oauthRedirectSignOut: userPoolClient.LogoutURLs + ? userPoolClient.LogoutURLs.join(',') + : '', + oauthResponseType: userPoolClient.AllowedOAuthFlows + ? userPoolClient.AllowedOAuthFlows.join(',') + : '', + oauthClientId: userPoolClient.ClientId, + }; + return data; + }; +} + +/** + * Entry point for the lambda-backend custom resource to retrieve auth outputs. + */ +export const handler = async ( + event: CloudFormationCustomResourceEvent +): Promise => { + const initializer = new ReferenceAuthInitializer( + new CognitoIdentityClient(), + new CognitoIdentityProviderClient(), + randomUUID + ); + return initializer.handleEvent(event); +}; diff --git a/packages/backend-auth/src/reference_construct.test.ts b/packages/backend-auth/src/reference_construct.test.ts new file mode 100644 index 0000000000..8c852add5e --- /dev/null +++ b/packages/backend-auth/src/reference_construct.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { + AmplifyReferenceAuth, + OUTPUT_PROPERTIES_PROVIDED_BY_AUTH_CUSTOM_RESOURCE, + authOutputKey, +} from './reference_construct.js'; +import { + BackendOutputEntry, + BackendOutputStorageStrategy, +} from '@aws-amplify/plugin-types'; +import { Template } from 'aws-cdk-lib/assertions'; +import { App, Stack } from 'aws-cdk-lib'; +import { ReferenceAuthProps } from './reference_factory.js'; +const refAuthProps: ReferenceAuthProps = { + authRoleArn: 'arn:aws:iam::000000000000:role/amplify-sample-auth-role-name', + unauthRoleArn: + 'arn:aws:iam::000000000000:role/amplify-sample-unauth-role-name', + identityPoolId: 'us-east-1:identityPoolId', + userPoolClientId: 'userPoolClientId', + userPoolId: 'us-east-1_userPoolId', +}; + +void describe('AmplifyConstruct', () => { + void it('creates custom resource initializer', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyReferenceAuth(stack, 'test', refAuthProps); + const template = Template.fromStack(stack); + // check that custom resource is created with properties + template.hasResourceProperties('Custom::AmplifyRefAuth', { + identityPoolId: refAuthProps.identityPoolId, + userPoolId: refAuthProps.userPoolId, + userPoolClientId: refAuthProps.userPoolClientId, + }); + }); + + void it('creates policy documents for custom resource', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyReferenceAuth(stack, 'test', refAuthProps); + const template = Template.fromStack(stack); + const policyStatements = [ + { + Action: [ + 'cognito-idp:DescribeUserPool', + 'cognito-idp:GetUserPoolMfaConfig', + 'cognito-idp:ListIdentityProviders', + 'cognito-idp:ListGroups', + 'cognito-idp:DescribeUserPoolClient', + ], + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':cognito-idp:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + `:userpool/${refAuthProps.userPoolId}`, + ], + ], + }, + }, + { + Action: [ + 'cognito-identity:DescribeIdentityPool', + 'cognito-identity:GetIdentityPoolRoles', + ], + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:aws:cognito-identity:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + `:identitypool/${refAuthProps.identityPoolId}`, + ], + ], + }, + }, + ]; + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: policyStatements, + Version: '2012-10-17', + }, + }); + }); + + void it('generates the correct output values', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyReferenceAuth(stack, 'test', refAuthProps); + const template = Template.fromStack(stack); + // check that outputs reference custom resource attributes + const outputs = template.findOutputs('*'); + for (const property of OUTPUT_PROPERTIES_PROVIDED_BY_AUTH_CUSTOM_RESOURCE) { + const expectedValue = { + 'Fn::GetAtt': ['AmplifyRefAuthCustomResource', `${property}`], + }; + assert.ok(outputs[property]); + const actualValue = outputs[property]['Value']; + assert.deepEqual(actualValue, expectedValue); + } + }); + + void describe('storeOutput strategy', () => { + let app: App; + let stack: Stack; + const storeOutputMock = mock.fn(); + const stubBackendOutputStorageStrategy: BackendOutputStorageStrategy = + { + addBackendOutputEntry: storeOutputMock, + appendToBackendOutputList: storeOutputMock, + }; + + void beforeEach(() => { + app = new App(); + stack = new Stack(app); + storeOutputMock.mock.resetCalls(); + }); + + void it('stores output using custom strategy and basic props', () => { + const authConstruct = new AmplifyReferenceAuth(stack, 'test', { + ...refAuthProps, + outputStorageStrategy: stubBackendOutputStorageStrategy, + }); + + const expectedUserPoolId = authConstruct.resources.userPool.userPoolId; + const expectedIdentityPoolId = authConstruct.resources.identityPoolId; + const expectedWebClientId = + authConstruct.resources.userPoolClient.userPoolClientId; + const expectedRegion = Stack.of(authConstruct).region; + + const storeOutputArgs = storeOutputMock.mock.calls[0].arguments; + assert.equal(storeOutputArgs.length, 2); + assert.equal(storeOutputArgs[0], authOutputKey); + assert.equal(storeOutputArgs[1]['version'], '1'); + const payload = storeOutputArgs[1]['payload']; + assert.equal(payload['userPoolId'], expectedUserPoolId); + assert.equal(payload['identityPoolId'], expectedIdentityPoolId); + assert.equal(payload['webClientId'], expectedWebClientId); + assert.equal(payload['authRegion'], expectedRegion); + }); + + void it('stores output when no storage strategy is injected', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyReferenceAuth(stack, 'test', refAuthProps); + + const template = Template.fromStack(stack); + template.templateMatches({ + Metadata: { + [authOutputKey]: { + version: '1', + stackOutputs: [ + 'userPoolId', + 'webClientId', + 'identityPoolId', + 'authRegion', + ...OUTPUT_PROPERTIES_PROVIDED_BY_AUTH_CUSTOM_RESOURCE, + ], + }, + }, + }); + }); + }); +}); diff --git a/packages/backend-auth/src/reference_construct.ts b/packages/backend-auth/src/reference_construct.ts new file mode 100644 index 0000000000..939319a26f --- /dev/null +++ b/packages/backend-auth/src/reference_construct.ts @@ -0,0 +1,224 @@ +import { Construct } from 'constructs'; +import { + CustomResource, + Duration, + Stack, + aws_cognito, + aws_iam, +} from 'aws-cdk-lib'; +import { + BackendOutputStorageStrategy, + ReferenceAuthResources, + ResourceProvider, +} from '@aws-amplify/plugin-types'; +import { + AttributionMetadataStorage, + StackMetadataBackendOutputStorageStrategy, +} from '@aws-amplify/backend-output-storage'; +import { AuthOutput } from '@aws-amplify/backend-output-schemas'; +import * as path from 'path'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { Runtime } from 'aws-cdk-lib/aws-lambda'; +import { Provider } from 'aws-cdk-lib/custom-resources'; +import { Role } from 'aws-cdk-lib/aws-iam'; +import { ReferenceAuthInitializerProps } from './lambda/reference_auth_initializer.js'; +import { fileURLToPath } from 'node:url'; +import { ReferenceAuthProps } from './reference_factory.js'; + +/** + * Expected key that auth output is stored under - must match backend-output-schemas's authOutputKey + */ +export const authOutputKey = 'AWS::Amplify::Auth'; + +const REFERENCE_AUTH_CUSTOM_RESOURCE_PROVIDER_ID = + 'AmplifyRefAuthCustomResourceProvider'; +const REFERENCE_AUTH_CUSTOM_RESOURCE_ID = 'AmplifyRefAuthCustomResource'; +const RESOURCE_TYPE = 'Custom::AmplifyRefAuth'; + +const filename = fileURLToPath(import.meta.url); +const dirname = path.dirname(filename); +const resourcesRoot = path.normalize(path.join(dirname, 'lambda')); +const refAuthLambdaFilePath = path.join( + resourcesRoot, + 'reference_auth_initializer.js' +); + +const authStackType = 'auth-Cognito'; + +/** + * These properties are fetched by the custom resource and must be accounted for + * in the final AuthOutput payload. + */ +export const OUTPUT_PROPERTIES_PROVIDED_BY_AUTH_CUSTOM_RESOURCE: (keyof AuthOutput['payload'])[] = + [ + 'allowUnauthenticatedIdentities', + 'signupAttributes', + 'usernameAttributes', + 'verificationMechanisms', + 'passwordPolicyMinLength', + 'passwordPolicyRequirements', + 'mfaConfiguration', + 'mfaTypes', + 'socialProviders', + 'oauthCognitoDomain', + 'oauthScope', + 'oauthRedirectSignIn', + 'oauthRedirectSignOut', + 'oauthResponseType', + 'oauthClientId', + ]; +/** + * Reference Auth construct for using external auth resources + */ +export class AmplifyReferenceAuth + extends Construct + implements ResourceProvider +{ + resources: ReferenceAuthResources; + + private configurationCustomResource: CustomResource; + + /** + * Create a new AmplifyConstruct + */ + constructor(scope: Construct, id: string, props: ReferenceAuthProps) { + super(scope, id); + + this.resources = { + userPool: aws_cognito.UserPool.fromUserPoolId( + this, + 'UserPool', + props.userPoolId + ), + userPoolClient: aws_cognito.UserPoolClient.fromUserPoolClientId( + this, + 'UserPoolClient', + props.userPoolClientId + ), + authenticatedUserIamRole: aws_iam.Role.fromRoleArn( + this, + 'authenticatedUserRole', + props.authRoleArn + ), + unauthenticatedUserIamRole: aws_iam.Role.fromRoleArn( + this, + 'unauthenticatedUserRole', + props.unauthRoleArn + ), + identityPoolId: props.identityPoolId, + groups: {}, + }; + + // mapping of existing group roles + if (props.groups) { + Object.entries(props.groups).forEach(([groupName, roleArn]) => { + this.resources.groups[groupName] = { + role: Role.fromRoleArn(this, `${groupName}GroupRole`, roleArn), + }; + }); + } + + // custom resource lambda + const refAuthLambda = new NodejsFunction( + scope, + `${REFERENCE_AUTH_CUSTOM_RESOURCE_PROVIDER_ID}Lambda`, + { + runtime: Runtime.NODEJS_18_X, + timeout: Duration.seconds(10), + entry: refAuthLambdaFilePath, + handler: 'handler', + } + ); + // UserPool & UserPoolClient specific permissions + refAuthLambda.grantPrincipal.addToPrincipalPolicy( + new aws_iam.PolicyStatement({ + effect: aws_iam.Effect.ALLOW, + actions: [ + 'cognito-idp:DescribeUserPool', + 'cognito-idp:GetUserPoolMfaConfig', + 'cognito-idp:ListIdentityProviders', + 'cognito-idp:ListGroups', + 'cognito-idp:DescribeUserPoolClient', + ], + resources: [this.resources.userPool.userPoolArn], + }) + ); + // IdentityPool specific permissions + const stack = Stack.of(this); + refAuthLambda.grantPrincipal.addToPrincipalPolicy( + new aws_iam.PolicyStatement({ + effect: aws_iam.Effect.ALLOW, + actions: [ + 'cognito-identity:DescribeIdentityPool', + 'cognito-identity:GetIdentityPoolRoles', + ], + resources: [ + `arn:aws:cognito-identity:${stack.region}:${stack.account}:identitypool/${this.resources.identityPoolId}`, + ], + }) + ); + const provider = new Provider( + scope, + REFERENCE_AUTH_CUSTOM_RESOURCE_PROVIDER_ID, + { + onEventHandler: refAuthLambda, + } + ); + const initializerProps: ReferenceAuthInitializerProps = { + userPoolId: props.userPoolId, + identityPoolId: props.identityPoolId, + userPoolClientId: props.userPoolClientId, + authRoleArn: props.authRoleArn, + unauthRoleArn: props.unauthRoleArn, + groups: props.groups ?? {}, + region: Stack.of(this).region, + }; + // custom resource + this.configurationCustomResource = new CustomResource( + scope, + REFERENCE_AUTH_CUSTOM_RESOURCE_ID, + { + serviceToken: provider.serviceToken, + properties: { + ...initializerProps, + }, + resourceType: RESOURCE_TYPE, + } + ); + + this.storeOutput(props.outputStorageStrategy); + new AttributionMetadataStorage().storeAttributionMetadata( + Stack.of(this), + authStackType, + fileURLToPath(new URL('../package.json', import.meta.url)) + ); + } + + /** + * Stores auth output using the provided strategy + */ + private storeOutput = ( + outputStorageStrategy: BackendOutputStorageStrategy = new StackMetadataBackendOutputStorageStrategy( + Stack.of(this) + ) + ): void => { + // these properties cannot be overwritten + const output: AuthOutput['payload'] = { + userPoolId: this.resources.userPool.userPoolId, + webClientId: this.resources.userPoolClient.userPoolClientId, + identityPoolId: this.resources.identityPoolId, + authRegion: Stack.of(this).region, + }; + + // assign cdk tokens which will be resolved during deployment + for (const property of OUTPUT_PROPERTIES_PROVIDED_BY_AUTH_CUSTOM_RESOURCE) { + output[property] = + this.configurationCustomResource.getAttString(property); + } + + outputStorageStrategy.addBackendOutputEntry(authOutputKey, { + version: '1', + payload: output, + }); + }; +} diff --git a/packages/backend-auth/src/reference_factory.test.ts b/packages/backend-auth/src/reference_factory.test.ts new file mode 100644 index 0000000000..ee16e7317e --- /dev/null +++ b/packages/backend-auth/src/reference_factory.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import { App, Stack } from 'aws-cdk-lib'; +import assert from 'node:assert'; +import { Template } from 'aws-cdk-lib/assertions'; +import { + BackendOutputEntry, + BackendOutputStorageStrategy, + ConstructContainer, + ConstructFactory, + ConstructFactoryGetInstanceProps, + ImportPathVerifier, + ResourceAccessAcceptorFactory, + ResourceNameValidator, + ResourceProvider, +} from '@aws-amplify/plugin-types'; +import { StackMetadataBackendOutputStorageStrategy } from '@aws-amplify/backend-output-storage'; +import { + ConstructContainerStub, + ImportPathVerifierStub, + ResourceNameValidatorStub, + StackResolverStub, +} from '@aws-amplify/backend-platform-test-stubs'; +import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { + AmplifyReferenceAuthProps, + BackendReferenceAuth, + referenceAuth, +} from './reference_factory.js'; +import { AmplifyAuthFactory } from './factory.js'; + +const defaultReferenceAuthProps: AmplifyReferenceAuthProps = { + authRoleArn: 'arn:aws:iam::000000000000:role/amplify-sample-auth-role-name', + unauthRoleArn: + 'arn:aws:iam::000000000000:role/amplify-sample-unauth-role-name', + identityPoolId: 'us-east-1:identityPoolId', + userPoolClientId: 'userPoolClientId', + userPoolId: 'us-east-1_userPoolId', +}; + +const createStackAndSetContext = (): Stack => { + const app = new App(); + app.node.setContext('amplify-backend-name', 'testEnvName'); + app.node.setContext('amplify-backend-namespace', 'testBackendId'); + app.node.setContext('amplify-backend-type', 'branch'); + const stack = new Stack(app); + return stack; +}; + +void describe('AmplifyReferenceAuthFactory', () => { + let authFactory: ConstructFactory; + let constructContainer: ConstructContainer; + let outputStorageStrategy: BackendOutputStorageStrategy; + let importPathVerifier: ImportPathVerifier; + let getInstanceProps: ConstructFactoryGetInstanceProps; + let resourceNameValidator: ResourceNameValidator; + let stack: Stack; + beforeEach(() => { + resetFactoryCount(); + authFactory = referenceAuth(defaultReferenceAuthProps); + + stack = createStackAndSetContext(); + + constructContainer = new ConstructContainerStub( + new StackResolverStub(stack) + ); + + outputStorageStrategy = new StackMetadataBackendOutputStorageStrategy( + stack + ); + + importPathVerifier = new ImportPathVerifierStub(); + + resourceNameValidator = new ResourceNameValidatorStub(); + + getInstanceProps = { + constructContainer, + outputStorageStrategy, + importPathVerifier, + resourceNameValidator, + }; + }); + + void it('returns singleton instance', () => { + const instance1 = authFactory.getInstance(getInstanceProps); + const instance2 = authFactory.getInstance(getInstanceProps); + + assert.strictEqual(instance1, instance2); + }); + + void it('verifies stack property exists and is equivalent to auth stack', () => { + const backendAuth = authFactory.getInstance(getInstanceProps); + assert.equal(backendAuth.stack, Stack.of(backendAuth.resources.userPool)); + }); + + void it('adds construct to stack', () => { + const backendAuth = authFactory.getInstance(getInstanceProps); + + const template = Template.fromStack(backendAuth.stack); + + template.resourceCountIs('Custom::AmplifyRefAuth', 1); + }); + + void it('verifies constructor import path', () => { + const importPathVerifier = { + verify: mock.fn(), + }; + + authFactory.getInstance({ ...getInstanceProps, importPathVerifier }); + + assert.ok( + (importPathVerifier.verify.mock.calls[0].arguments[0] as string).includes( + 'referenceAuth' + ) + ); + }); + + void it('should throw TooManyAmplifyAuthFactoryError when referenceAuth is called multiple times', () => { + assert.throws( + () => { + referenceAuth({ + ...defaultReferenceAuthProps, + }); + referenceAuth({ + ...defaultReferenceAuthProps, + }); + }, + new AmplifyUserError('MultipleSingletonResourcesError', { + message: + 'Multiple `defineAuth` or `referenceAuth` calls are not allowed within an Amplify backend', + resolution: 'Remove all but one `defineAuth` or `referenceAuth` call', + }) + ); + }); + + void it('if access is defined, it should attach valid policy to the resource', () => { + const mockAcceptResourceAccess = mock.fn(); + const lambdaResourceStub = { + getInstance: () => ({ + getResourceAccessAcceptor: () => ({ + acceptResourceAccess: mockAcceptResourceAccess, + }), + }), + } as unknown as ConstructFactory< + ResourceProvider & ResourceAccessAcceptorFactory + >; + + resetFactoryCount(); + + authFactory = referenceAuth({ + ...defaultReferenceAuthProps, + access: (allow) => [ + allow.resource(lambdaResourceStub).to(['managePasswordRecovery']), + allow.resource(lambdaResourceStub).to(['createUser']), + ], + }); + + const backendAuth = authFactory.getInstance(getInstanceProps); + + assert.equal(mockAcceptResourceAccess.mock.callCount(), 2); + assert.ok( + mockAcceptResourceAccess.mock.calls[0].arguments[0] instanceof Policy + ); + assert.deepStrictEqual( + mockAcceptResourceAccess.mock.calls[0].arguments[0].document.toJSON(), + { + Statement: [ + { + Action: [ + 'cognito-idp:AdminResetUserPassword', + 'cognito-idp:AdminSetUserPassword', + ], + Effect: 'Allow', + Resource: backendAuth.resources.userPool.userPoolArn, + }, + ], + Version: '2012-10-17', + } + ); + assert.ok( + mockAcceptResourceAccess.mock.calls[1].arguments[0] instanceof Policy + ); + assert.deepStrictEqual( + mockAcceptResourceAccess.mock.calls[1].arguments[0].document.toJSON(), + { + Statement: [ + { + Action: 'cognito-idp:AdminCreateUser', + Effect: 'Allow', + Resource: backendAuth.resources.userPool.userPoolArn, + }, + ], + Version: '2012-10-17', + } + ); + }); + + void describe('getResourceAccessAcceptor', () => { + void it('attaches policies to the authenticated role', () => { + const backendAuth = authFactory.getInstance(getInstanceProps); + const testPolicy = new Policy(stack, 'testPolicy', { + statements: [ + new PolicyStatement({ + actions: ['s3:GetObject'], + resources: ['testBucket/testObject/*'], + }), + ], + }); + const resourceAccessAcceptor = backendAuth.getResourceAccessAcceptor( + 'authenticatedUserIamRole' + ); + + assert.equal( + resourceAccessAcceptor.identifier, + 'authenticatedUserIamRoleResourceAccessAcceptor' + ); + + resourceAccessAcceptor.acceptResourceAccess(testPolicy, [ + { name: 'test', path: 'test' }, + ]); + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::IAM::Policy', 1); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 's3:GetObject', + Effect: 'Allow', + Resource: 'testBucket/testObject/*', + }, + ], + }, + Roles: [backendAuth.resources.authenticatedUserIamRole.roleName], + }); + }); + + void it('attaches policies to the unauthenticated role', () => { + const backendAuth = authFactory.getInstance(getInstanceProps); + const testPolicy = new Policy(stack, 'testPolicy', { + statements: [ + new PolicyStatement({ + actions: ['s3:GetObject'], + resources: ['testBucket/testObject/*'], + }), + ], + }); + const resourceAccessAcceptor = backendAuth.getResourceAccessAcceptor( + 'unauthenticatedUserIamRole' + ); + + assert.equal( + resourceAccessAcceptor.identifier, + 'unauthenticatedUserIamRoleResourceAccessAcceptor' + ); + + resourceAccessAcceptor.acceptResourceAccess(testPolicy, [ + { name: 'test', path: 'test' }, + ]); + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::IAM::Policy', 1); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 's3:GetObject', + Effect: 'Allow', + Resource: 'testBucket/testObject/*', + }, + ], + }, + Roles: [backendAuth.resources.unauthenticatedUserIamRole.roleName], + }); + }); + }); +}); + +const resetFactoryCount = () => { + AmplifyAuthFactory.factoryCount = 0; +}; diff --git a/packages/backend-auth/src/reference_factory.ts b/packages/backend-auth/src/reference_factory.ts new file mode 100644 index 0000000000..926db833c9 --- /dev/null +++ b/packages/backend-auth/src/reference_factory.ts @@ -0,0 +1,239 @@ +import { + AuthRoleName, + BackendOutputStorageStrategy, + ConstructContainerEntryGenerator, + ConstructFactory, + ConstructFactoryGetInstanceProps, + GenerateContainerEntryProps, + ReferenceAuthResources, + ResourceAccessAcceptor, + ResourceAccessAcceptorFactory, + ResourceProvider, + StackProvider, +} from '@aws-amplify/plugin-types'; +import { AuthAccessGenerator, Expand } from './types.js'; +import { authAccessBuilder as _authAccessBuilder } from './access_builder.js'; +import path from 'path'; +import { AmplifyUserError, TagName } from '@aws-amplify/platform-core'; +import { AuthAccessPolicyArbiterFactory } from './auth_access_policy_arbiter.js'; +import { Stack, Tags } from 'aws-cdk-lib'; +import { Policy } from 'aws-cdk-lib/aws-iam'; +import { UserPoolAccessPolicyFactory } from './userpool_access_policy_factory.js'; +import { AmplifyAuthFactory } from './factory.js'; +import { AmplifyReferenceAuth } from './reference_construct.js'; +import { AuthOutput } from '@aws-amplify/backend-output-schemas'; + +export type ReferenceAuthProps = { + /** + * @internal + */ + outputStorageStrategy?: BackendOutputStorageStrategy; + /** + * Existing UserPool Id + */ + userPoolId: string; + /** + * Existing IdentityPool Id + */ + identityPoolId: string; + /** + * Existing UserPoolClient Id + */ + userPoolClientId: string; + /** + * Existing AuthRole ARN + */ + authRoleArn: string; + /** + * Existing UnauthRole ARN + */ + unauthRoleArn: string; + /** + * A mapping of existing group names and their associated role ARNs + * which can be used for group permissions. + */ + groups?: { + [groupName: string]: string; + }; +}; + +export type BackendReferenceAuth = ResourceProvider & + ResourceAccessAcceptorFactory & + StackProvider; + +export type AmplifyReferenceAuthProps = Expand< + Omit & { + /** + * Configure access to auth for other Amplify resources + * @see https://docs.amplify.aws/react/build-a-backend/auth/grant-access-to-auth-resources/ + * @example + * access: (allow) => [allow.resource(postConfirmation).to(["addUserToGroup"])] + * @example + * access: (allow) => [allow.resource(groupManager).to(["manageGroups"])] + */ + access?: AuthAccessGenerator; + } +>; +/** + * Singleton factory for AmplifyReferenceAuth that can be used in Amplify project files. + * + * Exported for testing purpose only & should NOT be exported out of the package. + */ +export class AmplifyReferenceAuthFactory + implements ConstructFactory +{ + readonly provides = 'AuthResources'; + + private generator: ConstructContainerEntryGenerator; + + /** + * Set the properties that will be used to initialize AmplifyReferenceAuth + */ + constructor( + private readonly props: AmplifyReferenceAuthProps, + // eslint-disable-next-line amplify-backend-rules/prefer-amplify-errors + private readonly importStack = new Error().stack + ) { + if (AmplifyAuthFactory.factoryCount > 0) { + throw new AmplifyUserError('MultipleSingletonResourcesError', { + message: + 'Multiple `defineAuth` or `referenceAuth` calls are not allowed within an Amplify backend', + resolution: 'Remove all but one `defineAuth` or `referenceAuth` call', + }); + } + AmplifyAuthFactory.factoryCount++; + } + /** + * Get a singleton instance of AmplifyReferenceAuth + */ + getInstance = ( + getInstanceProps: ConstructFactoryGetInstanceProps + ): BackendReferenceAuth => { + const { constructContainer, importPathVerifier } = getInstanceProps; + importPathVerifier?.verify( + this.importStack, + path.join('amplify', 'auth', 'resource'), + 'Amplify Auth must be defined in amplify/auth/resource.ts' + ); + if (!this.generator) { + this.generator = new AmplifyReferenceAuthGenerator( + this.props, + getInstanceProps + ); + } + return constructContainer.getOrCompute( + this.generator + ) as BackendReferenceAuth; + }; +} +class AmplifyReferenceAuthGenerator + implements ConstructContainerEntryGenerator +{ + readonly resourceGroupName = 'auth'; + private readonly name: string; + + constructor( + private readonly props: AmplifyReferenceAuthProps, + private readonly getInstanceProps: ConstructFactoryGetInstanceProps, + private readonly authAccessBuilder = _authAccessBuilder, + private readonly authAccessPolicyArbiterFactory = new AuthAccessPolicyArbiterFactory() + ) { + this.name = 'amplifyAuth'; + } + + generateContainerEntry = ({ + scope, + ssmEnvironmentEntriesGenerator, + }: GenerateContainerEntryProps) => { + const authProps: ReferenceAuthProps = { + ...this.props, + outputStorageStrategy: this.getInstanceProps.outputStorageStrategy, + }; + + let authConstruct: AmplifyReferenceAuth; + try { + authConstruct = new AmplifyReferenceAuth(scope, this.name, authProps); + } catch (error) { + throw new AmplifyUserError( + 'AmplifyReferenceAuthConstructInitializationError', + { + message: 'Failed to instantiate reference auth construct', + resolution: 'See the underlying error message for more details.', + }, + error as Error + ); + } + + Tags.of(authConstruct).add(TagName.FRIENDLY_NAME, this.name); + + const authConstructMixin: BackendReferenceAuth = { + ...authConstruct, + /** + * Returns a resourceAccessAcceptor for the given role + * @param roleIdentifier Either the auth or unauth role name or the name of a UserPool group + */ + getResourceAccessAcceptor: ( + roleIdentifier: AuthRoleName | string + ): ResourceAccessAcceptor => ({ + identifier: `${roleIdentifier}ResourceAccessAcceptor`, + acceptResourceAccess: (policy: Policy) => { + const role = roleNameIsAuthRoleName(roleIdentifier) + ? authConstruct.resources[roleIdentifier] + : authConstruct.resources.groups?.[roleIdentifier]?.role; + if (!role) { + throw new AmplifyUserError('InvalidResourceAccessConfigError', { + message: `No auth IAM role found for "${roleIdentifier}".`, + resolution: `If you are trying to configure UserPool group access, ensure that the group name is specified correctly.`, + }); + } + policy.attachToRole(role); + }, + }), + stack: Stack.of(authConstruct), + }; + if (!this.props.access) { + return authConstructMixin; + } + // props.access is the access callback defined by the customer + // here we inject the authAccessBuilder into the callback and run it + // this produces the access definition that will be used to create the auth access policies + const accessDefinition = this.props.access(this.authAccessBuilder); + + const ssmEnvironmentEntries = + ssmEnvironmentEntriesGenerator.generateSsmEnvironmentEntries({ + [`${this.name}_USERPOOL_ID`]: + authConstructMixin.resources.userPool.userPoolId, + }); + + const authPolicyArbiter = this.authAccessPolicyArbiterFactory.getInstance( + accessDefinition, + this.getInstanceProps, + ssmEnvironmentEntries, + new UserPoolAccessPolicyFactory(authConstruct.resources.userPool) + ); + + authPolicyArbiter.arbitratePolicies(); + + return authConstructMixin; + }; +} + +const roleNameIsAuthRoleName = (roleName: string): roleName is AuthRoleName => { + return ( + roleName === 'authenticatedUserIamRole' || + roleName === 'unauthenticatedUserIamRole' + ); +}; + +/** + * Provide references to existing auth resources. + */ +export const referenceAuth = ( + props: AmplifyReferenceAuthProps +): ConstructFactory => { + return new AmplifyReferenceAuthFactory( + props, + // eslint-disable-next-line amplify-backend-rules/prefer-amplify-errors + new Error().stack + ); +}; diff --git a/packages/backend-auth/src/test-resources/sample_data.ts b/packages/backend-auth/src/test-resources/sample_data.ts new file mode 100644 index 0000000000..1ac099f65b --- /dev/null +++ b/packages/backend-auth/src/test-resources/sample_data.ts @@ -0,0 +1,448 @@ +import { IdentityPool as IdentityPoolType } from '@aws-sdk/client-cognito-identity'; +import { + GetUserPoolMfaConfigCommandOutput, + ListGroupsResponse, + ProviderDescription, + UserPoolClientType, + UserPoolType, +} from '@aws-sdk/client-cognito-identity-provider'; +import { ReferenceAuthInitializerProps } from '../lambda/reference_auth_initializer.js'; +/** + * Sample referenceAuth properties + */ +export const SampleInputProperties: ReferenceAuthInitializerProps = { + authRoleArn: 'arn:aws:iam::000000000000:role/service-role/ref-auth-role-1', + unauthRoleArn: 'arn:aws:iam::000000000000:role/service-role/ref-unauth-role1', + identityPoolId: 'us-east-1:sample-identity-pool-id', + userPoolClientId: 'sampleUserPoolClientId', + userPoolId: 'us-east-1_userpoolTest', + groups: { + ADMINS: 'arn:aws:iam::000000000000:role/sample-group-role', + }, + region: 'us-east-1', +}; +/** + * Sample response from describe user pool command + */ +export const UserPool: Readonly = { + Id: SampleInputProperties.userPoolId, + Name: 'ref-auth-userpool-1', + Policies: { + PasswordPolicy: { + MinimumLength: 10, + RequireUppercase: true, + RequireLowercase: true, + RequireNumbers: true, + RequireSymbols: false, + TemporaryPasswordValidityDays: 7, + }, + }, + DeletionProtection: 'ACTIVE', + LambdaConfig: {}, + SchemaAttributes: [ + { + Name: 'profile', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'address', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'birthdate', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '10', + MaxLength: '10', + }, + }, + { + Name: 'gender', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'preferred_username', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'updated_at', + AttributeDataType: 'Number', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + NumberAttributeConstraints: { + MinValue: '0', + }, + }, + { + Name: 'website', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'picture', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'identities', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: {}, + }, + { + Name: 'sub', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: false, + Required: true, + StringAttributeConstraints: { + MinLength: '1', + MaxLength: '2048', + }, + }, + { + Name: 'phone_number', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'phone_number_verified', + AttributeDataType: 'Boolean', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + }, + { + Name: 'zoneinfo', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + // eslint-disable-next-line spellcheck/spell-checker + Name: 'custom:duplicateemail', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: {}, + }, + { + Name: 'locale', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'email', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: true, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'email_verified', + AttributeDataType: 'Boolean', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + }, + { + Name: 'given_name', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'family_name', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'middle_name', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'name', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + { + Name: 'nickname', + AttributeDataType: 'String', + DeveloperOnlyAttribute: false, + Mutable: true, + Required: false, + StringAttributeConstraints: { + MinLength: '0', + MaxLength: '2048', + }, + }, + ], + AutoVerifiedAttributes: ['email'], + UsernameAttributes: ['email'], + VerificationMessageTemplate: { + DefaultEmailOption: 'CONFIRM_WITH_CODE', + }, + UserAttributeUpdateSettings: { + AttributesRequireVerificationBeforeUpdate: ['email'], + }, + MfaConfiguration: 'ON', + EstimatedNumberOfUsers: 0, + EmailConfiguration: { + EmailSendingAccount: 'COGNITO_DEFAULT', + }, + UserPoolTags: {}, + Domain: 'ref-auth-userpool-1', + AdminCreateUserConfig: { + AllowAdminCreateUserOnly: false, + UnusedAccountValidityDays: 7, + }, + UsernameConfiguration: { + CaseSensitive: false, + }, + Arn: `arn:aws:cognito-idp:us-east-1:000000000000:userpool/${SampleInputProperties.userPoolId}`, + AccountRecoverySetting: { + RecoveryMechanisms: [ + { + Priority: 1, + Name: 'verified_email', + }, + ], + }, +}; + +export const UserPoolGroups: Readonly = { + Groups: [ + { + GroupName: 'sample-group-name', + RoleArn: 'arn:aws:iam::000000000000:role/sample-group-role', + }, + ], +}; + +/** + * Sample data from get user pool mfa config + */ +export const MFAResponse: Readonly< + Omit +> = { + SoftwareTokenMfaConfiguration: { + Enabled: true, + }, + MfaConfiguration: 'ON', +}; + +/** + * Sample data from list identity providers + */ +export const IdentityProviders: Readonly = [ + { + ProviderName: 'Facebook', + ProviderType: 'Facebook', + }, + { + ProviderName: 'Google', + ProviderType: 'Google', + }, + { + ProviderName: 'LoginWithAmazon', + ProviderType: 'LoginWithAmazon', + }, +]; + +/** + * Sample data for describe identity pool + */ +export const IdentityPool: Readonly = { + IdentityPoolId: SampleInputProperties.identityPoolId, + IdentityPoolName: 'sample-identity-pool-name', + AllowUnauthenticatedIdentities: true, + AllowClassicFlow: false, + CognitoIdentityProviders: [ + { + ProviderName: `cognito-idp.us-east-1.amazonaws.com/${SampleInputProperties.userPoolId}`, + ClientId: SampleInputProperties.userPoolClientId, + ServerSideTokenCheck: false, + }, + ], + IdentityPoolTags: {}, +}; + +/** + * Sample data for get identity pool roles + */ +export const IdentityPoolRoles = { + IdentityPoolId: SampleInputProperties.identityPoolId, + Roles: { + authenticated: SampleInputProperties.authRoleArn, + unauthenticated: SampleInputProperties.unauthRoleArn, + }, +}; + +/** + * Sample data from describe user pool client + */ +export const UserPoolClient: Readonly = { + UserPoolId: SampleInputProperties.userPoolId, + ClientName: 'ref-auth-app-client-1', + ClientId: SampleInputProperties.userPoolClientId, + RefreshTokenValidity: 30, + AccessTokenValidity: 60, + IdTokenValidity: 60, + TokenValidityUnits: { + AccessToken: 'minutes', + IdToken: 'minutes', + RefreshToken: 'days', + }, + ReadAttributes: [ + 'address', + 'birthdate', + // eslint-disable-next-line spellcheck/spell-checker + 'custom:duplicateemail', + 'email', + 'email_verified', + 'family_name', + 'gender', + 'given_name', + 'locale', + 'middle_name', + 'name', + 'nickname', + 'phone_number', + 'phone_number_verified', + 'picture', + 'preferred_username', + 'profile', + 'updated_at', + 'website', + 'zoneinfo', + ], + WriteAttributes: [ + 'address', + 'birthdate', + // eslint-disable-next-line spellcheck/spell-checker + 'custom:duplicateemail', + 'email', + 'family_name', + 'gender', + 'given_name', + 'locale', + 'middle_name', + 'name', + 'nickname', + 'phone_number', + 'picture', + 'preferred_username', + 'profile', + 'updated_at', + 'website', + 'zoneinfo', + ], + ExplicitAuthFlows: ['ALLOW_REFRESH_TOKEN_AUTH', 'ALLOW_USER_SRP_AUTH'], + SupportedIdentityProviders: [ + 'COGNITO', + 'Facebook', + 'Google', + 'LoginWithAmazon', + ], + CallbackURLs: ['https://redirect.com', 'https://redirect2.com'], + LogoutURLs: ['https://anotherlogouturl.com', 'https://logouturl.com'], + AllowedOAuthFlows: ['code'], + AllowedOAuthScopes: ['email', 'openid', 'phone'], + AllowedOAuthFlowsUserPoolClient: true, + PreventUserExistenceErrors: 'ENABLED', + EnableTokenRevocation: true, + EnablePropagateAdditionalUserContextData: false, + AuthSessionValidity: 3, +}; diff --git a/packages/backend-auth/tsconfig.json b/packages/backend-auth/tsconfig.json index b98614a812..42a487d8e7 100644 --- a/packages/backend-auth/tsconfig.json +++ b/packages/backend-auth/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "lib" }, "references": [ { "path": "../auth-construct" }, + { "path": "../backend-output-schemas" }, { "path": "../backend-output-storage" }, { "path": "../plugin-types" }, { "path": "../backend-platform-test-stubs" }, diff --git a/packages/backend-data/src/convert_authorization_modes.test.ts b/packages/backend-data/src/convert_authorization_modes.test.ts index f685414449..f728c7bd72 100644 --- a/packages/backend-data/src/convert_authorization_modes.test.ts +++ b/packages/backend-data/src/convert_authorization_modes.test.ts @@ -36,6 +36,7 @@ void describe('buildConstructFactoryProvidedAuthConfig', () => { userPool: 'ThisIsAUserPool', authenticatedUserIamRole: 'ThisIsAnAuthenticatedUserIamRole', unauthenticatedUserIamRole: 'ThisIsAnUnauthenticatedUserIamRole', + identityPoolId: 'us-fake-1:123123-123123', cfnResources: { cfnIdentityPool: { logicalId: 'IdentityPoolLogicalId', diff --git a/packages/backend-data/src/convert_authorization_modes.ts b/packages/backend-data/src/convert_authorization_modes.ts index fadfec4fbb..02df9d4a04 100644 --- a/packages/backend-data/src/convert_authorization_modes.ts +++ b/packages/backend-data/src/convert_authorization_modes.ts @@ -20,6 +20,7 @@ import { import { AuthResources, ConstructFactoryGetInstanceProps, + ReferenceAuthResources, ResourceProvider, } from '@aws-amplify/plugin-types'; import { AmplifyUserError } from '@aws-amplify/platform-core'; @@ -38,14 +39,14 @@ export type ProvidedAuthConfig = { * Function instance provider which uses the */ export const buildConstructFactoryProvidedAuthConfig = ( - authResourceProvider: ResourceProvider | undefined + authResourceProvider: + | ResourceProvider + | undefined ): ProvidedAuthConfig | undefined => { if (!authResourceProvider) return; - return { userPool: authResourceProvider.resources.userPool, - identityPoolId: - authResourceProvider.resources.cfnResources.cfnIdentityPool.ref, + identityPoolId: authResourceProvider.resources.identityPoolId, authenticatedUserRole: authResourceProvider.resources.authenticatedUserIamRole, unauthenticatedUserRole: diff --git a/packages/backend-data/src/factory.test.ts b/packages/backend-data/src/factory.test.ts index c4cc45b9ad..51e2aabf45 100644 --- a/packages/backend-data/src/factory.test.ts +++ b/packages/backend-data/src/factory.test.ts @@ -85,6 +85,7 @@ const createConstructContainerWithUserPoolAuthRegistered = ( authenticatedUserIamRole: new Role(stack, 'testAuthRole', { assumedBy: new ServicePrincipal('test.amazon.com'), }), + identityPoolId: 'identityPoolId', cfnResources: { cfnUserPool: new CfnUserPool(stack, 'CfnUserPool', {}), cfnUserPoolClient: new CfnUserPoolClient(stack, 'CfnUserPoolClient', { diff --git a/packages/backend-data/src/factory.ts b/packages/backend-data/src/factory.ts index ebe3d1b083..578d0cbcbc 100644 --- a/packages/backend-data/src/factory.ts +++ b/packages/backend-data/src/factory.ts @@ -7,6 +7,7 @@ import { ConstructFactory, ConstructFactoryGetInstanceProps, GenerateContainerEntryProps, + ReferenceAuthResources, ResourceProvider, } from '@aws-amplify/plugin-types'; import { @@ -97,9 +98,9 @@ export class DataFactory implements ConstructFactory { this.props, buildConstructFactoryProvidedAuthConfig( props.constructContainer - .getConstructFactory>( - 'AuthResources' - ) + .getConstructFactory< + ResourceProvider + >('AuthResources') ?.getInstance(props) ), props, diff --git a/packages/backend/API.md b/packages/backend/API.md index ba53fec175..c9d790427f 100644 --- a/packages/backend/API.md +++ b/packages/backend/API.md @@ -26,6 +26,7 @@ import { defineStorage } from '@aws-amplify/backend-storage'; import { FunctionResources } from '@aws-amplify/plugin-types'; import { GenerateContainerEntryProps } from '@aws-amplify/plugin-types'; import { ImportPathVerifier } from '@aws-amplify/plugin-types'; +import { referenceAuth } from '@aws-amplify/backend-auth'; import { ResourceAccessAcceptorFactory } from '@aws-amplify/plugin-types'; import { ResourceProvider } from '@aws-amplify/plugin-types'; import { SsmEnvironmentEntriesGenerator } from '@aws-amplify/plugin-types'; @@ -90,6 +91,8 @@ export { GenerateContainerEntryProps } export { ImportPathVerifier } +export { referenceAuth } + export { ResourceProvider } // @public diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 46adc6515e..5150f93a8e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -17,7 +17,7 @@ export { defineData } from '@aws-amplify/backend-data'; export { type ClientSchema, a } from '@aws-amplify/data-schema'; // auth -export { defineAuth } from '@aws-amplify/backend-auth'; +export { defineAuth, referenceAuth } from '@aws-amplify/backend-auth'; // storage export { defineStorage } from '@aws-amplify/backend-storage'; diff --git a/packages/integration-tests/src/resource-creation/auth_resource_creator.ts b/packages/integration-tests/src/resource-creation/auth_resource_creator.ts new file mode 100644 index 0000000000..da405b6bbe --- /dev/null +++ b/packages/integration-tests/src/resource-creation/auth_resource_creator.ts @@ -0,0 +1,372 @@ +import { + CognitoIdentityProviderClient, + CreateGroupCommand, + CreateGroupCommandInput, + CreateIdentityProviderCommand, + CreateIdentityProviderCommandInput, + CreateUserPoolClientCommand, + CreateUserPoolClientCommandInput, + CreateUserPoolCommand, + CreateUserPoolCommandInput, + CreateUserPoolDomainCommand, + CreateUserPoolDomainCommandInput, + DeleteGroupCommand, + DeleteIdentityProviderCommand, + DeleteUserPoolClientCommand, + DeleteUserPoolCommand, + DeleteUserPoolDomainCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { + CreateRoleCommand, + CreateRoleCommandInput, + DeleteRoleCommand, + IAMClient, +} from '@aws-sdk/client-iam'; +import { + CognitoIdentityClient, + CreateIdentityPoolCommand, + CreateIdentityPoolCommandInput, + DeleteIdentityPoolCommand, + SetIdentityPoolRolesCommand, +} from '@aws-sdk/client-cognito-identity'; +import { shortUuid } from '../short_uuid.js'; +import { e2eToolingClientConfig } from '../e2e_tooling_client_config.js'; +const TEST_AMPLIFY_RESOURCE_PREFIX = 'amplify-'; + +type CleanupTask = { + run: () => Promise; + arn?: string | undefined; + id?: string | undefined; +}; +/** + * Provides a way to create auth resources using aws sdk + */ +export class AuthResourceCreator { + private cleanup: CleanupTask[] = []; + + /** + * Setup a new auth resource creator + * @param cognitoIdentityProviderClient client + * @param cognitoIdentityClient client + * @param iamClient client + */ + constructor( + private cognitoIdentityProviderClient: CognitoIdentityProviderClient = new CognitoIdentityProviderClient( + e2eToolingClientConfig + ), + private cognitoIdentityClient: CognitoIdentityClient = new CognitoIdentityClient( + e2eToolingClientConfig + ), + private iamClient: IAMClient = new IAMClient(e2eToolingClientConfig), + private createResourceNameSuffix: () => string = shortUuid + ) {} + + cleanupResources = async () => { + // delete in reverse order + const list = this.cleanup.map((t) => t.arn ?? t.id); + console.log( + `Attempting to delete a total of ${this.cleanup.length} resources` + ); + console.log('Resource descriptions/ARNs/IDs:', list); + const failedTasks: CleanupTask[] = []; + for (let i = this.cleanup.length - 1; i >= 0; i--) { + const task = this.cleanup[i]; + try { + await task.run(); + console.log(`Deleted: ${task.arn ?? task.id}`); + } catch (e) { + failedTasks.push(task); + console.error(`Failed to delete resource: ${task.arn ?? task.id}`, e); + } + } + console.error( + 'Failed tasks:', + failedTasks.map((t) => t.arn ?? t.id) + ); + }; + + createUserPoolBase = async (props: CreateUserPoolCommandInput) => { + const result = await this.cognitoIdentityProviderClient.send( + new CreateUserPoolCommand({ + ...props, + PoolName: `${TEST_AMPLIFY_RESOURCE_PREFIX}${ + props.PoolName + }-${this.createResourceNameSuffix()}`, + }) + ); + const userPool = result.UserPool; + if (!userPool) { + throw new Error('Failed to create user pool.'); + } + this.cleanup.push({ + run: async () => { + await this.cognitoIdentityProviderClient.send( + new DeleteUserPoolCommand({ UserPoolId: userPool.Id }) + ); + }, + arn: userPool.Arn, + }); + return userPool; + }; + + createUserPoolClientBase = async ( + props: CreateUserPoolClientCommandInput + ) => { + const result = await this.cognitoIdentityProviderClient.send( + new CreateUserPoolClientCommand({ + ...props, + ClientName: `${TEST_AMPLIFY_RESOURCE_PREFIX}${ + props.ClientName + }-${this.createResourceNameSuffix()}`, + }) + ); + const client = result.UserPoolClient; + if (!client) { + throw new Error('Failed to create user pool client.'); + } + this.cleanup.push({ + run: async () => { + await this.cognitoIdentityProviderClient.send( + new DeleteUserPoolClientCommand({ + ClientId: client.ClientId, + UserPoolId: client.UserPoolId, + }) + ); + }, + id: `UserPoolClientId: ${client.ClientId}`, + }); + return client; + }; + + createUserPoolDomainBase = async ( + props: CreateUserPoolDomainCommandInput + ) => { + const domain = `${TEST_AMPLIFY_RESOURCE_PREFIX}${ + props.Domain + }-${this.createResourceNameSuffix()}`; + await this.cognitoIdentityProviderClient.send( + new CreateUserPoolDomainCommand({ + ...props, + Domain: domain, + }) + ); + // if it didn't throw, domain was created. + this.cleanup.push({ + run: async () => { + await this.cognitoIdentityProviderClient.send( + new DeleteUserPoolDomainCommand({ + Domain: domain, + UserPoolId: props.UserPoolId, + }) + ); + }, + id: `Domain: ${domain}`, + }); + return domain; + }; + + createIdentityProviderBase = async ( + props: CreateIdentityProviderCommandInput + ) => { + const result = await this.cognitoIdentityProviderClient.send( + new CreateIdentityProviderCommand({ + ...props, + }) + ); + const provider = result.IdentityProvider; + if (!provider) { + throw new Error( + `An error occurred while creating the identity provider ${props.ProviderName}` + ); + } + this.cleanup.push({ + run: async () => { + await this.cognitoIdentityProviderClient.send( + new DeleteIdentityProviderCommand({ + UserPoolId: props.UserPoolId, + ProviderName: provider.ProviderName, + }) + ); + }, + id: `Provider: ${provider.ProviderName}`, + }); + return provider; + }; + + createIdentityPoolBase = async (props: CreateIdentityPoolCommandInput) => { + const identityPoolResponse = await this.cognitoIdentityClient.send( + new CreateIdentityPoolCommand({ + ...props, + IdentityPoolName: `${TEST_AMPLIFY_RESOURCE_PREFIX}${ + props.IdentityPoolName + }-${this.createResourceNameSuffix()}`, + }) + ); + const identityPoolId = identityPoolResponse.IdentityPoolId; + if (!identityPoolId) { + throw new Error('An error occurred while creating the identity pool'); + } + this.cleanup.push({ + run: async () => { + await this.cognitoIdentityClient.send( + new DeleteIdentityPoolCommand({ IdentityPoolId: identityPoolId }) + ); + }, + id: `IdentityPool: ${identityPoolResponse.IdentityPoolId}`, + }); + return { + ...identityPoolResponse, + // the line below ensures that the type engine sees IdentityPoolId as string, not string | undefined. + IdentityPoolId: identityPoolId, + }; + }; + + createRoleBase = async (props: CreateRoleCommandInput) => { + const result = await this.iamClient.send( + new CreateRoleCommand({ + ...props, + RoleName: `${TEST_AMPLIFY_RESOURCE_PREFIX}${ + props.RoleName + }-${this.createResourceNameSuffix()}`, + }) + ); + const role = result.Role; + if (!role) { + throw new Error( + `An error occurred while creating the role: ${props.RoleName}` + ); + } + this.cleanup.push({ + run: async () => { + await this.iamClient.send( + new DeleteRoleCommand({ RoleName: role.RoleName }) + ); + }, + arn: role.Arn, + }); + return role; + }; + + createUserPoolGroupBase = async (props: CreateGroupCommandInput) => { + const result = await this.cognitoIdentityProviderClient.send( + new CreateGroupCommand({ + ...props, + GroupName: `${TEST_AMPLIFY_RESOURCE_PREFIX}${ + props.GroupName + }-${this.createResourceNameSuffix()}`, + }) + ); + const group = result.Group; + if (!group || !group.GroupName) { + throw new Error(`Error creating group with name: ${props.GroupName}`); + } + this.cleanup.push({ + run: async () => { + await this.cognitoIdentityProviderClient.send( + new DeleteGroupCommand({ + UserPoolId: props.UserPoolId, + GroupName: group.GroupName, + }) + ); + }, + id: `Group: ${group.GroupName}`, + }); + return group; + }; + + setupUserPoolGroup = async ( + groupName: string, + userPoolId: string, + identityPoolId: string + ) => { + const groupRole = await this.createRoleBase({ + RoleName: 'ref-auth-group-role', + AssumeRolePolicyDocument: this.getIdentityPoolAssumeRolePolicyDocument( + identityPoolId, + 'authenticated' + ), + }); + const group = await this.createUserPoolGroupBase({ + GroupName: groupName, + UserPoolId: userPoolId, + RoleArn: groupRole.Arn, + }); + return group; + }; + + /** + * Setup standard auth and unauth roles for an identity pool + * @param userPoolId user pool id + * @param userPoolClientId user pool client id + * @param identityPoolId identity pool id + * @returns auth and unauth roles + */ + setupIdentityPoolRoles = async ( + userPoolId: string, + userPoolClientId: string, + identityPoolId: string + ) => { + const authRole = await this.createRoleBase({ + RoleName: `ref-auth-role`, + AssumeRolePolicyDocument: this.getIdentityPoolAssumeRolePolicyDocument( + identityPoolId, + 'authenticated' + ), + }); + const unauthRole = await this.createRoleBase({ + RoleName: `ref-unauth-role`, + AssumeRolePolicyDocument: this.getIdentityPoolAssumeRolePolicyDocument( + identityPoolId, + 'unauthenticated' + ), + }); + const region = await this.cognitoIdentityClient.config.region(); + await this.cognitoIdentityClient.send( + new SetIdentityPoolRolesCommand({ + IdentityPoolId: identityPoolId, + Roles: { + unauthenticated: unauthRole.Arn!, + authenticated: authRole.Arn!, + }, + RoleMappings: { + [`cognito-idp.${region}.amazonaws.com/${userPoolId}:${userPoolClientId}`]: + { + Type: 'Token', + AmbiguousRoleResolution: 'AuthenticatedRole', + }, + }, + }) + ); + + return { + authRole, + unauthRole, + }; + }; + + private getIdentityPoolAssumeRolePolicyDocument = ( + identityPoolId: string, + roleType: 'authenticated' | 'unauthenticated' + ) => { + return `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "cognito-identity.amazonaws.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "cognito-identity.amazonaws.com:aud": "${identityPoolId}" + }, + "ForAnyValue:StringLike": { + "cognito-identity.amazonaws.com:amr": "${roleType}" + } + } + } + ] + }`; + }; +} diff --git a/packages/integration-tests/src/test-e2e/deployment/reference_auth_project.deployment.test.ts b/packages/integration-tests/src/test-e2e/deployment/reference_auth_project.deployment.test.ts new file mode 100644 index 0000000000..2a30ffa54e --- /dev/null +++ b/packages/integration-tests/src/test-e2e/deployment/reference_auth_project.deployment.test.ts @@ -0,0 +1,4 @@ +import { ReferenceAuthTestProjectCreator } from '../../test-project-setup/reference_auth_project.js'; +import { defineDeploymentTest } from './deployment.test.template.js'; + +defineDeploymentTest(new ReferenceAuthTestProjectCreator()); diff --git a/packages/integration-tests/src/test-e2e/sandbox/reference_auth_project.sandbox.test.ts b/packages/integration-tests/src/test-e2e/sandbox/reference_auth_project.sandbox.test.ts new file mode 100644 index 0000000000..c7c19bdcf0 --- /dev/null +++ b/packages/integration-tests/src/test-e2e/sandbox/reference_auth_project.sandbox.test.ts @@ -0,0 +1,4 @@ +import { ReferenceAuthTestProjectCreator } from '../../test-project-setup/reference_auth_project.js'; +import { defineSandboxTest } from './sandbox.test.template.js'; + +defineSandboxTest(new ReferenceAuthTestProjectCreator()); diff --git a/packages/integration-tests/src/test-project-setup/reference_auth_project.ts b/packages/integration-tests/src/test-project-setup/reference_auth_project.ts new file mode 100644 index 0000000000..66c893c9c1 --- /dev/null +++ b/packages/integration-tests/src/test-project-setup/reference_auth_project.ts @@ -0,0 +1,340 @@ +import { TestProjectBase } from './test_project_base.js'; +import fsp from 'fs/promises'; +import { createEmptyAmplifyProject } from './create_empty_amplify_project.js'; +import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; +import { TestProjectCreator } from './test_project_creator.js'; +import { AmplifyClient } from '@aws-sdk/client-amplify'; +import { AuthResourceCreator } from '../resource-creation/auth_resource_creator.js'; +import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; +import { CognitoIdentityClient } from '@aws-sdk/client-cognito-identity'; +import { IAMClient } from '@aws-sdk/client-iam'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { e2eToolingClientConfig } from '../e2e_tooling_client_config.js'; + +/** + * Creates a reference auth project + */ +export class ReferenceAuthTestProjectCreator implements TestProjectCreator { + readonly name = 'reference-auth'; + + /** + * Creates project creator. + */ + constructor( + private readonly cfnClient: CloudFormationClient = new CloudFormationClient( + e2eToolingClientConfig + ), + private readonly amplifyClient: AmplifyClient = new AmplifyClient( + e2eToolingClientConfig + ), + private readonly cognitoIdentityProviderClient: CognitoIdentityProviderClient = new CognitoIdentityProviderClient( + e2eToolingClientConfig + ), + private readonly cognitoIdentityClient: CognitoIdentityClient = new CognitoIdentityClient( + e2eToolingClientConfig + ), + private readonly iamClient: IAMClient = new IAMClient( + e2eToolingClientConfig + ) + ) {} + + createProject = async (e2eProjectDir: string): Promise => { + const { projectName, projectRoot, projectAmplifyDir } = + await createEmptyAmplifyProject(this.name, e2eProjectDir); + + const project = new ReferenceAuthTestProject( + projectName, + projectRoot, + projectAmplifyDir, + this.cfnClient, + this.amplifyClient, + this.cognitoIdentityProviderClient, + this.cognitoIdentityClient, + this.iamClient + ); + + await fsp.cp( + project.sourceProjectAmplifyDirURL, + project.projectAmplifyDirPath, + { + recursive: true, + } + ); + + // generate resources + const { + userPool, + userPoolClient, + identityPool, + authRole, + unauthRole, + adminGroup, + } = await project.setupTestResources(); + // copy generated resource ids into project's auth/resource.ts file + const authResourceFilePath = `${project.projectAmplifyDirPath}/auth/resource.ts`; + await fsp.writeFile( + authResourceFilePath, + `import { referenceAuth } from '@aws-amplify/backend'; + import { addUserToGroup } from "../data/add-user-to-group/resource.js"; + + export const auth = referenceAuth({ + identityPoolId: "${identityPool.IdentityPoolId}", + authRoleArn: "${authRole.Arn}", + unauthRoleArn: "${unauthRole.Arn}", + userPoolId: "${userPool.Id}", + userPoolClientId: "${userPoolClient.ClientId}", + groups: { + "ADMINS": '${adminGroup.RoleArn}', + }, + access: (allow) => [ + allow.resource(addUserToGroup).to(["addUserToGroup"]) + ], + })` + ); + return project; + }; +} + +/** + * The minimal test with typescript idioms. + */ +class ReferenceAuthTestProject extends TestProjectBase { + readonly sourceProjectDirPath = '../../src/test-projects/reference-auth'; + + readonly sourceProjectAmplifyDirSuffix = `${this.sourceProjectDirPath}/amplify`; + + readonly sourceProjectAmplifyDirURL: URL = new URL( + this.sourceProjectAmplifyDirSuffix, + import.meta.url + ); + + authResourceCreator: AuthResourceCreator; + + /** + * Create a test project instance. + */ + constructor( + name: string, + projectDirPath: string, + projectAmplifyDirPath: string, + cfnClient: CloudFormationClient, + amplifyClient: AmplifyClient, + cognitoIdentityProviderClient: CognitoIdentityProviderClient, + private cognitoIdentityClient: CognitoIdentityClient, + iamClient: IAMClient + ) { + super( + name, + projectDirPath, + projectAmplifyDirPath, + cfnClient, + amplifyClient + ); + this.authResourceCreator = new AuthResourceCreator( + cognitoIdentityProviderClient, + cognitoIdentityClient, + iamClient + ); + } + + setupTestResources = async () => { + try { + const userPool = await this.authResourceCreator.createUserPoolBase({ + PoolName: `RefUserPool`, + AccountRecoverySetting: { + RecoveryMechanisms: [ + { + Name: 'verified_email', + Priority: 1, + }, + ], + }, + AdminCreateUserConfig: { + AllowAdminCreateUserOnly: false, + }, + AutoVerifiedAttributes: ['email'], + UserAttributeUpdateSettings: { + AttributesRequireVerificationBeforeUpdate: ['email'], + }, + EmailConfiguration: { + EmailSendingAccount: 'COGNITO_DEFAULT', + }, + Schema: [ + { + Name: 'email', + Required: true, + }, + ], + Policies: { + PasswordPolicy: { + MinimumLength: 8, + RequireUppercase: true, + RequireLowercase: true, + RequireNumbers: true, + RequireSymbols: true, + TemporaryPasswordValidityDays: 7, + }, + }, + UsernameAttributes: ['email'], + UsernameConfiguration: { + CaseSensitive: false, + }, + MfaConfiguration: 'OFF', + DeletionProtection: 'INACTIVE', + }); + + const domain = await this.authResourceCreator.createUserPoolDomainBase({ + UserPoolId: userPool.Id, + Domain: `ref-auth`, + }); + + await this.authResourceCreator.createIdentityProviderBase({ + UserPoolId: userPool.Id, + ProviderType: 'Facebook', + ProviderDetails: { + client_id: 'clientId', + client_secret: 'clientSecret', + authorize_scopes: 'openid,email', + api_version: 'v17.0', + }, + AttributeMapping: { + email: 'email', + }, + ProviderName: 'Facebook', + }); + + await this.authResourceCreator.createIdentityProviderBase({ + UserPoolId: userPool.Id, + ProviderType: 'Google', + ProviderDetails: { + client_id: 'clientId', + client_secret: 'clientSecret', + authorize_scopes: 'openid,email', + }, + AttributeMapping: { + email: 'email', + }, + ProviderName: 'Google', + }); + + const userPoolClient = + await this.authResourceCreator.createUserPoolClientBase({ + ClientName: `ref-auth-client`, + UserPoolId: userPool.Id, + ExplicitAuthFlows: [ + 'ALLOW_REFRESH_TOKEN_AUTH', + 'ALLOW_USER_SRP_AUTH', + ], + AuthSessionValidity: 3, + RefreshTokenValidity: 30, + AccessTokenValidity: 60, + IdTokenValidity: 60, + TokenValidityUnits: { + RefreshToken: 'days', + AccessToken: 'minutes', + IdToken: 'minutes', + }, + EnableTokenRevocation: true, + PreventUserExistenceErrors: 'ENABLED', + AllowedOAuthFlows: ['code'], + AllowedOAuthScopes: ['openid', 'phone', 'email'], + SupportedIdentityProviders: ['COGNITO', 'Facebook', 'Google'], + CallbackURLs: ['https://callback.com'], + LogoutURLs: ['https://logout.com'], + AllowedOAuthFlowsUserPoolClient: true, + GenerateSecret: false, + ReadAttributes: [ + 'address', + 'birthdate', + 'email', + 'email_verified', + 'family_name', + 'gender', + 'given_name', + 'locale', + 'middle_name', + 'name', + 'nickname', + 'phone_number', + 'phone_number_verified', + 'picture', + 'preferred_username', + 'profile', + 'updated_at', + 'website', + 'zoneinfo', + ], + WriteAttributes: [ + 'address', + 'birthdate', + 'email', + 'family_name', + 'gender', + 'given_name', + 'locale', + 'middle_name', + 'name', + 'nickname', + 'phone_number', + 'picture', + 'preferred_username', + 'profile', + 'updated_at', + 'website', + 'zoneinfo', + ], + }); + + const region = await this.cognitoIdentityClient.config.region(); + const identityPool = + await this.authResourceCreator.createIdentityPoolBase({ + AllowUnauthenticatedIdentities: true, + IdentityPoolName: `ref-auth-ip`, + AllowClassicFlow: false, + CognitoIdentityProviders: [ + { + ClientId: userPoolClient.ClientId, + ProviderName: `cognito-idp.${region}.amazonaws.com/${userPool.Id}`, + ServerSideTokenCheck: false, + }, + ], + SupportedLoginProviders: { + 'graph.facebook.com': 'clientId', + 'accounts.google.com': 'clientId', + }, + }); + + const roles = await this.authResourceCreator.setupIdentityPoolRoles( + userPool.Id!, + userPoolClient.ClientId!, + identityPool.IdentityPoolId + ); + + const adminGroup = await this.authResourceCreator.setupUserPoolGroup( + 'ADMINS', + userPool.Id!, + identityPool.IdentityPoolId + ); + return { + userPool, + userPoolClient, + domain, + identityPool, + authRole: roles.authRole, + unauthRole: roles.unauthRole, + adminGroup, + }; + } catch (e) { + await this.authResourceCreator.cleanupResources(); + throw e; + } + }; + + /** + * @inheritdoc + */ + override async tearDown(backendIdentifier: BackendIdentifier) { + await super.tearDown(backendIdentifier, true); + await this.authResourceCreator.cleanupResources(); + } +} diff --git a/packages/integration-tests/src/test-project-setup/test_project_base.ts b/packages/integration-tests/src/test-project-setup/test_project_base.ts index 1b1650a5b3..706d68114c 100644 --- a/packages/integration-tests/src/test-project-setup/test_project_base.ts +++ b/packages/integration-tests/src/test-project-setup/test_project_base.ts @@ -14,7 +14,9 @@ import { import { CloudFormationClient, + CloudFormationServiceException, DeleteStackCommand, + DescribeStacksCommand, } from '@aws-sdk/client-cloudformation'; import fsp from 'fs/promises'; import assert from 'node:assert'; @@ -100,19 +102,80 @@ export abstract class TestProjectBase { /** * Tear down the project. */ - async tearDown(backendIdentifier: BackendIdentifier) { + async tearDown( + backendIdentifier: BackendIdentifier, + waitForStackDeletion: boolean = false + ) { if (backendIdentifier.type === 'sandbox') { await ampxCli(['sandbox', 'delete'], this.projectDirPath) .do(confirmDeleteSandbox()) .run(); } else { + const stackName = + BackendIdentifierConversions.toStackName(backendIdentifier); await this.cfnClient.send( new DeleteStackCommand({ - StackName: - BackendIdentifierConversions.toStackName(backendIdentifier), + StackName: stackName, }) ); + if (waitForStackDeletion) { + await this.waitForStackDeletion(stackName); + } + } + } + + /** + * Wait for a stack to be deleted, returns true if deleted within allotted time. + * @param stackName name of the stack + * @returns true if delete completes within allotted time (3 minutes) + */ + async waitForStackDeletion( + stackName: string, + timeoutInMS: number = 3 * 60 * 1000 + ): Promise { + let attempts = 0; + let totalTimeWaitedMs = 0; + const maxIntervalMs = 32 * 1000; + while (totalTimeWaitedMs < timeoutInMS) { + attempts++; + const intervalMs = Math.min(Math.pow(2, attempts) * 1000, maxIntervalMs); + console.log(`waiting: ${intervalMs} milliseconds`); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + totalTimeWaitedMs += intervalMs; + try { + const status = await this.cfnClient.send( + new DescribeStacksCommand({ + StackName: stackName, + }) + ); + console.log( + JSON.stringify(status.Stacks?.map((s) => s.StackName) ?? []) + ); + if (!status.Stacks || status.Stacks.length == 0) { + console.log(`Stack ${stackName} was deleted successfully.`); + return true; + } + } catch (e) { + if ( + e instanceof CloudFormationServiceException && + e.message.includes('does not exist') + ) { + console.log(`Stack ${stackName} was deleted successfully.`); + return true; + } + console.error( + `Could not describe stack ${stackName} while waiting for deletion.`, + e + ); + throw e; + } } + console.error( + `Stack ${stackName} did not delete within ${ + timeoutInMS / 1000 + } seconds, continuing.` + ); + return false; } /** diff --git a/packages/integration-tests/src/test-projects/reference-auth/amplify/auth/resource.ts b/packages/integration-tests/src/test-projects/reference-auth/amplify/auth/resource.ts new file mode 100644 index 0000000000..bb04424328 --- /dev/null +++ b/packages/integration-tests/src/test-projects/reference-auth/amplify/auth/resource.ts @@ -0,0 +1,14 @@ +import { referenceAuth } from '@aws-amplify/backend'; +import { addUserToGroup } from '../data/add-user-to-group/resource.js'; + +export const auth = referenceAuth({ + identityPoolId: '', + authRoleArn: '', + unauthRoleArn: '', + userPoolId: '', + userPoolClientId: '', + groups: { + ADMINS: '', + }, + access: (allow) => [allow.resource(addUserToGroup).to(['addUserToGroup'])], +}); diff --git a/packages/integration-tests/src/test-projects/reference-auth/amplify/backend.ts b/packages/integration-tests/src/test-projects/reference-auth/amplify/backend.ts new file mode 100644 index 0000000000..8aac23b543 --- /dev/null +++ b/packages/integration-tests/src/test-projects/reference-auth/amplify/backend.ts @@ -0,0 +1,10 @@ +import { defineBackend } from '@aws-amplify/backend'; +import { auth } from './auth/resource.js'; +import { data } from './data/resource.js'; +import { storage } from './storage/resource.js'; + +defineBackend({ + auth, + data, + storage, +}); diff --git a/packages/integration-tests/src/test-projects/reference-auth/amplify/data/add-user-to-group/handler.ts b/packages/integration-tests/src/test-projects/reference-auth/amplify/data/add-user-to-group/handler.ts new file mode 100644 index 0000000000..48a0db7cb6 --- /dev/null +++ b/packages/integration-tests/src/test-projects/reference-auth/amplify/data/add-user-to-group/handler.ts @@ -0,0 +1,3 @@ +export const handler = async (event: any) => { + return 'Hello world!'; +}; diff --git a/packages/integration-tests/src/test-projects/reference-auth/amplify/data/add-user-to-group/resource.ts b/packages/integration-tests/src/test-projects/reference-auth/amplify/data/add-user-to-group/resource.ts new file mode 100644 index 0000000000..dd1d930b07 --- /dev/null +++ b/packages/integration-tests/src/test-projects/reference-auth/amplify/data/add-user-to-group/resource.ts @@ -0,0 +1,5 @@ +import { defineFunction } from '@aws-amplify/backend'; + +export const addUserToGroup = defineFunction({ + name: 'add-user-to-group', +}); diff --git a/packages/integration-tests/src/test-projects/reference-auth/amplify/data/resource.ts b/packages/integration-tests/src/test-projects/reference-auth/amplify/data/resource.ts new file mode 100644 index 0000000000..d6842ab5d0 --- /dev/null +++ b/packages/integration-tests/src/test-projects/reference-auth/amplify/data/resource.ts @@ -0,0 +1,24 @@ +import { type ClientSchema, a, defineData } from '@aws-amplify/backend'; +import { addUserToGroup } from './add-user-to-group/resource.js'; + +const schema = a.schema({ + Todo: a + .model({ + name: a.string(), + description: a.string(), + }) + .authorization((allow) => allow.group('ADMINS')), + addUserToGroup: a + .mutation() + .arguments({ + userId: a.string().required(), + groupName: a.string().required(), + }) + .authorization((allow) => [allow.group('ADMINS')]) + .handler(a.handler.function(addUserToGroup)) + .returns(a.json()), +}) as never; + +export type Schema = ClientSchema; + +export const data = defineData({ schema }); diff --git a/packages/integration-tests/src/test-projects/reference-auth/amplify/storage/resource.ts b/packages/integration-tests/src/test-projects/reference-auth/amplify/storage/resource.ts new file mode 100644 index 0000000000..9404344b2b --- /dev/null +++ b/packages/integration-tests/src/test-projects/reference-auth/amplify/storage/resource.ts @@ -0,0 +1,15 @@ +import { defineStorage } from '@aws-amplify/backend'; +export const storage = defineStorage({ + name: 'amplifyTeamDrive', + access: (allow) => ({ + 'profile-pictures/{entity_id}/*': [ + allow.guest.to(['read']), + allow.groups(['ADMINS']).to(['read']), + allow.entity('identity').to(['read', 'write', 'delete']), + ], + 'picture-submissions/*': [ + allow.authenticated.to(['read', 'write']), + allow.guest.to(['read', 'write']), + ], + }), +}); diff --git a/packages/integration-tests/src/test-projects/reference-auth/test-types/env/add-user-to-group.ts b/packages/integration-tests/src/test-projects/reference-auth/test-types/env/add-user-to-group.ts new file mode 100644 index 0000000000..f58ac10994 --- /dev/null +++ b/packages/integration-tests/src/test-projects/reference-auth/test-types/env/add-user-to-group.ts @@ -0,0 +1,10 @@ +export const env = process.env as { + TEST_NAME_BUCKET_NAME: string; + AWS_REGION: string; + AWS_ACCESS_KEY_ID: string; + AWS_SECRET_ACCESS_KEY: string; + AWS_SESSION_TOKEN: string; + TEST_SECRET: string; + TEST_SHARED_SECRET: string; + AMPLIFY_AUTH_USERPOOL_ID: string; +}; diff --git a/packages/plugin-types/API.md b/packages/plugin-types/API.md index 97bf48886a..5633bd63c6 100644 --- a/packages/plugin-types/API.md +++ b/packages/plugin-types/API.md @@ -43,6 +43,7 @@ export type AuthResources = { userPoolClient: IUserPoolClient; authenticatedUserIamRole: IRole; unauthenticatedUserIamRole: IRole; + identityPoolId: string; cfnResources: AuthCfnResources; groups: { [groupName: string]: { @@ -192,6 +193,20 @@ export type PackageManagerController = { // @public (undocumented) export type ProjectName = string; +// @public +export type ReferenceAuthResources = { + userPool: IUserPool; + userPoolClient: IUserPoolClient; + authenticatedUserIamRole: IRole; + unauthenticatedUserIamRole: IRole; + identityPoolId: string; + groups: { + [groupName: string]: { + role: IRole; + }; + }; +}; + // @public (undocumented) export type ResolvePathResult = { branchSecretPath: string; diff --git a/packages/plugin-types/src/auth_resources.ts b/packages/plugin-types/src/auth_resources.ts index 3b571a497c..0112e06a31 100644 --- a/packages/plugin-types/src/auth_resources.ts +++ b/packages/plugin-types/src/auth_resources.ts @@ -51,6 +51,10 @@ export type AuthResources = { * The generated unauth role. */ unauthenticatedUserIamRole: IRole; + /** + * Identity pool Id + */ + identityPoolId: string; /** * L1 Cfn Resources, for when dipping down a level of abstraction is desirable. */ @@ -72,6 +76,43 @@ export type AuthResources = { }; }; +/** + * Reference auth resources + */ +export type ReferenceAuthResources = { + /** + * The referenced UserPool L2 Resource. + */ + userPool: IUserPool; + /** + * The referenced UserPoolClient L2 Resource. + */ + userPoolClient: IUserPoolClient; + /** + * The referenced auth role. + */ + authenticatedUserIamRole: IRole; + /** + * The referenced unauth role. + */ + unauthenticatedUserIamRole: IRole; + /** + * Identity pool Id + */ + identityPoolId: string; + /** + * A map of existing group names and their associated group role. + */ + groups: { + [groupName: string]: { + /** + * The generated Role for this group + */ + role: IRole; + }; + }; +}; + export type AuthRoleName = keyof Pick< AuthResources, 'authenticatedUserIamRole' | 'unauthenticatedUserIamRole'