IdentityCenterSamlFactory.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.interfaces.ApplicationSpec;
import com.cloudforge.core.interfaces.OidcIntegration;
import com.cloudforgeci.api.util.CfnStringUtils;
import software.amazon.awscdk.CfnOutput;
import software.amazon.awscdk.Fn;
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.constructs.Construct;

import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

/**
 * IAM Identity Center SAML Factory for automated SAML 2.0 application provisioning.
 *
 * <p>This factory creates a SAML 2.0 application in AWS IAM Identity Center (formerly AWS SSO)
 * and configures it for use with applications like Mattermost that support SAML authentication.</p>
 *
 * <p><b>Quick Start:</b></p>
 * <pre>
 * {
 *   "authMode": "application-oidc",
 *   "autoProvisionIdentityCenter": true,
 *   "ssoInstanceArn": "arn:aws:sso:::instance/ssoins-xxxxxxxxxxxx"
 * }
 * </pre>
 *
 * <p><b>What Gets Created:</b></p>
 * <ul>
 *   <li>SAML 2.0 application in IAM Identity Center</li>
 *   <li>Attribute mappings (email, firstName, lastName, groups)</li>
 *   <li>IdP certificate stored in Secrets Manager</li>
 *   <li>SAML metadata URL for automatic configuration</li>
 * </ul>
 *
 * <p><b>Prerequisites:</b></p>
 * <ul>
 *   <li>AWS Organizations enabled in the account</li>
 *   <li>IAM Identity Center enabled and configured</li>
 *   <li>SSO Instance ARN available (Settings page in Identity Center console)</li>
 * </ul>
 *
 * <p><b>Post-Deployment:</b></p>
 * <ol>
 *   <li>Assign users/groups to the application in IAM Identity Center console</li>
 *   <li>Users can then sign in using "Sign in with AWS IAM Identity Center"</li>
 * </ol>
 *
 * @see <a href="https://docs.aws.amazon.com/singlesignon/latest/userguide/samlapps.html">IAM Identity Center SAML Apps</a>
 */
public class IdentityCenterSamlFactory extends BaseFactory {

    private static final Logger LOG = Logger.getLogger(IdentityCenterSamlFactory.class.getName());

    @DeploymentContext("authMode")
    private AuthMode authMode;

    @DeploymentContext("autoProvisionIdentityCenter")
    private Boolean autoProvisionIdentityCenter;

    @DeploymentContext("ssoInstanceArn")
    private String ssoInstanceArn;

    @DeploymentContext("stackName")
    private String stackName;

    @DeploymentContext("region")
    private String region;

    @DeploymentContext("fqdn")
    private String fqdn;

    @DeploymentContext("domain")
    private String domain;

    @DeploymentContext("subdomain")
    private String subdomain;

    @DeploymentContext("enableSsl")
    private Boolean enableSsl;

    // Reuse cognitoInitialAdminEmail for Identity Center - they're mutually exclusive
    @DeploymentContext("cognitoInitialAdminEmail")
    private String initialAdminEmail;

    @SystemContext("applicationSpec")
    private ApplicationSpec applicationSpec;

    @SystemContext("alb")
    private software.amazon.awscdk.services.elasticloadbalancingv2.ApplicationLoadBalancer alb;

    public IdentityCenterSamlFactory(Construct scope, String id) {
        super(scope, id);
    }

