diff --git a/datahub-frontend/app/auth/AuthUtils.java b/datahub-frontend/app/auth/AuthUtils.java index 490f52bece6517..f882fb906bb45b 100644 --- a/datahub-frontend/app/auth/AuthUtils.java +++ b/datahub-frontend/app/auth/AuthUtils.java @@ -81,6 +81,12 @@ public class AuthUtils { public static final String PREFERRED_JWS_ALGORITHM = "preferredJwsAlgorithm"; public static final String PREFERRED_JWS_ALGORITHM_2 = "preferredJwsAlgorithm2"; + // Private Key JWT (certificate-based) authentication constants + public static final String PRIVATE_KEY_FILE_PATH = "privateKeyFilePath"; + public static final String PUBLIC_KEY_FILE_PATH = "publicKeyFilePath"; + public static final String PRIVATE_KEY_PASSWORD = "privateKeyPassword"; + public static final String PRIVATE_KEY_JWT_ALGORITHM = "privateKeyJwtAlgorithm"; + /** * Determines whether the inbound request should be forward to downstream Metadata Service. Today, * this simply checks for the presence of an "Authorization" header or the presence of a valid diff --git a/datahub-frontend/app/auth/sso/oidc/OidcConfigs.java b/datahub-frontend/app/auth/sso/oidc/OidcConfigs.java index 37cfe167900e36..9c7a6b6b225e3d 100644 --- a/datahub-frontend/app/auth/sso/oidc/OidcConfigs.java +++ b/datahub-frontend/app/auth/sso/oidc/OidcConfigs.java @@ -47,6 +47,12 @@ public class OidcConfigs extends SsoConfigs { public static final String OIDC_HTTP_RETRY_ATTEMPTS = "auth.oidc.httpRetryAttempts"; public static final String OIDC_HTTP_RETRY_DELAY = "auth.oidc.httpRetryDelay"; + // Private Key JWT (certificate-based) authentication configs + public static final String OIDC_PRIVATE_KEY_FILE_PATH = "auth.oidc.privateKeyFilePath"; + public static final String OIDC_PUBLIC_KEY_FILE_PATH = "auth.oidc.publicKeyFilePath"; + public static final String OIDC_PRIVATE_KEY_PASSWORD = "auth.oidc.privateKeyPassword"; + public static final String OIDC_PRIVATE_KEY_JWT_ALGORITHM = "auth.oidc.privateKeyJwtAlgorithm"; + /** Default values */ private static final String DEFAULT_OIDC_USERNAME_CLAIM = "email"; @@ -64,6 +70,8 @@ public class OidcConfigs extends SsoConfigs { private static final String DEFAULT_OIDC_CONNECT_TIMEOUT = "1000"; private static final String DEFAULT_OIDC_HTTP_RETRY_ATTEMPTS = "3"; private static final String DEFAULT_OIDC_HTTP_RETRY_DELAY = "1000"; + private static final String DEFAULT_OIDC_PRIVATE_KEY_JWT_ALGORITHM = "RS256"; + private static final String PRIVATE_KEY_JWT_METHOD = "private_key_jwt"; private final String clientId; private final String clientSecret; @@ -90,6 +98,12 @@ public class OidcConfigs extends SsoConfigs { private final String httpRetryAttempts; private final String httpRetryDelay; + // Private Key JWT authentication fields + private final Optional privateKeyFilePath; + private final Optional publicKeyFilePath; + private final Optional privateKeyPassword; + private final String privateKeyJwtAlgorithm; + public OidcConfigs(Builder builder) { super(builder); this.clientId = builder.clientId; @@ -116,6 +130,10 @@ public OidcConfigs(Builder builder) { this.grantType = builder.grantType; this.httpRetryAttempts = builder.httpRetryAttempts; this.httpRetryDelay = builder.httpRetryDelay; + this.privateKeyFilePath = builder.privateKeyFilePath; + this.publicKeyFilePath = builder.publicKeyFilePath; + this.privateKeyPassword = builder.privateKeyPassword; + this.privateKeyJwtAlgorithm = builder.privateKeyJwtAlgorithm; } public String getHttpRetryAttempts() { @@ -154,12 +172,23 @@ public static class Builder extends SsoConfigs.Builder { private Optional acrValues = Optional.empty(); private String httpRetryAttempts = DEFAULT_OIDC_HTTP_RETRY_ATTEMPTS; private String httpRetryDelay = DEFAULT_OIDC_HTTP_RETRY_DELAY; + private Optional privateKeyFilePath = Optional.empty(); + private Optional publicKeyFilePath = Optional.empty(); + private Optional privateKeyPassword = Optional.empty(); + private String privateKeyJwtAlgorithm = DEFAULT_OIDC_PRIVATE_KEY_JWT_ALGORITHM; public Builder from(final com.typesafe.config.Config configs) { super.from(configs); clientId = getRequired(configs, OIDC_CLIENT_ID_CONFIG_PATH); - clientSecret = getRequired(configs, OIDC_CLIENT_SECRET_CONFIG_PATH); discoveryUri = getRequired(configs, OIDC_DISCOVERY_URI_CONFIG_PATH); + + clientAuthenticationMethod = + getOptional( + configs, + OIDC_CLIENT_AUTHENTICATION_METHOD_CONFIG_PATH, + DEFAULT_OIDC_CLIENT_AUTHENTICATION_METHOD); + + clientSecret = getOptional(configs, OIDC_CLIENT_SECRET_CONFIG_PATH, null); userNameClaim = getOptional(configs, OIDC_USERNAME_CLAIM_CONFIG_PATH, DEFAULT_OIDC_USERNAME_CLAIM); userNameClaimRegex = @@ -167,11 +196,6 @@ public Builder from(final com.typesafe.config.Config configs) { configs, OIDC_USERNAME_CLAIM_REGEX_CONFIG_PATH, DEFAULT_OIDC_USERNAME_CLAIM_REGEX); scope = getOptional(configs, OIDC_SCOPE_CONFIG_PATH, DEFAULT_OIDC_SCOPE); clientName = getOptional(configs, OIDC_CLIENT_NAME_CONFIG_PATH, DEFAULT_OIDC_CLIENT_NAME); - clientAuthenticationMethod = - getOptional( - configs, - OIDC_CLIENT_AUTHENTICATION_METHOD_CONFIG_PATH, - DEFAULT_OIDC_CLIENT_AUTHENTICATION_METHOD); jitProvisioningEnabled = Boolean.parseBoolean( getOptional( @@ -206,6 +230,15 @@ public Builder from(final com.typesafe.config.Config configs) { httpRetryAttempts = getOptional(configs, OIDC_HTTP_RETRY_ATTEMPTS, DEFAULT_OIDC_HTTP_RETRY_ATTEMPTS); httpRetryDelay = getOptional(configs, OIDC_HTTP_RETRY_DELAY, DEFAULT_OIDC_HTTP_RETRY_DELAY); + + // Private Key JWT authentication configs + privateKeyFilePath = getOptional(configs, OIDC_PRIVATE_KEY_FILE_PATH); + publicKeyFilePath = getOptional(configs, OIDC_PUBLIC_KEY_FILE_PATH); + privateKeyPassword = getOptional(configs, OIDC_PRIVATE_KEY_PASSWORD); + privateKeyJwtAlgorithm = + getOptional( + configs, OIDC_PRIVATE_KEY_JWT_ALGORITHM, DEFAULT_OIDC_PRIVATE_KEY_JWT_ALGORITHM); + return this; } @@ -276,16 +309,41 @@ public Builder from(final com.typesafe.config.Config configs, final String ssoSe grantType = Optional.ofNullable(getOptional(configs, OIDC_GRANT_TYPE, null)); acrValues = Optional.ofNullable(getOptional(configs, OIDC_ACR_VALUES, null)); + if (jsonNode.has(PRIVATE_KEY_FILE_PATH)) { + privateKeyFilePath = Optional.of(jsonNode.get(PRIVATE_KEY_FILE_PATH).asText()); + } + if (jsonNode.has(PUBLIC_KEY_FILE_PATH)) { + publicKeyFilePath = Optional.of(jsonNode.get(PUBLIC_KEY_FILE_PATH).asText()); + } + if (jsonNode.has(PRIVATE_KEY_PASSWORD)) { + privateKeyPassword = Optional.of(jsonNode.get(PRIVATE_KEY_PASSWORD).asText()); + } + if (jsonNode.has(PRIVATE_KEY_JWT_ALGORITHM)) { + privateKeyJwtAlgorithm = jsonNode.get(PRIVATE_KEY_JWT_ALGORITHM).asText(); + } + return this; } public OidcConfigs build() { Objects.requireNonNull(oidcEnabled, "oidcEnabled is required"); Objects.requireNonNull(clientId, "clientId is required"); - Objects.requireNonNull(clientSecret, "clientSecret is required"); Objects.requireNonNull(discoveryUri, "discoveryUri is required"); Objects.requireNonNull(authBaseUrl, "authBaseUrl is required"); + if (PRIVATE_KEY_JWT_METHOD.equals(clientAuthenticationMethod)) { + if (privateKeyFilePath.isEmpty()) { + throw new IllegalArgumentException( + "privateKeyFilePath is required when using private_key_jwt authentication"); + } + if (publicKeyFilePath.isEmpty()) { + throw new IllegalArgumentException( + "publicKeyFilePath is required when using private_key_jwt authentication"); + } + } else { + Objects.requireNonNull(clientSecret, "clientSecret is required"); + } + return new OidcConfigs(this); } } diff --git a/datahub-frontend/app/auth/sso/oidc/PrivateKeyJwtUtils.java b/datahub-frontend/app/auth/sso/oidc/PrivateKeyJwtUtils.java new file mode 100644 index 00000000000000..04d214c65bf390 --- /dev/null +++ b/datahub-frontend/app/auth/sso/oidc/PrivateKeyJwtUtils.java @@ -0,0 +1,155 @@ +package auth.sso.oidc; + +import java.io.FileReader; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.util.Base64; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMDecryptorProvider; +import org.bouncycastle.openssl.PEMEncryptedKeyPair; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; + +/** + * Utility class for loading private keys and certificates for private_key_jwt client + * authentication (RFC 7523). + */ +public final class PrivateKeyJwtUtils { + + static { + // Register BouncyCastle as a JCA security provider for encrypted key support + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private PrivateKeyJwtUtils() {} + + /** + * Loads an RSA private key from a PEM file. + * + *

Supports both encrypted (password-protected) and unencrypted PEM files. The PEM file can + * contain: + * + *

    + *
  • PKCS#8 format (BEGIN PRIVATE KEY / BEGIN ENCRYPTED PRIVATE KEY) + *
  • Traditional RSA format (BEGIN RSA PRIVATE KEY) + *
+ * + * @param filePath Path to the PEM file containing the private key + * @param password Password for encrypted keys, or null for unencrypted keys + * @return The RSA private key + * @throws IOException If the file cannot be read + * @throws IllegalArgumentException If the key format is invalid or unsupported + */ + public static RSAPrivateKey loadPrivateKey(@Nonnull String filePath, @Nullable String password) + throws IOException { + try (PEMParser pemParser = new PEMParser(new FileReader(filePath))) { + Object pemObject = pemParser.readObject(); + + if (pemObject == null) { + throw new IllegalArgumentException( + "No PEM object found in file: " + filePath); + } + + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + PrivateKeyInfo privateKeyInfo; + + if (pemObject instanceof PEMEncryptedKeyPair) { + // Encrypted traditional format (e.g., BEGIN RSA PRIVATE KEY with encryption) + if (password == null || password.isEmpty()) { + throw new IllegalArgumentException( + "Private key is encrypted but no password was provided"); + } + PEMDecryptorProvider decryptor = + new JcePEMDecryptorProviderBuilder().build(password.toCharArray()); + PEMKeyPair keyPair = ((PEMEncryptedKeyPair) pemObject).decryptKeyPair(decryptor); + privateKeyInfo = keyPair.getPrivateKeyInfo(); + + } else if (pemObject instanceof PEMKeyPair) { + // Unencrypted traditional format (BEGIN RSA PRIVATE KEY) + privateKeyInfo = ((PEMKeyPair) pemObject).getPrivateKeyInfo(); + + } else if (pemObject instanceof PrivateKeyInfo) { + // PKCS#8 format (BEGIN PRIVATE KEY) + privateKeyInfo = (PrivateKeyInfo) pemObject; + + } else if (pemObject instanceof org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo) { + // Encrypted PKCS#8 format (BEGIN ENCRYPTED PRIVATE KEY) + if (password == null || password.isEmpty()) { + throw new IllegalArgumentException( + "Private key is encrypted but no password was provided"); + } + org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo encryptedInfo = + (org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo) pemObject; + org.bouncycastle.operator.InputDecryptorProvider decryptorProvider = + new org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder() + .build(password.toCharArray()); + privateKeyInfo = encryptedInfo.decryptPrivateKeyInfo(decryptorProvider); + + } else { + throw new IllegalArgumentException( + "Unsupported PEM object type: " + pemObject.getClass().getName()); + } + + return (RSAPrivateKey) converter.getPrivateKey(privateKeyInfo); + + } catch (org.bouncycastle.operator.OperatorCreationException + | org.bouncycastle.pkcs.PKCSException e) { + throw new IllegalArgumentException("Failed to decrypt private key: " + e.getMessage(), e); + } + } + + /** + * Loads an X.509 certificate from a PEM file. + * + * @param filePath Path to the PEM file containing the certificate + * @return The X.509 certificate + * @throws IOException If the file cannot be read + * @throws CertificateException If the certificate is invalid + */ + public static X509Certificate loadCertificate(@Nonnull String filePath) + throws IOException, CertificateException { + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + try (var inputStream = Files.newInputStream(Path.of(filePath))) { + return (X509Certificate) certFactory.generateCertificate(inputStream); + } + } + + /** + * Computes the SHA-256 thumbprint of an X.509 certificate, Base64URL encoded. + * + *

This is used as the key ID (kid) in JWT headers for private_key_jwt authentication. Azure AD + * and other IdPs use this format to identify which certificate was used to sign the JWT. + * + * @param certificate The X.509 certificate + * @return The SHA-256 thumbprint, Base64URL encoded (no padding) + * @throws CertificateEncodingException If the certificate cannot be encoded + */ + public static String computeThumbprint(@Nonnull X509Certificate certificate) + throws CertificateEncodingException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(certificate.getEncoded()); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is always available in Java + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } + +} diff --git a/datahub-frontend/app/auth/sso/oidc/custom/CustomOidcAuthenticator.java b/datahub-frontend/app/auth/sso/oidc/custom/CustomOidcAuthenticator.java index fa92ae30772655..8c68308aa2105f 100644 --- a/datahub-frontend/app/auth/sso/oidc/custom/CustomOidcAuthenticator.java +++ b/datahub-frontend/app/auth/sso/oidc/custom/CustomOidcAuthenticator.java @@ -1,6 +1,8 @@ package auth.sso.oidc.custom; import auth.sso.oidc.OidcConfigs; +import auth.sso.oidc.PrivateKeyJwtUtils; +import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.oauth2.sdk.AuthorizationCode; import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant; import com.nimbusds.oauth2.sdk.ParseException; @@ -11,6 +13,7 @@ import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; import com.nimbusds.oauth2.sdk.auth.ClientSecretPost; +import com.nimbusds.oauth2.sdk.auth.PrivateKeyJWT; import com.nimbusds.oauth2.sdk.auth.Secret; import com.nimbusds.oauth2.sdk.http.HTTPRequest; import com.nimbusds.oauth2.sdk.http.HTTPResponse; @@ -23,6 +26,8 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -48,6 +53,7 @@ public class CustomOidcAuthenticator extends OidcAuthenticator { Arrays.asList( ClientAuthenticationMethod.CLIENT_SECRET_POST, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, + ClientAuthenticationMethod.PRIVATE_KEY_JWT, ClientAuthenticationMethod.NONE); private final ClientAuthentication clientAuthentication; @@ -107,6 +113,10 @@ public CustomOidcAuthenticator(final OidcClient client, final OidcConfigs oidcCo } else if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(chosenMethod)) { final Secret secret = new Secret(configuration.getSecret()); clientAuthentication = new ClientSecretBasic(clientID, secret); + } else if (ClientAuthenticationMethod.PRIVATE_KEY_JWT.equals(chosenMethod)) { + clientAuthentication = + createPrivateKeyJwtAuthentication( + clientID, providerMetadata.getTokenEndpointURI(), oidcConfigs); } else if (ClientAuthenticationMethod.NONE.equals(chosenMethod)) { clientAuthentication = null; // No client authentication in none mode } else { @@ -153,6 +163,53 @@ private static ClientAuthenticationMethod firstSupportedMethod( } } + /** + * Creates a PrivateKeyJWT client authentication for certificate-based SSO. + * + * @param clientID The OIDC client ID + * @param tokenEndpoint The token endpoint URI from provider metadata + * @param oidcConfigs The OIDC configuration containing key file paths + * @return A PrivateKeyJWT client authentication instance + * @throws TechnicalException if key loading or JWT creation fails + */ + private ClientAuthentication createPrivateKeyJwtAuthentication( + ClientID clientID, URI tokenEndpoint, OidcConfigs oidcConfigs) { + try { + String privateKeyPath = + oidcConfigs + .getPrivateKeyFilePath() + .orElseThrow( + () -> + new IllegalArgumentException( + "privateKeyFilePath is required for private_key_jwt authentication")); + + PrivateKey privateKey = + PrivateKeyJwtUtils.loadPrivateKey( + privateKeyPath, oidcConfigs.getPrivateKeyPassword().orElse(null)); + + String publicKeyPath = + oidcConfigs + .getPublicKeyFilePath() + .orElseThrow( + () -> + new IllegalArgumentException( + "publicKeyFilePath is required for private_key_jwt authentication")); + + X509Certificate certificate = PrivateKeyJwtUtils.loadCertificate(publicKeyPath); + String keyId = PrivateKeyJwtUtils.computeThumbprint(certificate); + + JWSAlgorithm algorithm = JWSAlgorithm.parse(oidcConfigs.getPrivateKeyJwtAlgorithm()); + + logger.info( + "Creating PrivateKeyJWT authentication with algorithm: {}, keyId: {}", algorithm, keyId); + + return new PrivateKeyJWT(clientID, tokenEndpoint, algorithm, privateKey, keyId, null); + } catch (Exception e) { + throw new TechnicalException( + "Failed to create PrivateKeyJWT client authentication: " + e.getMessage(), e); + } + } + @Override public Optional validate(CallContext ctx, Credentials cred) { OidcCredentials credentials = (OidcCredentials) cred; diff --git a/datahub-frontend/gradle.lockfile b/datahub-frontend/gradle.lockfile index 87a286a27df31d..2de413d8cf1147 100644 --- a/datahub-frontend/gradle.lockfile +++ b/datahub-frontend/gradle.lockfile @@ -352,9 +352,9 @@ org.apache.zookeeper:zookeeper:3.8.4=compileClasspath,runtimeClasspath,testCompi org.apiguardian:apiguardian-api:1.1.0=testCompileClasspath org.atteo.classindex:classindex:3.4=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.2.0=testCompileClasspath,testRuntimeClasspath -org.bouncycastle:bcpkix-jdk18on:1.78.1=testRuntimeClasspath -org.bouncycastle:bcprov-jdk18on:1.83=testRuntimeClasspath -org.bouncycastle:bcutil-jdk18on:1.78.1=testRuntimeClasspath +org.bouncycastle:bcpkix-jdk18on:1.83=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.bouncycastle:bcprov-jdk18on:1.83=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.bouncycastle:bcutil-jdk18on:1.83=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.brotli:dec:0.1.2=testCompileClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.37.0=compileClasspath,runtimeClasspath,spotless865458226,testCompileClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.8.0=play diff --git a/datahub-frontend/play.gradle b/datahub-frontend/play.gradle index 9d33bf04805f1b..964b4c7f82598d 100644 --- a/datahub-frontend/play.gradle +++ b/datahub-frontend/play.gradle @@ -87,6 +87,10 @@ dependencies { implementation externalDependency.azureIdentityExtensions implementation externalDependency.azureIdentity + // BouncyCastle for private_key_jwt certificate authentication (PEM parsing) + implementation 'org.bouncycastle:bcprov-jdk18on:1.83' + implementation 'org.bouncycastle:bcpkix-jdk18on:1.83' + testImplementation 'org.seleniumhq.selenium:htmlunit-driver:2.67.0' testImplementation externalDependency.mockito testImplementation externalDependency.mockitoInline diff --git a/datahub-frontend/test/auth/sso/oidc/PrivateKeyJwtUtilsTest.java b/datahub-frontend/test/auth/sso/oidc/PrivateKeyJwtUtilsTest.java new file mode 100644 index 00000000000000..dde87abb1242b3 --- /dev/null +++ b/datahub-frontend/test/auth/sso/oidc/PrivateKeyJwtUtilsTest.java @@ -0,0 +1,145 @@ +package auth.sso.oidc; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class PrivateKeyJwtUtilsTest { + + private static final String TEST_RESOURCES_PATH = "test/resources/"; + + @Test + void testLoadPrivateKey_ValidUnencryptedPem() throws IOException { + String keyPath = TEST_RESOURCES_PATH + "test-private-key.pem"; + + RSAPrivateKey privateKey = PrivateKeyJwtUtils.loadPrivateKey(keyPath, null); + + assertNotNull(privateKey); + assertEquals("RSA", privateKey.getAlgorithm()); + assertNotNull(privateKey.getModulus()); + assertTrue(privateKey.getModulus().bitLength() >= 2048); + } + + @Test + void testLoadPrivateKey_ValidEncryptedPem() throws IOException { + String keyPath = TEST_RESOURCES_PATH + "test-private-key-encrypted.pem"; + + RSAPrivateKey privateKey = PrivateKeyJwtUtils.loadPrivateKey(keyPath, "testpassword"); + + assertNotNull(privateKey); + assertEquals("RSA", privateKey.getAlgorithm()); + assertNotNull(privateKey.getModulus()); + } + + @Test + void testLoadPrivateKey_EncryptedPemWithoutPassword() { + String keyPath = TEST_RESOURCES_PATH + "test-private-key-encrypted.pem"; + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> PrivateKeyJwtUtils.loadPrivateKey(keyPath, null)); + + assertTrue(exception.getMessage().contains("encrypted")); + } + + @Test + void testLoadPrivateKey_EncryptedPemWithWrongPassword() { + String keyPath = TEST_RESOURCES_PATH + "test-private-key-encrypted.pem"; + + assertThrows( + IllegalArgumentException.class, + () -> PrivateKeyJwtUtils.loadPrivateKey(keyPath, "wrongpassword")); + } + + @Test + void testLoadPrivateKey_InvalidPath() { + String keyPath = TEST_RESOURCES_PATH + "nonexistent-key.pem"; + + assertThrows(IOException.class, () -> PrivateKeyJwtUtils.loadPrivateKey(keyPath, null)); + } + + @Test + void testLoadPrivateKey_EmptyFile(@TempDir Path tempDir) throws IOException { + Path emptyFile = tempDir.resolve("empty.pem"); + Files.writeString(emptyFile, ""); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> PrivateKeyJwtUtils.loadPrivateKey(emptyFile.toString(), null)); + + assertTrue(exception.getMessage().contains("No PEM object found")); + } + + @Test + void testLoadCertificate_ValidPem() throws IOException, CertificateException { + String certPath = TEST_RESOURCES_PATH + "test-certificate.pem"; + + X509Certificate certificate = PrivateKeyJwtUtils.loadCertificate(certPath); + + assertNotNull(certificate); + assertEquals("X.509", certificate.getType()); + assertNotNull(certificate.getSubjectX500Principal()); + } + + @Test + void testLoadCertificate_InvalidPath() { + String certPath = TEST_RESOURCES_PATH + "nonexistent-cert.pem"; + + assertThrows(IOException.class, () -> PrivateKeyJwtUtils.loadCertificate(certPath)); + } + + @Test + void testComputeThumbprint() throws IOException, CertificateException { + String certPath = TEST_RESOURCES_PATH + "test-certificate.pem"; + + X509Certificate certificate = PrivateKeyJwtUtils.loadCertificate(certPath); + String thumbprint = PrivateKeyJwtUtils.computeThumbprint(certificate); + + assertNotNull(thumbprint); + // Base64URL encoded SHA-256 hash should be 43 characters (256 bits / 6 bits per char, no + // padding) + assertEquals(43, thumbprint.length()); + // Should be Base64URL safe (no + or /) + assertFalse(thumbprint.contains("+")); + assertFalse(thumbprint.contains("/")); + assertFalse(thumbprint.contains("=")); + } + + @Test + void testComputeThumbprint_Consistency() throws IOException, CertificateException { + String certPath = TEST_RESOURCES_PATH + "test-certificate.pem"; + + X509Certificate certificate = PrivateKeyJwtUtils.loadCertificate(certPath); + String thumbprint1 = PrivateKeyJwtUtils.computeThumbprint(certificate); + String thumbprint2 = PrivateKeyJwtUtils.computeThumbprint(certificate); + + assertEquals(thumbprint1, thumbprint2, "Thumbprint should be consistent for same certificate"); + } + + @Test + void testKeyAndCertificateMatch() throws Exception { + String keyPath = TEST_RESOURCES_PATH + "test-private-key.pem"; + String certPath = TEST_RESOURCES_PATH + "test-certificate.pem"; + + RSAPrivateKey privateKey = PrivateKeyJwtUtils.loadPrivateKey(keyPath, null); + X509Certificate certificate = PrivateKeyJwtUtils.loadCertificate(certPath); + + // The public key in the certificate should have the same modulus as the private key + java.security.interfaces.RSAPublicKey publicKey = + (java.security.interfaces.RSAPublicKey) certificate.getPublicKey(); + + assertEquals( + privateKey.getModulus(), + publicKey.getModulus(), + "Private key and certificate should form a matching key pair"); + } +} diff --git a/datahub-frontend/test/auth/sso/oidc/custom/CustomOidcAuthenticatorTest.java b/datahub-frontend/test/auth/sso/oidc/custom/CustomOidcAuthenticatorTest.java index 13481317ede967..356eac7f192fdd 100644 --- a/datahub-frontend/test/auth/sso/oidc/custom/CustomOidcAuthenticatorTest.java +++ b/datahub-frontend/test/auth/sso/oidc/custom/CustomOidcAuthenticatorTest.java @@ -4,7 +4,10 @@ import static org.mockito.Mockito.*; import auth.sso.oidc.OidcConfigs; +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod; import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import java.net.URI; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,6 +15,7 @@ import org.pac4j.core.context.WebContext; import org.pac4j.core.context.session.SessionStore; import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.exception.TechnicalException; import org.pac4j.oidc.client.OidcClient; import org.pac4j.oidc.config.OidcConfiguration; import org.pac4j.oidc.credentials.OidcCredentials; @@ -132,4 +136,76 @@ void testValidateWithNullCredentials() throws Exception { void testConstructorInitializesCorrectly() { assertNotNull(authenticator); } + + @Test + void testConstructor_PrivateKeyJwtMethod() throws Exception { + // Setup for private_key_jwt authentication + OidcConfiguration pkjConfiguration = mock(OidcConfiguration.class); + OidcConfigs pkjOidcConfigs = mock(OidcConfigs.class); + OidcClient pkjClient = mock(OidcClient.class); + OidcOpMetadataResolver pkjMetadataResolver = mock(OidcOpMetadataResolver.class); + OIDCProviderMetadata pkjProviderMetadata = mock(OIDCProviderMetadata.class); + + when(pkjClient.getConfiguration()).thenReturn(pkjConfiguration); + when(pkjOidcConfigs.getHttpRetryAttempts()).thenReturn("3"); + when(pkjOidcConfigs.getHttpRetryDelay()).thenReturn("100"); + + when(pkjConfiguration.getClientId()).thenReturn("test-client-id"); + when(pkjConfiguration.getOpMetadataResolver()).thenReturn(pkjMetadataResolver); + when(pkjMetadataResolver.load()).thenReturn(pkjProviderMetadata); + + // Configure provider metadata to support private_key_jwt + when(pkjProviderMetadata.getTokenEndpointAuthMethods()) + .thenReturn(List.of(ClientAuthenticationMethod.PRIVATE_KEY_JWT)); + when(pkjProviderMetadata.getTokenEndpointURI()).thenReturn(new URI("https://example.com/token")); + + // Configure private_key_jwt method with test key files + when(pkjConfiguration.getClientAuthenticationMethod()) + .thenReturn(ClientAuthenticationMethod.PRIVATE_KEY_JWT); + when(pkjOidcConfigs.getPrivateKeyFilePath()) + .thenReturn(Optional.of("test/resources/test-private-key.pem")); + when(pkjOidcConfigs.getPublicKeyFilePath()) + .thenReturn(Optional.of("test/resources/test-certificate.pem")); + when(pkjOidcConfigs.getPrivateKeyPassword()).thenReturn(Optional.empty()); + when(pkjOidcConfigs.getPrivateKeyJwtAlgorithm()).thenReturn("RS256"); + + // Create authenticator with private_key_jwt config + CustomOidcAuthenticator pkjAuthenticator = new CustomOidcAuthenticator(pkjClient, pkjOidcConfigs); + + assertNotNull(pkjAuthenticator); + } + + @Test + void testConstructor_PrivateKeyJwtMethod_MissingPrivateKeyPath() throws Exception { + // Setup for private_key_jwt with missing key path + OidcConfiguration pkjConfiguration = mock(OidcConfiguration.class); + OidcConfigs pkjOidcConfigs = mock(OidcConfigs.class); + OidcClient pkjClient = mock(OidcClient.class); + OidcOpMetadataResolver pkjMetadataResolver = mock(OidcOpMetadataResolver.class); + OIDCProviderMetadata pkjProviderMetadata = mock(OIDCProviderMetadata.class); + + when(pkjClient.getConfiguration()).thenReturn(pkjConfiguration); + when(pkjOidcConfigs.getHttpRetryAttempts()).thenReturn("3"); + when(pkjOidcConfigs.getHttpRetryDelay()).thenReturn("100"); + + when(pkjConfiguration.getClientId()).thenReturn("test-client-id"); + when(pkjConfiguration.getOpMetadataResolver()).thenReturn(pkjMetadataResolver); + when(pkjMetadataResolver.load()).thenReturn(pkjProviderMetadata); + + when(pkjProviderMetadata.getTokenEndpointAuthMethods()) + .thenReturn(List.of(ClientAuthenticationMethod.PRIVATE_KEY_JWT)); + when(pkjProviderMetadata.getTokenEndpointURI()).thenReturn(new URI("https://example.com/token")); + + when(pkjConfiguration.getClientAuthenticationMethod()) + .thenReturn(ClientAuthenticationMethod.PRIVATE_KEY_JWT); + // Missing private key path + when(pkjOidcConfigs.getPrivateKeyFilePath()).thenReturn(Optional.empty()); + when(pkjOidcConfigs.getPublicKeyFilePath()) + .thenReturn(Optional.of("test/resources/test-certificate.pem")); + + // Should throw TechnicalException because privateKeyFilePath is missing + assertThrows( + TechnicalException.class, + () -> new CustomOidcAuthenticator(pkjClient, pkjOidcConfigs)); + } } diff --git a/datahub-frontend/test/resources/test-certificate.pem b/datahub-frontend/test/resources/test-certificate.pem new file mode 100644 index 00000000000000..e7253426e3e542 --- /dev/null +++ b/datahub-frontend/test/resources/test-certificate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIC/zCCAeegAwIBAgIUbSCWMBe/4SRh5LCI/LEStaqlGLEwDQYJKoZIhvcNAQEL +BQAwDzENMAsGA1UEAwwEdGVzdDAeFw0yNTEyMDQwNzU0MDVaFw0yNjEyMDQwNzU0 +MDVaMA8xDTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC6BpgdGo/n6wS7xfq6nAZNFeasj9UsBrwXyaRP/zUOEW5C7PWoM5bsjI2M +aC22/EXQg6Tt5lO3daTEMDZQk7kPQxnXEv2YPSzavmbOUBlZhtpnJbe+ISdWkj6Y +ep+giotFqOkH/w2reqXohBN4M2pXiipYevZlC+KnPiviqMKP/3021298oFq9lpsd +qdMmcygtx60D7ukHZHPhiQ/V9I9IVarbNjBKzxlQoGo+EJYrE98tXK0n3iD1C81G +A0Yayma8O7z/5p1U76livDmWh998QT015AlGrZgQ1tOyacv/kQAyGjmJjGNKwQ/i +iTQtzCKLVRpRiAd/bZ/eVoVeWeJDAgMBAAGjUzBRMB0GA1UdDgQWBBSkGmC2QYCO +StP2Wr+9w8IPGsNYnTAfBgNVHSMEGDAWgBSkGmC2QYCOStP2Wr+9w8IPGsNYnTAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBML+rQa21ID3js4/2H +zX8BWbZoPL3Hamq+6v1684ZZIWz/e6dlceAgmMhXt3SOllrx8knhB6AbygZyZz54 +6Jh7VPC3up7p/jnP8Kst0OaMZgTJ9LTAdaNfEsUb61QcDqAkc+Pxr0ML6O24em3b +sWal/HZDDAd+HQTL9kg0MKYn9dLgSIz80wTp9S2VXaa6yPtkFnf83Aj3jTWctj5u +2H0ihPwAEJqWFAM9cuUznAkGO+hIt+/VLt5P41+BAmJGOBnAnBQRPVjREh+XcMNL +CZmtz1HuhUet1SAKtPPM1FL2sH3pCqlFrsG15l9olhBTLJmoS518glYKvNumZgiM +RMX0 +-----END CERTIFICATE----- diff --git a/datahub-frontend/test/resources/test-private-key-encrypted.pem b/datahub-frontend/test/resources/test-private-key-encrypted.pem new file mode 100644 index 00000000000000..5b1695150c7ec2 --- /dev/null +++ b/datahub-frontend/test/resources/test-private-key-encrypted.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQGK3MHTZ+RiT/gxjg +qyhhrgICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEO21i+W2QdFDvuHa +2FVOI6cEggTQrupdsRCBJFt0rLPeazRZI0moiTNIgeX8QwfXmKvwMzDF+DCOoG7h +lnPssA1I/uIpcbKMzAG1bilEnjLPwd+LfWPMokEAzh9exE8tBaThkPTZ78fyEZyh +Z6sF+1T08mksDuRg4HfBXebJ295P7p7G9OL4ReKlNBPmcx4250ihQmhrLgXzWOob +IS5mFd6VOnvf4Qd99pA5Z3DQ0a2cQBwvSraOacSITrF7ry8ts9qDm9noFbT6JP3T +Pwm4h3HT8Jqv2HkIry/tyNSevx+1BbnjcGe5aMQeUC1/VIkbT4TgHTS8F3JY7+P3 +0xWBT+l5odALClwvGBR6r1MLEykTFJ+WWkbUGCaMs2MsQ1/o5uqAQqo4pw6YlrRF +24myRD6zo8RgEjJ0C9FjkfqvoPsRyD2tpfjW6nOVReEPZJsbe6i2A5Cm3s6Op1yT +nSp3Lqsw3efAxOESTQ1/wAbrq5KZjOaoVviT9gxB4ioDVnRGcxdqy2YBgc5S0LFT +DO/+53n8PQvVekP3o0nzQMTHExC4MQliL61SNAGFGazbZ0NSq1LiTFt3674k4DF5 +3hDrHXZYjPAtags4P2amfpcg/rB8JKRUi2zYGTYBykWAK16l0CawdYC39rw1oOQF +KJ4Qqu4FtdIoQ8dN6t7wi1dr7y3Ct3Fg4cZw9EAMCRv20+EMsZ5yZElj5G3gKIwB +i2tZ6rzngMft1k7b4V30HSyjYuQ5E0t1pybhogbKY/5l9z6+Splsv9gWohdghP+d +nNkK2e0uz2+mprBL7nu5nhe4+ks+6IKlt5hbatoC6ialoml/0Y2rcugU1rT0j/Me +ep8X0kcDWCRMockA4GCYQ5OTqpUuMFjrKhirLRdOJogYFBkuJSlY2a6n3eZ3M5wp +jKuZmdqOYLn2SqsbSnxUnvX+B5MT2ad7ZEOiOD8fQQclk/YgdB6yTCw7csWneXPb +KOvoHbdnSAA7UHCoTeiRvMwodYtyD+fS8GuNMy7XXOTYYzfmpcHF1PAt7v0ycAhO +u1CbC6nIoK1QcUhZsDHZ9yzooKv5lkSdqG42+jyhqd1sgbno4YMTsdZr9tNguhH/ +ZpRFW9Kpq7wP3vvkAPvbefivysVilEmqROw0ZN4GRORY2mAa+EXdwnicGOvtLOyX +ow7+/rn+4vkvCuhmRm/xH5CXd/tlNqGjdhakeQPn0NRl//RYZsPznQ94XpY8Nea4 +taWUkoQSEu49Ntdn8GdYw5biBwWRv6697cyPAzyPHCXK9SYBdQw/EBX9a/dLu/yY +DAAblsJFaPAPF9F1szLsz9v//7fsq/sYLbRpuLhVtEh/PucMTynLZ75fM1wTmMC5 +HyI5t37aEhs+eJ/rwM1mZyPS7KYaI+CtusoX8knpllKYtR2SL/zvNmSwh+7RObxL +Ru/7sCcZ0qaviq01wujNPJ5/a21ZDWYSn1324YXga8AehJ6lK1xtAZr3oe1PDHQW +l723qlu+vO5hqBaKkgcBm91fZm/5CCJN9F39wbW5oBNKrNYTCijWUiZCjDxU89ld +/mWVUA1hmqkie2F+6Ih5RVfpqV3zw6Rb3RZanrYQIlk7CsNRU4W3JZ77QfTVxwH4 +XgA1JhiyCFsLM7reKxueOYsK4nogh26QX7BdssHLWsTn7/7L9Nkjd5Q= +-----END ENCRYPTED PRIVATE KEY----- diff --git a/datahub-frontend/test/resources/test-private-key.pem b/datahub-frontend/test/resources/test-private-key.pem new file mode 100644 index 00000000000000..372d833911c66d --- /dev/null +++ b/datahub-frontend/test/resources/test-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC6BpgdGo/n6wS7 +xfq6nAZNFeasj9UsBrwXyaRP/zUOEW5C7PWoM5bsjI2MaC22/EXQg6Tt5lO3daTE +MDZQk7kPQxnXEv2YPSzavmbOUBlZhtpnJbe+ISdWkj6Yep+giotFqOkH/w2reqXo +hBN4M2pXiipYevZlC+KnPiviqMKP/3021298oFq9lpsdqdMmcygtx60D7ukHZHPh +iQ/V9I9IVarbNjBKzxlQoGo+EJYrE98tXK0n3iD1C81GA0Yayma8O7z/5p1U76li +vDmWh998QT015AlGrZgQ1tOyacv/kQAyGjmJjGNKwQ/iiTQtzCKLVRpRiAd/bZ/e +VoVeWeJDAgMBAAECggEACnrMnvccHEiVH67rjQ+1GyqcsCP6Mly+w+hMJWWPK5e3 +ynVm8W7WlcTlRH+p86EKndCMvgo3tSIfgh3lMWzhBAcXsZIUTztDB5+qUv0YMH70 +VtM9IqzjdnEOrbBaxcSITAhT+GtZ7csfBZZLMn5TA1N5RzrGHrMotZs/BanULCBz +4g0iaQNY0oTZEWNZIjpvM34pjHCSeH0qxcKgVvTncR5Bx7gzod9yDCWk+Jc7C/So +UPkKNVrmptX+Mu2nfjHA5XG3+BKAqxZgo12EwUHWoOgumy7Yj2bx14FXNYVhallZ +s0lrfuB4Upd10ztl0OQ83kkDfyk7sdKotFGmiUK0oQKBgQDmHkIraNLavYUobAW6 +S6NZQvvjw8zA41QOfRKrP5AHpnTEuA9rPqreq3UOvhZdr5fgBdXURKSQTS2gR0AW +6nQna0dtFCEKdYH6QmQdztaT/Y08IV0EDui0bgv7mItv1hZmpnzzkMrvKZn/CfmO +nhsAQ6vwE/HpBBbZJhIfLH8bVwKBgQDO8srDgy2djEtxtPC//cniedFC1F9AMoL+ +2Ezr0+EshOpnzZaW3McK99fLrJ4p1sFRI4y6fVut72GSKphNtWGul3+5eDhZEoc4 +TARWqhzqyNb8Ib1magFEQ3A83EEsMredYllnyeKDuf219rVLZoAyeBFT1Pjv84xo +fU3ooLYI9QKBgQCTvmejcUi3cii1HC3NP1TgMXaB9KtYSFwmdTzwP/4cO04LI1Wa ++IBCOHkOfxLjEaMEDRsNjwjNMRATMMvAKsVqraENMnhIO8u4vrmCvdLc71Snf5A1 +09CqT2FYJseXhS+atlfRFWyFgwNBUMkuPOp/D/OrT1Lo1VxKDPxjkXEGPwKBgDMz +EkMosyJlUU0CbQWy7j2f0cvs6tvhI1OVhAJcWzs+bxfbX0RXvs7R3ou/WyoSvaUr +XGfPF92CeetFqoSfOVmOlcT3H0m6McYV2ITwdVMI+VdLqaYq18FhxeTowR8ENTU0 +3dW1ttrMKx7XuKa85z6VQgQxjp0e7j/zsFC7Yg35AoGAVCHESAP4CzNw7nuEYE4G +zxfQLAvdQGsO3vBq+YSbydFuKIUYfQA46tfX2l0VfN7sYsXYwjWXQjZ6wC+sSpNQ +3b71nxTXrZQjV5NaCL7bQK5k8jGyYqD3epLM99/mYUyEUOAWmnpHcOhbO5IS9F5s +1Vl7ftPccTjndGGvsojD3B8= +-----END PRIVATE KEY----- diff --git a/docs/authentication/guides/sso/configure-oidc-react.md b/docs/authentication/guides/sso/configure-oidc-react.md index cb6d43f348cabc..41bc53e71ec233 100644 --- a/docs/authentication/guides/sso/configure-oidc-react.md +++ b/docs/authentication/guides/sso/configure-oidc-react.md @@ -103,13 +103,37 @@ AUTH_OIDC_SCOPE=your-custom-scope AUTH_OIDC_CLIENT_AUTHENTICATION_METHOD=authentication-method ``` -| Configuration | Description | Default | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -| AUTH_OIDC_USER_NAME_CLAIM | The attribute that will contain the username used on the DataHub platform. By default, this is "email" providedas part of the standard `email` scope. | | -| AUTH_OIDC_USER_NAME_CLAIM_REGEX | A regex string used for extracting the username from the userNameClaim attribute. For example, if the userNameClaim field will contain an email address, and we want to omit the domain name suffix of the email, we can specify a customregex to do so. (e.g. `([^@]+)`) | | -| AUTH_OIDC_SCOPE | A string representing the scopes to be requested from the identity provider, granted by the end user. For more info, see [OpenID Connect Scopes](https://auth0.com/docs/scopes/openid-connect-scopes). | | -| AUTH_OIDC_CLIENT_AUTHENTICATION_METHOD | a string representing the token authentication method to use with the identity provider. Default value is `client_secret_basic`, which uses HTTP Basic authentication. Another option is `client_secret_post`, which includes the client_id and secret_id as form parameters in the HTTP POST request. For more info, see [OAuth 2.0 Client Authentication](https://darutk.medium.com/oauth-2-0-client-authentication-4b5f929305d4) | client_secret_basic | -| AUTH_OIDC_PREFERRED_JWS_ALGORITHM | Can be used to select a preferred signing algorithm for id tokens. Examples include: `RS256` or `HS256`. If your IdP includes `none` before `RS256`/`HS256` in the list of signing algorithms, then this value **MUST** be set. | | +| Configuration | Description | Default | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| AUTH_OIDC_USER_NAME_CLAIM | The attribute that will contain the username used on the DataHub platform. By default, this is "email" providedas part of the standard `email` scope. | | +| AUTH_OIDC_USER_NAME_CLAIM_REGEX | A regex string used for extracting the username from the userNameClaim attribute. For example, if the userNameClaim field will contain an email address, and we want to omit the domain name suffix of the email, we can specify a customregex to do so. (e.g. `([^@]+)`) | | +| AUTH_OIDC_SCOPE | A string representing the scopes to be requested from the identity provider, granted by the end user. For more info, see [OpenID Connect Scopes](https://auth0.com/docs/scopes/openid-connect-scopes). | | +| AUTH_OIDC_CLIENT_AUTHENTICATION_METHOD | a string representing the token authentication method to use with the identity provider. Default value is `client_secret_basic`, which uses HTTP Basic authentication. Another option is `client_secret_post`, which includes the client_id and secret_id as form parameters in the HTTP POST request. A third option is `private_key_jwt`, which uses certificate-based authentication (see below). For more info, see [OAuth 2.0 Client Authentication](https://darutk.medium.com/oauth-2-0-client-authentication-4b5f929305d4) | client_secret_basic | +| AUTH_OIDC_PREFERRED_JWS_ALGORITHM | Can be used to select a preferred signing algorithm for id tokens. Examples include: `RS256` or `HS256`. If your IdP includes `none` before `RS256`/`HS256` in the list of signing algorithms, then this value **MUST** be set. | | + +### Certificate-Based Authentication (private_key_jwt) + +For identity providers that support certificate-based authentication (e.g., Azure AD with certificate credentials), you can use the `private_key_jwt` authentication method instead of a client secret. This is useful for environments where certificate-based auth is preferred or required. + +``` +# Certificate-based authentication (alternative to client secret) +AUTH_OIDC_CLIENT_AUTHENTICATION_METHOD=private_key_jwt +AUTH_OIDC_PRIVATE_KEY_FILE_PATH=/path/to/private-key.pem +AUTH_OIDC_PUBLIC_KEY_FILE_PATH=/path/to/certificate.pem +AUTH_OIDC_PRIVATE_KEY_PASSWORD=optional-password-if-key-is-encrypted +AUTH_OIDC_PRIVATE_KEY_JWT_ALGORITHM=RS256 +``` + +| Configuration | Description | Default | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| AUTH_OIDC_PRIVATE_KEY_FILE_PATH | Path to the PEM file containing the private key. Required when using `private_key_jwt`. | | +| AUTH_OIDC_PUBLIC_KEY_FILE_PATH | Path to the PEM file containing the X.509 certificate. The certificate thumbprint is used as the key ID (kid) in the JWT. Required when using `private_key_jwt`. | | +| AUTH_OIDC_PRIVATE_KEY_PASSWORD | Password for the private key file, if it is encrypted. Leave empty for unencrypted keys. | | +| AUTH_OIDC_PRIVATE_KEY_JWT_ALGORITHM | The signing algorithm to use for the client assertion JWT. Supported values: `RS256`, `RS384`, `RS512`. | RS256 | + +:::note +When using `private_key_jwt`, the `AUTH_OIDC_CLIENT_SECRET` configuration is optional and can be omitted. +::: ### User & Group Provisioning (JIT Provisioning)