JenkinsOidcIntegration.java

package com.cloudforge.core.oidc;

import com.cloudforge.core.interfaces.Ec2Context;
import com.cloudforge.core.interfaces.OidcConfiguration;
import com.cloudforge.core.interfaces.OidcIntegration;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * OIDC integration for Jenkins using the OpenID Connect Authentication Plugin.
 *
 * <p>Jenkins requires the "oic-auth" plugin for OIDC support. This integration
 * configures Jenkins to authenticate users against AWS Cognito or IAM Identity Center.</p>
 *
 * <p><strong>Supported OIDC Providers:</strong></p>
 * <ul>
 *   <li>Amazon Cognito</li>
 *   <li>IAM Identity Center</li>
 *   <li>Any OIDC-compliant provider</li>
 * </ul>
 *
 * <p><strong>Features:</strong></p>
 * <ul>
 *   <li>Auto-create users on first login</li>
 *   <li>Group/role mapping from OIDC claims</li>
 *   <li>Full user information synchronization</li>
 *   <li>Token-based session management</li>
 * </ul>
 *
 * <p><strong>Required Plugin:</strong></p>
 * <ul>
 *   <li>OpenID Connect Authentication Plugin (oic-auth)</li>
 * </ul>
 *
 * @see <a href="https://plugins.jenkins.io/oic-auth/">Jenkins OIDC Plugin Documentation</a>
 */
public class JenkinsOidcIntegration implements OidcIntegration {

    @Override
    public boolean isSupported() {
        return true;
    }

    @Override
    public String getIntegrationMethod() {
        return "OpenID Connect Authentication Plugin (oic-auth)";
    }

    @Override
    public Map<String, String> getEnvironmentVariables(OidcConfiguration config) {
        // Jenkins OIDC is configured via Jenkins Configuration as Code (JCasC)
        Map<String, String> env = new HashMap<>();

        // Tell Jenkins Configuration as Code where to find our OIDC config file
        env.put("CASC_JENKINS_CONFIG", "/var/jenkins_home/casc_configs");

        // Note: JENKINS_OIDC_CLIENT_SECRET is provided by ContainerFactory as an ECS secret
        // mounted from AWS Secrets Manager at runtime. No need to set it here.

        return env;
    }

