diff --git a/.changeset/auth-oidc-cognito-federated-custom-resource.md b/.changeset/auth-oidc-cognito-federated-custom-resource.md new file mode 100644 index 000000000..6c2b62e3f --- /dev/null +++ b/.changeset/auth-oidc-cognito-federated-custom-resource.md @@ -0,0 +1,46 @@ +--- +"@aws-blocks/bb-auth-oidc": minor +"@aws-blocks/blocks": patch +--- + +fix(auth-oidc): make `cognitoFederated()` deployable by registering the IdP via a deploy-time custom resource + +`cognitoFederated()` previously produced a CloudFormation template that always +failed to deploy (#447). It registered the federated identity provider with a +native `AWS::Cognito::UserPoolIdentityProvider` resource, writing the IdP +`client_id` / `client_secret` into `ProviderDetails` as +`{{resolve:ssm-secure:...}}` dynamic references. CloudFormation only permits +`ssm-secure` references on a small allowlist of properties that excludes +`ProviderDetails`, so `cdk synth` succeeded but every deploy failed at +change-set creation — before any resource was created — leaving the stack in +`REVIEW_IN_PROGRESS` with `SSM Secure reference is not supported in [...ProviderDetails...]`. + +The IdP is now registered by a **deploy-time custom resource**: a small +Lambda-backed provider reads and decrypts the IdP credential SecureString +parameters via the SDK at deploy time and calls Cognito's +`CreateIdentityProvider` / `UpdateIdentityProvider` / `DeleteIdentityProvider`. +Only the parameter *names* cross into the CloudFormation template — the secret +values never appear in it. The handler's role is least-privilege: scoped to +`cognito-idp:{Create,Update,Delete}IdentityProvider` on the pool ARN, +`ssm:GetParameter` on the specific parameter ARNs, and `kms:Decrypt` conditioned +on `kms:ViaService = ssm.`. + +A `secret: true` `AppSetting` is an SSM SecureString at `/` (not an AWS +Secrets Manager entry — the `blocks secret` CLI does not apply). Set its value by +writing the SecureString directly (`aws ssm put-parameter … --type SecureString +--overwrite`) or via the `AppSetting` runtime `put()`. Because the credentials +are read at deploy time and are not stack properties, the custom resource +re-reads SSM on every `cdk deploy`, so a credential set or rotation takes effect +on the next deploy. A synth-time check rejects two providers configured with the +same `identityProvider`. + +Verified end-to-end against a real account: `cdk deploy` succeeds (no change-set +rejection) and the identity provider is created on the User Pool, including the +path where a real `AppSetting(secret: true)` provisions the SecureString during +the same deploy. + +This is a `minor` bump for `@aws-blocks/bb-auth-oidc` (pre-1.0 minor = a behavior +change): the synthesized template for a `cognitoFederated()` provider no longer +contains a native `UserPoolIdentityProvider` resource — the IdP is now a custom +resource — so anyone asserting on that resource in a snapshot will see a diff. +The public API is unchanged. diff --git a/package-lock.json b/package-lock.json index 229d0285d..5c34b8e97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55935,12 +55935,14 @@ "@aws-blocks/bb-kv-store": "^0.1.8", "@aws-blocks/bb-logger": "^0.1.6", "@aws-blocks/core": "^0.4.0", + "@aws-sdk/client-cognito-identity-provider": "^3.0.0", "@aws-sdk/client-ssm": "^3.0.0", "jose": "^6.2.3", "openid-client": "^6.8.4" }, "devDependencies": { "@types/node": "^20.0.0", + "esbuild": "^0.25.0", "typescript": "^5.3.0" }, "peerDependencies": { @@ -55948,6 +55950,490 @@ "constructs": "^10.6.0" } }, + "packages/bb-auth-oidc/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/bb-auth-oidc/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "packages/bb-cron-job": { "name": "@aws-blocks/bb-cron-job", "version": "0.2.0", diff --git a/packages/bb-auth-oidc/DESIGN.md b/packages/bb-auth-oidc/DESIGN.md index ea1c6c13f..1033850f6 100644 --- a/packages/bb-auth-oidc/DESIGN.md +++ b/packages/bb-auth-oidc/DESIGN.md @@ -86,14 +86,88 @@ can target the correct IdP's token endpoint without iterating all providers. The customer passes `AppSetting` instances for the IdP credentials (e.g. Google OAuth client ID/secret). These serve dual purposes: -- **CDK layer** reads `appSetting.fullId` → derives SSM parameter name → - writes `{{resolve:ssm-secure:/path}}` into the CloudFormation template for - Cognito IdP registration. +- **CDK layer** reads `appSetting.fullId` → derives the SSM parameter name → + passes that **name** (never the value) to the deploy-time IdP-registration + custom resource (see below). - **Runtime** calls `appSetting.get()` lazily on the first auth request. The Cognito App Client credentials (internal plumbing) are auto-generated by CDK and injected via Lambda env vars. The customer never sees them. +### IdP registration — deploy-time custom resource (why not native, #447) + +The IdP is **not** registered with a native +`AWS::Cognito::UserPoolIdentityProvider` resource. That path requires the client +id/secret in `ProviderDetails`, and the only way to keep them out of the +plaintext template is a `{{resolve:ssm-secure:/path}}` dynamic reference — but +CloudFormation supports `ssm-secure` references only on a small allowlist of +properties that **excludes `ProviderDetails`**. `cdk synth` would succeed and +deploy would then fail at change-set creation (stack stuck in +`REVIEW_IN_PROGRESS`) with: + +``` +SSM Secure reference is not supported in: +[AWS::Cognito::UserPoolIdentityProvider/Properties/ProviderDetails/client_secret, .../client_id] +``` + +Instead, `provisionCognitoFederation` creates a small Lambda-backed custom +resource (one handler + `Provider` per AuthOIDC instance, one `CustomResource` +per provider). The handler lives in `src/idp-registration-lambda.ts` and is +bundled to `dist/idp-registration-lambda/` by the `build:lambda` esbuild step +(`@aws-sdk/*` external — provided by the Lambda runtime), so it is +type-checked, lint-covered, and unit-tested (`idp-registration-lambda.test.ts`) +rather than an inline template string. It: + +- **Create** — reads + decrypts both SecureString parameters (`ssm:GetParameter` + `WithDecryption`; a terminal `ParameterNotFound` surfaces as an actionable + "set the SecureString" message, with a short retry as a secondary guard), + merges them into `ProviderDetails`, and calls `CreateIdentityProvider` (falling + back to `UpdateIdentityProvider` on `DuplicateProviderException` for idempotency). + +Ordering: each IdP custom resource takes an explicit CloudFormation dependency on +bb-app-setting's shared `BlocksSecretsBulk` resource (located by construct id via +`Stack.of(this).node.tryFindChild('BlocksSecretsBulk')`, since it is a direct +child of the stack). That guarantees the SecureString parameters are written +before the handler reads them, rather than relying on the read-retry. +- **Update** — `UpdateIdentityProvider`, falling back to `CreateIdentityProvider` + on `ResourceNotFoundException`. `PhysicalResourceId` is + `||`, so a `ProviderName`/`ProviderType` change forces a + clean replacement. +- **Delete** — `DeleteIdentityProvider` (ignoring `ResourceNotFoundException`). + +Only the parameter **names** cross into CloudFormation; the secret values are +read via the SDK at deploy time and never appear in the template. The handler's +role is scoped to `cognito-idp:{Create,Update,Delete}IdentityProvider` on the +pool ARN, `ssm:GetParameter` on the specific parameter ARNs, and `kms:Decrypt` +conditioned on `kms:ViaService = ssm.` (covers the default `aws/ssm` key +and CMKs). The app client depends on each custom resource, so the IdP exists +before the client lists it in `SupportedIdentityProviders`. A synth-time check +rejects two providers that share the same `identityProvider` (Cognito allows +one provider per name per pool). + +### Credential values, placeholders, and rotation + +A `secret: true` `AppSetting` is an **SSM SecureString** at `/` — not an +AWS Secrets Manager entry, so the `blocks secret` CLI (Secrets Manager) does not +populate it. The value is set by writing the SecureString directly +(`aws ssm put-parameter … --type SecureString --overwrite`) or via the +`AppSetting` runtime `put()`. + +`bb-app-setting`'s bulk secret-init resource seeds every managed secret with a +random 32-byte placeholder (`Overwrite: false`) on first deploy, so the +parameter is never empty. Two consequences: + +1. Deploying before the real value is set registers the IdP with the placeholder + (sign-in then fails at the provider). This is documented, not fatal — set the + real value and redeploy. +2. Because the credential values are read at deploy time and are **not** custom + resource properties, an out-of-band value change (a set or rotation) does not + by itself change any property, so CloudFormation would not re-invoke the + handler. To close that gap the custom resource carries a `Trigger` property + that changes every synth, so each `cdk deploy` re-reads SSM and re-registers + the IdP with the current value. Trade-off: the resource shows as updated on + every deploy; the `UpdateIdentityProvider` call is idempotent. + ## Decisions ### D1 — `openid-client` over `oauth4webapi` diff --git a/packages/bb-auth-oidc/README.md b/packages/bb-auth-oidc/README.md index a51343bba..ef734fde1 100644 --- a/packages/bb-auth-oidc/README.md +++ b/packages/bb-auth-oidc/README.md @@ -326,7 +326,15 @@ Unlike the password providers, OIDC sign-in is a browser redirect to the IdP, so Delegate the OIDC flow to a Cognito User Pool. Cognito handles PKCE, token verification, MFA, and brute-force protection. Your Lambda only exchanges the code and reads the session. -`cognitoFederated()` takes `AppSetting` instances (not closures) for the IdP credentials — the CDK layer needs to read them at synth time to register the IdP in Cognito via CloudFormation dynamic references. +`cognitoFederated()` takes `AppSetting` instances (not closures) for the IdP credentials. The IdP is registered on the User Pool by a **deploy-time custom resource**: a Lambda reads and decrypts those SecureString parameters via the SDK at deploy time and calls Cognito's `CreateIdentityProvider`. (A native `AWS::Cognito::UserPoolIdentityProvider` resource can't be used — CloudFormation rejects the `{{resolve:ssm-secure}}` dynamic references it would need in `ProviderDetails`.) The credential values therefore never appear in the CloudFormation template. + +**Setting the credential values.** A `secret: true` `AppSetting` is an SSM SecureString at `/` — **not** an AWS Secrets Manager entry, so the `blocks secret` CLI (which manages Secrets Manager) does not apply here. Set the value by writing the SecureString directly, or via the `AppSetting`'s runtime `put()`: + +```bash +aws ssm put-parameter --name / --type SecureString --value '' --overwrite +``` + +On the first deploy the framework seeds each secret parameter with a random placeholder, so if you deploy before setting the real value the IdP registers with that placeholder and sign-in fails at the provider — set the real value and redeploy. The registration re-reads SSM on every `cdk deploy`, so setting or rotating a credential takes effect on the next deploy (the custom resource shows as updated each deploy; the update is idempotent). ```typescript import { AuthOIDC, cognitoFederated } from '@aws-blocks/bb-auth-oidc'; diff --git a/packages/bb-auth-oidc/package.json b/packages/bb-auth-oidc/package.json index 0b29f3d25..37c8cd5d6 100644 --- a/packages/bb-auth-oidc/package.json +++ b/packages/bb-auth-oidc/package.json @@ -42,7 +42,8 @@ }, "scripts": { "prebuild": "node ../../scripts/generate-version.mjs AuthOidc", - "build": "tsc --build", + "build": "tsc --build && npm run build:lambda", + "build:lambda": "esbuild src/idp-registration-lambda.ts --bundle --platform=node --target=node22 --outfile=dist/idp-registration-lambda/index.js --format=cjs --external:@aws-sdk/*", "test": "node --test --test-concurrency=1 dist/**/*.test.js" }, "dependencies": { @@ -51,12 +52,14 @@ "@aws-blocks/bb-kv-store": "^0.1.8", "@aws-blocks/bb-logger": "^0.1.6", "@aws-blocks/core": "^0.4.0", + "@aws-sdk/client-cognito-identity-provider": "^3.0.0", "@aws-sdk/client-ssm": "^3.0.0", "jose": "^6.2.3", "openid-client": "^6.8.4" }, "devDependencies": { "@types/node": "^20.0.0", + "esbuild": "^0.25.0", "typescript": "^5.3.0" }, "peerDependencies": { diff --git a/packages/bb-auth-oidc/src/idp-registration-lambda.test.ts b/packages/bb-auth-oidc/src/idp-registration-lambda.test.ts new file mode 100644 index 000000000..dbfc72aac --- /dev/null +++ b/packages/bb-auth-oidc/src/idp-registration-lambda.test.ts @@ -0,0 +1,139 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Unit tests for the IdP-registration custom-resource handler, exercising the + * paths that only run at deploy time and can't be seen in a synth snapshot: + * SSM read + retry/terminal behavior, the Create<->Update idempotency + * fallbacks, and the Delete not-found swallow. Clients are faked — no AWS. + */ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { createHandler, type CfnEvent } from './idp-registration-lambda.js'; + +/** Record every command the handler sends, keyed by command class name. */ +function recorder() { + const calls: { name: string; input: any }[] = []; + const send = async (cmd: any) => { + calls.push({ name: cmd.constructor.name, input: cmd.input }); + return {}; + }; + return { calls, send }; +} + +const baseProps = { + UserPoolId: 'us-west-2_pool', + ProviderName: 'Google', + ProviderType: 'Google', + ClientIdParam: '/app-google-client-id', + ClientSecretParam: '/app-google-client-secret', + ProviderDetails: { authorize_scopes: 'openid email profile' }, + AttributeMapping: { email: 'email', name: 'name' }, +}; + +// An SSM fake that returns a value keyed by parameter name. +function ssmReturning(values: Record) { + return { + send: async (cmd: any) => ({ Parameter: { Value: values[cmd.input.Name] } }), + }; +} + +const fast = { retries: 3, retryDelayMs: 1 }; + +test('Create: reads both secrets and registers the IdP with merged credentials', async () => { + const ssm = ssmReturning({ '/app-google-client-id': 'CID', '/app-google-client-secret': 'CSECRET' }); + const idp = recorder(); + const handler = createHandler(ssm, idp, fast); + const res = await handler({ RequestType: 'Create', ResourceProperties: baseProps } as CfnEvent); + + assert.strictEqual(res.PhysicalResourceId, 'us-west-2_pool|Google|Google'); + assert.strictEqual(idp.calls.length, 1); + assert.strictEqual(idp.calls[0].name, 'CreateIdentityProviderCommand'); + assert.strictEqual(idp.calls[0].input.ProviderDetails.client_id, 'CID'); + assert.strictEqual(idp.calls[0].input.ProviderDetails.client_secret, 'CSECRET'); + assert.strictEqual(idp.calls[0].input.ProviderDetails.authorize_scopes, 'openid email profile'); +}); + +test('Create: DuplicateProviderException falls back to UpdateIdentityProvider', async () => { + const ssm = ssmReturning({ '/app-google-client-id': 'CID', '/app-google-client-secret': 'CSECRET' }); + let first = true; + const calls: string[] = []; + const idp = { + send: async (cmd: any) => { + calls.push(cmd.constructor.name); + if (first && cmd.constructor.name === 'CreateIdentityProviderCommand') { + first = false; + throw { name: 'DuplicateProviderException' }; + } + return {}; + }, + }; + const handler = createHandler(ssm, idp, fast); + await handler({ RequestType: 'Create', ResourceProperties: baseProps } as CfnEvent); + assert.deepStrictEqual(calls, ['CreateIdentityProviderCommand', 'UpdateIdentityProviderCommand']); +}); + +test('Update: calls UpdateIdentityProvider', async () => { + const ssm = ssmReturning({ '/app-google-client-id': 'CID', '/app-google-client-secret': 'CSECRET' }); + const idp = recorder(); + const handler = createHandler(ssm, idp, fast); + await handler({ RequestType: 'Update', ResourceProperties: baseProps } as CfnEvent); + assert.deepStrictEqual(idp.calls.map((c) => c.name), ['UpdateIdentityProviderCommand']); +}); + +test('Update: ResourceNotFoundException falls back to CreateIdentityProvider', async () => { + const ssm = ssmReturning({ '/app-google-client-id': 'CID', '/app-google-client-secret': 'CSECRET' }); + const calls: string[] = []; + const idp = { + send: async (cmd: any) => { + calls.push(cmd.constructor.name); + if (cmd.constructor.name === 'UpdateIdentityProviderCommand') throw { name: 'ResourceNotFoundException' }; + return {}; + }, + }; + const handler = createHandler(ssm, idp, fast); + await handler({ RequestType: 'Update', ResourceProperties: baseProps } as CfnEvent); + assert.deepStrictEqual(calls, ['UpdateIdentityProviderCommand', 'CreateIdentityProviderCommand']); +}); + +test('Delete: calls DeleteIdentityProvider and swallows ResourceNotFoundException', async () => { + const ssm = ssmReturning({}); + const idp = { + send: async (cmd: any) => { + assert.strictEqual(cmd.constructor.name, 'DeleteIdentityProviderCommand'); + throw { name: 'ResourceNotFoundException' }; + }, + }; + const handler = createHandler(ssm, idp, fast); + const res = await handler({ RequestType: 'Delete', ResourceProperties: baseProps } as CfnEvent); + assert.strictEqual(res.PhysicalResourceId, 'us-west-2_pool|Google|Google'); +}); + +test('readSecret: a terminal ParameterNotFound throws an actionable message', async () => { + const ssm = { send: async () => { throw { name: 'ParameterNotFound' }; } }; + const idp = recorder(); + const handler = createHandler(ssm, idp, fast); + await assert.rejects( + handler({ RequestType: 'Create', ResourceProperties: baseProps } as CfnEvent), + /was not found.*put-parameter/s, + ); + assert.strictEqual(idp.calls.length, 0, 'no Cognito call when the credential is missing'); +}); + +test('readSecret: a not-yet-present parameter is retried, then succeeds', async () => { + const attemptsByName: Record = {}; + const ssm = { + send: async (cmd: any) => { + const name = cmd.input.Name; + attemptsByName[name] = (attemptsByName[name] ?? 0) + 1; + // Fail the first read of each param, then return a value. + if (attemptsByName[name] < 2) throw { name: 'ParameterNotFound' }; + return { Parameter: { Value: name.includes('secret') ? 'CSECRET' : 'CID' } }; + }, + }; + const idp = recorder(); + const handler = createHandler(ssm as any, idp, fast); + await handler({ RequestType: 'Create', ResourceProperties: baseProps } as CfnEvent); + assert.strictEqual(attemptsByName['/app-google-client-id'], 2, 'client-id read retried once'); + assert.strictEqual(idp.calls[0].input.ProviderDetails.client_id, 'CID'); +}); diff --git a/packages/bb-auth-oidc/src/idp-registration-lambda.ts b/packages/bb-auth-oidc/src/idp-registration-lambda.ts new file mode 100644 index 000000000..70dc2747d --- /dev/null +++ b/packages/bb-auth-oidc/src/idp-registration-lambda.ts @@ -0,0 +1,183 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Deploy-time custom-resource handler for Cognito federated IdP registration. + * + * Runs during `cdk deploy` (never at runtime). It reads and decrypts the IdP + * credential SecureString parameters *by name* via the SDK and calls Cognito's + * `CreateIdentityProvider` / `UpdateIdentityProvider` / `DeleteIdentityProvider`, + * so the credential values reach Cognito without ever appearing in the + * CloudFormation template. Bundled to `dist/idp-registration-lambda/` by the + * `build:lambda` esbuild step and referenced with `Code.fromAsset`. + * + * `@aws-sdk/*` is provided by the Node.js Lambda runtime and marked external at + * bundle time, so nothing SDK-related ships in the asset. + */ + +import { + SSMClient, + GetParameterCommand, + ParameterNotFound, +} from '@aws-sdk/client-ssm'; +import { + CognitoIdentityProviderClient, + CreateIdentityProviderCommand, + UpdateIdentityProviderCommand, + DeleteIdentityProviderCommand, + type IdentityProviderTypeType, +} from '@aws-sdk/client-cognito-identity-provider'; + +/** Minimal shapes so the handler can be unit-tested with fake clients. */ +export interface SsmLike { + send(command: GetParameterCommand): Promise<{ Parameter?: { Value?: string } }>; +} +export interface IdpLike { + send( + command: CreateIdentityProviderCommand | UpdateIdentityProviderCommand | DeleteIdentityProviderCommand, + ): Promise; +} + +export interface CfnEvent { + RequestType: 'Create' | 'Update' | 'Delete'; + PhysicalResourceId?: string; + ResourceProperties: { + UserPoolId: string; + ProviderName: string; + ProviderType: string; + ClientIdParam: string; + ClientSecretParam: string; + ProviderDetails?: Record; + AttributeMapping?: Record; + }; +} + +const DEFAULT_RETRIES = 6; +const DEFAULT_RETRY_DELAY_MS = 2000; + +const sleep = (ms: number): Promise => new Promise((res) => setTimeout(res, ms)); + +/** Retry knobs — overridable so tests don't wait real seconds. */ +export interface HandlerOptions { + retries?: number; + retryDelayMs?: number; +} + +/** + * Build a handler bound to the given clients. Production uses the real SDK + * clients; tests inject fakes. + */ +export function createHandler(ssm: SsmLike, idp: IdpLike, options: HandlerOptions = {}) { + const retries = options.retries ?? DEFAULT_RETRIES; + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + // Read + decrypt a SecureString by name. Retries on a not-yet-present + // parameter (the bulk secret-init resource may land slightly later) and on + // transient errors; a terminal not-found surfaces an actionable message. + async function readSecret(name: string, providerName: string): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < retries; attempt++) { + try { + const r = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true })); + return r.Parameter?.Value ?? ''; + } catch (e) { + lastErr = e; + if (attempt < retries - 1) { + await sleep(retryDelayMs); + } + } + } + if (lastErr instanceof ParameterNotFound || (lastErr as { name?: string })?.name === 'ParameterNotFound') { + throw new Error( + `AuthOIDC: IdP credential parameter "${name}" for provider "${providerName}" was not found. ` + + `Set it before deploying — write the SSM SecureString directly ` + + `(e.g. \`aws ssm put-parameter --name ${name} --type SecureString --value --overwrite\`) ` + + `or via the AppSetting's runtime \`put()\`.`, + ); + } + throw lastErr; + } + + return async function handler(event: CfnEvent): Promise<{ PhysicalResourceId: string }> { + const p = event.ResourceProperties; + // ProviderType is part of the identity so a type change forces a clean + // replacement (Update cannot change an IdP's type). + const physicalId = `${p.UserPoolId}|${p.ProviderName}|${p.ProviderType}`; + + if (event.RequestType === 'Delete') { + try { + await idp.send( + new DeleteIdentityProviderCommand({ UserPoolId: p.UserPoolId, ProviderName: p.ProviderName }), + ); + } catch (e) { + if ((e as { name?: string })?.name !== 'ResourceNotFoundException') throw e; + } + return { PhysicalResourceId: physicalId }; + } + + const clientId = await readSecret(p.ClientIdParam, p.ProviderName); + const clientSecret = await readSecret(p.ClientSecretParam, p.ProviderName); + if (!clientId || !clientSecret) { + throw new Error( + `AuthOIDC: IdP credentials for provider "${p.ProviderName}" are empty. ` + + `Set the SecureString values before deploying.`, + ); + } + + const details = { ...(p.ProviderDetails ?? {}), client_id: clientId, client_secret: clientSecret }; + const attributeMapping = p.AttributeMapping ?? {}; + + if (event.RequestType === 'Create') { + try { + await idp.send( + new CreateIdentityProviderCommand({ + UserPoolId: p.UserPoolId, + ProviderName: p.ProviderName, + ProviderType: p.ProviderType as IdentityProviderTypeType, + ProviderDetails: details, + AttributeMapping: attributeMapping, + }), + ); + } catch (e) { + // Idempotent create: an earlier failed run may have left the IdP behind. + if ((e as { name?: string })?.name !== 'DuplicateProviderException') throw e; + await idp.send( + new UpdateIdentityProviderCommand({ + UserPoolId: p.UserPoolId, + ProviderName: p.ProviderName, + ProviderDetails: details, + AttributeMapping: attributeMapping, + }), + ); + } + return { PhysicalResourceId: physicalId }; + } + + // Update — same pool + provider name + type. Fall back to Create if the + // IdP went missing out of band. + try { + await idp.send( + new UpdateIdentityProviderCommand({ + UserPoolId: p.UserPoolId, + ProviderName: p.ProviderName, + ProviderDetails: details, + AttributeMapping: attributeMapping, + }), + ); + } catch (e) { + if ((e as { name?: string })?.name !== 'ResourceNotFoundException') throw e; + await idp.send( + new CreateIdentityProviderCommand({ + UserPoolId: p.UserPoolId, + ProviderName: p.ProviderName, + ProviderType: p.ProviderType as IdentityProviderTypeType, + ProviderDetails: details, + AttributeMapping: attributeMapping, + }), + ); + } + return { PhysicalResourceId: physicalId }; + }; +} + +/** Production entry point wired to the real SDK clients. */ +export const handler = createHandler(new SSMClient({}), new CognitoIdentityProviderClient({})); diff --git a/packages/bb-auth-oidc/src/index.cdk.test.ts b/packages/bb-auth-oidc/src/index.cdk.test.ts new file mode 100644 index 000000000..49903ae88 --- /dev/null +++ b/packages/bb-auth-oidc/src/index.cdk.test.ts @@ -0,0 +1,221 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * CDK-side regression tests for AuthOIDC's `cognitoFederated()` provider. + * + * History (bug #447): the CDK layer registered the federated IdP with native + * `AWS::Cognito::UserPoolIdentityProvider` resources, writing the client + * id/secret into `ProviderDetails` as `{{resolve:ssm-secure:...}}` dynamic + * references. CloudFormation only allows `ssm-secure` references on a small + * allowlist that excludes `ProviderDetails`, so `cdk synth` succeeded but every + * deploy failed at change-set creation, leaving the stack in + * `REVIEW_IN_PROGRESS`. + * + * Fix: register the IdP through a deploy-time custom resource whose handler + * reads and decrypts the SecureString parameters via the SDK and calls + * `CreateIdentityProvider`. The synthesized template therefore contains no + * native IdP resource and no `ssm-secure` reference — only the parameter names. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert'; +import * as cdk from 'aws-cdk-lib'; +import type { Construct } from 'constructs'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk'; +import { AuthOIDC, cognitoFederated, google } from './index.cdk.js'; +import type { AppSettingLike } from './providers.js'; + +class StubBlocksStack extends cdk.Stack { + public readonly handler: cdk.aws_lambda.Function; + public readonly id: string; + constructor(scope: Construct, id: string) { + super(scope, id); + this.id = id; + (globalThis as any).CURRENT_BLOCKS_STACK = this; + this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', { + runtime: DEFAULT_NODE_RUNTIME, + handler: 'index.handler', + code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'), + }); + } +} + +afterEach(() => { + delete (globalThis as any).CURRENT_BLOCKS_STACK; +}); + +function setup(): { stack: StubBlocksStack; parent: Scope } { + const app = new cdk.App(); + const stack = new StubBlocksStack(app, 'TestStack'); + const parent = new Scope('app'); + return { stack, parent }; +} + +// An AppSetting-shaped stub: the CDK layer only reads `fullId`; `get()` is the +// runtime path and is never called at synth. +function appSettingStub(fullId: string): AppSettingLike { + return { fullId, get: async () => 'unused-at-synth' }; +} + +function synthFederated(): Template { + const { stack, parent } = setup(); + new AuthOIDC(parent, 'auth', { + providers: [ + cognitoFederated({ + name: 'google', + identityProvider: 'Google', + cognitoDomain: 'myapp-abc123', + region: 'us-east-1', + clientId: appSettingStub('app-google-client-id'), + clientSecret: appSettingStub('app-google-client-secret'), + }), + ], + }); + return Template.fromStack(stack); +} + +test('CDK: cognitoFederated() emits NO native IdP resource and NO ssm-secure reference (#447)', () => { + const template = synthFederated(); + // The bug: a native IdP resource carrying ssm-secure refs in ProviderDetails. + template.resourceCountIs('AWS::Cognito::UserPoolIdentityProvider', 0); + // No ssm-secure dynamic reference anywhere in the synthesized template. + const json = JSON.stringify(template.toJSON()); + assert.ok(!json.includes('{{resolve:ssm-secure'), 'template must not contain any ssm-secure dynamic reference'); + // The pool itself is still provisioned. + template.resourceCountIs('AWS::Cognito::UserPool', 1); +}); + +test('CDK: cognitoFederated() registers the IdP via a custom resource that names (not embeds) the SSM params', () => { + const template = synthFederated(); + const crs = template.findResources('AWS::CloudFormation::CustomResource'); + const idpCr = Object.values(crs).find( + (r: any) => r.Properties?.ProviderName === 'Google' && r.Properties?.ProviderType === 'Google', + ) as any; + assert.ok(idpCr, 'an IdP-registration custom resource should exist'); + // Only the parameter NAMES cross into the template — never the secret values. + assert.strictEqual(idpCr.Properties.ClientIdParam, '/app-google-client-id'); + assert.strictEqual(idpCr.Properties.ClientSecretParam, '/app-google-client-secret'); + assert.strictEqual(idpCr.Properties.ProviderDetails.authorize_scopes, 'openid email profile'); +}); + +test('CDK: the IdP custom resource depends on BlocksSecretsBulk when present (param exists before the read)', () => { + // A real BlocksStack has bb-app-setting's shared `BlocksSecretsBulk` resource + // (it writes every secret AppSetting's SecureString). The plain test stub does + // not model that, so stand one in with the same construct id; AuthOIDC should + // wire a dependency onto it so the credential parameters exist before the + // handler reads them. + const TOKEN = 'arn:aws:lambda:us-east-1:1:function:x'; + const { stack, parent } = setup(); + new cdk.CustomResource(stack, 'BlocksSecretsBulk', { serviceToken: TOKEN }); + new AuthOIDC(parent, 'auth', { + providers: [ + cognitoFederated({ + name: 'google', identityProvider: 'Google', cognitoDomain: 'myapp-abc123', region: 'us-east-1', + clientId: appSettingStub('app-google-client-id'), clientSecret: appSettingStub('app-google-client-secret'), + }), + ], + }); + const template = Template.fromStack(stack); + const crs = template.findResources('AWS::CloudFormation::CustomResource'); + const bulkLogicalId = Object.keys(crs).find((k) => crs[k].Properties?.ServiceToken === TOKEN); + assert.ok(bulkLogicalId, 'stand-in BlocksSecretsBulk should be in the template'); + const idpEntry = Object.entries(crs).find(([, r]: [string, any]) => r.Properties?.ProviderName === 'Google'); + assert.ok(idpEntry, 'IdP custom resource should exist'); + const dependsOn: string[] = (idpEntry![1] as any).DependsOn ?? []; + assert.ok(dependsOn.includes(bulkLogicalId as string), 'IdP CR must depend on the bulk secret-init resource'); +}); + +test('CDK: the IdP-registration Lambda is granted cognito-idp, ssm:GetParameter and scoped kms:Decrypt', () => { + const template = synthFederated(); + // The three grants land across the role's managed statements; assert each + // independently (kms:ViaService resolves to a region token, so match its + // presence, not an exact string). + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ Action: Match.arrayWith(['cognito-idp:CreateIdentityProvider']) }), + ]), + }, + }); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { Statement: Match.arrayWith([Match.objectLike({ Action: 'ssm:GetParameter' })]) }, + }); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'kms:Decrypt', + Condition: Match.objectLike({ StringEquals: Match.objectLike({ 'kms:ViaService': Match.anyValue() }) }), + }), + ]), + }, + }); +}); + +test('CDK: a self-hosted provider (google) provisions no Cognito resources', () => { + const { stack, parent } = setup(); + new AuthOIDC(parent, 'auth', { + providers: [google({ clientId: async () => 'id', clientSecret: async () => 'secret' })], + }); + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::Cognito::UserPool', 0); + template.resourceCountIs('AWS::Cognito::UserPoolIdentityProvider', 0); +}); + +// Pin the per-provider ProviderType + ProviderDetails so a future edit can't +// silently drift them away from what the old L2 constructs produced. +function idpProps(provider: Parameters[0]): any { + const { stack, parent } = setup(); + new AuthOIDC(parent, 'auth', { providers: [cognitoFederated(provider)] }); + const crs = Template.fromStack(stack).findResources('AWS::CloudFormation::CustomResource'); + return Object.values(crs).find((r: any) => r.Properties?.ProviderType && r.Properties?.ClientIdParam) as any; +} + +test('CDK: Facebook provider maps to ProviderType=Facebook, scopes "public_profile email"', () => { + const cr = idpProps({ + name: 'fb', identityProvider: 'Facebook', cognitoDomain: 'd1', region: 'us-east-1', + clientId: appSettingStub('fb-id'), clientSecret: appSettingStub('fb-secret'), + }); + assert.strictEqual(cr.Properties.ProviderType, 'Facebook'); + assert.strictEqual(cr.Properties.ProviderDetails.authorize_scopes, 'public_profile email'); +}); + +test('CDK: LoginWithAmazon provider maps to ProviderType=LoginWithAmazon, scopes "profile"', () => { + const cr = idpProps({ + name: 'amzn', identityProvider: 'LoginWithAmazon', cognitoDomain: 'd2', region: 'us-east-1', + clientId: appSettingStub('amzn-id'), clientSecret: appSettingStub('amzn-secret'), + }); + assert.strictEqual(cr.Properties.ProviderType, 'LoginWithAmazon'); + assert.strictEqual(cr.Properties.ProviderDetails.authorize_scopes, 'profile'); +}); + +test('CDK: a custom OIDC provider maps to ProviderType=OIDC with oidc_issuer + GET', () => { + const cr = idpProps({ + name: 'corp', identityProvider: 'CorpIdP', idpIssuerUrl: 'https://idp.example.com', + cognitoDomain: 'd3', region: 'us-east-1', + clientId: appSettingStub('corp-id'), clientSecret: appSettingStub('corp-secret'), + }); + assert.strictEqual(cr.Properties.ProviderType, 'OIDC'); + assert.strictEqual(cr.Properties.ProviderDetails.oidc_issuer, 'https://idp.example.com'); + assert.strictEqual(cr.Properties.ProviderDetails.attributes_request_method, 'GET'); +}); + +test('CDK: two providers with the same identityProvider fail fast at synth', () => { + const { parent } = setup(); + assert.throws( + () => new AuthOIDC(parent, 'auth', { + providers: [ + cognitoFederated({ + name: 'g1', identityProvider: 'Google', cognitoDomain: 'd', region: 'us-east-1', + clientId: appSettingStub('g1-id'), clientSecret: appSettingStub('g1-secret'), + }), + cognitoFederated({ + name: 'g2', identityProvider: 'Google', cognitoDomain: 'd', region: 'us-east-1', + clientId: appSettingStub('g2-id'), clientSecret: appSettingStub('g2-secret'), + }), + ], + }), + /duplicate cognitoFederated identityProvider 'Google'/, + ); +}); diff --git a/packages/bb-auth-oidc/src/index.cdk.ts b/packages/bb-auth-oidc/src/index.cdk.ts index 522f48412..791e37a02 100644 --- a/packages/bb-auth-oidc/src/index.cdk.ts +++ b/packages/bb-auth-oidc/src/index.cdk.ts @@ -29,13 +29,22 @@ */ import type { ScopeParent } from '@aws-blocks/core'; -import { Scope, registerConfig } from '@aws-blocks/core/cdk'; +import { Scope, registerConfig, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk'; import { AppSetting } from '@aws-blocks/bb-app-setting'; import { KVStore } from '@aws-blocks/bb-kv-store'; import * as cdk from 'aws-cdk-lib'; +import { CustomResource } from 'aws-cdk-lib'; import * as cognito from 'aws-cdk-lib/aws-cognito'; +import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda'; +import { LogGroup } from 'aws-cdk-lib/aws-logs'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { Provider } from 'aws-cdk-lib/custom-resources'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; import type { IDependable } from 'constructs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); import type { AuthOIDCOptions, CognitoFederatedProvider, ProviderConfig } from './types.js'; import { DEFAULT_CALLBACK_PATH, @@ -128,6 +137,21 @@ export class AuthOIDC< ): void { const stack = cdk.Stack.of(this); + // Cognito allows only one identity provider per provider name per pool. + // Two `cognitoFederated()` configs with the same `identityProvider` would + // otherwise synth two custom resources writing the same Cognito provider — + // a silent last-writer-wins overwrite. Fail fast at synth instead. + const seen = new Set(); + for (const p of cognitoProviders) { + if (seen.has(p.identityProvider)) { + throw new Error( + `AuthOIDC: duplicate cognitoFederated identityProvider '${p.identityProvider}'. ` + + 'Each Cognito identity provider name may be configured only once per user pool.', + ); + } + seen.add(p.identityProvider); + } + const pool = new cognito.UserPool(this, 'cognito-pool', { userPoolName: `${this.fullId}-federation`, selfSignUpEnabled: false, @@ -144,9 +168,59 @@ export class AuthOIDC< }); } + // IdP registration runs through a deploy-time custom resource rather than + // native `AWS::Cognito::UserPoolIdentityProvider` resources. The native path + // would write the IdP client id/secret into `ProviderDetails` as + // `{{resolve:ssm-secure:...}}` dynamic references, which CloudFormation does + // not permit on that property — deploy fails at change-set creation. Instead, + // a Lambda reads the SecureString parameters via the SDK at deploy time and + // calls Cognito's `CreateIdentityProvider`, so the credentials reach Cognito + // without ever appearing in the CloudFormation template. See DESIGN.md. + const idpParamNames: string[] = []; + const idpFn = new LambdaFunction(this, 'idp-registration-fn', { + runtime: DEFAULT_NODE_RUNTIME, + handler: 'index.handler', + timeout: cdk.Duration.minutes(2), + // Own the log group so its retention follows the stack-wide default + // instead of AWS's infinite retention. + logGroup: new LogGroup(this, 'idp-registration-logs', { + retention: this.defaults.logRetention, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }), + code: Code.fromAsset(join(__dirname, 'idp-registration-lambda')), + }); + // Register / update / deregister the IdP on the pool. + idpFn.addToRolePolicy(new PolicyStatement({ + actions: [ + 'cognito-idp:CreateIdentityProvider', + 'cognito-idp:UpdateIdentityProvider', + 'cognito-idp:DeleteIdentityProvider', + ], + resources: [pool.userPoolArn], + })); + // Read the IdP credential SecureString parameters at deploy time. ARNs are + // resolved lazily as providers register below. + idpFn.addToRolePolicy(new PolicyStatement({ + actions: ['ssm:GetParameter'], + resources: cdk.Lazy.list({ + produce: () => idpParamNames.map(n => + stack.formatArn({ service: 'ssm', resource: 'parameter', resourceName: n.replace(/^\//, '') }), + ), + }), + })); + // SecureString decryption goes through KMS via the SSM service. Scoping to + // `kms:ViaService = ssm.` covers both the default `aws/ssm` key and + // any customer-managed key (whose own key policy must also allow this role). + idpFn.addToRolePolicy(new PolicyStatement({ + actions: ['kms:Decrypt'], + resources: ['*'], + conditions: { StringEquals: { 'kms:ViaService': `ssm.${stack.region}.amazonaws.com` } }, + })); + const idpProvider = new Provider(this, 'idp-registration-provider', { onEventHandler: idpFn }); + const idpDependencies: IDependable[] = []; for (const provider of cognitoProviders) { - const idp = this.registerIdentityProvider(pool, provider); + const idp = this.registerIdentityProvider(pool, provider, idpProvider.serviceToken, idpParamNames); if (idp) idpDependencies.push(idp); } @@ -184,70 +258,85 @@ export class AuthOIDC< } /** - * Register a federated identity provider on the Cognito User Pool. - * Uses CloudFormation dynamic references to read IdP credentials from - * SSM at deploy time. + * Register a federated identity provider on the Cognito User Pool via a + * deploy-time custom resource. Only the SSM parameter *names* (never the + * secret values) are passed to CloudFormation; the handler reads and + * decrypts the credentials via the SDK at deploy time. Returns the custom + * resource so the app client can depend on it (the IdP must exist before the + * client lists it in `SupportedIdentityProviders`). */ private registerIdentityProvider( pool: cognito.UserPool, provider: CognitoFederatedProvider, + serviceToken: string, + paramNames: string[], ): IDependable | undefined { - const idpClientIdParam = `/${provider.idpClientId.fullId}`; - const idpClientSecretParam = `/${provider.idpClientSecret.fullId}`; - const clientIdRef = `{{resolve:ssm-secure:${idpClientIdParam}}}`; - const clientSecretRef = `{{resolve:ssm-secure:${idpClientSecretParam}}}`; + const clientIdParam = `/${provider.idpClientId.fullId}`; + const clientSecretParam = `/${provider.idpClientSecret.fullId}`; + // Provider-type-specific, non-secret `ProviderDetails` + `AttributeMapping`. + // The handler merges the resolved client_id/client_secret into these. + let providerType: string; + let providerDetails: Record; + const attributeMapping: Record = { email: 'email', name: 'name' }; switch (provider.identityProvider) { case 'Google': - return new cognito.UserPoolIdentityProviderGoogle(this, `idp-${provider.name}`, { - userPool: pool, - clientId: clientIdRef, - clientSecretValue: cdk.SecretValue.unsafePlainText(clientSecretRef), - scopes: ['openid', 'email', 'profile'], - attributeMapping: { - email: cognito.ProviderAttribute.GOOGLE_EMAIL, - fullname: cognito.ProviderAttribute.GOOGLE_NAME, - }, - }); + providerType = 'Google'; + providerDetails = { authorize_scopes: 'openid email profile' }; + break; case 'Facebook': - return new cognito.UserPoolIdentityProviderFacebook(this, `idp-${provider.name}`, { - userPool: pool, - clientId: clientIdRef, - clientSecret: clientSecretRef, - scopes: ['public_profile', 'email'], - attributeMapping: { - email: cognito.ProviderAttribute.FACEBOOK_EMAIL, - fullname: cognito.ProviderAttribute.FACEBOOK_NAME, - }, - }); + providerType = 'Facebook'; + providerDetails = { authorize_scopes: 'public_profile email' }; + break; case 'LoginWithAmazon': - return new cognito.UserPoolIdentityProviderAmazon(this, `idp-${provider.name}`, { - userPool: pool, - clientId: clientIdRef, - clientSecret: clientSecretRef, - attributeMapping: { - email: cognito.ProviderAttribute.AMAZON_EMAIL, - fullname: cognito.ProviderAttribute.AMAZON_NAME, - }, - }); + providerType = 'LoginWithAmazon'; + providerDetails = { authorize_scopes: 'profile' }; + break; default: // Custom OIDC IdP — requires idpIssuerUrl on the provider config. - if (provider.idpIssuerUrl) { - return new cognito.UserPoolIdentityProviderOidc(this, `idp-${provider.name}`, { - userPool: pool, - name: provider.identityProvider, - clientId: clientIdRef, - clientSecret: clientSecretRef, - issuerUrl: provider.idpIssuerUrl, - scopes: ['openid', 'email', 'profile'], - attributeMapping: { - email: cognito.ProviderAttribute.other('email'), - fullname: cognito.ProviderAttribute.other('name'), - }, - }); - } - return undefined; + if (!provider.idpIssuerUrl) return undefined; + providerType = 'OIDC'; + providerDetails = { + authorize_scopes: 'openid email profile', + oidc_issuer: provider.idpIssuerUrl, + attributes_request_method: 'GET', + }; + break; } + + paramNames.push(clientIdParam, clientSecretParam); + + const cr = new CustomResource(this, `idp-${provider.name}`, { + serviceToken, + properties: { + UserPoolId: pool.userPoolId, + ProviderName: provider.identityProvider, + ProviderType: providerType, + ClientIdParam: clientIdParam, + ClientSecretParam: clientSecretParam, + ProviderDetails: providerDetails, + AttributeMapping: attributeMapping, + // The credential values live in SSM and are read at deploy time, so + // they never appear as custom-resource properties. That means a + // credential set/rotation (an out-of-band SecureString write) does not + // change any property and would not, on its own, re-invoke the handler. + // This nonce changes every synth so each `cdk deploy` re-reads SSM and + // re-registers the IdP with the current value. Trade-off: the resource + // shows as updated on every deploy (the Update is idempotent). + Trigger: Date.now().toString(), + }, + }); + + // A `secret: true` AppSetting's SecureString value is written by the shared + // bb-app-setting bulk-init custom resource (`BlocksSecretsBulk`, a direct + // child of the stack). Depend on it so the parameter exists before this + // handler reads it — a hard ordering guarantee rather than leaving it to the + // handler's read-retry. (Skipped only if the customer somehow configured no + // secret AppSetting, in which case the retry remains the fallback.) + const bulkSecrets = cdk.Stack.of(this).node.tryFindChild('BlocksSecretsBulk'); + if (bulkSecrets) cr.node.addDependency(bulkSecrets); + + return cr; } /** diff --git a/packages/bb-auth-oidc/src/providers.ts b/packages/bb-auth-oidc/src/providers.ts index e8e34ab5b..5023271ff 100644 --- a/packages/bb-auth-oidc/src/providers.ts +++ b/packages/bb-auth-oidc/src/providers.ts @@ -242,14 +242,16 @@ export interface CognitoFederatedOpts { /** * The IdP's OAuth client ID as an `AppSetting` instance. * This is the same credential you'd pass to `google()` — e.g. your Google OAuth client ID. - * CDK reads the parameter name for CloudFormation dynamic references. + * The CDK layer passes the derived SSM parameter name (not the value) to the + * deploy-time IdP-registration custom resource, which reads it via the SDK. * Runtime calls `.get()` to resolve the value. */ clientId: AppSettingLike; /** * The IdP's OAuth client secret as an `AppSetting` instance. - * CDK reads the parameter name for CloudFormation dynamic references. - * Runtime calls `.get()` to resolve the value. + * The CDK layer passes the derived SSM parameter name (not the value) to the + * deploy-time IdP-registration custom resource, which reads and decrypts it + * via the SDK. Runtime calls `.get()` to resolve the value. */ clientSecret: AppSettingLike; /** @@ -286,6 +288,28 @@ export interface CognitoFederatedOpts { * derived from the original IdP identity (extracted from Cognito's * `identities` claim), not from Cognito's internal UUID. * + * @remarks + * The IdP is registered on the User Pool by a **deploy-time custom resource**, + * not a native `AWS::Cognito::UserPoolIdentityProvider` resource. That native + * path would write the client id/secret into `ProviderDetails` as + * `{{resolve:ssm-secure:...}}` dynamic references, which CloudFormation rejects + * on that property. Instead a Lambda reads and decrypts the credential + * SecureString parameters via the SDK at deploy time and calls Cognito's + * `CreateIdentityProvider`, so the credentials reach Cognito without ever + * appearing in the CloudFormation template. + * + * **Setting the credential values.** A `secret: true` `AppSetting` is an SSM + * SecureString at `/`; its value is *not* managed by the + * `blocks secret` CLI (that CLI manages AWS Secrets Manager, a different store). + * Set it by writing the SecureString directly — e.g. + * `aws ssm put-parameter --name / --type SecureString --value --overwrite` + * — or through the `AppSetting`'s runtime `put()`. On the first deploy the + * framework seeds the parameter with a random placeholder, so if you deploy + * before setting the real value the IdP registers with that placeholder and + * sign-in fails at the provider; set the real value and redeploy. The + * registration re-reads SSM on every `cdk deploy`, so a set or rotation takes + * effect on the next deploy. + * * @example * ```typescript * import { AuthOIDC, cognitoFederated } from '@aws-blocks/bb-auth-oidc';