    @Override
    public void create() {
        // Only provision if authMode is APPLICATION_OIDC (SAML for app-level auth)
        if (authMode != AuthMode.APPLICATION_OIDC) {
            LOG.info("Application-level OIDC not enabled - skipping Identity Center SAML setup");
            return;
        }

        // Check if auto-provisioning is enabled
        if (autoProvisionIdentityCenter == null || !autoProvisionIdentityCenter) {
            LOG.info("Identity Center auto-provisioning not enabled - skipping SAML setup");
            return;
        }

        // Validate SSO instance ARN
        if (ssoInstanceArn == null || ssoInstanceArn.isEmpty()) {
            LOG.severe("ssoInstanceArn is required for Identity Center auto-provisioning");
            throw new IllegalArgumentException(
                "ssoInstanceArn is required when autoProvisionIdentityCenter = true. " +
                "Find it in IAM Identity Center console > Settings > ARN"
            );
        }

        // Check if application supports OIDC/SAML integration
        if (applicationSpec == null || !applicationSpec.supportsOidcIntegration()) {
            LOG.info("Application does not support OIDC/SAML integration - skipping Identity Center setup");
            return;
        }

        OidcIntegration oidcIntegration = applicationSpec.getOidcIntegration();
        if (oidcIntegration == null) {
            LOG.info("Application has no OIDC integration configured - skipping Identity Center setup");
            return;
        }

        // Check if application supports Identity Center SAML specifically
        if (!oidcIntegration.supportsIdentityCenterSaml()) {
            LOG.warning("Application '" + applicationSpec.applicationId() + "' does not support IAM Identity Center SAML");
            LOG.warning("  Auth type: " + oidcIntegration.getAuthenticationType());
            LOG.warning("  Supports Cognito: " + oidcIntegration.supportsCognito());
            LOG.warning("  Supports Identity Center SAML: false");
            LOG.warning("Skipping Identity Center SAML setup - use Cognito OIDC instead");
            return;
        }

        LOG.info("Creating IAM Identity Center SAML application for: " + applicationSpec.applicationId());
        LOG.info("SSO Instance ARN: " + ssoInstanceArn);

        // Create the SAML application
        createSamlApplication();
    }

    /**
     * Creates a SAML 2.0 application in IAM Identity Center using Custom Resources.
     *
     * <p>The SSO Admin API is used to create and configure the application:</p>
     * <ul>
     *   <li>CreateApplication - Creates the SAML app</li>
     *   <li>PutApplicationAccessScope - Configures access scopes</li>
     *   <li>PutApplicationAssignmentConfiguration - Configures user assignment</li>
     * </ul>
     */
    private void createSamlApplication() {
        String appName = stackName + "-" + applicationSpec.applicationId();
        String siteUrl = constructSiteUrl();
        String acsUrl = siteUrl + "/login/sso/saml";

        LOG.info("Creating SAML application: " + appName);
        LOG.info("  Site URL: " + siteUrl);
        LOG.info("  ACS URL: " + acsUrl);

        // Extract instance ID from ARN (format: arn:aws:sso:::instance/ssoins-xxxxxxxxxxxx)
        String instanceId = ssoInstanceArn.substring(ssoInstanceArn.lastIndexOf("/") + 1);

        // Create SAML application using Custom Resource
        // Note: CDK doesn't have native SSO Admin constructs, so we use Custom Resources
        // Use ApplicationArn from response as PhysicalResourceId so it can be used in onDelete
        AwsSdkCall createAppCall = AwsSdkCall.builder()
                .service("SSOAdmin")
                .action("createApplication")
                .parameters(Map.of(
                    "ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom",
                    "InstanceArn", ssoInstanceArn,
                    "Name", appName,
                    "Description", "SAML application for " + applicationSpec.applicationId() + " created by CloudForge",
                    "PortalOptions", Map.of(
                        "SignInOptions", Map.of(
                            "Origin", "APPLICATION",
                            "ApplicationUrl", siteUrl
                        ),
                        "Visibility", "ENABLED"
                    ),
                    "Status", "ENABLED"
                ))
                // Use ApplicationArn from response as physical ID - this allows onDelete to reference it
                .physicalResourceId(PhysicalResourceId.fromResponse("ApplicationArn"))
                .region(region)
                .build();

        // Delete the SAML application on stack deletion
        // PhysicalResourceIdReference resolves to the ApplicationArn set above
        AwsSdkCall deleteAppCall = AwsSdkCall.builder()
                .service("SSOAdmin")
                .action("deleteApplication")
                .parameters(Map.of(
                    "ApplicationArn", new software.amazon.awscdk.customresources.PhysicalResourceIdReference()
                ))
                .region(region)
                .ignoreErrorCodesMatching("ResourceNotFoundException")
                .build();

        AwsCustomResource samlApp = AwsCustomResource.Builder.create(this, "SamlApplication")
                .onCreate(createAppCall)
                .onDelete(deleteAppCall)
                .policy(AwsCustomResourcePolicy.fromStatements(List.of(
                    software.amazon.awscdk.services.iam.PolicyStatement.Builder.create()
                        .actions(List.of(
                            "sso:CreateApplication",
                            "sso:DeleteApplication",
                            "sso:DescribeApplication",
                            "sso:PutApplicationGrant",
                            "sso:PutApplicationAuthenticationMethod",
                            "sso:PutApplicationAccessScope",
                            "sso:PutApplicationAssignmentConfiguration",
                            "sso:GetApplicationGrant",
                            "sso:ListApplicationGrants"
                        ))
                        .resources(List.of("*"))
                        .build()
                )))
                .build();

        // Get the application ARN from the response
        String applicationArn = samlApp.getResponseField("ApplicationArn");

        // Configure SAML authentication method
        configureSamlAuthentication(applicationArn, siteUrl, acsUrl);

        // Store IdP metadata URL and certificate in Secrets Manager
        storeIdpConfiguration(instanceId, applicationArn);

        // Export the SAML configuration to SystemContext
        exportSamlConfiguration(siteUrl, acsUrl, instanceId);

        // Output useful information
        createOutputs(appName, siteUrl, acsUrl, instanceId);

        LOG.info("IAM Identity Center SAML application created successfully");
    }

