diff --git a/docs/changelog/137230.yaml b/docs/changelog/137230.yaml new file mode 100644 index 0000000000000..fe9289e609962 --- /dev/null +++ b/docs/changelog/137230.yaml @@ -0,0 +1,5 @@ +pr: 137230 +summary: Principal Extraction from Certificate RDN Attribute Value in PKI Realm +area: Security +type: bug +issues: [] diff --git a/docs/reference/elasticsearch/configuration-reference/security-settings.md b/docs/reference/elasticsearch/configuration-reference/security-settings.md index cb1a4e65f0436..5fe852d306002 100644 --- a/docs/reference/elasticsearch/configuration-reference/security-settings.md +++ b/docs/reference/elasticsearch/configuration-reference/security-settings.md @@ -769,6 +769,18 @@ In addition to the [settings that are valid for all realms](#ref-realm-settings) `username_pattern` : ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The regular expression pattern used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The first match group is the used as the username. Defaults to `CN=(.*?)(?:,|$)`. + This setting is ignored if either `username_rdn_oid` or `username_rdn_name` is set. + +`username_rdn_oid` +: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The relative distinguished name (RDN) attribute OID used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The value of the most specific RDN matching this attribute OID is used as the username. + + This setting takes precedent over `username_pattern`. You cannot use this setting and `username_rdn_name` at the same time. + +`username_rdn_name` +: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The relative distinguished name (RDN) attribute name used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The value of the most specific RDN matching this attribute name is used as the username. + + This setting takes precedent over `username_pattern`. You cannot use this setting and `username_rdn_oid` at the same time. + `certificate_authorities` : ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) List of paths to the PEM certificate files that should be used to authenticate a user’s certificate as trusted. Defaults to the trusted certificates configured for SSL. This setting cannot be used with `truststore.path`. diff --git a/libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/DerParser.java b/libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/DerParser.java index f0b6d737c713b..ad540ba336675 100644 --- a/libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/DerParser.java +++ b/libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/DerParser.java @@ -36,21 +36,22 @@ public final class DerParser { private static final int CONSTRUCTED = 0x20; // Tag and data types - static final class Type { - static final int INTEGER = 0x02; - static final int OCTET_STRING = 0x04; - static final int OBJECT_OID = 0x06; - static final int SEQUENCE = 0x10; - static final int NUMERIC_STRING = 0x12; - static final int PRINTABLE_STRING = 0x13; - static final int VIDEOTEX_STRING = 0x15; - static final int IA5_STRING = 0x16; - static final int GRAPHIC_STRING = 0x19; - static final int ISO646_STRING = 0x1A; - static final int GENERAL_STRING = 0x1B; - static final int UTF8_STRING = 0x0C; - static final int UNIVERSAL_STRING = 0x1C; - static final int BMP_STRING = 0x1E; + public static final class Type { + public static final int INTEGER = 0x02; + public static final int OCTET_STRING = 0x04; + public static final int OBJECT_OID = 0x06; + public static final int SEQUENCE = 0x10; + public static final int SET = 0x11; + public static final int NUMERIC_STRING = 0x12; + public static final int PRINTABLE_STRING = 0x13; + public static final int VIDEOTEX_STRING = 0x15; + public static final int IA5_STRING = 0x16; + public static final int GRAPHIC_STRING = 0x19; + public static final int ISO646_STRING = 0x1A; + public static final int GENERAL_STRING = 0x1B; + public static final int UTF8_STRING = 0x0C; + public static final int UNIVERSAL_STRING = 0x1C; + public static final int BMP_STRING = 0x1E; } private InputStream derInputStream; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java index 0c9555cfcada1..60dab4d521463 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java @@ -6,6 +6,10 @@ */ package org.elasticsearch.xpack.core.security.authc.pki; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldap.sdk.schema.AttributeTypeDefinition; +import com.unboundid.ldap.sdk.schema.Schema; + import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.core.TimeValue; @@ -29,6 +33,33 @@ public final class PkiRealmSettings { key -> new Setting<>(key, DEFAULT_USERNAME_PATTERN, s -> Pattern.compile(s, Pattern.CASE_INSENSITIVE), Setting.Property.NodeScope) ); + public static final Setting.AffixSetting USERNAME_RDN_OID_SETTING = Setting.affixKeySetting( + RealmSettings.realmSettingPrefix(TYPE), + "username_rdn_oid", + key -> Setting.simpleString(key, Setting.Property.NodeScope) + ); + + public static final Setting.AffixSetting USERNAME_RDN_NAME_SETTING = Setting.affixKeySetting( + RealmSettings.realmSettingPrefix(TYPE), + "username_rdn_name", + key -> new Setting<>(key, (String) null, s -> { + if (s == null) { + return ""; + } + Schema schema; + try { + schema = Schema.getDefaultStandardSchema(); + } catch (LDAPException e) { + throw new IllegalStateException("Unexpected error occurred obtaining default LDAP schema", e); + } + AttributeTypeDefinition atd = schema.getAttributeType(s); + if (atd == null) { + throw new IllegalArgumentException("Unknown RDN name [" + s + "] for setting [" + key + "]"); + } + return atd.getOID(); + }, Setting.Property.NodeScope) + ); + private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20); public static final Setting.AffixSetting CACHE_TTL_SETTING = Setting.affixKeySetting( RealmSettings.realmSettingPrefix(TYPE), @@ -75,6 +106,8 @@ private PkiRealmSettings() {} public static Set> getSettings() { Set> settings = new HashSet<>(); settings.add(USERNAME_PATTERN_SETTING); + settings.add(USERNAME_RDN_OID_SETTING); + settings.add(USERNAME_RDN_NAME_SETTING); settings.add(CACHE_TTL_SETTING); settings.add(CACHE_MAX_USERS_SETTING); settings.add(DELEGATION_ENABLED_SETTING); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java index 51d8323ef068b..b41774fd07f08 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java @@ -12,6 +12,7 @@ import org.elasticsearch.common.cache.Cache; import org.elasticsearch.common.cache.CacheBuilder; import org.elasticsearch.common.hash.MessageDigests; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslConfiguration; import org.elasticsearch.common.ssl.SslTrustConfig; import org.elasticsearch.common.util.concurrent.ReleasableLock; @@ -51,6 +52,7 @@ import javax.net.ssl.X509ExtendedTrustManager; import javax.net.ssl.X509TrustManager; +import javax.security.auth.x500.X500Principal; import static org.elasticsearch.core.Strings.format; @@ -76,6 +78,7 @@ public class PkiRealm extends Realm implements CachingRealm { private final X509TrustManager trustManager; private final Pattern principalPattern; + private final String principalRdnOid; private final UserRoleMapper roleMapper; private final Cache cache; private DelegatedAuthorizationSupport delegatedRealms; @@ -91,6 +94,18 @@ public PkiRealm(RealmConfig config, ResourceWatcherService watcherService, UserR this.delegationEnabled = config.getSetting(PkiRealmSettings.DELEGATION_ENABLED_SETTING); this.trustManager = trustManagers(config); this.principalPattern = config.getSetting(PkiRealmSettings.USERNAME_PATTERN_SETTING); + String rdnOid = config.getSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING); + String rdnOidFromName = config.getSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING); + if (false == rdnOid.isEmpty() && false == rdnOidFromName.isEmpty()) { + throw new SettingsException( + "Both [" + + config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING).getKey() + + "] and [" + + config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING).getKey() + + "] are set. Only one of these settings can be configured." + ); + } + this.principalRdnOid = false == rdnOid.isEmpty() ? rdnOid : (false == rdnOidFromName.isEmpty() ? rdnOidFromName : null); this.roleMapper = roleMapper; this.roleMapper.clearRealmCacheOnChange(this); this.cache = CacheBuilder.builder() @@ -133,7 +148,7 @@ public X509AuthenticationToken token(ThreadContext context) { // validation). In this case the principal should be set by the realm that completes the authentication. But in the common case, // where a single PKI realm is configured, there is no risk of eagerly parsing the principal before authentication and it also // maintains BWC. - String parsedPrincipal = getPrincipalFromSubjectDN(principalPattern, token, logger); + String parsedPrincipal = getPrincipalFromToken(token); if (parsedPrincipal == null) { return null; } @@ -164,7 +179,7 @@ public void authenticate(AuthenticationToken authToken, ActionListener format( @@ -231,6 +246,24 @@ public void lookupUser(String username, ActionListener listener) { listener.onResponse(null); } + String getPrincipalFromToken(X509AuthenticationToken token) { + return principalRdnOid != null + ? getPrincipalFromRdnAttribute(principalRdnOid, token, logger) + : getPrincipalFromSubjectDN(principalPattern, token, logger); + } + + static String getPrincipalFromRdnAttribute(String principalRdnOid, X509AuthenticationToken token, Logger logger) { + X500Principal certPrincipal = token.credentials()[0].getSubjectX500Principal(); + String principal = RdnFieldExtractor.extract(certPrincipal.getEncoded(), principalRdnOid); + if (principal == null) { + logger.debug( + () -> format("the extracted principal from DN [%s] using RDN OID [%s] is empty", certPrincipal.toString(), principalRdnOid) + ); + return null; + } + return principal; + } + static String getPrincipalFromSubjectDN(Pattern principalPattern, X509AuthenticationToken token, Logger logger) { String dn = token.credentials()[0].getSubjectX500Principal().toString(); Matcher matcher = principalPattern.matcher(dn); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractor.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractor.java new file mode 100644 index 0000000000000..d0481cf30f443 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractor.java @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.pki; + +import org.elasticsearch.common.ssl.DerParser; + +import java.io.IOException; + +/** + * Utility class to extract RDN field values from X500 principal DER encoding. + */ +public class RdnFieldExtractor { + + public static String extract(byte[] encoded, String oid) { + try { + return doExtract(encoded, oid); + } catch (IOException | IllegalStateException e) { + return null; // invalid encoding + } + } + + private static String doExtract(byte[] encoded, String oid) throws IOException { + DerParser parser = new DerParser(encoded); + + DerParser.Asn1Object dnSequence = parser.readAsn1Object(DerParser.Type.SEQUENCE); + DerParser sequenceParser = dnSequence.getParser(); + + String value = null; + + while (true) { + try { + DerParser.Asn1Object rdnSet = sequenceParser.readAsn1Object(DerParser.Type.SET); // throws IOException on EOF + DerParser setParser = rdnSet.getParser(); + + while (true) { + try { + DerParser.Asn1Object attrSeq = setParser.readAsn1Object(DerParser.Type.SEQUENCE); // throws IOException on EOF + DerParser attrParser = attrSeq.getParser(); + + String attrOid = attrParser.readAsn1Object().getOid(); + DerParser.Asn1Object attrValue = attrParser.readAsn1Object(); + if (oid.equals(attrOid)) { + value = attrValue.getString(); // retain last (most-significant) occurrence + } + } catch (IOException e) { + break; // RDN SET EOF + } + } + } catch (IOException e) { + break; // DN SEQUENCE EOF + } + } + + return value; + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/PkiRealmTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/PkiRealmTests.java index eef5b0b105255..ec4d104166060 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/PkiRealmTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/PkiRealmTests.java @@ -13,6 +13,7 @@ import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.common.ssl.SslConfigException; import org.elasticsearch.common.util.CollectionUtils; import org.elasticsearch.common.util.concurrent.ThreadContext; @@ -230,7 +231,7 @@ private AuthenticationResult authenticate(X509AuthenticationToken token, P } public void testCustomUsernamePatternMatches() throws Exception { - final Settings settings = Settings.builder() + Settings settings = Settings.builder() .put(globalSettings) .put("xpack.security.authc.realms.pki.my_pki.username_pattern", "OU=(.*?),") .build(); @@ -249,6 +250,74 @@ public void testCustomUsernamePatternMatches() throws Exception { assertThat(user.roles().length, is(0)); } + public void testRdnOidMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.11") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + User user = authenticate(token, realm).getValue(); + assertThat(user, is(notNullValue())); + assertThat(user.principal(), is("elasticsearch")); + } + + public void testRdnOidNameMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "OU") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + User user = authenticate(token, realm).getValue(); + assertThat(user, is(notNullValue())); + assertThat(user.principal(), is("elasticsearch")); + } + + public void testRdnOidNameNotMatches() throws Exception { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID") + .build(); + ThreadContext threadContext = new ThreadContext(settings); + X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt")); + UserRoleMapper roleMapper = buildRoleMapper(); + PkiRealm realm = buildRealm(roleMapper, settings); + threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate }); + + X509AuthenticationToken token = realm.token(threadContext); + assertThat(token, is(nullValue())); + } + + public void testRdnOidNameUnknown() { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UNKNOWN_OID_NAME") + .build(); + UserRoleMapper roleMapper = buildRoleMapper(); + assertThrows(IllegalArgumentException.class, () -> buildRealm(roleMapper, settings)); + } + + public void testRedundantRdnOidSettings() { + Settings settings = Settings.builder() + .put(globalSettings) + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.3") + .put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID") + .build(); + UserRoleMapper roleMapper = buildRoleMapper(); + assertThrows(SettingsException.class, () -> buildRealm(roleMapper, settings)); + } + public void testCustomUsernamePatternMismatchesAndNullToken() throws Exception { final Settings settings = Settings.builder() .put(globalSettings) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractorTests.java new file mode 100644 index 0000000000000..b563f6057421d --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/RdnFieldExtractorTests.java @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authc.pki; + +import org.elasticsearch.test.ESTestCase; + +import java.util.List; +import java.util.Map; + +import javax.security.auth.x500.X500Principal; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +public class RdnFieldExtractorTests extends ESTestCase { + + private static final String OID_CN = "2.5.4.3"; // Common Name + private static final String OID_OU = "2.5.4.11"; // Organizational Unit + private static final String OID_O = "2.5.4.10"; // Organization + private static final String OID_C = "2.5.4.6"; // Country + private static final String OID_ST = "2.5.4.8"; // State or Province + private static final String OID_L = "2.5.4.7"; // Locality + private static final String OID_EMAIL = "1.2.840.113549.1.9.1"; // Email Address + + // Custom/domain-specific OID (fictional private enterprise OID) + // Format: 1.3.6.1.4.1.. + private static final String OID_EMPLOYEE_ID = "1.3.6.1.4.1.50000.1.1"; // Fictional: Employee ID + + private record ExtractionTestCase(String dn, String oid, String expectedValue) {} + + private static String extractFromDN(String dn, String oid) { + X500Principal principal = new X500Principal(dn); + return RdnFieldExtractor.extract(principal.getEncoded(), oid); + } + + private void assertExtractions(String dn, Map expectedExtractions) { + byte[] encoded = new X500Principal(dn).getEncoded(); + for (Map.Entry entry : expectedExtractions.entrySet()) { + String oid = entry.getKey(); + String expected = entry.getValue(); + String actual = RdnFieldExtractor.extract(encoded, oid); + assertThat("OID " + oid + " extraction failed for DN: " + dn, actual, is(equalTo(expected))); + } + } + + public void testExtractBasicAttributes() { + List testCases = List.of( + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_CN, "John Doe"), + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_OU, "Engineering"), + new ExtractionTestCase("CN=John Doe, OU=Engineering, O=Elastic", OID_O, "Elastic"), + new ExtractionTestCase("CN=John Doe, C=US", OID_C, "US"), + new ExtractionTestCase("CN=John Doe, ST=California, C=US", OID_ST, "California"), + new ExtractionTestCase("CN=John Doe, L=Mountain View, ST=California, C=US", OID_L, "Mountain View"), + new ExtractionTestCase("EMAILADDRESS=john@elastic.co, CN=John Doe", OID_EMAIL, "john@elastic.co") + ); + + for (ExtractionTestCase testCase : testCases) { + String result = extractFromDN(testCase.dn, testCase.oid); + assertThat("Failed to extract from DN: " + testCase.dn, result, is(equalTo(testCase.expectedValue))); + } + } + + public void testExtractFirstOUWhenMultipleExist() { + // When multiple RDNs with the same OID exist, should return the last one encountered in DER encoding + // Note: X.500 encoding reverses the order - the last attribute in the DN string is first in DER encoding + String ou = extractFromDN("CN=John Doe, OU=Security Team, OU=Engineering, O=Elastic", OID_OU); + assertThat(ou, is(equalTo("Security Team"))); + } + + public void testExtractOidNotFound() { + assertThat(extractFromDN("CN=John Doe, OU=Engineering", OID_C), is(nullValue())); + } + + public void testExtractWithEmptyEncoding() { + assertThat(RdnFieldExtractor.extract(new byte[0], OID_CN), is(nullValue())); + } + + public void testExtractWithMalformedDerData() { + byte[] malformedBytes = randomByteArrayOfLength(50); + + String result = RdnFieldExtractor.extract(malformedBytes, OID_CN); + assertThat(result, is(nullValue())); + } + + public void testExtractWithSpecialCharacters() { + assertExtractions("CN=Test\\, User, OU=R\\+D, O=Elastic\\\\Co", Map.of(OID_CN, "Test, User", OID_OU, "R+D", OID_O, "Elastic\\Co")); + } + + public void testExtractWithUtf8Characters() { + assertExtractions( + "CN=José García, OU=Ingeniería, O=Elástico", + Map.of(OID_CN, "José García", OID_OU, "Ingeniería", OID_O, "Elástico") + ); + } + + public void testExtractCustomDomainSpecificOid() { + // Test with a custom OID that might be used in a private PKI infrastructure + // This demonstrates the extractor works with any valid OID, not just RFC-standardized ones + // Using OID format: 1.3.6.1.4.1.. + String dnWithCustomOid = OID_EMPLOYEE_ID + "=EMP-2024-42, CN=Jane Developer, OU=Engineering, O=Acme Corp"; + String employeeId = extractFromDN(dnWithCustomOid, OID_EMPLOYEE_ID); + assertThat("Custom domain-specific OID extraction failed", employeeId, is(equalTo("EMP-2024-42"))); + } + + public void testExtractFromMultiValuedRdn() { + // Multi-valued RDNs use "+" to combine multiple attributes in a single RDN component (SET) + // Example: "CN=John Doe+OU=Engineering" - both CN and OU are in the same RDN SET + String multiValuedRdn = "CN=John Smith+OU=Development, O=Acme Corp"; + assertThat(extractFromDN(multiValuedRdn, OID_CN), is("John Smith")); + assertThat(extractFromDN(multiValuedRdn, OID_OU), is("Development")); + assertThat(extractFromDN(multiValuedRdn, OID_O), is("Acme Corp")); + } +}