    @Override
    public String getConfigurationFile(OidcConfiguration config) {
        // Check if group-based access control is enabled
        boolean groupsEnabled = config.isGroupBasedAccessEnabled();

        // Get group names from configuration
        String adminGroup = (config.getAdminGroupName() != null && !config.getAdminGroupName().isEmpty())
            ? config.getAdminGroupName()
            : "admins";
        String developerGroup = (config.getDeveloperGroupName() != null && !config.getDeveloperGroupName().isEmpty())
            ? config.getDeveloperGroupName()
            : "developers";
        String viewerGroup = (config.getViewerGroupName() != null && !config.getViewerGroupName().isEmpty())
            ? config.getViewerGroupName()
            : "viewers";

        // Get Jenkins URL from OidcConfiguration (supports both custom domain and ALB URL)
        String jenkinsUrl = config.getApplicationUrl();

        // Build Jenkins Configuration as Code (JCasC) YAML
        // Reference: https://github.com/jenkinsci/oic-auth-plugin/blob/master/docs/configuration/README.md
        // serverConfiguration contains ONLY wellKnown (or manual) - nothing else
        // clientId, clientSecret, userNameField etc. go at the oic level
        StringBuilder jcascConfig = new StringBuilder();
        jcascConfig.append("# Jenkins OIDC Configuration\n");
        jcascConfig.append("# Jenkins Configuration as Code (JCasC)\n");
        if (!groupsEnabled) {
            jcascConfig.append("# Group-based access control: DISABLED\n");
            jcascConfig.append("# All authenticated users will have full admin access\n");
        }
        jcascConfig.append("# Added by CloudForge\n");
        jcascConfig.append("\n");
        jcascConfig.append("jenkins:\n");
        jcascConfig.append("  securityRealm:\n");
        jcascConfig.append("    oic:\n");
        jcascConfig.append("      serverConfiguration:\n");
        // Use manual configuration to support Cognito's custom logout URL format
        // Reference: https://github.com/jenkinsci/oic-auth-plugin/issues/95
        jcascConfig.append("        manual:\n");
        jcascConfig.append(String.format("          authorizationServerUrl: \"%s\"\n", config.getAuthorizationEndpoint()));
        jcascConfig.append(String.format("          tokenServerUrl: \"%s\"\n", config.getTokenEndpoint()));
        jcascConfig.append(String.format("          userInfoServerUrl: \"%s\"\n", config.getUserInfoEndpoint()));
        jcascConfig.append(String.format("          jwksServerUrl: \"%s\"\n", config.getJwksUri()));
        jcascConfig.append(String.format("          issuer: \"%s\"\n", config.getIssuerUrl()));
        jcascConfig.append(String.format("          scopes: \"%s\"\n", config.getScopes()));
        // Configure Cognito logout with pre-formatted URL including required parameters
        String logoutEndpoint = config.getLogoutEndpoint();
        if (logoutEndpoint != null && !logoutEndpoint.isEmpty() && jenkinsUrl != null) {
            String cognitoLogoutUrl = logoutEndpoint + "?client_id=" + config.getClientId()
                + "&logout_uri=" + jenkinsUrl + "/";
            jcascConfig.append(String.format("          endSessionUrl: \"%s\"\n", cognitoLogoutUrl));
        }
        jcascConfig.append(String.format("      clientId: \"%s\"\n", config.getClientId()));
        jcascConfig.append("      clientSecret: \"${JENKINS_OIDC_CLIENT_SECRET}\"\n");
        jcascConfig.append(String.format("      userNameField: %s\n", sanitizeJmesPath(config.getUsernameClaim())));
        jcascConfig.append("      fullNameFieldName: \"name\"\n");
        jcascConfig.append("      emailFieldName: \"email\"\n");
        jcascConfig.append(String.format("      groupsFieldName: %s\n", sanitizeJmesPath(config.getGroupsClaim())));
        jcascConfig.append("      disableSslVerification: false\n");
        jcascConfig.append("      logoutFromOpenidProvider: true\n");
        if (jenkinsUrl != null) {
            jcascConfig.append(String.format("      postLogoutRedirectUrl: \"%s/\"\n", jenkinsUrl));
        } else {
            jcascConfig.append("      postLogoutRedirectUrl: \"\"\n");
        }
        jcascConfig.append("\n");
        jcascConfig.append("  authorizationStrategy:\n");

        if (groupsEnabled) {
            // Group-based authorization: use project matrix with group permissions
            jcascConfig.append("    projectMatrix:\n");
            jcascConfig.append("      permissions:\n");
            jcascConfig.append("        # Admin group gets full permissions (from OIDC configuration)\n");
            jcascConfig.append(String.format("        - \"Overall/Administer:%s\"\n", adminGroup));
            jcascConfig.append(String.format("        - \"Overall/Read:%s\"\n", adminGroup));
            jcascConfig.append("        # Developer group gets build and configure permissions\n");
            jcascConfig.append(String.format("        - \"Overall/Read:%s\"\n", developerGroup));
            jcascConfig.append(String.format("        - \"Job/Build:%s\"\n", developerGroup));
            jcascConfig.append(String.format("        - \"Job/Configure:%s\"\n", developerGroup));
            jcascConfig.append(String.format("        - \"Job/Create:%s\"\n", developerGroup));
            jcascConfig.append(String.format("        - \"Job/Read:%s\"\n", developerGroup));
            jcascConfig.append(String.format("        - \"Job/Workspace:%s\"\n", developerGroup));
            jcascConfig.append("        # Viewer group gets read-only permissions\n");
            jcascConfig.append(String.format("        - \"Overall/Read:%s\"\n", viewerGroup));
            jcascConfig.append(String.format("        - \"Job/Read:%s\"\n", viewerGroup));
            jcascConfig.append("        # Deny anonymous access\n");
            jcascConfig.append("        - \"Overall/Read:authenticated\"\n");
        } else {
            // Groups disabled: grant full admin access to all authenticated users
            jcascConfig.append("    loggedInUsersCanDoAnything:\n");
            jcascConfig.append("      allowAnonymousRead: false\n");
        }
        jcascConfig.append("\n");
        jcascConfig.append("# Configure Jenkins URL and audit trail\n");
        jcascConfig.append("unclassified:\n");

        // Add Jenkins URL configuration if available (required for email notifications, PR status, BUILD_URL)
        if (jenkinsUrl != null && !jenkinsUrl.isEmpty()) {
            jcascConfig.append("  location:\n");
            jcascConfig.append(String.format("    url: \"%s\"\n", jenkinsUrl));
        }

        jcascConfig.append("  audit-trail:\n");
        jcascConfig.append("    logBuildCause: true\n");
        jcascConfig.append("    pattern: \".*/.*\"\n");
        jcascConfig.append("    loggers:\n");
        jcascConfig.append("      - logFile:\n");
        jcascConfig.append("          log: /var/jenkins_home/logs/audit.log\n");
        jcascConfig.append("          limit: 10\n");
        jcascConfig.append("          count: 5\n");

        return jcascConfig.toString();
    }

