OidcAuthenticationFactory.java
package com.cloudforgeci.api.security;
import com.cloudforgeci.api.core.annotation.BaseFactory;
import com.cloudforge.core.annotation.DeploymentContext;
import com.cloudforge.core.enums.AuthMode;
import software.amazon.awscdk.RemovalPolicy;
import software.amazon.awscdk.SecretValue;
import software.amazon.awscdk.Stack;
import software.amazon.awscdk.services.elasticloadbalancingv2.*;
import software.amazon.awscdk.services.secretsmanager.Secret;
import software.constructs.Construct;
import java.util.List;
import java.util.logging.Logger;
/**
* OIDC Authentication Factory for ALB-based authentication with AWS IAM Identity Center.
*
* This factory handles OIDC authentication ONLY for AWS IAM Identity Center (formerly AWS SSO).
* For Cognito User Pool authentication, use CognitoAuthenticationFactory instead.
*
* Provides:
* - Infrastructure-level authentication before requests reach Jenkins
* - Integration with AWS IAM Identity Center for enterprise SSO
* - Compliance with security requirements (PCI-DSS Req 8, HIPAA ยง164.312(d), SOC 2 CC6.2, GDPR Art. 32)
*
* Configuration (MANUAL OIDC ENDPOINTS - Recommended):
* - authMode: "alb-oidc" to enable this factory
* - oidcIssuer: OIDC issuer URL from IAM Identity Center application
* - oidcAuthorizationEndpoint: Authorization endpoint URL
* - oidcTokenEndpoint: Token endpoint URL
* - oidcUserInfoEndpoint: UserInfo endpoint URL
* - oidcClientId: Client ID from IAM Identity Center application
* - oidcClientSecretName: Secrets Manager secret name (default: jenkins/oidc/client-secret)
*
* Setup steps for IAM Identity Center:
* 1. Go to AWS IAM Identity Center console
* 2. Navigate to "Applications" > "Add application"
* 3. Choose "I have an application I want to set up" > "OAuth 2.0" or "OIDC"
* 4. Configure the application:
* - Redirect URLs: https://your-domain.com/oauth2/idpresponse
* - Grant types: Authorization code
* - Scopes: openid
* 5. Copy the OIDC endpoints and client ID
* 6. Add them to your deployment-context.json
* 7. After deployment, update the client secret in AWS Secrets Manager
*
* Legacy Configuration (AUTO-CONSTRUCTED ENDPOINTS - Not recommended):
* - authMode: "alb-oidc"
* - ssoInstanceArn: AWS IAM Identity Center instance ARN
* Note: This auto-constructs endpoints but may not work with all IAM Identity Center configurations
*
* Note: For Cognito User Pool authentication, use CognitoAuthenticationFactory which provides
* native ALB Cognito integration without requiring Secrets Manager.
*/
public class OidcAuthenticationFactory extends BaseFactory {
private static final Logger LOG = Logger.getLogger(OidcAuthenticationFactory.class.getName());
@DeploymentContext("authMode")
private AuthMode authMode;
@DeploymentContext("stackName")
private String stackName;
// Manual OIDC configuration (recommended)
@DeploymentContext("oidcIssuer")
private String oidcIssuer;
@DeploymentContext("oidcAuthorizationEndpoint")
private String oidcAuthorizationEndpoint;
@DeploymentContext("oidcTokenEndpoint")
private String oidcTokenEndpoint;
@DeploymentContext("oidcUserInfoEndpoint")
private String oidcUserInfoEndpoint;
@DeploymentContext("oidcClientId")
private String oidcClientId;
@DeploymentContext("oidcClientSecretName")
private String oidcClientSecretName;
// Legacy auto-construction (not recommended)
@DeploymentContext("ssoInstanceArn")
private String ssoInstanceArn;
@DeploymentContext("region")
private String region;
// ========== 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;
@com.cloudforge.core.annotation.SystemContext("applicationSpec")
private com.cloudforge.core.interfaces.ApplicationSpec applicationSpec;
public OidcAuthenticationFactory(Construct scope, String id) {
super(scope, id);
}
@Override
public void create() {
// Only configure OIDC if authMode is ALB_OIDC
if (authMode != AuthMode.ALB_OIDC) {
LOG.info("ALB-OIDC authentication not enabled (authMode = " + authMode + ")");
return;
}
// Priority 1: Check if manual OIDC endpoints are provided (for IAM Identity Center or external IdP)
// Note: Cognito User Pool authentication is now handled by CognitoAuthenticationFactory directly
boolean hasManualConfig = oidcIssuer != null && !oidcIssuer.isEmpty()
&& oidcAuthorizationEndpoint != null && !oidcAuthorizationEndpoint.isEmpty()
&& oidcTokenEndpoint != null && !oidcTokenEndpoint.isEmpty()
&& oidcUserInfoEndpoint != null && !oidcUserInfoEndpoint.isEmpty()
&& oidcClientId != null && !oidcClientId.isEmpty();
if (hasManualConfig) {
LOG.info("Configuring ALB-OIDC authentication with manually provided endpoints");
LOG.info("OIDC Issuer: [REDACTED]");
LOG.info("Client ID: [REDACTED]");
configureOidcAuthentication();
return;
}
// Priority 3: Fall back to legacy auto-construction approach (not recommended)
if (ssoInstanceArn != null && !ssoInstanceArn.isEmpty()) {
LOG.warning("Using legacy auto-constructed OIDC endpoints from ssoInstanceArn");
LOG.warning("This may not work with all IAM Identity Center configurations");
LOG.warning("Recommended: Use Cognito (cognitoAutoProvision = true) or manually configure OIDC endpoints");
LOG.info("SSO Instance ARN: " + ssoInstanceArn);
configureOidcAuthentication();
return;
}
// No OIDC configuration provided
LOG.warning("ALB-OIDC enabled but no OIDC configuration provided");
LOG.warning("Option 1 (Recommended): Use Cognito User Pool - set cognitoAutoProvision = true");
LOG.warning("Option 2: Provide manual OIDC endpoints from IAM Identity Center:");
LOG.warning(" - oidcIssuer, oidcAuthorizationEndpoint, oidcTokenEndpoint, oidcUserInfoEndpoint, oidcClientId");
LOG.warning("Option 3 (Legacy): Provide ssoInstanceArn for auto-constructed endpoints (may not work)");
}
/**
* Configure OIDC authentication on the HTTPS listener.
* This adds a listener rule with OIDC authentication that forwards to the target group.
* Uses manual endpoints if provided, otherwise auto-constructs from ssoInstanceArn.
*
* <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>
*
* Creates a placeholder secret as a CDK resource if it doesn't exist, ensuring graceful
* stack deletion. Users must update the secret value after deployment.
*/
private void configureOidcAuthentication() {
// Determine which endpoints to use
String issuer;
String authorizationEndpoint;
String tokenEndpoint;
String userInfoEndpoint;
String clientId;
String secretName;
if (oidcIssuer != null && !oidcIssuer.isEmpty()) {
// Use manually provided endpoints
issuer = oidcIssuer;
authorizationEndpoint = oidcAuthorizationEndpoint;
tokenEndpoint = oidcTokenEndpoint;
userInfoEndpoint = oidcUserInfoEndpoint;
clientId = oidcClientId;
secretName = (oidcClientSecretName != null && !oidcClientSecretName.isEmpty())
? oidcClientSecretName
: stackName + "/jenkins/oidc/client-secret";
LOG.info("Using manually configured OIDC endpoints");
} else {
// Auto-construct from SSO instance ARN (legacy)
issuer = constructOidcIssuer(ssoInstanceArn);
authorizationEndpoint = issuer + "/authorize";
tokenEndpoint = issuer + "/token";
userInfoEndpoint = issuer + "/userinfo";
clientId = Stack.of(this).getAccount();
secretName = stackName + "/jenkins/oidc/client-secret";
LOG.info("Using auto-constructed OIDC endpoints (legacy mode)");
}
LOG.info("OIDC Endpoints:");
LOG.info(" Issuer: [REDACTED]");
LOG.info(" Authorization: [REDACTED]");
LOG.info(" Token: [REDACTED]");
LOG.info(" UserInfo: [REDACTED]");
LOG.info(" Client ID: [REDACTED]");
LOG.info(" Secret Name: [REDACTED]");
// Only create secret if using manual OIDC endpoints (not IAM Identity Center)
// IAM Identity Center path (ssoInstanceArn) has secret created by IdentityCenterFactory
boolean isIdentityCenterPath = (ssoInstanceArn != null && !ssoInstanceArn.isEmpty());
if (!isIdentityCenterPath) {
// Create placeholder secret as a CDK resource for proper lifecycle management
// This ensures the secret can be deleted gracefully when the stack is destroyed
LOG.info("Creating placeholder secret in Secrets Manager: [REDACTED]");
Secret.Builder.create(this, "OidcClientSecret")
.secretName(secretName)
.description("OIDC Client Secret for " + stackName + " (External IdP)")
.secretStringValue(SecretValue.unsafePlainText("PLACEHOLDER-UPDATE-WITH-ACTUAL-CLIENT-SECRET"))
.removalPolicy(RemovalPolicy.DESTROY) // Allow deletion when stack is deleted
.build();
LOG.warning("IMPORTANT: Placeholder secret created. Update with actual client secret:");
LOG.warning(" aws secretsmanager put-secret-value --secret-id [SECRET_NAME] --secret-string \"<your-client-secret>\"");
LOG.info("Note: If secret already exists, deployment will fail. Delete the existing secret first or use a different secret name.");
} else {
LOG.info("Using secret created by IdentityCenterFactory: [REDACTED]");
LOG.info("Note: Update the secret with your IAM Identity Center client secret after deployment");
}
// Store OIDC config for use in listener rules
final String finalIssuer = issuer;
final String finalAuthEndpoint = authorizationEndpoint;
final String finalTokenEndpoint = tokenEndpoint;
final String finalUserInfoEndpoint = userInfoEndpoint;
final String finalClientId = clientId;
final String finalSecretName = secretName;
// Wait for HTTPS listener to be available
ctx.https.onSet(https -> {
// Also need target group to forward to after authentication
ctx.albTargetGroup.onSet(targetGroup -> {
LOG.info("Adding OIDC authentication rule to HTTPS listener");
// 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");
configureFullOidcAuthenticationRule(https, targetGroup, finalIssuer, finalAuthEndpoint,
finalTokenEndpoint, finalUserInfoEndpoint, finalClientId, finalSecretName);
} else {
// Specific paths defined - create path-based authentication rules
LOG.info("Path-based authentication enabled with " + effectiveProtectedPaths.size() + " protected path(s)");
configurePathBasedOidcAuthenticationRules(https, targetGroup, finalIssuer, finalAuthEndpoint,
finalTokenEndpoint, finalUserInfoEndpoint, finalClientId, finalSecretName, effectiveProtectedPaths);
}
LOG.info("OIDC authentication configured successfully");
LOG.info(" Target Group: " + targetGroup.getTargetGroupName());
});
});
}
/**
* 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 OIDC authentication rule that protects all paths (original behavior).
*/
private void configureFullOidcAuthenticationRule(
ApplicationListener https,
IApplicationTargetGroup targetGroup,
String issuer,
String authorizationEndpoint,
String tokenEndpoint,
String userInfoEndpoint,
String clientId,
String secretName) {
// Create OIDC authentication action with forward to target group
https.addAction("OidcAuth", AddApplicationActionProps.builder()
.priority(1) // High priority to catch all requests before default action
.conditions(List.of(
ListenerCondition.pathPatterns(List.of("/*")) // Match all paths
))
.action(ListenerAction.authenticateOidc(
AuthenticateOidcOptions.builder()
.issuer(issuer)
.authorizationEndpoint(authorizationEndpoint)
.tokenEndpoint(tokenEndpoint)
.userInfoEndpoint(userInfoEndpoint)
.clientId(clientId)
.clientSecret(SecretValue.secretsManager(secretName))
.scope("openid")
.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 OIDC authentication before reaching application");
}
/**
* Configure path-based OIDC 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 configurePathBasedOidcAuthenticationRules(
ApplicationListener https,
IApplicationTargetGroup targetGroup,
String issuer,
String authorizationEndpoint,
String tokenEndpoint,
String userInfoEndpoint,
String clientId,
String secretName,
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 = "OidcAuth" + (priority > 1 ? "-" + priority : "");
https.addAction(ruleName, AddApplicationActionProps.builder()
.priority(priority)
.conditions(List.of(
ListenerCondition.pathPatterns(batch)
))
.action(ListenerAction.authenticateOidc(
AuthenticateOidcOptions.builder()
.issuer(issuer)
.authorizationEndpoint(authorizationEndpoint)
.tokenEndpoint(tokenEndpoint)
.userInfoEndpoint(userInfoEndpoint)
.clientId(clientId)
.clientSecret(SecretValue.secretsManager(secretName))
.scope("openid")
.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 OIDC authentication");
}
/**
* Construct OIDC issuer URL from AWS SSO instance ARN.
* Format: arn:aws:sso:::instance/ssoins-xxxxxxxxxxxx
* OR just the instance ID: ssoins-xxxxxxxxxxxx
* Returns: https://portal.sso.[region].amazonaws.com/saml/assertion/ssoins-xxxxxxxxxxxx
*
* Note: This is a placeholder URL structure. AWS SSO's actual OIDC endpoint may differ.
* AWS SSO primarily uses SAML, not OIDC. For production use with AWS SSO:
* 1. Register application in AWS SSO console
* 2. Get actual OIDC endpoints from application configuration
* 3. Store client secret in Secrets Manager
*
* Alternative: Use Amazon Cognito User Pool for simpler OIDC setup.
*/
private String constructOidcIssuer(String instanceArnOrId) {
// Extract instance ID from ARN or use as-is if already just the ID
String instanceId;
if (instanceArnOrId.contains("/")) {
// Full ARN format: arn:aws:sso:::instance/ssoins-xxxxxxxxxxxx
instanceId = instanceArnOrId.substring(instanceArnOrId.lastIndexOf('/') + 1);
} else {
// Just the instance ID: ssoins-xxxxxxxxxxxx
instanceId = instanceArnOrId;
}
// Construct issuer URL
// Note: AWS SSO uses a different URL pattern depending on the region
// This is a placeholder - actual OIDC URLs should come from SSO application registration
return "https://portal.sso." + region + ".amazonaws.com/saml/assertion/" + instanceId;
}
/**
* Extract AWS account ID from SSO instance ARN.
* Format: arn:aws:sso:::instance/ssoins-xxxxxxxxxxxx
* The account ID is typically in the ARN structure, but IAM Identity Center ARNs
* don't include account IDs in the standard format.
*
* @return The AWS account ID from CDK stack context
*/
@SuppressWarnings("unused")
private String extractAccountIdFromArn() {
// IAM Identity Center ARNs don't contain account IDs
// Use the account from the CDK stack instead
return Stack.of(this).getAccount();
}
}