    /**
     * Configure SAML authentication method for the application.
     *
     * <p>Note: The SSO Admin API for custom SAML applications has limited support
     * for programmatic configuration. The CreateApplication call creates the app,
     * but detailed SAML settings (ACS URL, attribute mappings) must be configured
     * via the IAM Identity Center console.</p>
     *
     * <p>The application is created with status ENABLED and visible in the portal.
     * Post-deployment steps:</p>
     * <ol>
     *   <li>Go to IAM Identity Center > Applications</li>
     *   <li>Select the created application</li>
     *   <li>Configure SAML settings (ACS URL, Entity ID, Attributes)</li>
     *   <li>Assign users/groups</li>
     * </ol>
     */
    private void configureSamlAuthentication(String applicationArn, String siteUrl, String acsUrl) {
        // Note: For custom SAML applications, the grant types and authentication methods
        // are pre-configured by AWS when using ApplicationProviderArn = "arn:aws:sso::aws:applicationProvider/custom"
        //
        // The SSO Admin API doesn't support SAML-specific grant types like "saml2-bearer"
        // and the PutApplicationAuthenticationMethod only accepts "IAM" which is for
        // IAM Identity Center managed applications, not custom SAML apps.
        //
        // Instead, SAML configuration is done through:
        // 1. The portal settings in CreateApplication (SignInOptions, Visibility)
        // 2. Manual configuration in the IAM Identity Center console
        //
        // We skip the grant and auth method calls to avoid API errors.

        LOG.info("SAML application created. ACS URL for manual configuration: " + acsUrl);
        LOG.info("Entity ID (Audience) for manual configuration: " + siteUrl);
        LOG.info("Post-deployment: Configure SAML settings in IAM Identity Center console");
    }

