From 165ccb34f49a0b338d947158dd710333683fec55 Mon Sep 17 00:00:00 2001 From: osama-rizk Date: Tue, 8 Sep 2026 17:40:31 +0200 Subject: [PATCH 1/5] fix(auth-oidc): fail fast at synth when cognitoFederated() is configured cognitoFederated() emitted a CloudFormation template that always failed to deploy: the CDK layer wrote the IdP client_id/client_secret into AWS::Cognito::UserPoolIdentityProvider.ProviderDetails as {{resolve:ssm-secure}} dynamic references, which CloudFormation does not permit on that property. synth succeeded; deploy failed at change-set creation (stack left REVIEW_IN_PROGRESS). Surface the limitation at synth via Annotations.addError with an actionable message pointing at the self-hosted runtime providers (google/github/customOidc/ customOauth2), which resolve credentials at runtime and deploy cleanly. Docs + regression test added. Fixes #447 --- ...auth-oidc-cognito-federated-synth-guard.md | 26 ++++++ packages/bb-auth-oidc/DESIGN.md | 21 +++++ packages/bb-auth-oidc/README.md | 3 + packages/bb-auth-oidc/src/index.cdk.test.ts | 89 +++++++++++++++++++ packages/bb-auth-oidc/src/index.cdk.ts | 22 +++++ packages/bb-auth-oidc/src/providers.ts | 12 +++ 6 files changed, 173 insertions(+) create mode 100644 .changeset/auth-oidc-cognito-federated-synth-guard.md create mode 100644 packages/bb-auth-oidc/src/index.cdk.test.ts diff --git a/.changeset/auth-oidc-cognito-federated-synth-guard.md b/.changeset/auth-oidc-cognito-federated-synth-guard.md new file mode 100644 index 000000000..e1df745a7 --- /dev/null +++ b/.changeset/auth-oidc-cognito-federated-synth-guard.md @@ -0,0 +1,26 @@ +--- +"@aws-blocks/bb-auth-oidc": patch +"@aws-blocks/blocks": patch +--- + +fix(auth-oidc): fail fast at synth when `cognitoFederated()` is configured, instead of emitting an undeployable template + +`cognitoFederated()` produced a CloudFormation template that always failed to +deploy. The CDK layer registered the federated identity provider by writing the +IdP `client_id` / `client_secret` into +`AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` as +`{{resolve:ssm-secure:...}}` dynamic references, but CloudFormation only permits +`ssm-secure` references on a small allowlist of properties that excludes +`ProviderDetails`. `cdk synth` succeeded, so the problem was invisible until +`cdk deploy`, which failed at change-set creation — before any resource was +created — leaving the stack in `REVIEW_IN_PROGRESS`. + +AuthOIDC now surfaces this limitation at **synth** time: configuring a +`cognitoFederated()` provider registers a synth error (via CDK annotations) that +aborts deploy with an actionable message naming the offending provider(s) and +pointing at the self-hosted runtime providers — `google()`, `github()`, +`customOidc()`, `customOauth2()` — which resolve IdP credentials at runtime via +`AppSetting.get()` (not through CloudFormation) and deploy cleanly. This is a +strict DX improvement: the path was 100% undeployable before, so no working +configuration is affected. The README and DESIGN docs document the limitation +and the deploy-time custom-resource fix that will eventually lift it. diff --git a/packages/bb-auth-oidc/DESIGN.md b/packages/bb-auth-oidc/DESIGN.md index ea1c6c13f..2bf847032 100644 --- a/packages/bb-auth-oidc/DESIGN.md +++ b/packages/bb-auth-oidc/DESIGN.md @@ -94,6 +94,27 @@ OAuth client ID/secret). These serve dual purposes: The Cognito App Client credentials (internal plumbing) are auto-generated by CDK and injected via Lambda env vars. The customer never sees them. +### Known limitation — `ssm-secure` refs are undeployable (#447) + +The CDK credential flow above **cannot currently deploy.** The IdP registration +writes `{{resolve:ssm-secure:/path}}` into +`AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` (`client_id` / +`client_secret`), but CloudFormation only supports `ssm-secure` dynamic +references on a small allowlist of properties that excludes `ProviderDetails`. +`cdk synth` succeeds, then deploy fails at change-set creation — before any +resource is created — leaving the stack in `REVIEW_IN_PROGRESS`. + +Interim behavior: the CDK layer (`provisionCognitoFederation`) surfaces this at +synth via `Annotations.of(this).addError(...)`, which aborts deploy with an +actionable message pointing at the self-hosted runtime providers (`google()`, +`github()`, `customOidc()`, `customOauth2()`) — those resolve credentials at +runtime through `AppSetting.get()` and are unaffected. + +The proper fix is to register the IdP through a deploy-time custom resource +(`AwsCustomResource` calling `CreateIdentityProvider` / `UpdateIdentityProvider`) +that reads the SecureString parameter at deploy time instead of via a +CloudFormation dynamic reference. When that lands, remove the synth-time guard. + ## Decisions ### D1 — `openid-client` over `oauth4webapi` diff --git a/packages/bb-auth-oidc/README.md b/packages/bb-auth-oidc/README.md index a51343bba..73a38ce5d 100644 --- a/packages/bb-auth-oidc/README.md +++ b/packages/bb-auth-oidc/README.md @@ -324,6 +324,9 @@ Unlike the password providers, OIDC sign-in is a browser redirect to the IdP, so ## Cognito-mediated federation +> [!WARNING] +> **`cognitoFederated()` is not currently deployable.** The CDK layer registers the IdP by writing the client id/secret into `AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` as `{{resolve:ssm-secure:...}}` dynamic references, which CloudFormation does not permit on that property — deploy fails at change-set creation. AuthOIDC now surfaces this at **synth** time with an actionable error rather than emitting an undeployable template. Until the deploy-time custom-resource fix lands, use a self-hosted runtime provider instead (`google()`, `github()`, `customOidc()`, `customOauth2()`); those resolve IdP credentials at runtime via `AppSetting.get()` and deploy cleanly. See [the tracking issue](https://github.com/aws-devtools-labs/aws-blocks/issues/447). + 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. 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..5b807a14a --- /dev/null +++ b/packages/bb-auth-oidc/src/index.cdk.test.ts @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * CDK-side regression test for AuthOIDC's `cognitoFederated()` provider. + * + * History (bug #447): the CDK layer registered the federated IdP by writing the + * client id/secret into `AWS::Cognito::UserPoolIdentityProvider.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` with no resources created. Until the + * deploy-time custom-resource fix lands, `cognitoFederated()` must fail fast at + * synth with an actionable message instead of emitting an undeployable template. + */ +import { test, afterEach } from 'node:test'; +import * as cdk from 'aws-cdk-lib'; +import type { Construct } from 'constructs'; +import { Annotations, 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' }; +} + +test('CDK: cognitoFederated() surfaces a synth error (undeployable ssm-secure refs, #447)', () => { + 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'), + }), + ], + }); + // The error names the offending provider and points at the runtime-provider + // workaround; its presence is what blocks `cdk deploy`. + Annotations.fromStack(stack).hasError( + '*', + Match.stringLikeRegexp("cognitoFederated\\(\\) provider\\(s\\) 'google' cannot be deployed"), + ); +}); + +test('CDK: a self-hosted provider (google) synthesizes with no such error', () => { + const { stack, parent } = setup(); + new AuthOIDC(parent, 'auth', { + providers: [ + google({ + clientId: async () => 'id', + clientSecret: async () => 'secret', + }), + ], + }); + Annotations.fromStack(stack).hasNoError('*', Match.stringLikeRegexp('cannot be deployed')); +}); diff --git a/packages/bb-auth-oidc/src/index.cdk.ts b/packages/bb-auth-oidc/src/index.cdk.ts index 522f48412..f051187a2 100644 --- a/packages/bb-auth-oidc/src/index.cdk.ts +++ b/packages/bb-auth-oidc/src/index.cdk.ts @@ -126,6 +126,28 @@ export class AuthOIDC< cognitoProviders: CognitoFederatedProvider[], options: AuthOIDCOptions, ): void { + // `cognitoFederated()` cannot currently be deployed. The IdP registration + // below writes the client id/secret into + // `AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` as + // `{{resolve:ssm-secure:...}}` dynamic references, but CloudFormation only + // permits `ssm-secure` references on a small allowlist of properties that + // excludes `ProviderDetails`. Without this guard `cdk synth` succeeds and + // deploy fails during change-set creation — before any resource is created — + // leaving the stack in `REVIEW_IN_PROGRESS`. Surface the limitation at synth + // with an actionable message instead of emitting a template that can never + // deploy. See DESIGN.md ("Cognito federation credential flow") for the + // deploy-time custom-resource fix that would lift this restriction. + const names = cognitoProviders.map(p => `'${p.name}'`).join(', '); + cdk.Annotations.of(this).addError( + `AuthOIDC: cognitoFederated() provider(s) ${names} cannot be deployed. ` + + 'CloudFormation rejects the {{resolve:ssm-secure}} dynamic references this ' + + 'path writes into AWS::Cognito::UserPoolIdentityProvider ProviderDetails ' + + '(client_id / client_secret), so the synthesized template fails at change-set ' + + 'creation. Use a self-hosted runtime provider instead — google(), github(), ' + + 'customOidc() or customOauth2() resolve IdP credentials at runtime via ' + + 'AppSetting.get() rather than through CloudFormation, and deploy cleanly.', + ); + const stack = cdk.Stack.of(this); const pool = new cognito.UserPool(this, 'cognito-pool', { diff --git a/packages/bb-auth-oidc/src/providers.ts b/packages/bb-auth-oidc/src/providers.ts index e8e34ab5b..a71b0e725 100644 --- a/packages/bb-auth-oidc/src/providers.ts +++ b/packages/bb-auth-oidc/src/providers.ts @@ -286,6 +286,18 @@ export interface CognitoFederatedOpts { * derived from the original IdP identity (extracted from Cognito's * `identities` claim), not from Cognito's internal UUID. * + * @remarks + * **Not currently deployable.** The CDK layer registers the IdP by writing the + * client id/secret into `AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` + * as `{{resolve:ssm-secure:...}}` dynamic references, but CloudFormation does not + * allow `ssm-secure` references on that property — `cdk deploy` fails at + * change-set creation. AuthOIDC therefore surfaces an error at synth if a + * `cognitoFederated()` provider is configured. Until the deploy-time + * custom-resource fix lands, use a self-hosted runtime provider instead — + * {@link google}, {@link github}, {@link customOidc} or {@link customOauth2} + * resolve IdP credentials at runtime via `AppSetting.get()` (not through + * CloudFormation) and deploy cleanly. + * * @example * ```typescript * import { AuthOIDC, cognitoFederated } from '@aws-blocks/bb-auth-oidc'; From 1fc99295fbc7c562c8de3087758905ad0fcfd177 Mon Sep 17 00:00:00 2001 From: osama-rizk Date: Wed, 9 Sep 2026 11:52:32 +0200 Subject: [PATCH 2/5] fix(auth-oidc): register cognitoFederated IdP via deploy-time custom resource Supersede the synth-time fail-fast guard with the real fix. Instead of a native AWS::Cognito::UserPoolIdentityProvider (whose ProviderDetails would need a {{resolve:ssm-secure}} ref CloudFormation rejects), a Lambda-backed custom resource reads + decrypts the IdP credential SecureString params via the SDK at deploy time and calls Cognito CreateIdentityProvider. Only the parameter names cross into the template; the secret values never appear in it. - Handler: Create (idempotent, falls back to Update on DuplicateProvider), Update (Create fallback on ResourceNotFound), Delete (ignore NotFound); reads params with a short retry to tolerate the bulk secret-init resource. - IAM least-privilege: cognito-idp:*IdentityProvider on the pool ARN, ssm:GetParameter on the specific param ARNs, kms:Decrypt via kms:ViaService=ssm. - App client depends on each CR so the IdP exists before it is listed. - Docs (README/DESIGN/JSDoc) updated; regression tests assert no ssm-secure ref, the custom resource shape, and the IAM grants. Verified end-to-end on a real account: cdk deploy succeeds and the Google IdP is created on the pool with the client_id resolved from the SSM SecureString. Fixes #447 --- ...-oidc-cognito-federated-custom-resource.md | 37 +++ ...auth-oidc-cognito-federated-synth-guard.md | 26 -- packages/bb-auth-oidc/DESIGN.md | 62 ++-- packages/bb-auth-oidc/README.md | 5 +- packages/bb-auth-oidc/src/index.cdk.test.ts | 98 ++++-- packages/bb-auth-oidc/src/index.cdk.ts | 282 +++++++++++++----- packages/bb-auth-oidc/src/providers.ts | 20 +- 7 files changed, 367 insertions(+), 163 deletions(-) create mode 100644 .changeset/auth-oidc-cognito-federated-custom-resource.md delete mode 100644 .changeset/auth-oidc-cognito-federated-synth-guard.md 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..0f640d6a5 --- /dev/null +++ b/.changeset/auth-oidc-cognito-federated-custom-resource.md @@ -0,0 +1,37 @@ +--- +"@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:*IdentityProvider` on the pool ARN, `ssm:GetParameter` on the +specific parameter ARNs, and `kms:Decrypt` conditioned on +`kms:ViaService = ssm.`. Set the credential values with `blocks secret` +before deploying; a deploy with unset credentials fails fast with an actionable +message. + +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. + +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/.changeset/auth-oidc-cognito-federated-synth-guard.md b/.changeset/auth-oidc-cognito-federated-synth-guard.md deleted file mode 100644 index e1df745a7..000000000 --- a/.changeset/auth-oidc-cognito-federated-synth-guard.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@aws-blocks/bb-auth-oidc": patch -"@aws-blocks/blocks": patch ---- - -fix(auth-oidc): fail fast at synth when `cognitoFederated()` is configured, instead of emitting an undeployable template - -`cognitoFederated()` produced a CloudFormation template that always failed to -deploy. The CDK layer registered the federated identity provider by writing the -IdP `client_id` / `client_secret` into -`AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` as -`{{resolve:ssm-secure:...}}` dynamic references, but CloudFormation only permits -`ssm-secure` references on a small allowlist of properties that excludes -`ProviderDetails`. `cdk synth` succeeded, so the problem was invisible until -`cdk deploy`, which failed at change-set creation — before any resource was -created — leaving the stack in `REVIEW_IN_PROGRESS`. - -AuthOIDC now surfaces this limitation at **synth** time: configuring a -`cognitoFederated()` provider registers a synth error (via CDK annotations) that -aborts deploy with an actionable message naming the offending provider(s) and -pointing at the self-hosted runtime providers — `google()`, `github()`, -`customOidc()`, `customOauth2()` — which resolve IdP credentials at runtime via -`AppSetting.get()` (not through CloudFormation) and deploy cleanly. This is a -strict DX improvement: the path was 100% undeployable before, so no working -configuration is affected. The README and DESIGN docs document the limitation -and the deploy-time custom-resource fix that will eventually lift it. diff --git a/packages/bb-auth-oidc/DESIGN.md b/packages/bb-auth-oidc/DESIGN.md index 2bf847032..3418ace35 100644 --- a/packages/bb-auth-oidc/DESIGN.md +++ b/packages/bb-auth-oidc/DESIGN.md @@ -86,34 +86,50 @@ 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. -### Known limitation — `ssm-secure` refs are undeployable (#447) - -The CDK credential flow above **cannot currently deploy.** The IdP registration -writes `{{resolve:ssm-secure:/path}}` into -`AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` (`client_id` / -`client_secret`), but CloudFormation only supports `ssm-secure` dynamic -references on a small allowlist of properties that excludes `ProviderDetails`. -`cdk synth` succeeds, then deploy fails at change-set creation — before any -resource is created — leaving the stack in `REVIEW_IN_PROGRESS`. - -Interim behavior: the CDK layer (`provisionCognitoFederation`) surfaces this at -synth via `Annotations.of(this).addError(...)`, which aborts deploy with an -actionable message pointing at the self-hosted runtime providers (`google()`, -`github()`, `customOidc()`, `customOauth2()`) — those resolve credentials at -runtime through `AppSetting.get()` and are unaffected. - -The proper fix is to register the IdP through a deploy-time custom resource -(`AwsCustomResource` calling `CreateIdentityProvider` / `UpdateIdentityProvider`) -that reads the SecureString parameter at deploy time instead of via a -CloudFormation dynamic reference. When that lands, remove the synth-time guard. +### 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: + +- **Create** — reads + decrypts both SecureString parameters (`ssm:GetParameter` + `WithDecryption`, with a short retry to tolerate the bulk secret-init resource + landing slightly later), merges them into `ProviderDetails`, and calls + `CreateIdentityProvider` (falling back to `UpdateIdentityProvider` on + `DuplicateProviderException` for idempotency). +- **Update** — `UpdateIdentityProvider` (a `ProviderName`/`ProviderType` change + forces a replacement via a new `PhysicalResourceId`). +- **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:*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`. ## Decisions diff --git a/packages/bb-auth-oidc/README.md b/packages/bb-auth-oidc/README.md index 73a38ce5d..9d0a4f9df 100644 --- a/packages/bb-auth-oidc/README.md +++ b/packages/bb-auth-oidc/README.md @@ -324,12 +324,9 @@ Unlike the password providers, OIDC sign-in is a browser redirect to the IdP, so ## Cognito-mediated federation -> [!WARNING] -> **`cognitoFederated()` is not currently deployable.** The CDK layer registers the IdP by writing the client id/secret into `AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` as `{{resolve:ssm-secure:...}}` dynamic references, which CloudFormation does not permit on that property — deploy fails at change-set creation. AuthOIDC now surfaces this at **synth** time with an actionable error rather than emitting an undeployable template. Until the deploy-time custom-resource fix lands, use a self-hosted runtime provider instead (`google()`, `github()`, `customOidc()`, `customOauth2()`); those resolve IdP credentials at runtime via `AppSetting.get()` and deploy cleanly. See [the tracking issue](https://github.com/aws-devtools-labs/aws-blocks/issues/447). - 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. **Set them with `blocks secret` before deploying** — a deploy with unset credentials fails fast with a clear message. ```typescript import { AuthOIDC, cognitoFederated } from '@aws-blocks/bb-auth-oidc'; diff --git a/packages/bb-auth-oidc/src/index.cdk.test.ts b/packages/bb-auth-oidc/src/index.cdk.test.ts index 5b807a14a..8e47e073b 100644 --- a/packages/bb-auth-oidc/src/index.cdk.test.ts +++ b/packages/bb-auth-oidc/src/index.cdk.test.ts @@ -2,21 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 /** - * CDK-side regression test for AuthOIDC's `cognitoFederated()` provider. + * CDK-side regression tests for AuthOIDC's `cognitoFederated()` provider. * - * History (bug #447): the CDK layer registered the federated IdP by writing the - * client id/secret into `AWS::Cognito::UserPoolIdentityProvider.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` with no resources created. Until the - * deploy-time custom-resource fix lands, `cognitoFederated()` must fail fast at - * synth with an actionable message instead of emitting an undeployable template. + * 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 { Annotations, Match } from 'aws-cdk-lib/assertions'; +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'; @@ -53,7 +58,7 @@ function appSettingStub(fullId: string): AppSettingLike { return { fullId, get: async () => 'unused-at-synth' }; } -test('CDK: cognitoFederated() surfaces a synth error (undeployable ssm-secure refs, #447)', () => { +function synthFederated(): Template { const { stack, parent } = setup(); new AuthOIDC(parent, 'auth', { providers: [ @@ -67,23 +72,66 @@ test('CDK: cognitoFederated() surfaces a synth error (undeployable ssm-secure re }), ], }); - // The error names the offending provider and points at the runtime-provider - // workaround; its presence is what blocks `cdk deploy`. - Annotations.fromStack(stack).hasError( - '*', - Match.stringLikeRegexp("cognitoFederated\\(\\) provider\\(s\\) 'google' cannot be deployed"), - ); + 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: a self-hosted provider (google) synthesizes with no such error', () => { +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-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', - }), - ], + providers: [google({ clientId: async () => 'id', clientSecret: async () => 'secret' })], }); - Annotations.fromStack(stack).hasNoError('*', Match.stringLikeRegexp('cannot be deployed')); + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::Cognito::UserPool', 0); + template.resourceCountIs('AWS::Cognito::UserPoolIdentityProvider', 0); }); diff --git a/packages/bb-auth-oidc/src/index.cdk.ts b/packages/bb-auth-oidc/src/index.cdk.ts index f051187a2..eeb87dbd3 100644 --- a/packages/bb-auth-oidc/src/index.cdk.ts +++ b/packages/bb-auth-oidc/src/index.cdk.ts @@ -29,11 +29,16 @@ */ 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 type { IDependable } from 'constructs'; import type { AuthOIDCOptions, CognitoFederatedProvider, ProviderConfig } from './types.js'; @@ -61,6 +66,108 @@ export type { StubUser, } from './types.js'; +/** + * Inline handler for the IdP-registration custom resource. Runs at deploy time: + * reads the IdP credential SecureString parameters (by name, with decryption) + * and calls Cognito's Create/Update/DeleteIdentityProvider. The secret values + * are read here via the SDK and never transit CloudFormation. `@aws-sdk/*` is + * provided by the Node.js Lambda runtime, so nothing is bundled. + */ +const IDP_REGISTRATION_HANDLER = ` +const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm'); +const { + CognitoIdentityProviderClient, + CreateIdentityProviderCommand, + UpdateIdentityProviderCommand, + DeleteIdentityProviderCommand, +} = require('@aws-sdk/client-cognito-identity-provider'); + +const ssm = new SSMClient({}); +const idp = new CognitoIdentityProviderClient({}); + +// Tolerate the SecureString parameter being created slightly after this resource +// (the bulk secret-init custom resource and the customer's \`blocks secret\` CLI +// both feed it). Retry a few times before giving up. +async function readSecret(name) { + for (let i = 0; i < 6; i++) { + try { + const r = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true })); + return (r.Parameter && r.Parameter.Value) || ''; + } catch (e) { + if (e.name === 'ParameterNotFound' && i < 5) { + await new Promise((res) => setTimeout(res, 2000)); + continue; + } + throw e; + } + } + return ''; +} + +exports.handler = async (event) => { + const p = event.ResourceProperties; + const physicalId = p.UserPoolId + '|' + p.ProviderName; + + if (event.RequestType === 'Delete') { + try { + await idp.send(new DeleteIdentityProviderCommand({ UserPoolId: p.UserPoolId, ProviderName: p.ProviderName })); + } catch (e) { + if (e.name !== 'ResourceNotFoundException') throw e; + } + return { PhysicalResourceId: physicalId }; + } + + const clientId = await readSecret(p.ClientIdParam); + const clientSecret = await readSecret(p.ClientSecretParam); + if (!clientId || !clientSecret) { + throw new Error( + 'AuthOIDC: IdP credentials for provider "' + p.ProviderName + '" are not set. ' + + 'Set them with \`blocks secret\` before deploying.', + ); + } + + const details = Object.assign({}, 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, + ProviderDetails: details, + AttributeMapping: attributeMapping, + })); + } catch (e) { + // Idempotent create: an earlier failed run may have left the IdP behind. + if (e.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 — ProviderType/Name changes force a + // replacement via a new PhysicalResourceId). 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.name !== 'ResourceNotFoundException') throw e; + await idp.send(new CreateIdentityProviderCommand({ + UserPoolId: p.UserPoolId, ProviderName: p.ProviderName, ProviderType: p.ProviderType, + ProviderDetails: details, AttributeMapping: attributeMapping, + })); + } + return { PhysicalResourceId: physicalId }; +}; +`; + /** * CDK-synth `AuthOIDC`. * @@ -126,28 +233,6 @@ export class AuthOIDC< cognitoProviders: CognitoFederatedProvider[], options: AuthOIDCOptions, ): void { - // `cognitoFederated()` cannot currently be deployed. The IdP registration - // below writes the client id/secret into - // `AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` as - // `{{resolve:ssm-secure:...}}` dynamic references, but CloudFormation only - // permits `ssm-secure` references on a small allowlist of properties that - // excludes `ProviderDetails`. Without this guard `cdk synth` succeeds and - // deploy fails during change-set creation — before any resource is created — - // leaving the stack in `REVIEW_IN_PROGRESS`. Surface the limitation at synth - // with an actionable message instead of emitting a template that can never - // deploy. See DESIGN.md ("Cognito federation credential flow") for the - // deploy-time custom-resource fix that would lift this restriction. - const names = cognitoProviders.map(p => `'${p.name}'`).join(', '); - cdk.Annotations.of(this).addError( - `AuthOIDC: cognitoFederated() provider(s) ${names} cannot be deployed. ` - + 'CloudFormation rejects the {{resolve:ssm-secure}} dynamic references this ' - + 'path writes into AWS::Cognito::UserPoolIdentityProvider ProviderDetails ' - + '(client_id / client_secret), so the synthesized template fails at change-set ' - + 'creation. Use a self-hosted runtime provider instead — google(), github(), ' - + 'customOidc() or customOauth2() resolve IdP credentials at runtime via ' - + 'AppSetting.get() rather than through CloudFormation, and deploy cleanly.', - ); - const stack = cdk.Stack.of(this); const pool = new cognito.UserPool(this, 'cognito-pool', { @@ -166,9 +251,60 @@ 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.fromInline(IDP_REGISTRATION_HANDLER), + }); + // Register / update / deregister the IdP on the pool. + idpFn.addToRolePolicy(new PolicyStatement({ + actions: [ + 'cognito-idp:CreateIdentityProvider', + 'cognito-idp:UpdateIdentityProvider', + 'cognito-idp:DeleteIdentityProvider', + 'cognito-idp:DescribeIdentityProvider', + ], + 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); } @@ -206,70 +342,66 @@ 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); + + return new CustomResource(this, `idp-${provider.name}`, { + serviceToken, + properties: { + UserPoolId: pool.userPoolId, + ProviderName: provider.identityProvider, + ProviderType: providerType, + ClientIdParam: clientIdParam, + ClientSecretParam: clientSecretParam, + ProviderDetails: providerDetails, + AttributeMapping: attributeMapping, + }, + }); } /** diff --git a/packages/bb-auth-oidc/src/providers.ts b/packages/bb-auth-oidc/src/providers.ts index a71b0e725..eed0289ad 100644 --- a/packages/bb-auth-oidc/src/providers.ts +++ b/packages/bb-auth-oidc/src/providers.ts @@ -287,16 +287,16 @@ export interface CognitoFederatedOpts { * `identities` claim), not from Cognito's internal UUID. * * @remarks - * **Not currently deployable.** The CDK layer registers the IdP by writing the - * client id/secret into `AWS::Cognito::UserPoolIdentityProvider.ProviderDetails` - * as `{{resolve:ssm-secure:...}}` dynamic references, but CloudFormation does not - * allow `ssm-secure` references on that property — `cdk deploy` fails at - * change-set creation. AuthOIDC therefore surfaces an error at synth if a - * `cognitoFederated()` provider is configured. Until the deploy-time - * custom-resource fix lands, use a self-hosted runtime provider instead — - * {@link google}, {@link github}, {@link customOidc} or {@link customOauth2} - * resolve IdP credentials at runtime via `AppSetting.get()` (not through - * CloudFormation) and deploy cleanly. + * 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. Set the credential values with + * `blocks secret` before deploying — a deploy with unset credentials fails fast + * with an actionable message. * * @example * ```typescript From 0e772cf5fd2e15119fa1902e927934d259d8c470 Mon Sep 17 00:00:00 2001 From: osama-rizk Date: Wed, 9 Sep 2026 13:24:18 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(auth-oidc):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20testable=20handler,=20propagate=20credentials,=20de?= =?UTF-8?q?dupe=20guard,=20doc=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the deploy-time custom-resource fix, addressing PR review: - Extract the inline handler to src/idp-registration-lambda.ts, bundled via esbuild (build:lambda). Now type-checked, lint-covered, unit-tested, and no longer bumping the 4KB Code.fromInline cap. - Credentials now propagate: the CR carries a per-synth Trigger so every deploy re-reads the SSM SecureString and re-registers the IdP. Previously a value set/rotation (an out-of-band SSM write) changed no property, so CFN never re-invoked the handler and Cognito stayed on the random placeholder that BlocksSecretsBulk seeds — the IdP could never get real credentials. - PhysicalResourceId now includes ProviderType so a type change forces a clean replacement (was Update-with-no-type). - Drop unused cognito-idp:DescribeIdentityProvider from the role (least-privilege). - Synth-time guard: reject two providers sharing an identityProvider. - readSecret: remove unreachable return; retry transient errors too; surface a terminal ParameterNotFound as the actionable 'set the SecureString' message. - Docs corrected everywhere (README/DESIGN/providers JSDoc + @remarks/changeset): a secret AppSetting is an SSM SecureString at /, set via aws ssm put-parameter --overwrite or AppSetting.put() — NOT 'blocks secret' (Secrets Manager). Removed the inaccurate 'fails fast if unset' claim; documented the placeholder-seed behavior. - Tests: handler unit tests (Create/Update/Delete + idempotency fallbacks + retry/terminal), per-provider ProviderDetails assertions (Facebook/Amazon/OIDC), and the duplicate-provider synth guard. Verified e2e on a real account with a genuine AppSetting(secret:true): the BlocksSecretsBulk-created SecureString value matched the IdP client_id in Cognito. --- ...-oidc-cognito-federated-custom-resource.md | 21 +- packages/bb-auth-oidc/DESIGN.md | 52 ++++- packages/bb-auth-oidc/README.md | 10 +- packages/bb-auth-oidc/package.json | 5 +- .../src/idp-registration-lambda.test.ts | 139 +++++++++++++ .../src/idp-registration-lambda.ts | 183 ++++++++++++++++++ packages/bb-auth-oidc/src/index.cdk.test.ts | 57 ++++++ packages/bb-auth-oidc/src/index.cdk.ts | 132 +++---------- packages/bb-auth-oidc/src/providers.ts | 24 ++- 9 files changed, 495 insertions(+), 128 deletions(-) create mode 100644 packages/bb-auth-oidc/src/idp-registration-lambda.test.ts create mode 100644 packages/bb-auth-oidc/src/idp-registration-lambda.ts diff --git a/.changeset/auth-oidc-cognito-federated-custom-resource.md b/.changeset/auth-oidc-cognito-federated-custom-resource.md index 0f640d6a5..6c2b62e3f 100644 --- a/.changeset/auth-oidc-cognito-federated-custom-resource.md +++ b/.changeset/auth-oidc-cognito-federated-custom-resource.md @@ -21,14 +21,23 @@ 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:*IdentityProvider` on the pool ARN, `ssm:GetParameter` on the -specific parameter ARNs, and `kms:Decrypt` conditioned on -`kms:ViaService = ssm.`. Set the credential values with `blocks secret` -before deploying; a deploy with unset credentials fails fast with an actionable -message. +`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. +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 diff --git a/packages/bb-auth-oidc/DESIGN.md b/packages/bb-auth-oidc/DESIGN.md index 3418ace35..b019430f7 100644 --- a/packages/bb-auth-oidc/DESIGN.md +++ b/packages/bb-auth-oidc/DESIGN.md @@ -112,24 +112,56 @@ SSM Secure reference is not supported in: Instead, `provisionCognitoFederation` creates a small Lambda-backed custom resource (one handler + `Provider` per AuthOIDC instance, one `CustomResource` -per provider). The handler: +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`, with a short retry to tolerate the bulk secret-init resource - landing slightly later), merges them into `ProviderDetails`, and calls - `CreateIdentityProvider` (falling back to `UpdateIdentityProvider` on + landing slightly later, and a terminal `ParameterNotFound` surfaced as an + actionable "set the SecureString" message), merges them into `ProviderDetails`, + and calls `CreateIdentityProvider` (falling back to `UpdateIdentityProvider` on `DuplicateProviderException` for idempotency). -- **Update** — `UpdateIdentityProvider` (a `ProviderName`/`ProviderType` change - forces a replacement via a new `PhysicalResourceId`). +- **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:*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`. +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 diff --git a/packages/bb-auth-oidc/README.md b/packages/bb-auth-oidc/README.md index 9d0a4f9df..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 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. **Set them with `blocks secret` before deploying** — a deploy with unset credentials fails fast with a clear message. +`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 index 8e47e073b..7124fc660 100644 --- a/packages/bb-auth-oidc/src/index.cdk.test.ts +++ b/packages/bb-auth-oidc/src/index.cdk.test.ts @@ -135,3 +135,60 @@ test('CDK: a self-hosted provider (google) provisions no Cognito resources', () 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 eeb87dbd3..995b290fc 100644 --- a/packages/bb-auth-oidc/src/index.cdk.ts +++ b/packages/bb-auth-oidc/src/index.cdk.ts @@ -39,8 +39,12 @@ 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, @@ -66,108 +70,6 @@ export type { StubUser, } from './types.js'; -/** - * Inline handler for the IdP-registration custom resource. Runs at deploy time: - * reads the IdP credential SecureString parameters (by name, with decryption) - * and calls Cognito's Create/Update/DeleteIdentityProvider. The secret values - * are read here via the SDK and never transit CloudFormation. `@aws-sdk/*` is - * provided by the Node.js Lambda runtime, so nothing is bundled. - */ -const IDP_REGISTRATION_HANDLER = ` -const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm'); -const { - CognitoIdentityProviderClient, - CreateIdentityProviderCommand, - UpdateIdentityProviderCommand, - DeleteIdentityProviderCommand, -} = require('@aws-sdk/client-cognito-identity-provider'); - -const ssm = new SSMClient({}); -const idp = new CognitoIdentityProviderClient({}); - -// Tolerate the SecureString parameter being created slightly after this resource -// (the bulk secret-init custom resource and the customer's \`blocks secret\` CLI -// both feed it). Retry a few times before giving up. -async function readSecret(name) { - for (let i = 0; i < 6; i++) { - try { - const r = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true })); - return (r.Parameter && r.Parameter.Value) || ''; - } catch (e) { - if (e.name === 'ParameterNotFound' && i < 5) { - await new Promise((res) => setTimeout(res, 2000)); - continue; - } - throw e; - } - } - return ''; -} - -exports.handler = async (event) => { - const p = event.ResourceProperties; - const physicalId = p.UserPoolId + '|' + p.ProviderName; - - if (event.RequestType === 'Delete') { - try { - await idp.send(new DeleteIdentityProviderCommand({ UserPoolId: p.UserPoolId, ProviderName: p.ProviderName })); - } catch (e) { - if (e.name !== 'ResourceNotFoundException') throw e; - } - return { PhysicalResourceId: physicalId }; - } - - const clientId = await readSecret(p.ClientIdParam); - const clientSecret = await readSecret(p.ClientSecretParam); - if (!clientId || !clientSecret) { - throw new Error( - 'AuthOIDC: IdP credentials for provider "' + p.ProviderName + '" are not set. ' + - 'Set them with \`blocks secret\` before deploying.', - ); - } - - const details = Object.assign({}, 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, - ProviderDetails: details, - AttributeMapping: attributeMapping, - })); - } catch (e) { - // Idempotent create: an earlier failed run may have left the IdP behind. - if (e.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 — ProviderType/Name changes force a - // replacement via a new PhysicalResourceId). 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.name !== 'ResourceNotFoundException') throw e; - await idp.send(new CreateIdentityProviderCommand({ - UserPoolId: p.UserPoolId, ProviderName: p.ProviderName, ProviderType: p.ProviderType, - ProviderDetails: details, AttributeMapping: attributeMapping, - })); - } - return { PhysicalResourceId: physicalId }; -}; -`; - /** * CDK-synth `AuthOIDC`. * @@ -235,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, @@ -270,7 +187,7 @@ export class AuthOIDC< retention: this.defaults.logRetention, removalPolicy: cdk.RemovalPolicy.DESTROY, }), - code: Code.fromInline(IDP_REGISTRATION_HANDLER), + code: Code.fromAsset(join(__dirname, 'idp-registration-lambda')), }); // Register / update / deregister the IdP on the pool. idpFn.addToRolePolicy(new PolicyStatement({ @@ -278,7 +195,6 @@ export class AuthOIDC< 'cognito-idp:CreateIdentityProvider', 'cognito-idp:UpdateIdentityProvider', 'cognito-idp:DeleteIdentityProvider', - 'cognito-idp:DescribeIdentityProvider', ], resources: [pool.userPoolArn], })); @@ -400,6 +316,14 @@ export class AuthOIDC< 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(), }, }); } diff --git a/packages/bb-auth-oidc/src/providers.ts b/packages/bb-auth-oidc/src/providers.ts index eed0289ad..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; /** @@ -294,9 +296,19 @@ export interface CognitoFederatedOpts { * 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. Set the credential values with - * `blocks secret` before deploying — a deploy with unset credentials fails fast - * with an actionable message. + * 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 From 32dcfff9fdc6fcaee29e8574cd98225feaf9b1b3 Mon Sep 17 00:00:00 2001 From: osama-rizk Date: Wed, 9 Sep 2026 16:36:41 +0200 Subject: [PATCH 4/5] fix(auth-oidc): explicit dependency on BlocksSecretsBulk for IdP registration ordering Address review follow-up: rather than relying only on the handler's read-retry, the IdP-registration custom resource now takes an explicit CloudFormation dependency on bb-app-setting's shared BlocksSecretsBulk resource (located by construct id via Stack.of(this).node.tryFindChild('BlocksSecretsBulk'), a direct child of the stack). This guarantees the credential SecureString parameters are written before the handler reads them. Falls back to the retry if no secret AppSetting is configured (no bulk resource). Test + DESIGN note added. --- packages/bb-auth-oidc/DESIGN.md | 15 ++++++++---- packages/bb-auth-oidc/src/index.cdk.test.ts | 27 +++++++++++++++++++++ packages/bb-auth-oidc/src/index.cdk.ts | 13 +++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/bb-auth-oidc/DESIGN.md b/packages/bb-auth-oidc/DESIGN.md index b019430f7..1033850f6 100644 --- a/packages/bb-auth-oidc/DESIGN.md +++ b/packages/bb-auth-oidc/DESIGN.md @@ -119,11 +119,16 @@ 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`, with a short retry to tolerate the bulk secret-init resource - landing slightly later, and a terminal `ParameterNotFound` surfaced as an - actionable "set the SecureString" message), merges them into `ProviderDetails`, - and calls `CreateIdentityProvider` (falling back to `UpdateIdentityProvider` on - `DuplicateProviderException` for idempotency). + `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 diff --git a/packages/bb-auth-oidc/src/index.cdk.test.ts b/packages/bb-auth-oidc/src/index.cdk.test.ts index 7124fc660..49903ae88 100644 --- a/packages/bb-auth-oidc/src/index.cdk.test.ts +++ b/packages/bb-auth-oidc/src/index.cdk.test.ts @@ -99,6 +99,33 @@ test('CDK: cognitoFederated() registers the IdP via a custom resource that names 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 diff --git a/packages/bb-auth-oidc/src/index.cdk.ts b/packages/bb-auth-oidc/src/index.cdk.ts index 995b290fc..791e37a02 100644 --- a/packages/bb-auth-oidc/src/index.cdk.ts +++ b/packages/bb-auth-oidc/src/index.cdk.ts @@ -306,7 +306,7 @@ export class AuthOIDC< paramNames.push(clientIdParam, clientSecretParam); - return new CustomResource(this, `idp-${provider.name}`, { + const cr = new CustomResource(this, `idp-${provider.name}`, { serviceToken, properties: { UserPoolId: pool.userPoolId, @@ -326,6 +326,17 @@ export class AuthOIDC< 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; } /** From 70ef2e9d51c56b7edff9796b07d51c4ec69911d0 Mon Sep 17 00:00:00 2001 From: osama-rizk Date: Wed, 9 Sep 2026 17:48:07 +0200 Subject: [PATCH 5/5] chore: sync package-lock for bb-auth-oidc esbuild + cognito SDK deps The IdP-registration handler extraction added esbuild (devDependency) and @aws-sdk/client-cognito-identity-provider (dependency) to bb-auth-oidc but the root lockfile wasn't regenerated, so CI's `npm ci` failed (out-of-sync lock). --- package-lock.json | 486 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 486 insertions(+) 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",