Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 36 additions & 18 deletions infra/bin/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 ---
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -175,27 +187,31 @@ 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)",
table: data.table,
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,
Expand All @@ -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);
}
}
36 changes: 31 additions & 5 deletions infra/lib/agent-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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. */
Expand Down Expand Up @@ -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,
Expand All @@ -188,8 +214,8 @@ export class AgentStack extends cdk.Stack {
protocolConfiguration: "HTTP",
authorizerConfiguration: {
customJwtAuthorizer: {
discoveryUrl: props.oidcDiscoveryUrl,
allowedClients: props.allowedClients,
discoveryUrl,
allowedClients,
},
},
requestHeaderConfiguration: {
Expand Down
101 changes: 99 additions & 2 deletions infra/lib/auth-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,16 +26,30 @@ 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;
/** App client ID (used as allowedClients for JWT authorizer). */
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;
Expand Down Expand Up @@ -113,12 +132,90 @@ 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 });
if (mcpClient) {
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
}
}
Loading
Loading