    @Override
    public String getConfigurationFilePath() {
        return "/var/jenkins_home/casc_configs/oidc.yaml";
    }

    @Override
    public List<String> getUserDataCommands(OidcConfiguration config, Ec2Context context) {
        List<String> commands = new ArrayList<>();

        commands.add("# Configure Jenkins OIDC integration");
        commands.add("# Retrieve client secret from AWS Secrets Manager");
        commands.add("export JENKINS_OIDC_CLIENT_SECRET=$(aws secretsmanager get-secret-value \\");
        commands.add("  --secret-id " + config.getClientSecretArn() + " \\");
        commands.add("  --query SecretString --output text)");
        commands.add("");

        // Install OIDC plugin and compliance plugins
        commands.add("# Install Jenkins OIDC and compliance plugins");
        commands.add("# This will be done via Jenkins plugin installation script");
        commands.add("cat > /tmp/install-oidc-plugin.sh <<'EOFPLUGIN'");
        commands.add("#!/bin/bash");
        commands.add("# Wait for Jenkins to be ready");
        commands.add("until curl -s http://localhost:8080/login >/dev/null; do");
        commands.add("  echo 'Waiting for Jenkins to start...'");
        commands.add("  sleep 10");
        commands.add("done");
        commands.add("");
        commands.add("# Install OIDC plugin and compliance plugins using Jenkins CLI");
        commands.add("JENKINS_URL=http://localhost:8080");
        commands.add("JENKINS_CLI=\"java -jar /var/jenkins_home/jenkins-cli.jar -s ${JENKINS_URL}\"");
        commands.add("");
        commands.add("# Download Jenkins CLI");
        commands.add("curl -s ${JENKINS_URL}/jnlpJars/jenkins-cli.jar -o /var/jenkins_home/jenkins-cli.jar");
        commands.add("");
        commands.add("# Install OIDC authentication plugin");
        commands.add("echo 'Installing OIDC authentication plugin...'");
        commands.add("${JENKINS_CLI} install-plugin oic-auth");
        commands.add("");
        commands.add("# Install compliance and security plugins");
        commands.add("echo 'Installing compliance and security plugins...'");
        commands.add("${JENKINS_CLI} install-plugin audit-trail");
        commands.add("${JENKINS_CLI} install-plugin job-dsl");
        commands.add("${JENKINS_CLI} install-plugin configuration-as-code");
        commands.add("${JENKINS_CLI} install-plugin role-strategy");
        commands.add("${JENKINS_CLI} install-plugin credentials-binding");
        commands.add("${JENKINS_CLI} install-plugin matrix-auth");
        commands.add("");
        commands.add("# Restart Jenkins to activate plugins");
        commands.add("echo 'Restarting Jenkins to activate plugins...'");
        commands.add("${JENKINS_CLI} safe-restart");
        commands.add("EOFPLUGIN");
        commands.add("chmod +x /tmp/install-oidc-plugin.sh");
        commands.add("");

        // Create JCasC configuration directory
        commands.add("# Create Jenkins Configuration as Code directory");
        commands.add("mkdir -p /var/jenkins_home/casc_configs");
        commands.add("");

        // Create OIDC configuration file
        commands.add("# Create OIDC configuration file");
        commands.add("cat > /var/jenkins_home/casc_configs/oidc.yaml <<'EOFJENKINS'");
        commands.add("# Jenkins OIDC Configuration");
        commands.add("# Jenkins Configuration as Code (JCasC)");
        commands.add("# Added by CloudForge");
        commands.add("");
        commands.add("jenkins:");

        // Add Jenkins URL configuration if available (required for email notifications, PR status, BUILD_URL)
        String jenkinsUrl = config.getApplicationUrl();
        if (jenkinsUrl != null && !jenkinsUrl.isEmpty()) {
            commands.add("  # Configure Jenkins root URL for reverse proxy");
            commands.add("  location:");
            commands.add("    url: \"" + jenkinsUrl + "\"");
            commands.add("");
        }

        // Use manual configuration to support Cognito's custom logout URL format
        // Reference: https://github.com/jenkinsci/oic-auth-plugin/issues/95
        commands.add("  securityRealm:");
        commands.add("    oic:");
        commands.add("      serverConfiguration:");
        commands.add("        manual:");
        commands.add("          authorizationServerUrl: \"" + config.getAuthorizationEndpoint() + "\"");
        commands.add("          tokenServerUrl: \"" + config.getTokenEndpoint() + "\"");
        commands.add("          userInfoServerUrl: \"" + config.getUserInfoEndpoint() + "\"");
        commands.add("          jwksServerUrl: \"" + config.getJwksUri() + "\"");
        commands.add("          issuer: \"" + config.getIssuerUrl() + "\"");
        commands.add("          scopes: \"" + config.getScopes() + "\"");
        // Configure Cognito logout with pre-formatted URL including required parameters
        String logoutEndpointEc2 = config.getLogoutEndpoint();
        if (logoutEndpointEc2 != null && !logoutEndpointEc2.isEmpty() && jenkinsUrl != null) {
            String cognitoLogoutUrlEc2 = logoutEndpointEc2 + "?client_id=" + config.getClientId() + "&logout_uri=" + jenkinsUrl + "/";
            commands.add("          endSessionUrl: \"" + cognitoLogoutUrlEc2 + "\"");
        }
        commands.add("      clientId: \"" + config.getClientId() + "\"");
        commands.add("      clientSecret: \"${JENKINS_OIDC_CLIENT_SECRET}\"");
        commands.add("      userNameField: \"" + config.getUsernameClaim() + "\"");
        commands.add("      fullNameFieldName: \"name\"");
        commands.add("      emailFieldName: \"email\"");
        commands.add("      groupsFieldName: \"" + config.getGroupsClaim() + "\"");
        commands.add("      disableSslVerification: false");
        commands.add("      logoutFromOpenidProvider: true");
        if (jenkinsUrl != null) {
            commands.add("      postLogoutRedirectUrl: \"" + jenkinsUrl + "/\"");
        } else {
            commands.add("      postLogoutRedirectUrl: \"\"");
        }
        // Disable escape hatch for security - OIDC-only authentication
        // For emergency access, SSH into instance and retrieve initial admin password from logs
        commands.add("      escapeHatchEnabled: false");
        commands.add("");

        // Check if group-based access control is enabled
        boolean groupsEnabled = config.isGroupBasedAccessEnabled();

        // Get group names from configuration
        String adminGroup = (config.getAdminGroupName() != null && !config.getAdminGroupName().isEmpty())
            ? config.getAdminGroupName()
            : "admins";
        String developerGroup = (config.getDeveloperGroupName() != null && !config.getDeveloperGroupName().isEmpty())
            ? config.getDeveloperGroupName()
            : "developers";
        String viewerGroup = (config.getViewerGroupName() != null && !config.getViewerGroupName().isEmpty())
            ? config.getViewerGroupName()
            : "viewers";

        commands.add("  authorizationStrategy:");

        if (groupsEnabled) {
            // Group-based authorization: use project matrix with group permissions
            commands.add("    projectMatrix:");
            commands.add("      permissions:");
            commands.add("        # Admin group gets full permissions (from OIDC configuration)");
            commands.add("        - \"Overall/Administer:" + adminGroup + "\"");
            commands.add("        - \"Overall/Read:" + adminGroup + "\"");
            commands.add("        # Developer group gets build and configure permissions");
            commands.add("        - \"Overall/Read:" + developerGroup + "\"");
            commands.add("        - \"Job/Build:" + developerGroup + "\"");
            commands.add("        - \"Job/Configure:" + developerGroup + "\"");
            commands.add("        - \"Job/Create:" + developerGroup + "\"");
            commands.add("        - \"Job/Read:" + developerGroup + "\"");
            commands.add("        - \"Job/Workspace:" + developerGroup + "\"");
            commands.add("        # Viewer group gets read-only permissions");
            commands.add("        - \"Overall/Read:" + viewerGroup + "\"");
            commands.add("        - \"Job/Read:" + viewerGroup + "\"");
            commands.add("        # Deny anonymous access");
            commands.add("        - \"Overall/Read:authenticated\"");
        } else {
            // Groups disabled: grant full admin access to all authenticated users
            commands.add("    loggedInUsersCanDoAnything:");
            commands.add("      allowAnonymousRead: false");
        }
        commands.add("");
        commands.add("# Configure audit trail for compliance");
        commands.add("unclassified:");
        commands.add("  auditTrail:");
        commands.add("    logBuildCause: true");
        commands.add("    pattern: \".*/.*\"");
        commands.add("    loggers:");
        commands.add("      - logFile:");
        commands.add("          log: /var/jenkins_home/logs/audit.log");
        commands.add("          limit: 10");
        commands.add("          count: 5");
        commands.add("EOFJENKINS");
        commands.add("");

        // Replace secret placeholder with actual value
        commands.add("# Replace secret placeholder with actual value");
        commands.add("sed -i \"s|\\${JENKINS_OIDC_CLIENT_SECRET}|$JENKINS_OIDC_CLIENT_SECRET|g\" /var/jenkins_home/casc_configs/oidc.yaml");
        commands.add("");

        // Set proper ownership
        commands.add("# Set proper ownership for Jenkins files");
        commands.add("chown -R 1000:1000 /var/jenkins_home/casc_configs");
        commands.add("");

        // Execute plugin installation script in background
        commands.add("# Execute OIDC plugin installation in background");
        commands.add("echo 'Starting OIDC plugin installation in background...' >> /var/log/userdata.log");
        commands.add("nohup /tmp/install-oidc-plugin.sh >> /var/log/jenkins-oidc-setup.log 2>&1 &");
        commands.add("echo 'OIDC plugin installation script started (check /var/log/jenkins-oidc-setup.log for progress)' >> /var/log/userdata.log");

        return commands;
    }

