diff --git a/infra/bin/infra.ts b/infra/bin/infra.ts index 2a19465d..ee196e14 100644 --- a/infra/bin/infra.ts +++ b/infra/bin/infra.ts @@ -46,7 +46,7 @@ const env = { const externalOidc = config.auth?.oidcDiscoveryUrl; const externalClients = config.auth?.allowedClients; -let oidcDiscoveryUrl: string; +let oidcDiscoveryUrl: string | undefined; let allowedClients: string[]; let authStack: AuthStack | undefined; @@ -61,8 +61,13 @@ if (externalOidc && externalClients) { description: "Spec-Driven Presentation Maker - Auth (uksb-ynuz0lkrea)(tag:auth)", mcpCallbackUrls: config.auth?.mcpCallbackUrls, }); - oidcDiscoveryUrl = authStack.oidcDiscoveryUrl; - allowedClients = [authStack.clientId]; + // In AuthStack mode, downstream stacks read Auth values (oidcDiscoveryUrl, + // WebClient ID, MCP custom scope, etc.) from SSM Parameter Store published + // by AuthStack. Keeping these undefined / empty here prevents CDK from + // emitting auto-generated cross-stack exports that would collide with the + // retained legacy exports in AuthStack. + oidcDiscoveryUrl = undefined; + allowedClients = []; } // --- Required stacks --- @@ -94,21 +99,28 @@ const runtime = new RuntimeStack(app, "SdpmRuntime", { table: data.table, pptxBucket: data.pptxBucket, resourceBucket: data.resourceBucket, - oidcDiscoveryUrl, - allowedClients: authStack - ? [authStack.clientId, ...(authStack.mcpClientId ? [authStack.mcpClientId] : [])] - : allowedClients, + // oidcDiscoveryUrl is used only when useAuthStack=false (external IdP). + // In AuthStack mode, the value is read from SSM. + oidcDiscoveryUrl: authStack ? undefined : oidcDiscoveryUrl, + // allowedClients is used only when useAuthStack=false (external IdP). + // In AuthStack mode, RuntimeStack reads the MCP custom scope from SSM and + // uses it as allowedScopes instead. Passing authStack.clientId here would + // cause CDK to emit an auto-generated cross-stack export which would + // collide with the legacy exports retained in AuthStack. + allowedClients: authStack ? [] : allowedClients, kbSsmParamName: data.kbSsmParamName || undefined, vectorBucketName: data.vectorBucketName || undefined, vectorIndexName: data.vectorIndexName || undefined, - userPoolId: authStack?.userPool.userPoolId, - cognitoDomainPrefix: authStack?.cognitoDomainPrefix, - mcpClientId: authStack?.mcpClientId || undefined, - mcpCustomScope: authStack?.mcpCustomScope, + useAuthStack: !!authStack, enableDCR: config.auth?.enableDCR !== false, - // Prefer allowedScopes (works with DCR); fall back to allowedClients for external IdP. - allowedScopes: authStack?.mcpCustomScope ? [authStack.mcpCustomScope] : undefined, }); +// When AuthStack is present, explicitly declare the dependency so that SSM +// parameters published by AuthStack exist before RuntimeStack reads them. +// This replaces the implicit dependency that CDK used to infer from +// cross-stack construct references. +if (authStack) { + runtime.addDependency(authStack); +} // --- Model configuration & validation --- const defaultChatModelId: string = config.model?.defaults?.chat ?? "global.anthropic.claude-sonnet-4-6"; @@ -175,18 +187,24 @@ if (config.stacks?.agent) { table: data.table, pptxBucket: data.pptxBucket, mcpRuntimeArn: runtime.runtimeArn, - oidcDiscoveryUrl, + oidcDiscoveryUrl: authStack ? undefined : oidcDiscoveryUrl, allowedClients, + useAuthStack: !!authStack, chatModelId: defaultChatModelId, createModelId: defaultCreateModelId, allowedModelIds, }); + // AgentStack reads the WebClient ID from SSM in AuthStack mode. + // Ensure AuthStack deploys first. + if (authStack) { + agent.addDependency(authStack); + } if (config.stacks?.webUi) { if (!authStack) { throw new Error("WebUiStack requires AuthStack (default Cognito). Remove auth.oidcDiscoveryUrl from config.yaml to use default Cognito, or deploy Web UI separately."); } - new WebUiStack(app, "SdpmWebUi", { + const webUi = new WebUiStack(app, "SdpmWebUi", { env, crossRegionReferences: wafEnabled, description: "Spec-Driven Presentation Maker - Web UI (uksb-ynuz0lkrea)(tag:web-ui)", @@ -194,8 +212,6 @@ if (config.stacks?.agent) { pptxBucket: data.pptxBucket, resourceBucket: data.resourceBucket, agentRuntimeArn: agent.agentRuntimeArn, - userPool: authStack.userPool, - userPoolClient: authStack.userPoolClient, memoryId: agent.memoryId, kbId: data.kbSsmParamName, vectorBucketName: data.vectorBucketName || undefined, @@ -206,7 +222,9 @@ if (config.stacks?.agent) { defaultChatModelId, defaultCreateModelId, allowedModels, - mcpCustomScope: authStack.mcpCustomScope, }); + // WebUiStack reads Cognito values from SSM parameters published by + // AuthStack. Ensure AuthStack deploys first. + webUi.addDependency(authStack); } } diff --git a/infra/lib/agent-stack.ts b/infra/lib/agent-stack.ts index 8b3b798e..821cb037 100644 --- a/infra/lib/agent-stack.ts +++ b/infra/lib/agent-stack.ts @@ -15,8 +15,10 @@ import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; import * as ecr_assets from "aws-cdk-lib/aws-ecr-assets"; import * as iam from "aws-cdk-lib/aws-iam"; import * as s3 from "aws-cdk-lib/aws-s3"; +import * as ssm from "aws-cdk-lib/aws-ssm"; import { Construct } from "constructs"; import * as path from "path"; +import { AUTH_SSM_PARAMS } from "./auth-stack"; interface AgentStackProps extends cdk.StackProps { /** Amazon DynamoDB table from DataStack. */ @@ -25,10 +27,24 @@ interface AgentStackProps extends cdk.StackProps { pptxBucket: s3.Bucket; /** MCP Server Runtime ARN. */ mcpRuntimeArn: string; - /** OIDC discovery URL for JWT authorizer. */ - oidcDiscoveryUrl: string; - /** Allowed client IDs for JWT authorizer. */ + /** + * OIDC discovery URL for JWT authorizer. + * Used only when useAuthStack=false (external IdP). When useAuthStack=true, + * the value is read from SSM instead. + */ + oidcDiscoveryUrl?: string; + /** + * Allowed client IDs for JWT authorizer. + * Used only when useAuthStack=false (external IdP). When useAuthStack=true, + * the WebClient ID is read from SSM instead. + */ allowedClients: string[]; + /** + * When true, read the WebClient ID from SSM Parameter Store (published by + * AuthStack) and prepend it to allowedClients. Avoids a direct construct + * reference to AuthStack that would trigger a cross-stack CFN export. + */ + useAuthStack: boolean; /** Bedrock model ID for the chat (conversation/planning) task. */ chatModelId?: string; /** Bedrock model ID for the create (generation) task. */ @@ -174,6 +190,16 @@ export class AgentStack extends cdk.Stack { // --- Amazon Bedrock AgentCore Runtime --- const defaultPolicy = role.node.findChild("DefaultPolicy") as iam.Policy; + // When AuthStack is in play, read the WebClient ID from SSM and include it + // in the allowedClients list. This avoids direct AuthStack construct + // references that would trigger cross-stack CFN exports. + const allowedClients = props.useAuthStack + ? [ssm.StringParameter.valueForStringParameter(this, AUTH_SSM_PARAMS.webClientId)] + : props.allowedClients; + const discoveryUrl = props.useAuthStack + ? ssm.StringParameter.valueForStringParameter(this, AUTH_SSM_PARAMS.oidcDiscoveryUrl) + : props.oidcDiscoveryUrl!; + const runtime = new bedrockagentcore.CfnRuntime(this, "AgentRuntime", { agentRuntimeName: "sdpm_agent", roleArn: role.roleArn, @@ -188,8 +214,8 @@ export class AgentStack extends cdk.Stack { protocolConfiguration: "HTTP", authorizerConfiguration: { customJwtAuthorizer: { - discoveryUrl: props.oidcDiscoveryUrl, - allowedClients: props.allowedClients, + discoveryUrl, + allowedClients, }, }, requestHeaderConfiguration: { diff --git a/infra/lib/auth-stack.ts b/infra/lib/auth-stack.ts index 428f712a..b5ce5872 100644 --- a/infra/lib/auth-stack.ts +++ b/infra/lib/auth-stack.ts @@ -6,12 +6,17 @@ * Creates a Amazon Cognito User Pool with Authorization Code + PKCE flow. * Customers using their own IdP (Entra ID, Auth0, Okta) skip this stack * and set auth.oidcDiscoveryUrl + auth.allowedClients in config.yaml. + * + * Publishes shared values to SSM Parameter Store for downstream stacks to + * consume without cross-stack CloudFormation exports. See + * `docs/internal/ssm-cross-stack-refs.md` for rationale. */ // Security: AWS manages infrastructure security. You manage access control, // data classification, and IAM policies. See SECURITY.md for details. import * as cdk from "aws-cdk-lib"; import * as cognito from "aws-cdk-lib/aws-cognito"; +import * as ssm from "aws-cdk-lib/aws-ssm"; import { Construct } from "constructs"; export interface AuthStackProps extends cdk.StackProps { @@ -21,6 +26,20 @@ export interface AuthStackProps extends cdk.StackProps { mcpCallbackUrls?: string[]; } +/** + * SSM Parameter names for cross-stack references. + * Downstream stacks read these via `ssm.StringParameter.valueForStringParameter`. + */ +export const AUTH_SSM_PARAMS = { + userPoolId: "/sdpm/auth/user-pool-id", + userPoolArn: "/sdpm/auth/user-pool-arn", + webClientId: "/sdpm/auth/web-client-id", + mcpClientId: "/sdpm/auth/mcp-client-id", + mcpCustomScope: "/sdpm/auth/mcp-custom-scope", + cognitoDomainPrefix: "/sdpm/auth/cognito-domain-prefix", + oidcDiscoveryUrl: "/sdpm/auth/oidc-discovery-url", +} as const; + export class AuthStack extends cdk.Stack { /** OIDC discovery URL for Runtime/Agent JWT authorizer. */ public readonly oidcDiscoveryUrl: string; @@ -28,9 +47,9 @@ export class AuthStack extends cdk.Stack { public readonly clientId: string; /** App client ID for external MCP clients (Claude.ai, Claude Desktop, Kiro). */ public readonly mcpClientId: string; - /** Amazon Cognito User Pool (passed to WebUiStack for API GW authorizer). */ + /** Amazon Cognito User Pool — do NOT pass to downstream stacks; use SSM instead. */ public readonly userPool: cognito.UserPool; - /** Amazon Cognito User Pool Client. */ + /** Amazon Cognito User Pool Client — do NOT pass to downstream stacks; use SSM instead. */ public readonly userPoolClient: cognito.UserPoolClient; /** Cognito domain prefix (used for OAuth endpoints in discovery metadata). */ public readonly cognitoDomainPrefix: string; @@ -113,6 +132,48 @@ export class AuthStack extends cdk.Stack { this.clientId = this.userPoolClient.userPoolClientId; this.mcpClientId = mcpClient?.userPoolClientId ?? ""; + // --- SSM Parameters for downstream stacks --- + // Downstream stacks read these via `ssm.StringParameter.valueForStringParameter` + // instead of receiving construct references via props. This breaks the + // CloudFormation Export/Import coupling that makes Auth changes fragile. + // See `docs/internal/ssm-cross-stack-refs.md`. + new ssm.StringParameter(this, "UserPoolIdParam", { + parameterName: AUTH_SSM_PARAMS.userPoolId, + stringValue: this.userPool.userPoolId, + description: "Cognito UserPool ID (shared with downstream stacks)", + }); + new ssm.StringParameter(this, "UserPoolArnParam", { + parameterName: AUTH_SSM_PARAMS.userPoolArn, + stringValue: this.userPool.userPoolArn, + description: "Cognito UserPool ARN (shared with downstream stacks)", + }); + new ssm.StringParameter(this, "WebClientIdParam", { + parameterName: AUTH_SSM_PARAMS.webClientId, + stringValue: this.clientId, + description: "WebUI Cognito app client ID (shared with downstream stacks)", + }); + new ssm.StringParameter(this, "McpClientIdParam", { + parameterName: AUTH_SSM_PARAMS.mcpClientId, + // Empty string is a valid SSM value; downstream treats empty as "no MCP client". + stringValue: this.mcpClientId === "" ? "-" : this.mcpClientId, + description: "External MCP Cognito app client ID ('-' means unset)", + }); + new ssm.StringParameter(this, "McpCustomScopeParam", { + parameterName: AUTH_SSM_PARAMS.mcpCustomScope, + stringValue: this.mcpCustomScope, + description: "Fully-qualified MCP custom OAuth scope (e.g. sdpm-mcp/invoke)", + }); + new ssm.StringParameter(this, "CognitoDomainPrefixParam", { + parameterName: AUTH_SSM_PARAMS.cognitoDomainPrefix, + stringValue: this.cognitoDomainPrefix, + description: "Cognito hosted UI domain prefix", + }); + new ssm.StringParameter(this, "OidcDiscoveryUrlParam", { + parameterName: AUTH_SSM_PARAMS.oidcDiscoveryUrl, + stringValue: this.oidcDiscoveryUrl, + description: "OIDC discovery URL for JWT authorizers", + }); + // --- Outputs --- new cdk.CfnOutput(this, "UserPoolId", { value: this.userPool.userPoolId }); new cdk.CfnOutput(this, "UserPoolClientId", { value: this.clientId }); @@ -120,5 +181,41 @@ export class AuthStack extends cdk.Stack { new cdk.CfnOutput(this, "McpClientId", { value: this.mcpClientId }); } new cdk.CfnOutput(this, "OidcDiscoveryUrl", { value: this.oidcDiscoveryUrl }); + + // --- Legacy exports retained for backward compatibility --- + // Older deployments have downstream stacks importing these auto-generated + // export names. If we simply remove the construct references in downstream + // stacks (which we do in this PR), CDK stops emitting these exports, + // and CloudFormation refuses to delete them as long as any deployed + // template imports them ("Cannot delete export"). We re-declare them here + // explicitly, using the exact same logical IDs and export names CDK used + // to auto-generate, so that in-place upgrades succeed from any prior + // deployed state. + // + // Retain indefinitely. Future maintainers: do NOT delete these unless you + // are certain every deployed environment has re-synthesized without the + // old imports, which is not guaranteed given the independent deployment + // model of this sample. + // The long hex hashes in the export names below (e.g. 6BA7E5F2Arn686ACC00) + // are CDK-generated stable resource hashes, not secrets — they encode the + // construct path. Marked with `pragma: allowlist secret` so detect-secrets + // does not flag them as Base64 high-entropy strings. + const userPoolArnExport = new cdk.CfnOutput(this, "LegacyUserPoolArnExport", { + value: this.userPool.userPoolArn, + exportName: `${this.stackName}:ExportsOutputFnGetAttUserPool6BA7E5F2Arn686ACC00`, // pragma: allowlist secret + }); + userPoolArnExport.overrideLogicalId("ExportsOutputFnGetAttUserPool6BA7E5F2Arn686ACC00"); // pragma: allowlist secret + + const userPoolIdExport = new cdk.CfnOutput(this, "LegacyUserPoolIdExport", { + value: this.userPool.userPoolId, + exportName: `${this.stackName}:ExportsOutputRefUserPool6BA7E5F296FD7236`, // pragma: allowlist secret + }); + userPoolIdExport.overrideLogicalId("ExportsOutputRefUserPool6BA7E5F296FD7236"); // pragma: allowlist secret + + const webClientIdExport = new cdk.CfnOutput(this, "LegacyWebClientIdExport", { + value: this.userPoolClient.userPoolClientId, + exportName: `${this.stackName}:ExportsOutputRefUserPoolWebClient4C9370B02E2C9FF9`, // pragma: allowlist secret + }); + webClientIdExport.overrideLogicalId("ExportsOutputRefUserPoolWebClient4C9370B02E2C9FF9"); // pragma: allowlist secret } } diff --git a/infra/lib/runtime-stack.ts b/infra/lib/runtime-stack.ts index 53d64b40..33f828b0 100644 --- a/infra/lib/runtime-stack.ts +++ b/infra/lib/runtime-stack.ts @@ -18,8 +18,10 @@ import * as ecr_assets from "aws-cdk-lib/aws-ecr-assets"; import * as iam from "aws-cdk-lib/aws-iam"; import * as lambda from "aws-cdk-lib/aws-lambda"; import * as s3 from "aws-cdk-lib/aws-s3"; +import * as ssm from "aws-cdk-lib/aws-ssm"; import { Construct } from "constructs"; import * as path from "path"; +import { AUTH_SSM_PARAMS } from "./auth-stack"; interface RuntimeStackProps extends cdk.StackProps { /** Amazon DynamoDB table from DataStack. */ @@ -28,26 +30,27 @@ interface RuntimeStackProps extends cdk.StackProps { pptxBucket: s3.Bucket; /** S3 bucket for templates, assets, references. */ resourceBucket: s3.Bucket; - /** OIDC discovery URL for JWT authorizer. */ - oidcDiscoveryUrl: string; - /** Allowed client IDs for JWT authorizer (unused when allowedScopes is set). */ + /** + * OIDC discovery URL for JWT authorizer. + * Used only when useAuthStack=false (external IdP). When useAuthStack=true, + * the value is read from SSM instead. + */ + oidcDiscoveryUrl?: string; + /** Allowed client IDs for JWT authorizer (used only when useAuthStack=false, i.e. external IdP). */ allowedClients: string[]; - /** Allowed OAuth scopes for JWT authorizer (preferred over allowedClients for DCR support). */ - allowedScopes?: string[]; /** KB SSM parameter name (empty if KB not enabled). */ kbSsmParamName?: string; /** S3 Vector Bucket name (empty if KB not enabled). */ vectorBucketName?: string; /** S3 Vector Index name (empty if KB not enabled). */ vectorIndexName?: string; - /** Cognito User Pool ID (for OAuth discovery metadata). */ - userPoolId?: string; - /** Cognito domain prefix (for OAuth discovery metadata). */ - cognitoDomainPrefix?: string; - /** MCP client ID for external MCP clients (for OAuth discovery metadata). */ - mcpClientId?: string; - /** Fully-qualified custom OAuth scope for MCP access (e.g. `sdpm-mcp/invoke`). */ - mcpCustomScope?: string; + /** + * When true, this stack reads Auth values from SSM Parameter Store + * (published by AuthStack) and enables the OAuth discovery endpoint for + * external MCP clients. When false (external IdP), Auth values are not + * available and the discovery endpoint is skipped. + */ + useAuthStack: boolean; /** Enable Dynamic Client Registration (RFC 7591) for external MCP clients. Default: true. */ enableDCR?: boolean; } @@ -201,6 +204,19 @@ export class RuntimeStack extends cdk.Stack { // --- Amazon Bedrock AgentCore Runtime (JWT Bearer authorizer) --- const defaultPolicy = runtimeRole.node.findChild("DefaultPolicy") as iam.Policy; + // When AuthStack is in play, prefer scope-based auth (DCR-compatible): + // read the MCP custom scope from SSM and use it as allowedScopes. + // Otherwise (external IdP), fall back to the static allowedClients list. + const mcpCustomScope = props.useAuthStack + ? ssm.StringParameter.valueForStringParameter(this, AUTH_SSM_PARAMS.mcpCustomScope) + : undefined; + const discoveryUrl = props.useAuthStack + ? ssm.StringParameter.valueForStringParameter(this, AUTH_SSM_PARAMS.oidcDiscoveryUrl) + : props.oidcDiscoveryUrl!; + const authorizerConfig = props.useAuthStack + ? { discoveryUrl, allowedScopes: [mcpCustomScope!] } + : { discoveryUrl, allowedClients: props.allowedClients }; + const runtime = new bedrockagentcore.CfnRuntime(this, "SdpmRuntime", { agentRuntimeName: "sdpm", roleArn: runtimeRole.roleArn, @@ -214,12 +230,7 @@ export class RuntimeStack extends cdk.Stack { }, protocolConfiguration: "MCP", authorizerConfiguration: { - customJwtAuthorizer: { - discoveryUrl: props.oidcDiscoveryUrl, - ...(props.allowedScopes && props.allowedScopes.length > 0 - ? { allowedScopes: props.allowedScopes } - : { allowedClients: props.allowedClients }), - }, + customJwtAuthorizer: authorizerConfig, }, requestHeaderConfiguration: { requestHeaderAllowlist: ["Authorization"], @@ -271,12 +282,26 @@ export class RuntimeStack extends cdk.Stack { // --- OAuth 2.1 Discovery for external MCP clients (RFC 9728 / RFC 8414) --- // HTTP API + Lambda for OAuth discovery, 401 challenge, and proxy routes. // Enables Claude.ai, Kiro, and other MCP clients to auto-discover OAuth config. - if (props.userPoolId && props.cognitoDomainPrefix) { + // Only enabled when AuthStack is in play (Cognito-backed). External IdP + // users (useAuthStack=false) handle discovery at their IdP directly. + if (props.useAuthStack) { + // Read Auth values from SSM Parameter Store (published by AuthStack). + const userPoolId = ssm.StringParameter.valueForStringParameter( + this, AUTH_SSM_PARAMS.userPoolId, + ); + const cognitoDomainPrefix = ssm.StringParameter.valueForStringParameter( + this, AUTH_SSM_PARAMS.cognitoDomainPrefix, + ); + const mcpClientIdRaw = ssm.StringParameter.valueForStringParameter( + this, AUTH_SSM_PARAMS.mcpClientId, + ); + // mcpCustomScope already fetched above for authorizerConfig. + const cognitoDomain = cdk.Fn.join("", [ - "https://", props.cognitoDomainPrefix!, ".auth.", this.region, ".amazoncognito.com", + "https://", cognitoDomainPrefix, ".auth.", this.region, ".amazoncognito.com", ]); const issuer = cdk.Fn.join("", [ - "https://cognito-idp.", this.region, ".amazonaws.com/", props.userPoolId!, + "https://cognito-idp.", this.region, ".amazonaws.com/", userPoolId, ]); // Lambda handles OAuth discovery, 401 challenge, and MCP proxy to AgentCore @@ -296,8 +321,9 @@ export class RuntimeStack extends cdk.Stack { COGNITO_DOMAIN: cognitoDomain, ISSUER: issuer, RUNTIME_URL: runtimeInvokeUrl, - USER_POOL_ID: props.userPoolId!, - MCP_SCOPES: ["openid", "profile", "email", ...(props.mcpCustomScope ? [props.mcpCustomScope] : [])].join(","), + USER_POOL_ID: userPoolId, + // SSM-backed scope is always present (AuthStack always publishes it). + MCP_SCOPES: cdk.Fn.join(",", ["openid", "profile", "email", mcpCustomScope!]), ENABLE_DCR: (props.enableDCR !== false) ? "true" : "false", }, timeout: cdk.Duration.seconds(30), @@ -319,7 +345,7 @@ export class RuntimeStack extends cdk.Stack { discoveryFn.addToRolePolicy(new iam.PolicyStatement({ actions: cognitoActions, resources: [cdk.Fn.join("", [ - "arn:aws:cognito-idp:", this.region, ":", this.account, ":userpool/", props.userPoolId!, + "arn:aws:cognito-idp:", this.region, ":", this.account, ":userpool/", userPoolId, ])], })); @@ -342,12 +368,13 @@ export class RuntimeStack extends cdk.Stack { value: httpApi.url!, description: "MCP Server URL for external MCP clients", }); - if (props.mcpClientId) { - new cdk.CfnOutput(this, "McpOAuthClientId", { - value: props.mcpClientId, - description: "OAuth Client ID for external MCP clients (static; DCR clients register dynamically)", - }); - } + // SSM tokens are not synth-time strings, so we cannot conditionally emit + // this Output based on whether mcpClientId is set. AuthStack publishes + // the sentinel "-" when no static MCP client exists (DCR-only mode). + new cdk.CfnOutput(this, "McpOAuthClientId", { + value: mcpClientIdRaw, + description: "OAuth Client ID for external MCP clients ('-' means DCR-only; clients register dynamically)", + }); } } } diff --git a/infra/lib/web-ui-stack.ts b/infra/lib/web-ui-stack.ts index aa6d6cbb..53586b63 100644 --- a/infra/lib/web-ui-stack.ts +++ b/infra/lib/web-ui-stack.ts @@ -25,10 +25,12 @@ import * as lambda from "aws-cdk-lib/aws-lambda"; import * as logs from "aws-cdk-lib/aws-logs"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as s3deploy from "aws-cdk-lib/aws-s3-deployment"; +import * as ssm from "aws-cdk-lib/aws-ssm"; import { CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2"; import { Construct } from "constructs"; import * as path from "path"; import { CommonWebAcl } from "./construct/common-web-acl"; +import { AUTH_SSM_PARAMS } from "./auth-stack"; interface WebUiStackProps extends cdk.StackProps { /** Amazon DynamoDB table from DataStack. */ @@ -39,10 +41,6 @@ interface WebUiStackProps extends cdk.StackProps { resourceBucket: s3.Bucket; /** Agent Runtime ARN. */ agentRuntimeArn: string; - /** Amazon Cognito User Pool from AuthStack. */ - userPool: cognito.UserPool; - /** Amazon Cognito User Pool Client from AuthStack. */ - userPoolClient: cognito.UserPoolClient; /** Amazon Bedrock AgentCore Memory ID for chat history retrieval. */ memoryId?: string; /** Amazon Bedrock KB ID (empty if KB not enabled). */ @@ -63,8 +61,6 @@ interface WebUiStackProps extends cdk.StackProps { defaultCreateModelId: string; /** Allowed models with resolved display metadata. */ allowedModels: Array<{ modelId: string; displayName: string; description?: string }>; - /** Custom OAuth scope for MCP access (e.g. `sdpm-mcp/invoke`). */ - mcpCustomScope?: string; } export class WebUiStack extends cdk.Stack { @@ -74,6 +70,26 @@ export class WebUiStack extends cdk.Stack { constructor(scope: Construct, id: string, props: WebUiStackProps) { super(scope, id, props); + // --- Read shared Auth values from SSM Parameter Store --- + // Decouples this stack from AuthStack: no cross-stack CloudFormation + // Export/Import. See docs/internal/ssm-cross-stack-refs.md. + const userPoolId = ssm.StringParameter.valueForStringParameter( + this, AUTH_SSM_PARAMS.userPoolId, + ); + const userPoolArn = ssm.StringParameter.valueForStringParameter( + this, AUTH_SSM_PARAMS.userPoolArn, + ); + const webClientId = ssm.StringParameter.valueForStringParameter( + this, AUTH_SSM_PARAMS.webClientId, + ); + const mcpCustomScope = ssm.StringParameter.valueForStringParameter( + this, AUTH_SSM_PARAMS.mcpCustomScope, + ); + // Reconstruct UserPool from SSM ARN for CognitoUserPoolsAuthorizer. + // `fromUserPoolArn` derives userPoolId from the ARN, so consumers can + // read `userPool.userPoolId` without a separate SSM lookup. + const userPool = cognito.UserPool.fromUserPoolArn(this, "AuthUserPool", userPoolArn); + // --- S3 bucket for static site --- const siteBucket = new s3.Bucket(this, "SiteBucket", { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, @@ -318,7 +334,7 @@ function handler(event) { }, }); const authorizer = new apigateway.CognitoUserPoolsAuthorizer(this, "CognitoAuthorizer", { - cognitoUserPools: [props.userPool], + cognitoUserPools: [userPool], }); const integration = new apigateway.LambdaIntegration(apiLambda); const auth = { authorizer, authorizationType: apigateway.AuthorizationType.COGNITO }; @@ -405,18 +421,18 @@ function handler(event) { redirect_uri: "${SiteUrl}", post_logout_redirect_uri: "${SiteUrl}", response_type: "code", - scope: "openid profile email${McpScope}", + scope: "openid profile email ${McpScope}", automaticSilentRenew: true, agentRuntimeArn: "${AgentRuntimeArn}", apiBaseUrl: "${ApiBaseUrl}", awsRegion: "${AWS::Region}", }), { - UserPoolId: props.userPool.userPoolId, - ClientId: props.userPoolClient.userPoolClientId, + UserPoolId: userPoolId, + ClientId: webClientId, SiteUrl: this.siteUrl, AgentRuntimeArn: props.agentRuntimeArn, ApiBaseUrl: api.url, - McpScope: props.mcpCustomScope ? ` ${props.mcpCustomScope}` : "", + McpScope: mcpCustomScope, }); const awsExports = new cr.AwsCustomResource(this, "WriteAwsExports", { @@ -449,14 +465,18 @@ function handler(event) { awsExports.node.addDependency(deployment); // --- Add Amazon CloudFront URL to Amazon Cognito callback/logout URLs --- - const oauthScopes = ["openid", "profile", "email", ...(props.mcpCustomScope ? [props.mcpCustomScope] : [])]; + // AllowedOAuthScopes always includes the MCP custom scope — AuthStack + // always publishes it. The SSM value is a CDK token so we construct + // the list inline here (Fn::Sub not needed because the SDK call accepts + // tokens directly). + const oauthScopes = ["openid", "profile", "email", mcpCustomScope]; new cr.AwsCustomResource(this, "UpdateCognitoCallbackUrls", { onCreate: { service: "CognitoIdentityServiceProvider", action: "updateUserPoolClient", parameters: { - UserPoolId: props.userPool.userPoolId, - ClientId: props.userPoolClient.userPoolClientId, + UserPoolId: userPoolId, + ClientId: webClientId, SupportedIdentityProviders: ["COGNITO"], AllowedOAuthFlows: ["code"], AllowedOAuthScopes: oauthScopes, @@ -475,8 +495,8 @@ function handler(event) { service: "CognitoIdentityServiceProvider", action: "updateUserPoolClient", parameters: { - UserPoolId: props.userPool.userPoolId, - ClientId: props.userPoolClient.userPoolClientId, + UserPoolId: userPoolId, + ClientId: webClientId, SupportedIdentityProviders: ["COGNITO"], AllowedOAuthFlows: ["code"], AllowedOAuthScopes: oauthScopes, @@ -494,7 +514,7 @@ function handler(event) { policy: cr.AwsCustomResourcePolicy.fromStatements([ new iam.PolicyStatement({ actions: ["cognito-idp:UpdateUserPoolClient"], - resources: [props.userPool.userPoolArn], + resources: [userPoolArn], }), ]), });