    /**
     * Store IdP certificate and metadata URL in Secrets Manager.
     * The application needs these to validate SAML responses.
     *
     * <p>Uses PutSecretValue which creates the secret if it doesn't exist
     * or updates it if it does. This ensures we always have current values.</p>
     */
    private void storeIdpConfiguration(String instanceId, String applicationArn) {
        String appId = applicationSpec != null ? applicationSpec.applicationId() : "app";

        // Construct IdP metadata URL
        // Format: https://portal.sso.{region}.amazonaws.com/saml/metadata/{instanceId}
        String metadataUrl = "https://portal.sso." + region + ".amazonaws.com/saml/metadata/" + instanceId;

        // Construct IdP SSO URL
        String ssoUrl = "https://portal.sso." + region + ".amazonaws.com/saml/assertion/" + instanceId;

        LOG.info("IdP Metadata URL: " + metadataUrl);
        LOG.info("IdP SSO URL: " + ssoUrl);

        String secretName = stackName + "/" + appId + "/saml/idp-config";
        String secretValue = String.format(
            "{\"metadataUrl\":\"%s\",\"ssoUrl\":\"%s\",\"instanceId\":\"%s\",\"region\":\"%s\"}",
            metadataUrl, ssoUrl, instanceId, region
        );
        String description = "IAM Identity Center SAML IdP configuration for " + appId;

        // Delete secret on stack deletion - ALWAYS delete, no RETAIN behavior for this config
        AwsSdkCall deleteSecretCall = AwsSdkCall.builder()
                .service("SecretsManager")
                .action("deleteSecret")
                .parameters(Map.of(
                        "SecretId", secretName,
                        "ForceDeleteWithoutRecovery", true
                ))
                .physicalResourceId(PhysicalResourceId.of("IdentityCenterSamlConfig-" + secretName))
                .region(region)
                .ignoreErrorCodesMatching("ResourceNotFoundException")
                .build();

        // Step 1: Create secret (ignore if exists)
        AwsSdkCall createSecretCall = AwsSdkCall.builder()
                .service("SecretsManager")
                .action("createSecret")
                .parameters(Map.of(
                        "Name", secretName,
                        "Description", description,
                        "SecretString", secretValue
                ))
                .physicalResourceId(PhysicalResourceId.of("IdentityCenterSamlConfig-" + secretName))
                .region(region)
                .ignoreErrorCodesMatching("ResourceExistsException")
                .build();

        // Step 2: Update secret value (always runs to ensure current values)
        AwsSdkCall updateSecretCall = AwsSdkCall.builder()
                .service("SecretsManager")
                .action("putSecretValue")
                .parameters(Map.of(
                        "SecretId", secretName,
                        "SecretString", secretValue
                ))
                .physicalResourceId(PhysicalResourceId.of("IdentityCenterSamlConfig-" + secretName))
                .region(region)
                .build();

        // Single custom resource that creates, updates, and deletes
        // Use scoped ARN pattern for least-privilege (secretName is known at synth time)
        // Pattern: arn:aws:secretsmanager:REGION:*:secret:STACKNAME/APP_ID/saml/*
        String secretArnPattern = "arn:aws:secretsmanager:" + region + ":*:secret:" + stackName + "/" + appId + "/saml/*";

        AwsCustomResource.Builder.create(this, "SamlIdpConfig")
                .onCreate(createSecretCall)
                .onUpdate(updateSecretCall)
                .onDelete(deleteSecretCall)
                .policy(AwsCustomResourcePolicy.fromStatements(List.of(
                    software.amazon.awscdk.services.iam.PolicyStatement.Builder.create()
                        .actions(List.of(
                            "secretsmanager:CreateSecret",
                            "secretsmanager:PutSecretValue",
                            "secretsmanager:DeleteSecret"
                        ))
                        .resources(List.of(secretArnPattern))
                        .build()
                )))
                .build();

        LOG.info("SAML IdP configuration stored in Secrets Manager");
        LOG.info("Secret will be deleted on stack deletion (ForceDeleteWithoutRecovery=true)");

        // Store the secret name in SystemContext for the application to use
        // Note: Just store the secret name - the full ARN isn't known until the secret is created
        ctx.samlConfigSecretArn.set(secretName);

        // Also store the metadata URL directly for MattermostSamlIntegration
        ctx.samlIdpMetadataUrl.set(metadataUrl);
        ctx.samlIdpSsoUrl.set(ssoUrl);
    }

    /**
     * Export SAML configuration to SystemContext for application use.
     */
    private void exportSamlConfiguration(String siteUrl, String acsUrl, String instanceId) {
        // Construct IdP endpoints
        String metadataUrl = "https://portal.sso." + region + ".amazonaws.com/saml/metadata/" + instanceId;
        String ssoUrl = "https://portal.sso." + region + ".amazonaws.com/saml/assertion/" + instanceId;

        // Store in SystemContext for MattermostSamlIntegration to use
        ctx.samlSiteUrl.set(siteUrl);
        ctx.samlAcsUrl.set(acsUrl);
        ctx.samlIdpMetadataUrl.set(metadataUrl);
        ctx.samlIdpSsoUrl.set(ssoUrl);
        ctx.samlIdpEntityId.set("urn:amazon:webservices");

        LOG.info("SAML configuration exported to SystemContext");
    }