    @Override
    public String getContainerStartupCommand() {
        // Install required plugins using jenkins-plugin-cli, then start Jenkins
        // This ensures plugins are installed even if Jenkins home already exists
        return "jenkins-plugin-cli --plugins configuration-as-code oic-auth matrix-auth audit-trail && /usr/local/bin/jenkins.sh";
    }

    @Override
    public boolean supportsCognito() {
        // Full support - Jenkins OIDC plugin works with Cognito
        return true;
    }

    @Override
    public boolean supportsIdentityCenterSaml() {
        // Jenkins uses OIDC, not SAML
        // Identity Center can work via OIDC endpoints but not SAML
        return false;
    }

    @Override
    public String getAuthenticationType() {
        return "OIDC";
    }

    @Override
    public String getPostDeploymentInstructions() {
        return """
                Jenkins OIDC Integration Setup
                ===============================

                ✅ OIDC plugin installation is AUTOMATIC!
                   - Plugin installation runs in background during EC2 boot
                   - Jenkins will restart automatically after plugins are installed
                   - Wait 5-10 minutes after deployment for setup to complete

                1. Verify OIDC configuration:
                   - Access Jenkins at: https://{your-domain}
                   - You should be redirected to your OIDC provider (Cognito/Identity Center)
                   - If not visible yet, check setup logs (see below)

                2. First login:
                   - Authenticate with your OIDC provider
                   - Jenkins will auto-create your user account
                   - Your groups/roles will be synced automatically

                3. Configure Authorization:
                   - Go to Manage Jenkins > Configure Global Security
                   - Adjust matrix-based security as needed
                   - Map OIDC groups to Jenkins roles

                🚨 Emergency Recovery (if OIDC fails):
                   1. SSH into the Jenkins EC2 instance
                   2. Retrieve the initial admin password:
                      sudo cat /var/lib/jenkins/secrets/initialAdminPassword
                   3. Access Jenkins at: https://{your-domain}/login
                   4. Login with username: admin, password: <from step 2>
                   5. Fix OIDC configuration or disable it if needed

                Configuration Files:
                - OIDC Config: /var/jenkins_home/casc_configs/oidc.yaml
                - Setup Progress: /var/log/jenkins-oidc-setup.log
                - Initial Admin Password: /var/lib/jenkins/secrets/initialAdminPassword

                Troubleshooting:
                - Check OIDC setup progress: sudo tail -f /var/log/jenkins-oidc-setup.log
                - Check UserData logs: sudo tail -f /var/log/userdata.log
                - Check Jenkins logs: sudo tail -f /var/lib/jenkins/logs/jenkins.log
                - Verify OIDC plugin installed: Manage Jenkins > Manage Plugins
                - Test OIDC endpoints: curl -v https://{cognito-domain}/oauth2/authorize
                """;
    }

    /**
     * Sanitizes OIDC claim names to be valid JMESPath expressions.
     *
     * <p>The Jenkins OIDC plugin uses JMESPath to extract claim values from OIDC tokens.
     * However, Cognito uses claim names with colons (e.g., "cognito:username", "cognito:groups")
     * which are invalid in bare JMESPath identifier syntax.</p>
     *
     * <p>JMESPath requires quoted identifiers for names with special characters.
     * The syntax is: "identifier-with-special-chars"</p>
     *
     * @param claimName the claim name from OIDC configuration
     * @return sanitized claim name that's valid JMESPath, properly quoted for YAML
     */
    private String sanitizeJmesPath(String claimName) {
        if (claimName == null || claimName.isEmpty()) {
            return claimName;
        }

        // JMESPath quoted identifier syntax: "name" for names with special chars like colons
        // In YAML, we need to wrap in single quotes to preserve the double quotes
        // Result in YAML: '"cognito:groups"' which JMESPath parses as identifier "cognito:groups"
        if (claimName.contains(":") || claimName.contains("-") || claimName.contains(" ")) {
            return "'\"" + claimName + "\"'";
        }

        // For simple claim names, just quote for YAML
        return "\"" + claimName + "\"";
    }
}