CognitoAuthenticationFactory.java
package com.cloudforgeci.api.security;
import com.cloudforgeci.api.core.annotation.BaseFactory;
import com.cloudforge.core.annotation.DeploymentContext;
import com.cloudforge.core.annotation.SystemContext;
import com.cloudforge.core.enums.AuthMode;
import com.cloudforge.core.enums.SecurityProfile;
import com.cloudforgeci.api.util.CfnStringUtils;
import software.amazon.awscdk.Fn;
import software.amazon.awscdk.RemovalPolicy;
import software.amazon.awscdk.customresources.AwsCustomResource;
import software.amazon.awscdk.customresources.AwsCustomResourcePolicy;
import software.amazon.awscdk.customresources.AwsSdkCall;
import software.amazon.awscdk.customresources.PhysicalResourceId;
import software.amazon.awscdk.customresources.SdkCallsPolicyOptions;
import software.amazon.awscdk.services.cognito.*;
import software.amazon.awscdk.services.elasticloadbalancingv2.*;
import software.amazon.awscdk.services.elasticloadbalancingv2.actions.*;
import software.amazon.awscdk.services.iam.PolicyStatement;
import software.amazon.awscdk.services.iam.Role;
import software.amazon.awscdk.services.iam.ServicePrincipal;
import software.amazon.awscdk.services.secretsmanager.Secret;
import software.amazon.awscdk.services.secretsmanager.SecretStringGenerator;
import software.constructs.Construct;
import io.github.cdklabs.cdknag.NagPackSuppression;
import io.github.cdklabs.cdknag.NagSuppressions;
import java.util.List;
import java.util.logging.Logger;
/**
* Manages AWS Cognito User Pools for OIDC authentication.
*
* <p><b>Quick Start:</b></p>
* <pre>
* {
* "authMode": "alb-oidc",
* "cognitoAutoProvision": true,
* "cognitoDomainPrefix": "myapp-auth",
* "cognitoMfaEnabled": true,
* "cognitoMfaMethod": "both" // TOTP + SMS
* }
* </pre>
*
* <p><b>Features:</b></p>
* <ul>
* <li>Auto-provision User Pools with security best practices</li>
* <li>OAuth 2.0 App Client for ALB OIDC integration</li>
* <li>MFA support: TOTP (authenticator apps) and SMS</li>
* <li>User groups with role-based access control</li>
* <li>Compliance-ready (PCI-DSS, HIPAA, SOC 2, GDPR)</li>
* </ul>
*
* <p><b>MFA Configuration:</b></p>
* <ul>
* <li>"totp" - Authenticator apps only (Google Authenticator, Authy)</li>
* <li>"sms" - Text message codes (requires AWS SMS spending limit > $0)</li>
* <li>"both" - Users choose their preferred method (default)</li>
* </ul>
*
* <p><b>SMS Requirements:</b> AWS accounts default to $0/month SMS spending limit.
* To enable SMS MFA: AWS Console → Service Quotas → Amazon SNS →
* "Account spending limit for SMS" → Request increase to $1-$10/month</p>
*
* <p><b>Removal Policy:</b> Production User Pools are RETAINED on stack deletion
* to prevent data loss. Reuse with cognitoUserPoolId in deployment context.</p>
*/
public class CognitoAuthenticationFactory extends BaseFactory {
private static final Logger LOG = Logger.getLogger(CognitoAuthenticationFactory.class.getName());
@DeploymentContext("authMode")
private AuthMode authMode;
@DeploymentContext("stackName")
private String stackName;
@DeploymentContext("region")
private String region;
@DeploymentContext("domain")
private String domain;
@DeploymentContext("subdomain")
private String subdomain;
@DeploymentContext("fqdn")
private String fqdn;
// Auto-provision new Cognito User Pool
@DeploymentContext("cognitoAutoProvision")
private Boolean cognitoAutoProvision;
@DeploymentContext("cognitoUserPoolName")
private String cognitoUserPoolName;
@DeploymentContext("cognitoDomainPrefix")
private String cognitoDomainPrefix;
@DeploymentContext("cognitoMfaEnabled")
private Boolean cognitoMfaEnabled;
@DeploymentContext("cognitoMfaMethod")
private String cognitoMfaMethod;
// User groups configuration
@DeploymentContext("cognitoCreateGroups")
private Boolean cognitoCreateGroups;
@DeploymentContext("cognitoAdminGroupName")
private String cognitoAdminGroupName;
@DeploymentContext("cognitoUserGroupName")
private String cognitoUserGroupName;
@DeploymentContext("cognitoInitialAdminEmail")
private String cognitoInitialAdminEmail;
// ========== Path-Based Authentication ==========
// These settings control which paths require authentication at the ALB level
@DeploymentContext("protectedPaths")
private java.util.List<String> protectedPaths;
@DeploymentContext("additionalProtectedPaths")
private java.util.List<String> additionalProtectedPaths;
@DeploymentContext("publicPaths")
private java.util.List<String> publicPaths;
@DeploymentContext("cognitoInitialAdminPhone")
private String cognitoInitialAdminPhone;
// Use existing Cognito User Pool
@DeploymentContext("cognitoUserPoolId")
private String cognitoUserPoolId;
@DeploymentContext("cognitoAppClientId")
private String cognitoAppClientId;
// Client secret configuration
@DeploymentContext("oidcClientSecretName")
private String oidcClientSecretName;
@SystemContext("securityProfileConfig")
private com.cloudforgeci.api.interfaces.SecurityProfileConfiguration securityProfileConfig;
@SystemContext("applicationSpec")
private com.cloudforge.core.interfaces.ApplicationSpec applicationSpec;
@SystemContext("alb")
private ApplicationLoadBalancer alb;
@SystemContext("cognitoUserPool")
private UserPool cognitoUserPool;
@SystemContext("cognitoUserPoolClient")
private UserPoolClient cognitoUserPoolClient;
@SystemContext("cognitoUserPoolDomain")
private UserPoolDomain cognitoUserPoolDomain;
public CognitoAuthenticationFactory(Construct scope, String id) {
super(scope, id);
}
@Override
public void create() {
// Only configure Cognito if authMode is OIDC-based
if (authMode != AuthMode.ALB_OIDC && authMode != AuthMode.APPLICATION_OIDC) {
LOG.info("Cognito authentication not applicable (authMode = " + authMode + ")");
return;
}
// Validate application supports Cognito (for application-oidc mode)
if (authMode == AuthMode.APPLICATION_OIDC && applicationSpec != null && applicationSpec.supportsOidcIntegration()) {
var oidcIntegration = applicationSpec.getOidcIntegration();
if (oidcIntegration != null && !oidcIntegration.supportsCognito()) {
LOG.warning("Application '" + applicationSpec.applicationId() + "' does not support Cognito");
LOG.warning(" Auth type: " + oidcIntegration.getAuthenticationType());
LOG.warning(" Supports Cognito: false");
LOG.warning(" Supports Identity Center SAML: " + oidcIntegration.supportsIdentityCenterSaml());
LOG.warning("Skipping Cognito setup - use Identity Center SAML instead");
return;
}
}
// Check if Cognito auto-provisioning is enabled
if (cognitoAutoProvision != null && cognitoAutoProvision) {
LOG.info("Auto-provisioning Cognito User Pool for OIDC authentication");
createCognitoUserPool();
configureAlbAuthentication();
return;
}
// Check if using existing Cognito User Pool
if (cognitoUserPoolId != null && !cognitoUserPoolId.isEmpty()) {
LOG.info("Using existing Cognito User Pool: " + cognitoUserPoolId);
configureExistingUserPool();
configureAlbAuthentication();
return;
}
LOG.info("Cognito authentication not configured (use cognitoAutoProvision = true or provide cognitoUserPoolId)");
}
/**
* Creates a new Cognito User Pool with security best practices.
*
* <p>Configures:
* <ul>
* <li>Strong password policy (12+ chars, mixed case, numbers, symbols)</li>
* <li>Email sign-in with verification</li>
* <li>MFA (TOTP and/or SMS based on cognitoMfaMethod)</li>
* <li>Custom domain for hosted UI</li>
* <li>OAuth 2.0 App Client for ALB OIDC</li>
* <li>User groups for role-based access</li>
* </ul>
*
* <p><b>Removal Policy:</b></p>
* <ul>
* <li>Production: RETAIN (prevents data loss, manual cleanup)</li>
* <li>Dev/Staging: DESTROY (automatic cleanup)</li>
* </ul>
*
* @throws IllegalArgumentException if cognitoDomainPrefix is missing
*/
private void createCognitoUserPool() {
// Validate required configuration
if (cognitoDomainPrefix == null || cognitoDomainPrefix.isEmpty()) {
LOG.severe("cognitoDomainPrefix is required for Cognito auto-provisioning");
throw new IllegalArgumentException("cognitoDomainPrefix is required when cognitoAutoProvision = true");
}
// Sanitize domain prefix: lowercase, only alphanumerics and hyphens
// Cognito requirement: lowercase letters, numbers, and hyphens only
String sanitizedDomainPrefix = cognitoDomainPrefix
.toLowerCase()
.replaceAll("[^a-z0-9-]", "-") // Replace invalid chars with hyphen
.replaceAll("-+", "-") // Collapse multiple hyphens
.replaceAll("^-|-$", ""); // Remove leading/trailing hyphens
if (!sanitizedDomainPrefix.equals(cognitoDomainPrefix)) {
LOG.warning("Cognito domain prefix sanitized: '" + cognitoDomainPrefix + "' -> '" + sanitizedDomainPrefix + "'");
}
// Make domain prefix stack-scoped to avoid conflicts between multiple stacks
String stackScopedPrefix = sanitizedDomainPrefix + "-" + stackName.toLowerCase()
.replaceAll("[^a-z0-9-]", "-")
.replaceAll("-+", "-")
.replaceAll("^-|-$", "");
// Use stack-scoped prefix
cognitoDomainPrefix = stackScopedPrefix;
LOG.info("Cognito domain prefix scoped to stack: '" + sanitizedDomainPrefix + "' -> '" + stackScopedPrefix + "'");
// Set default user pool name
String userPoolName = (cognitoUserPoolName != null && !cognitoUserPoolName.isEmpty())
? cognitoUserPoolName
: stackName + "-users";
// Determine removal policy based on security profile (injected via annotation)
boolean isProduction = (securityProfileConfig != null && securityProfileConfig.getSecurityProfile() == SecurityProfile.PRODUCTION);
RemovalPolicy userPoolRemovalPolicy = isProduction ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY;
LOG.info("Creating/Importing Cognito User Pool: " + userPoolName);
LOG.info("Domain prefix: " + cognitoDomainPrefix);
LOG.info("User Pool removal policy: " + userPoolRemovalPolicy + " (isProduction = " + isProduction + ")");
// Ensure securityProfileConfig is available - it should always be injected by BaseFactory
if (securityProfileConfig == null) {
throw new IllegalStateException("SecurityProfileConfiguration not injected - ensure SystemContext is properly initialized");
}
LOG.info("Security Profile: " + securityProfileConfig.getSecurityProfile());
LOG.info("Profile-aware authentication settings:");
LOG.info(" - MFA Required: " + securityProfileConfig.isMfaRequired());
LOG.info(" - Default MFA Method: " + securityProfileConfig.getDefaultMfaMethod());
LOG.info(" - Password Min Length: " + securityProfileConfig.getMinimumPasswordLength());
LOG.info(" - Temp Password Validity: " + securityProfileConfig.getTempPasswordValidityDays() + " days");
LOG.info(" - Access Token Validity: " + securityProfileConfig.getAccessTokenValidityHours() + " hours");
LOG.info(" - ID Token Validity: " + securityProfileConfig.getIdTokenValidityHours() + " hours");
LOG.info(" - Refresh Token Validity: " + securityProfileConfig.getRefreshTokenValidityDays() + " days");
LOG.info(" - Self-Signup Enabled: " + securityProfileConfig.isSelfSignupEnabled());
LOG.info(" - Prevent User Existence Errors: " + securityProfileConfig.isPreventUserExistenceErrorsEnabled());
LOG.info(" - Advanced Security: " + securityProfileConfig.isAdvancedSecurityEnabled());
// Determine which MFA methods to enable based on cognitoMfaMethod
// Valid values: "totp", "sms", "both"
// Use deployment context override if provided, otherwise use profile default
String mfaMethod = (cognitoMfaMethod != null && !cognitoMfaMethod.isEmpty())
? cognitoMfaMethod.toLowerCase()
: securityProfileConfig.getDefaultMfaMethod();
boolean enableTotp = false;
boolean enableSms = false;
switch (mfaMethod) {
case "totp":
enableTotp = true;
enableSms = false;
LOG.info("MFA method: TOTP (authenticator apps)");
break;
case "sms":
enableTotp = false;
enableSms = true;
LOG.info("MFA method: SMS");
break;
case "both":
enableTotp = true;
enableSms = true;
LOG.info("MFA method: Both TOTP and SMS");
break;
default:
LOG.warning("Invalid cognitoMfaMethod '" + mfaMethod + "', defaulting to TOTP");
enableTotp = true;
enableSms = false;
}
// SMS MFA Configuration:
// Cognito handles SMS delivery automatically using AWS-managed SNS integration
// No explicit IAM role needed for basic SMS MFA functionality
// For custom SMS configuration (sender ID, etc.), use CfnUserPool with SmsConfiguration
//
// IMPORTANT: SMS MFA requires AWS account SMS spending limit to be increased
// Default limit is $0/month which blocks ALL SMS messages
// To enable SMS:
// 1. Open AWS Console -> Service Quotas -> Amazon SNS
// 2. Request quota increase for "Account spending limit for SMS" to at least $1-$10/month
// 3. Wait for approval (usually instant for small increases)
if (enableSms) {
LOG.warning("SMS MFA is enabled - ensure AWS account SMS spending limit is configured");
LOG.warning("Check: AWS Console > Service Quotas > Amazon SNS > Account spending limit for SMS");
LOG.warning("Default is $0/month which blocks all SMS. Increase to at least $1-$10/month.");
}
// Configure MFA second factor
// Note: Both otp and sms must be explicitly set (CDK requirement)
MfaSecondFactor mfaSecondFactor = MfaSecondFactor.builder()
.otp(enableTotp)
.sms(enableSms)
.build();
// PRODUCTION REUSE STRATEGY:
// Production UserPools have RETAIN policy - they are NOT deleted when stack is destroyed
// This prevents data loss but requires manual reuse on subsequent deployments.
//
// To reuse an existing production UserPool:
// 1. Find the UserPool ID: aws cognito-idp list-user-pools --max-results 60
// 2. Add to deployment-context.json: "cognitoUserPoolId": "us-east-1_abc123xyz"
// 3. Set cognitoAutoProvision: false (to skip creation)
// 4. Deploy - will import and reuse the existing pool
//
// Dev/Staging: UserPools have DESTROY policy - automatically deleted on stack deletion
if (isProduction) {
LOG.info("Production mode: User Pool will be RETAINED on stack deletion for safety");
LOG.info(" To reuse existing pool: set 'cognitoUserPoolId' in deployment context");
LOG.info(" Example: aws cognito-idp list-user-pools --max-results 60 | grep '" + userPoolName + "'");
}
// Create SNS role for SMS MFA if SMS is enabled
Role smsRole = null;
String externalId = null;
String snsRegion = region != null ? region : "us-east-1";
if (enableSms) {
// Generate a unique external ID for security (prevents confused deputy problem)
externalId = "cognito-sns-" + stackName;
// Create least-privilege SNS policy for Cognito SMS
// Only allow publishing to SNS topics (required for SMS MFA)
software.amazon.awscdk.services.iam.PolicyStatement snsPublishPolicy =
software.amazon.awscdk.services.iam.PolicyStatement.Builder.create()
.sid("CognitoSNSPublish")
.effect(software.amazon.awscdk.services.iam.Effect.ALLOW)
.actions(List.of("sns:Publish"))
.resources(List.of("*")) // Cognito needs wildcard for SMS
.build();
smsRole = Role.Builder.create(this, "CognitoSmsRole")
.assumedBy(ServicePrincipal.Builder.create("cognito-idp.amazonaws.com")
.build())
.externalIds(List.of(externalId))
.inlinePolicies(java.util.Map.of(
"CognitoSNSPolicy",
software.amazon.awscdk.services.iam.PolicyDocument.Builder.create()
.statements(List.of(snsPublishPolicy))
.build()
))
.build();
// RETAIN the SMS role when User Pool is retained (production)
// Without this, stack deletion removes the role but leaves the User Pool orphaned
smsRole.applyRemovalPolicy(userPoolRemovalPolicy);
LOG.info("Created SNS role for SMS MFA with least-privilege permissions: " + smsRole.getRoleArn());
LOG.info(" - External ID: [REDACTED]");
LOG.info(" - SNS Region: " + snsRegion);
LOG.info(" - Permissions: sns:Publish only (least privilege)");
LOG.info(" - Removal policy: " + userPoolRemovalPolicy);
}
// Create User Pool with strong security configuration
UserPool.Builder userPoolBuilder = UserPool.Builder.create(this, "UserPool")
.userPoolName(userPoolName)
// Password policy - profile-aware (stricter in production)
.passwordPolicy(PasswordPolicy.builder()
.minLength(securityProfileConfig.getMinimumPasswordLength())
.requireUppercase(true)
.requireLowercase(true)
.requireDigits(true)
.requireSymbols(true)
.tempPasswordValidity(software.amazon.awscdk.Duration.days(
securityProfileConfig.getTempPasswordValidityDays()))
.build())
// Email verification required
.signInAliases(SignInAliases.builder()
.email(true)
.username(false)
.build());
// Configure auto-verification based on MFA method
if (enableSms) {
// SMS MFA requires phone number verification
userPoolBuilder.autoVerify(AutoVerifiedAttrs.builder()
.email(true)
.phone(true) // Required for SMS MFA
.build());
LOG.info("Auto-verification enabled for email and phone (SMS MFA enabled)");
} else {
// TOTP only requires email verification
userPoolBuilder.autoVerify(AutoVerifiedAttrs.builder()
.email(true)
.build());
LOG.info("Auto-verification enabled for email only (TOTP MFA)");
}
// Determine MFA setting: use deployment context override, then security profile default
boolean mfaRequired = (cognitoMfaEnabled != null) ? cognitoMfaEnabled : securityProfileConfig.isMfaRequired();
userPoolBuilder
// MFA configuration - profile-aware (required in staging/production)
.mfa(mfaRequired ? Mfa.REQUIRED : Mfa.OPTIONAL)
.mfaSecondFactor(mfaSecondFactor)
// Account recovery
.accountRecovery(AccountRecovery.EMAIL_ONLY)
// Advanced security features - profile-aware (enabled for CDK-nag COG3 compliance)
// Note: Requires Cognito Plus tier for production deployments
.advancedSecurityMode(securityProfileConfig.isAdvancedSecurityEnabled() ?
AdvancedSecurityMode.ENFORCED : AdvancedSecurityMode.OFF)
// Feature plan - Plus tier required when Advanced Security is enabled
// CDK validates this, so we must set it even though we'll remove it from CloudFormation
.featurePlan(securityProfileConfig.isAdvancedSecurityEnabled() ?
FeaturePlan.PLUS : FeaturePlan.LITE)
// Self-service signup - profile-aware (disabled in staging/production)
.selfSignUpEnabled(securityProfileConfig.isSelfSignupEnabled())
// Removal policy - RETAIN for production, DESTROY for dev/staging
.removalPolicy(userPoolRemovalPolicy);
UserPool userPool = userPoolBuilder.build();
// Add CDK-nag suppression for DEV profile when AdvancedSecurityMode is disabled
if (!securityProfileConfig.isAdvancedSecurityEnabled()) {
NagSuppressions.addResourceSuppressions(
userPool,
List.of(
NagPackSuppression.builder()
.id("AwsSolutions-COG3")
.reason("AdvancedSecurityMode is intentionally disabled for DEV/non-production " +
"environments to reduce costs. Cognito Plus tier is required for ENFORCED mode. " +
"Production deployments enable AdvancedSecurityMode for enhanced threat protection.")
.build()
),
Boolean.TRUE
);
}
// Configure SMS role and phone number schema using CloudFormation escape hatch
CfnUserPool cfnUserPool = (CfnUserPool) userPool.getNode().getDefaultChild();
// Remove UserPoolTier from CloudFormation output - cfn-guard doesn't recognize this property
// The featurePlan is set above for CDK validation, but we remove it from the template
cfnUserPool.addPropertyDeletionOverride("UserPoolTier");
// Enable phone number as a standard attribute for SMS MFA
cfnUserPool.setSchema(List.of(
CfnUserPool.SchemaAttributeProperty.builder()
.name("email")
.attributeDataType("String")
.mutable(true)
.required(true)
.build(),
CfnUserPool.SchemaAttributeProperty.builder()
.name("phone_number")
.attributeDataType("String")
.mutable(true)
.required(false)
.build()
));
if (enableSms && smsRole != null) {
cfnUserPool.setSmsConfiguration(CfnUserPool.SmsConfigurationProperty.builder()
.snsCallerArn(smsRole.getRoleArn())
.externalId(externalId)
.snsRegion(snsRegion)
.build());
LOG.info("Configured SMS role for User Pool:");
LOG.info(" - Role ARN: " + smsRole.getRoleArn());
LOG.info(" - External ID: [REDACTED]");
LOG.info(" - SNS Region: " + snsRegion);
}
LOG.info("User Pool created: [REDACTED]");
LOG.info("MFA Configuration Summary:");
LOG.info(" - MFA Enabled: " + (cognitoMfaEnabled != null && cognitoMfaEnabled ? "REQUIRED" : "OPTIONAL"));
LOG.info(" - TOTP (Authenticator Apps): " + (enableTotp ? "ENABLED" : "DISABLED"));
LOG.info(" - SMS (Text Messages): " + (enableSms ? "ENABLED" : "DISABLED"));
if (enableSms) {
LOG.info(" - SMS Role ARN: " + (smsRole != null ? smsRole.getRoleArn() : "NOT CONFIGURED"));
LOG.info(" - Phone Number Attribute: ENABLED");
}
// Create user groups if enabled
createUserGroups(userPool);
// Create initial admin user if email provided (even if groups are disabled)
if (cognitoInitialAdminEmail != null && !cognitoInitialAdminEmail.isEmpty() &&
(cognitoCreateGroups == null || !cognitoCreateGroups)) {
// Groups disabled but admin email provided - create user without group attachment
createInitialAdminUser(userPool, null, null);
}
// Create custom domain for hosted UI
UserPoolDomain userPoolDomain = UserPoolDomain.Builder.create(this, "UserPoolDomain")
.userPool(userPool)
.cognitoDomain(CognitoDomainOptions.builder()
.domainPrefix(cognitoDomainPrefix)
.build())
.build();
LOG.info("User Pool domain created: " + cognitoDomainPrefix + ".auth." + region + ".amazoncognito.com");
// Construct redirect URL and logout URL
String redirectUrl = constructRedirectUrl();
String logoutUrl = constructLogoutRedirectUrl();
LOG.info("Redirect URL: " + redirectUrl);
LOG.info("Logout URL: " + logoutUrl);
// Create App Client for ALB OIDC authentication
UserPoolClient appClient = UserPoolClient.Builder.create(this, "AppClient")
.userPool(userPool)
.userPoolClientName(stackName + "-alb-client")
// Generate client secret (required for ALB OIDC)
.generateSecret(true)
// OAuth 2.0 configuration
.oAuth(OAuthSettings.builder()
.flows(OAuthFlows.builder()
.authorizationCodeGrant(true)
.build())
.scopes(List.of(
OAuthScope.OPENID,
OAuthScope.EMAIL,
OAuthScope.PROFILE
))
.callbackUrls(List.of(redirectUrl))
.logoutUrls(List.of(logoutUrl))
.build())
// Token validity - profile-aware (stricter in production)
.idTokenValidity(software.amazon.awscdk.Duration.hours(
securityProfileConfig.getIdTokenValidityHours()))
.accessTokenValidity(software.amazon.awscdk.Duration.hours(
securityProfileConfig.getAccessTokenValidityHours()))
.refreshTokenValidity(software.amazon.awscdk.Duration.days(
securityProfileConfig.getRefreshTokenValidityDays()))
// Prevent user existence errors - profile-aware (enabled in staging/production)
.preventUserExistenceErrors(securityProfileConfig.isPreventUserExistenceErrorsEnabled())
.build();
LOG.info("App Client created: " + appClient.getUserPoolClientId());
// Note: For ALB-level OIDC (alb-oidc), Cognito manages client secret internally
// For application-level OIDC (application-oidc), we need to store secret in Secrets Manager
// so the application container can retrieve it at runtime
String secretName = null;
// Check if we need to store the client secret for application-level OIDC
if (authMode == AuthMode.APPLICATION_OIDC) {
LOG.info("Application-level OIDC detected - storing Cognito client secret in Secrets Manager");
secretName = storeCognitoClientSecret(userPool, appClient);
} else {
LOG.info("ALB-level OIDC - Cognito will manage client secret internally");
LOG.info("Client secret retrieval command:");
LOG.info(" aws cognito-idp describe-user-pool-client --user-pool-id [REDACTED] --client-id [REDACTED]");
}
// Export OIDC endpoints to SystemContext for OidcAuthenticationFactory to use
exportOidcEndpoints(userPool.getUserPoolId(), appClient.getUserPoolClientId(), cognitoDomainPrefix, secretName);
// Export CDK objects for native ALB Cognito authentication
ctx.cognitoUserPool.set(userPool);
ctx.cognitoUserPoolClient.set(appClient);
ctx.cognitoUserPoolDomain.set(userPoolDomain);
LOG.info("Exported Cognito CDK objects to SystemContext for native ALB Cognito authentication");
// Store User Pool ARN in SSM Parameter Store for compliance tracking (PRODUCTION only)
storeUserPoolArnInSSM(userPool);
LOG.info("Cognito User Pool setup complete");
}
/**
* Configure existing Cognito User Pool by creating/updating app client.
*/
private void configureExistingUserPool() {
// Import existing User Pool
IUserPool userPool = UserPool.fromUserPoolId(this, "ImportedUserPool", cognitoUserPoolId);
LOG.info("Imported existing User Pool: " + cognitoUserPoolId);
// Export UserPool to SystemContext (available for both paths)
ctx.cognitoUserPool.set(userPool);
// If app client ID is provided, use it; otherwise create a new one
if (cognitoAppClientId != null && !cognitoAppClientId.isEmpty()) {
LOG.info("Using existing App Client: " + cognitoAppClientId);
// Extract region and domain prefix from existing configuration
// Note: We need domain prefix to construct OIDC endpoints
if (cognitoDomainPrefix == null || cognitoDomainPrefix.isEmpty()) {
LOG.warning("cognitoDomainPrefix not provided - cannot auto-configure OIDC endpoints");
LOG.warning("Please provide cognitoDomainPrefix or configure OIDC endpoints manually");
return;
}
// Import existing User Pool Client
IUserPoolClient appClient = UserPoolClient.fromUserPoolClientId(this, "ImportedAppClient", cognitoAppClientId);
ctx.cognitoUserPoolClient.set(appClient);
// Import existing User Pool Domain (requires full domain name)
String fullDomainName = cognitoDomainPrefix + ".auth." + region + ".amazoncognito.com";
IUserPoolDomain userPoolDomain = UserPoolDomain.fromDomainName(this, "ImportedUserPoolDomain", fullDomainName);
ctx.cognitoUserPoolDomain.set(userPoolDomain);
LOG.info("Imported and exported Cognito CDK objects to SystemContext");
// For application-oidc mode, we need to store the client secret in Secrets Manager
// so the application container can retrieve it at runtime
String secretName = null;
if (authMode == AuthMode.APPLICATION_OIDC) {
LOG.info("Application-level OIDC detected - storing Cognito client secret in Secrets Manager");
// For existing user pool with existing client, we need the secret to be provided externally
// or we retrieve it using Custom Resource
secretName = storeCognitoClientSecret(userPool, appClient);
}
// Export OIDC endpoints
exportOidcEndpoints(cognitoUserPoolId, cognitoAppClientId, cognitoDomainPrefix, secretName);
} else {
LOG.info("Creating new App Client for existing User Pool");
// Validate domain prefix is provided
if (cognitoDomainPrefix == null || cognitoDomainPrefix.isEmpty()) {
LOG.severe("cognitoDomainPrefix is required when creating app client");
throw new IllegalArgumentException("cognitoDomainPrefix is required");
}
// Construct redirect URL and logout URL
String redirectUrl = constructRedirectUrl();
String logoutUrl = constructLogoutRedirectUrl();
// Create new App Client
UserPoolClient appClient = UserPoolClient.Builder.create(this, "AppClient")
.userPool(userPool)
.userPoolClientName(stackName + "-alb-client")
.generateSecret(true)
.oAuth(OAuthSettings.builder()
.flows(OAuthFlows.builder()
.authorizationCodeGrant(true)
.build())
.scopes(List.of(OAuthScope.OPENID, OAuthScope.EMAIL, OAuthScope.PROFILE))
.callbackUrls(List.of(redirectUrl))
.logoutUrls(List.of(logoutUrl))
.build())
.preventUserExistenceErrors(true)
.build();
LOG.info("App Client created: " + appClient.getUserPoolClientId());
// Import existing User Pool Domain (requires full domain name)
String fullDomainName = cognitoDomainPrefix + ".auth." + region + ".amazoncognito.com";
IUserPoolDomain userPoolDomain = UserPoolDomain.fromDomainName(this, "ImportedUserPoolDomain", fullDomainName);
// Export CDK objects for native ALB Cognito authentication
ctx.cognitoUserPoolClient.set(appClient);
ctx.cognitoUserPoolDomain.set(userPoolDomain);
LOG.info("Exported Cognito CDK objects to SystemContext");
// For application-oidc mode, we need to store the client secret in Secrets Manager
// so the application container can retrieve it at runtime
String secretName = null;
if (authMode == AuthMode.APPLICATION_OIDC) {
LOG.info("Application-level OIDC detected - storing Cognito client secret in Secrets Manager");
secretName = storeCognitoClientSecret(userPool, appClient);
}
// Export OIDC endpoints
exportOidcEndpoints(cognitoUserPoolId, appClient.getUserPoolClientId(), cognitoDomainPrefix, secretName);
}
}
/**
* Construct redirect URL based on domain configuration and auth mode.
*
* <p>For ALB-OIDC: Uses /oauth2/idpresponse (ALB handles the callback)</p>
* <p>For Application-OIDC: Uses application-specific callback path (e.g., /securityRealm/finishLogin for Jenkins)</p>
*/
private String constructRedirectUrl() {
// Determine the callback path based on authMode
String callbackPath;
if (authMode == AuthMode.APPLICATION_OIDC) {
// For application-level OIDC, get the callback path from the application's OidcIntegration
callbackPath = getApplicationCallbackPath();
LOG.info("Using application-oidc callback path: " + callbackPath);
} else {
// For ALB-level OIDC, use the standard ALB callback path
callbackPath = "/oauth2/idpresponse";
}
// Use Fn.join to properly concatenate when base URL may be a CloudFormation token
// (e.g., when using ALB DNS name without custom domain)
String baseUrl = constructBaseUrl();
return Fn.join("", List.of(baseUrl, callbackPath));
}
/**
* Get the application-specific callback path from the OidcIntegration.
* Returns default Jenkins path if not available.
*/
private String getApplicationCallbackPath() {
// Try to get from SystemContext.applicationSpec if available
if (ctx != null && ctx.applicationSpec != null && ctx.applicationSpec.get().isPresent()) {
var appSpec = ctx.applicationSpec.get().orElse(null);
if (appSpec != null && appSpec.supportsOidcIntegration()) {
var integration = appSpec.getOidcIntegration();
if (integration != null) {
String callbackPath = integration.getOidcCallbackPath();
LOG.info("Retrieved callback path from ApplicationSpec: " + callbackPath);
return callbackPath;
}
}
}
// Fallback to Jenkins default
LOG.warning("Could not retrieve callback path from ApplicationSpec, using Jenkins default");
return "/securityRealm/finishLogin";
}
/**
* Construct logout redirect URL - where Cognito redirects after logout.
* This is typically the application root URL.
*/
private String constructLogoutRedirectUrl() {
// Use Fn.join to properly concatenate when base URL may be a CloudFormation token
String baseUrl = constructBaseUrl();
return Fn.join("", List.of(baseUrl, "/"));
}
/**
* Construct the base URL from domain configuration.
* Priority: fqdn > subdomain.domain > domain > ALB DNS name (with Private CA)
*
* <p>When no custom domain is configured, the ALB DNS name is used with HTTPS.
* This requires AWS Private CA which is automatically provisioned by FargateRuntimeConfiguration
* when enableSsl=true but no domain is configured.</p>
*
* <p><b>Note:</b> Private CA certificates are not trusted by browsers (users will see warnings),
* but they enable HTTPS for OIDC callbacks without requiring a custom domain.</p>
*/
private String constructBaseUrl() {
// Priority 1: Fully qualified domain name
if (fqdn != null && !fqdn.isEmpty()) {
return "https://" + fqdn;
}
// Priority 2: Domain with optional subdomain
if (domain != null && !domain.isEmpty()) {
if (subdomain != null && !subdomain.isEmpty()) {
return "https://" + subdomain + "." + domain;
}
return "https://" + domain;
}
// Priority 3: ALB DNS name with HTTPS (requires Private CA certificate)
// This is used when enableSsl=true but no custom domain is configured
// IMPORTANT: ALB DNS names contain mixed case (e.g., nJjqXp6M1K6T) but Cognito
// callback URLs are case-sensitive. We must lowercase the DNS name.
// See: https://github.com/aws/aws-cdk/issues/11171
if (alb != null) {
// Lowercase the ALB DNS name for Cognito callback URL compatibility
String lowercaseDns = CfnStringUtils.toLowerCase(alb.getLoadBalancerDnsName());
String albUrl = Fn.join("", List.of("https://", lowercaseDns));
LOG.info("Using ALB DNS name for callback URL with Private CA certificate (lowercased): " + albUrl);
LOG.warning("NOTE: Private CA certificates are not trusted by browsers. Users will see certificate warnings.");
return albUrl;
}
// ALB not yet available - use placeholder that will be resolved at deploy time
LOG.warning("ALB not yet available - callback URL will use placeholder");
LOG.warning("Ensure enableSsl=true is set for HTTPS callback URLs");
return "https://placeholder.invalid";
}
/**
* Export Cognito OIDC endpoints to DeploymentContext for OidcAuthenticationFactory.
* This allows seamless integration between CognitoAuthenticationFactory and OidcAuthenticationFactory.
*/
private void exportOidcEndpoints(String userPoolId, String clientId, String domainPrefix, String secretName) {
String issuer = "https://cognito-idp." + region + ".amazonaws.com/" + userPoolId;
String authEndpoint = "https://" + domainPrefix + ".auth." + region + ".amazoncognito.com/oauth2/authorize";
String tokenEndpoint = "https://" + domainPrefix + ".auth." + region + ".amazoncognito.com/oauth2/token";
String userInfoEndpoint = "https://" + domainPrefix + ".auth." + region + ".amazoncognito.com/oauth2/userInfo";
String logoutEndpoint = "https://" + domainPrefix + ".auth." + region + ".amazoncognito.com/logout";
LOG.info("Exporting OIDC endpoints to DeploymentContext:");
LOG.info(" Issuer: " + issuer);
LOG.info(" Authorization: [REDACTED]");
LOG.info(" Token: [REDACTED]");
LOG.info(" UserInfo: [REDACTED]");
LOG.info(" Logout: [REDACTED]");
LOG.info(" Client ID: [REDACTED]");
LOG.info(" Secret Name: " + (secretName != null ? "[REDACTED]" : "null"));
// Store in SystemContext slots for OidcAuthenticationFactory to use
ctx.cognitoIssuer.set(issuer);
ctx.cognitoAuthorizationEndpoint.set(authEndpoint);
ctx.cognitoTokenEndpoint.set(tokenEndpoint);
ctx.cognitoUserInfoEndpoint.set(userInfoEndpoint);
ctx.cognitoLogoutEndpoint.set(logoutEndpoint);
ctx.cognitoClientId.set(clientId);
ctx.cognitoClientSecretName.set(secretName);
ctx.cognitoUserPoolId.set(userPoolId);
ctx.cognitoDomainPrefix.set(domainPrefix);
LOG.info("OIDC endpoints exported - OidcAuthenticationFactory will use these for ALB configuration");
}
/**
* Create user groups for role-based access control.
*/
private void createUserGroups(UserPool userPool) {
if (cognitoCreateGroups == null || !cognitoCreateGroups) {
LOG.info("User groups creation disabled");
return;
}
String adminGroupName = (cognitoAdminGroupName != null && !cognitoAdminGroupName.isEmpty())
? cognitoAdminGroupName
: "Jenkins-Admins";
String userGroupName = (cognitoUserGroupName != null && !cognitoUserGroupName.isEmpty())
? cognitoUserGroupName
: "Jenkins-Users";
LOG.info("Creating user groups: " + adminGroupName + ", " + userGroupName);
// Create admin group
CfnUserPoolGroup adminGroup = CfnUserPoolGroup.Builder.create(this, "AdminGroup")
.userPoolId(userPool.getUserPoolId())
.groupName(adminGroupName)
.description("Jenkins administrators with full access")
.precedence(1) // Higher precedence (lower number = higher priority)
.build();
// Create user group
CfnUserPoolGroup userGroup = CfnUserPoolGroup.Builder.create(this, "UserGroup")
.userPoolId(userPool.getUserPoolId())
.groupName(userGroupName)
.description("Jenkins users with standard access")
.precedence(10) // Lower precedence
.build();
LOG.info("User groups created: " + adminGroupName + " (precedence 1), " + userGroupName + " (precedence 10)");
// Create initial admin user if email provided
if (cognitoInitialAdminEmail != null && !cognitoInitialAdminEmail.isEmpty()) {
createInitialAdminUser(userPool, adminGroupName, adminGroup);
}
}
/**
* Create initial admin user with temporary password.
* User will be required to change password on first login.
*/
private void createInitialAdminUser(UserPool userPool, String adminGroupName, CfnUserPoolGroup adminGroup) {
LOG.info("Creating initial admin user: " + cognitoInitialAdminEmail);
// Build user attributes list
java.util.List<CfnUserPoolUser.AttributeTypeProperty> attributes = new java.util.ArrayList<>();
// Add email attributes
attributes.add(CfnUserPoolUser.AttributeTypeProperty.builder()
.name("email")
.value(cognitoInitialAdminEmail)
.build());
attributes.add(CfnUserPoolUser.AttributeTypeProperty.builder()
.name("email_verified")
.value("true")
.build());
// Add phone number attributes if provided
if (cognitoInitialAdminPhone != null && !cognitoInitialAdminPhone.trim().isEmpty()) {
attributes.add(CfnUserPoolUser.AttributeTypeProperty.builder()
.name("phone_number")
.value(cognitoInitialAdminPhone)
.build());
attributes.add(CfnUserPoolUser.AttributeTypeProperty.builder()
.name("phone_number_verified")
.value("true")
.build());
LOG.info(" - Phone number configured: " + cognitoInitialAdminPhone);
}
// Create user with email as username
CfnUserPoolUser adminUser = CfnUserPoolUser.Builder.create(this, "InitialAdminUser")
.userPoolId(userPool.getUserPoolId())
.username(cognitoInitialAdminEmail)
.userAttributes(attributes)
// FORCE_CHANGE_PASSWORD means user must change password on first login
.desiredDeliveryMediums(List.of("EMAIL"))
.build();
// Add user to admin group if group name provided
if (adminGroupName != null && !adminGroupName.isEmpty() && adminGroup != null) {
CfnUserPoolUserToGroupAttachment groupAttachment = CfnUserPoolUserToGroupAttachment.Builder.create(this, "InitialAdminGroupAttachment")
.userPoolId(userPool.getUserPoolId())
.username(cognitoInitialAdminEmail)
.groupName(adminGroupName)
.build();
// Ensure user and group are created before adding to group
groupAttachment.getNode().addDependency(adminUser);
groupAttachment.getNode().addDependency(adminGroup);
LOG.info("Initial admin user created: " + cognitoInitialAdminEmail);
LOG.info(" - User will receive email with temporary password");
LOG.info(" - User must change password on first login");
LOG.info(" - User added to admin group: " + adminGroupName);
} else {
LOG.info("Initial admin user created: " + cognitoInitialAdminEmail);
LOG.info(" - User will receive email with temporary password");
LOG.info(" - User must change password on first login");
LOG.info(" - No group assignment (groups disabled)");
}
// Check if SMS MFA is enabled and warn about phone number requirement
if (cognitoMfaEnabled != null && cognitoMfaEnabled &&
cognitoMfaMethod != null && (cognitoMfaMethod.equals("sms") || cognitoMfaMethod.equals("both"))) {
if (cognitoInitialAdminPhone == null || cognitoInitialAdminPhone.trim().isEmpty()) {
LOG.warning(" - SMS MFA is enabled but no phone number configured!");
LOG.warning(" - User must add phone number after first login to enable SMS MFA");
LOG.warning(" - Add 'cognitoInitialAdminPhone' to cdk.json (E.164 format, e.g., +12025551234)");
} else {
LOG.info(" - SMS MFA enabled with phone number: " + cognitoInitialAdminPhone);
}
}
}
/**
* Configure native ALB Cognito authentication.
* This uses ALB's built-in Cognito support which eliminates the need for Secrets Manager.
* Only activates if authMode is "alb-oidc" (Cognito User Pool authentication).
*
* <p>Path-based authentication allows protecting only specific paths while
* leaving other paths public. This is configured via:</p>
* <ul>
* <li>ApplicationSpec.protectedPaths() - Application default protected paths</li>
* <li>DeploymentContext.protectedPaths - Override/replace application defaults</li>
* <li>DeploymentContext.additionalProtectedPaths - Add to application defaults</li>
* <li>DeploymentContext.publicPaths - Explicitly exclude paths from protection</li>
* </ul>
*/
private void configureAlbAuthentication() {
// Only configure ALB authentication if authMode is ALB_OIDC
if (authMode != AuthMode.ALB_OIDC) {
LOG.info("ALB authentication not applicable for authMode: " + authMode);
return;
}
LOG.info("Configuring native ALB Cognito authentication");
// Wait for HTTPS listener to be available
ctx.https.onSet(https -> {
// Also need target group to forward to after authentication
ctx.albTargetGroup.onSet(targetGroup -> {
// Check if Cognito CDK objects are available for native ALB Cognito authentication
if (ctx.cognitoUserPool.get().isPresent() &&
ctx.cognitoUserPoolClient.get().isPresent() &&
ctx.cognitoUserPoolDomain.get().isPresent()) {
LOG.info("Using native ALB Cognito authentication (no Secrets Manager required)");
var userPool = ctx.cognitoUserPool.get().orElseThrow();
var userPoolClient = ctx.cognitoUserPoolClient.get().orElseThrow();
var userPoolDomain = ctx.cognitoUserPoolDomain.get().orElseThrow();
// Calculate effective protected paths
List<String> effectiveProtectedPaths = calculateEffectiveProtectedPaths();
if (effectiveProtectedPaths.isEmpty()) {
// No specific paths defined - protect everything (existing behavior)
LOG.info("No specific protected paths defined - protecting all paths");
configureFullAuthenticationRule(https, targetGroup, userPool, userPoolClient, userPoolDomain);
} else {
// Specific paths defined - create path-based authentication rules
LOG.info("Path-based authentication enabled with " + effectiveProtectedPaths.size() + " protected path(s)");
configurePathBasedAuthenticationRules(https, targetGroup, userPool, userPoolClient, userPoolDomain, effectiveProtectedPaths);
}
LOG.info("✅ Native Cognito authentication configured successfully");
LOG.info(" User Pool ID: " + ctx.cognitoUserPoolId.get().orElse("N/A"));
LOG.info(" Target Group: " + targetGroup.getTargetGroupName());
LOG.info(" Scopes: openid, email, profile");
LOG.info(" Benefits: No Secrets Manager required, simplified configuration");
} else {
LOG.severe("❌ Cognito CDK objects not available in SystemContext");
LOG.severe("ALB authentication cannot be configured - check Cognito setup");
}
});
});
}
/**
* Calculate the effective list of protected paths based on ApplicationSpec and DeploymentContext.
*
* <p>Priority:</p>
* <ol>
* <li>If DeploymentContext.protectedPaths is set, it replaces ApplicationSpec defaults</li>
* <li>Otherwise, use ApplicationSpec.protectedPaths()</li>
* <li>Add any DeploymentContext.additionalProtectedPaths</li>
* <li>Remove any DeploymentContext.publicPaths from the result</li>
* </ol>
*
* @return List of path patterns requiring authentication (empty = protect everything)
*/
private List<String> calculateEffectiveProtectedPaths() {
java.util.Set<String> paths = new java.util.LinkedHashSet<>();
// Step 1: Start with base protected paths
if (protectedPaths != null && !protectedPaths.isEmpty()) {
// DeploymentContext overrides ApplicationSpec
paths.addAll(protectedPaths);
LOG.info("Using DeploymentContext.protectedPaths: " + protectedPaths);
} else if (applicationSpec != null && !applicationSpec.protectedPaths().isEmpty()) {
// Use ApplicationSpec defaults
paths.addAll(applicationSpec.protectedPaths());
LOG.info("Using ApplicationSpec.protectedPaths(): " + applicationSpec.protectedPaths());
}
// Step 2: Add additional protected paths from DeploymentContext
if (additionalProtectedPaths != null && !additionalProtectedPaths.isEmpty()) {
paths.addAll(additionalProtectedPaths);
LOG.info("Adding DeploymentContext.additionalProtectedPaths: " + additionalProtectedPaths);
}
// Step 3: Remove public paths from DeploymentContext
if (publicPaths != null && !publicPaths.isEmpty()) {
paths.removeAll(publicPaths);
LOG.info("Removing DeploymentContext.publicPaths: " + publicPaths);
}
// Step 4: Remove public paths from ApplicationSpec
if (applicationSpec != null && !applicationSpec.publicPaths().isEmpty()) {
paths.removeAll(applicationSpec.publicPaths());
LOG.info("Removing ApplicationSpec.publicPaths(): " + applicationSpec.publicPaths());
}
List<String> result = new java.util.ArrayList<>(paths);
LOG.info("Effective protected paths: " + (result.isEmpty() ? "[ALL PATHS]" : result));
return result;
}
/**
* Configure authentication rule that protects all paths (original behavior).
*/
private void configureFullAuthenticationRule(
ApplicationListener https,
IApplicationTargetGroup targetGroup,
IUserPool userPool,
IUserPoolClient userPoolClient,
IUserPoolDomain userPoolDomain) {
// Create native Cognito authentication action for all paths
https.addAction("CognitoAuth", AddApplicationActionProps.builder()
.priority(1) // High priority to catch all requests before default action
.conditions(List.of(
ListenerCondition.pathPatterns(List.of("/*")) // Match all paths
))
.action(AuthenticateCognitoAction.Builder.create()
.userPool(userPool)
.userPoolClient(userPoolClient)
.userPoolDomain(userPoolDomain)
.scope("openid email profile")
.onUnauthenticatedRequest(UnauthenticatedAction.AUTHENTICATE)
.next(ListenerAction.forward(List.of(targetGroup)))
.build()
)
.build());
LOG.info(" Priority: 1 (authenticate then forward to target group)");
LOG.info(" Authentication: All requests require Cognito User Pool authentication");
}
/**
* Configure path-based authentication rules.
*
* <p>Creates separate rules:</p>
* <ul>
* <li>Priority 1-N: Protected paths require authentication</li>
* <li>Priority N+1: Catch-all rule forwards without authentication</li>
* </ul>
*/
private void configurePathBasedAuthenticationRules(
ApplicationListener https,
IApplicationTargetGroup targetGroup,
IUserPool userPool,
IUserPoolClient userPoolClient,
IUserPoolDomain userPoolDomain,
List<String> protectedPathPatterns) {
// ALB rules can have multiple path patterns per rule (up to 5 values per condition)
// Group paths into batches of 5 for efficiency
int batchSize = 5;
int priority = 1;
for (int i = 0; i < protectedPathPatterns.size(); i += batchSize) {
int endIndex = Math.min(i + batchSize, protectedPathPatterns.size());
List<String> batch = protectedPathPatterns.subList(i, endIndex);
String ruleName = "CognitoAuth" + (priority > 1 ? "-" + priority : "");
https.addAction(ruleName, AddApplicationActionProps.builder()
.priority(priority)
.conditions(List.of(
ListenerCondition.pathPatterns(batch)
))
.action(AuthenticateCognitoAction.Builder.create()
.userPool(userPool)
.userPoolClient(userPoolClient)
.userPoolDomain(userPoolDomain)
.scope("openid email profile")
.onUnauthenticatedRequest(UnauthenticatedAction.AUTHENTICATE)
.next(ListenerAction.forward(List.of(targetGroup)))
.build()
)
.build());
LOG.info(" Rule '" + ruleName + "' (priority " + priority + "): Authenticate for paths " + batch);
priority++;
}
// Add catch-all rule that forwards without authentication (lower priority)
https.addAction("PublicForward", AddApplicationActionProps.builder()
.priority(priority)
.conditions(List.of(
ListenerCondition.pathPatterns(List.of("/*"))
))
.action(ListenerAction.forward(List.of(targetGroup)))
.build());
LOG.info(" Rule 'PublicForward' (priority " + priority + "): Forward without auth for all other paths");
LOG.info(" Authentication: Only protected paths require Cognito authentication");
}
/**
* Store Cognito User Pool ARN in SSM Parameter Store (deployment-time).
*
* This helper method stores the User Pool ARN in SSM for compliance tracking.
* The parameter persists after stack deletion for audit trail purposes.
*
* Only active in PRODUCTION mode.
*
* @param userPool The UserPool to track
*/
private void storeUserPoolArnInSSM(UserPool userPool) {
// Check if we have access to security profile (injected via annotation)
if (securityProfileConfig == null || securityProfileConfig.getSecurityProfile() != SecurityProfile.PRODUCTION) {
LOG.fine("Non-production mode: Skipping SSM tracking for User Pool");
return;
}
if (region == null || region.isEmpty() || region.contains("$")) {
LOG.warning("Region not available - cannot store User Pool ARN in SSM");
return;
}
LOG.info("Storing User Pool ARN in SSM Parameter Store for compliance tracking");
String ssmParameterName = "/cloudforge/shared/" + region + "/stack/" + this.stackName + "/cognito/user-pool-arn";
AwsSdkCall putParameterCall = AwsSdkCall.builder()
.service("SSM")
.action("putParameter")
.parameters(java.util.Map.of(
"Name", ssmParameterName,
"Value", userPool.getUserPoolArn(),
"Type", "String",
"Description", "CloudForge retained Cognito User Pool ARN for region " + region,
"Overwrite", true
))
.physicalResourceId(PhysicalResourceId.of("UserPoolArn-SSMWriter"))
.region(region)
.build();
// Use scoped ARN pattern for least-privilege SSM access
// Pattern: arn:aws:ssm:REGION:*:parameter/cloudforge/shared/REGION/stack/STACKNAME/*
String ssmArnPattern = "arn:aws:ssm:" + region + ":*:parameter/cloudforge/shared/" + region + "/stack/" + this.stackName + "/*";
AwsCustomResource ssmWriter = AwsCustomResource.Builder.create(this, "UserPoolArnSSMWriter")
.onCreate(putParameterCall)
.onUpdate(putParameterCall)
.policy(AwsCustomResourcePolicy.fromStatements(List.of(
software.amazon.awscdk.services.iam.PolicyStatement.Builder.create()
.actions(List.of("ssm:PutParameter"))
.resources(List.of(ssmArnPattern))
.build()
)))
.build();
ssmWriter.getNode().addDependency(userPool);
LOG.info("User Pool ARN will be tracked in SSM: " + ssmParameterName);
}
/**
* Store Cognito User Pool Client Secret in AWS Secrets Manager.
*
* <p>This is required for application-level OIDC authentication where the application
* needs to retrieve the client secret at runtime. For ALB-level OIDC, Cognito manages
* the secret internally and this method is not called.</p>
*
* <p>The client secret is retrieved from the Cognito User Pool Client and stored directly
* in Secrets Manager using a Custom Resource Lambda. This ensures the secret value never
* appears in the CloudFormation template (avoiding the security issue with unsafePlainText).</p>
*
* <p>The Custom Resource uses a Lambda function that:
* 1. Calls DescribeUserPoolClient to get the client secret
* 2. Calls PutSecretValue to store it in Secrets Manager
* This keeps the secret value within AWS and out of CloudFormation.</p>
*
* @param userPool The Cognito User Pool
* @param appClient The Cognito User Pool Client
* @return The Secrets Manager secret ARN
*/
private String storeCognitoClientSecret(IUserPool userPool, IUserPoolClient appClient) {
// Determine application ID for secret path
String appId = "jenkins"; // Default for backward compatibility with alb-oidc mode
if (applicationSpec != null) {
appId = applicationSpec.applicationId();
}
String secretName = stackName + "/" + appId + "/oidc/client-secret";
LOG.info("Storing Cognito client secret in Secrets Manager for application: " + appId);
// First, create an empty secret in Secrets Manager
// The Custom Resource will populate it with the actual client secret value
Secret cognitoSecret = Secret.Builder.create(this, "CognitoClientSecret")
.secretName(secretName)
.description("Cognito User Pool Client Secret for application-level OIDC authentication")
// Generate a placeholder - will be replaced by Custom Resource
.generateSecretString(SecretStringGenerator.builder()
.generateStringKey("placeholder")
.secretStringTemplate("{}")
.build())
// Always destroy - Cognito regenerates client secrets, this is just a synchronized copy
.removalPolicy(RemovalPolicy.DESTROY)
.build();
// Suppress SMG4 - Cognito client secrets cannot be rotated by Secrets Manager
// The secret is managed by Cognito and synchronized via Custom Resource.
// To rotate, you must regenerate the Cognito User Pool Client which requires
// updating all dependent applications (ALB OIDC actions, application configs).
NagSuppressions.addResourceSuppressions(
cognitoSecret,
List.of(
NagPackSuppression.builder()
.id("AwsSolutions-SMG4")
.reason("Cognito User Pool Client secrets are managed by AWS Cognito, not Secrets Manager. " +
"This secret is a synchronized copy for application use. Rotation requires regenerating " +
"the Cognito client and updating all dependent ALB OIDC actions and applications.")
.build()
),
Boolean.TRUE
);
// Use Custom Resource to retrieve the client secret from Cognito and store it in Secrets Manager
// This keeps the secret value within AWS - it never appears in the CloudFormation template
//
// SECURITY: We use a two-step Custom Resource approach:
// 1. First CR fetches the secret from Cognito (DescribeUserPoolClient)
// 2. Second CR stores it in Secrets Manager (PutSecretValue)
// This ensures the secret value is passed between CRs at runtime, never in CloudFormation.
AwsSdkCall getSecretCall = AwsSdkCall.builder()
.service("CognitoIdentityServiceProvider")
.action("describeUserPoolClient")
.parameters(java.util.Map.of(
"UserPoolId", userPool.getUserPoolId(),
"ClientId", appClient.getUserPoolClientId()
))
.outputPaths(List.of("UserPoolClient.ClientSecret"))
.physicalResourceId(PhysicalResourceId.of("CognitoClientSecretFetch-" + stackName))
.region(region)
.build();
// Step 1: Fetch the client secret from Cognito
AwsCustomResource secretFetcher = AwsCustomResource.Builder.create(this, "CognitoClientSecretFetcher")
.onCreate(getSecretCall)
.onUpdate(getSecretCall)
.policy(AwsCustomResourcePolicy.fromSdkCalls(
SdkCallsPolicyOptions.builder()
.resources(List.of(userPool.getUserPoolArn()))
.build()
))
.build();
secretFetcher.getNode().addDependency(appClient);
// Step 2: Store the secret value in Secrets Manager using PutSecretValue
// This Custom Resource takes the output from the fetcher and stores it
AwsSdkCall storeSecretCall = AwsSdkCall.builder()
.service("SecretsManager")
.action("putSecretValue")
.parameters(java.util.Map.of(
"SecretId", cognitoSecret.getSecretArn(),
"SecretString", secretFetcher.getResponseField("UserPoolClient.ClientSecret")
))
.physicalResourceId(PhysicalResourceId.of("CognitoClientSecretStore-" + stackName))
.region(region)
.build();
AwsCustomResource secretStorer = AwsCustomResource.Builder.create(this, "CognitoClientSecretStorer")
.onCreate(storeSecretCall)
.onUpdate(storeSecretCall)
.policy(AwsCustomResourcePolicy.fromStatements(List.of(
PolicyStatement.Builder.create()
.actions(List.of("secretsmanager:PutSecretValue"))
.resources(List.of(cognitoSecret.getSecretArn()))
.build()
)))
.build();
secretStorer.getNode().addDependency(secretFetcher);
secretStorer.getNode().addDependency(cognitoSecret);
LOG.info("Cognito client secret will be stored in Secrets Manager via Custom Resource");
// Store the secret in SystemContext for dependency tracking
ctx.cognitoClientSecretResourceInternal.set(cognitoSecret);
// Return the COMPLETE ARN with suffix (same as RDS pattern)
return cognitoSecret.getSecretArn();
}
}