    /**
     * Create CloudFormation outputs for easy reference.
     */
    private void createOutputs(String appName, String siteUrl, String acsUrl, String instanceId) {
        String metadataUrl = "https://portal.sso." + region + ".amazonaws.com/saml/metadata/" + instanceId;

        CfnOutput.Builder.create(this, "SamlApplicationName")
                .description("IAM Identity Center SAML Application Name")
                .value(appName)
                .build();

        CfnOutput.Builder.create(this, "SamlAcsUrl")
                .description("SAML Assertion Consumer Service URL")
                .value(acsUrl)
                .build();

        CfnOutput.Builder.create(this, "SamlEntityId")
                .description("SAML Service Provider Entity ID")
                .value(siteUrl)
                .build();

        CfnOutput.Builder.create(this, "SamlIdpMetadataUrl")
                .description("IAM Identity Center SAML Metadata URL")
                .value(metadataUrl)
                .build();

        // Direct console link for post-deployment configuration
        String consoleUrl = "https://" + region + ".console.aws.amazon.com/singlesignon/home?region=" + region + "#/applications";
        CfnOutput.Builder.create(this, "SamlConsoleUrl")
                .description("IAM Identity Center Applications Console - configure SAML settings here")
                .value(consoleUrl)
                .build();

        // CRITICAL: AWS SSO Admin API does NOT support SAML attribute mapping programmatically
        // Users MUST manually configure these in the IAM Identity Center console
        // The "Assign users" popup does NOT configure attributes - must use "Edit attribute mappings"
        CfnOutput.Builder.create(this, "SamlPostDeployment")
                .description("REQUIRED manual steps - AWS API limitation")
                .value("1) Open console URL, 2) Select '" + appName + "', 3) Actions > Edit attribute mappings (NOT Assign users), 4) Add mappings below, 5) Then assign users")
                .build();

        // Output required attribute mappings for the SAML application
        // These MUST be configured in IAM Identity Center for Mattermost to work
        // AWS API does not support programmatic SAML attribute mapping - console only
        CfnOutput.Builder.create(this, "SamlAttrMappings")
                .description("REQUIRED attribute mappings - add ALL of these in Edit attribute mappings")
                .value("email=${user:email}, firstName=${user:givenName}, lastName=${user:familyName}, preferred_username=${user:preferredUsername}")
                .build();

        // Output initial admin email if provided (reused from cognitoInitialAdminEmail)
        if (initialAdminEmail != null && !initialAdminEmail.isEmpty()) {
            CfnOutput.Builder.create(this, "SamlInitialAdminEmail")
                    .description("Initial admin email - assign this user in Identity Center")
                    .value(initialAdminEmail)
                    .build();

            LOG.info("Initial admin email for Identity Center assignment: " + initialAdminEmail);
        }
    }

    /**
     * Construct the site URL from domain configuration or ALB DNS name.
     */
    private String constructSiteUrl() {
        String protocol = (enableSsl != null && enableSsl) ? "https" : "http";

        if (fqdn != null && !fqdn.isEmpty()) {
            return protocol + "://" + fqdn;
        } else if (domain != null && !domain.isEmpty()) {
            if (subdomain != null && !subdomain.isEmpty()) {
                return protocol + "://" + subdomain + "." + domain;
            }
            return protocol + "://" + domain;
        }

        // For OIDC modes without custom domain, use ALB DNS name with Private CA
        // IMPORTANT: ALB DNS names contain mixed case but Cognito callback URLs are case-sensitive.
        // See: https://github.com/aws/aws-cdk/issues/11171
        if (alb != null) {
            // Lowercase the ALB DNS name for callback URL compatibility
            String lowercaseDns = CfnStringUtils.toLowerCase(alb.getLoadBalancerDnsName());
            String albUrl = Fn.join("", java.util.List.of(protocol, "://", lowercaseDns));
            LOG.info("No custom domain configured - using ALB DNS name (lowercased): " + albUrl);
            return albUrl;
        }

        // Fallback - should not happen in production
        LOG.warning("No domain or ALB configured - using placeholder URL");
        return "https://" + applicationSpec.applicationId() + ".example.com";
    }
}