From ce935abca3854ac33845603cadff0bb6785480b8 Mon Sep 17 00:00:00 2001 From: yuriyz Date: Mon, 6 Jul 2026 15:51:49 +0300 Subject: [PATCH 1/8] feat(jans-auth-server): added OAuth SPIFFE Client Authentication support #13401 Signed-off-by: YuriyZ --- .../model/configuration/AppConfiguration.java | 13 +++ .../SpiffeTrustDomainConfiguration.java | 97 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 jans-auth-server/model/src/main/java/io/jans/as/model/configuration/SpiffeTrustDomainConfiguration.java diff --git a/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java b/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java index d98fa78aa2b..633d10bba3d 100644 --- a/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java +++ b/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/AppConfiguration.java @@ -1040,6 +1040,10 @@ public class AppConfiguration implements Configuration { @DocProperty(description = "Boolean value specifying whether the authorization server includes the iss parameter in authorization responses per RFC 9207. Default: false.", defaultValue = "false") private Boolean authorizationResponseIssParameterSupported = false; + // SPIFFE-based client authentication (draft-ietf-oauth-spiffe-client-auth) Configuration + @DocProperty(description = "Admin-configured, out-of-band trust anchor mapping (trust domain -> SPIFFE Bundle Endpoint) used to validate SPIFFE X.509-SVID and JWT-SVID client credentials. A client-supplied `spiffe_bundle_endpoint` is never trusted as a trust anchor source; only trust domains listed here are honored.") + private List spiffeTrustDomains; + public Boolean getUseOpenidSubAttributeValueForPairwiseLocalAccountId() { if (useOpenidSubAttributeValueForPairwiseLocalAccountId == null) useOpenidSubAttributeValueForPairwiseLocalAccountId = false; return useOpenidSubAttributeValueForPairwiseLocalAccountId; @@ -3986,6 +3990,15 @@ public void setCimdMaxTtlMinutes(Integer cimdMaxTtlMinutes) { this.cimdMaxTtlMinutes = cimdMaxTtlMinutes; } + public List getSpiffeTrustDomains() { + if (spiffeTrustDomains == null) spiffeTrustDomains = new ArrayList<>(); + return spiffeTrustDomains; + } + + public void setSpiffeTrustDomains(List spiffeTrustDomains) { + this.spiffeTrustDomains = spiffeTrustDomains; + } + public Map getIdJagTrustedIdpIssuers() { if (idJagTrustedIdpIssuers == null) idJagTrustedIdpIssuers = new HashMap<>(); return idJagTrustedIdpIssuers; diff --git a/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/SpiffeTrustDomainConfiguration.java b/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/SpiffeTrustDomainConfiguration.java new file mode 100644 index 00000000000..889ac5a2732 --- /dev/null +++ b/jans-auth-server/model/src/main/java/io/jans/as/model/configuration/SpiffeTrustDomainConfiguration.java @@ -0,0 +1,97 @@ +package io.jans.as.model.configuration; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Admin-configured, out-of-band trust anchor mapping for a single SPIFFE trust domain, used by + * SPIFFE-based client authentication (draft-ietf-oauth-spiffe-client-auth). The bundle endpoint + * is deliberately configured here rather than trusted from client-supplied metadata + * (`spiffe_bundle_endpoint`), since trusting a client-supplied URL for trust-anchor material + * would let a client vouch for itself. + * + * @author Yuriy Zabrovarnyy + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SpiffeTrustDomainConfiguration { + + public static final int DEFAULT_BUNDLE_CACHE_LIFETIME_IN_MINUTES = 60; + + private String trustDomain; + private String bundleEndpointUrl; + private Integer bundleCacheLifetimeInMinutes = DEFAULT_BUNDLE_CACHE_LIFETIME_IN_MINUTES; + + public SpiffeTrustDomainConfiguration() { + } + + @JsonCreator + public SpiffeTrustDomainConfiguration( + @JsonProperty("trustDomain") String trustDomain, + @JsonProperty("bundleEndpointUrl") String bundleEndpointUrl, + @JsonProperty("bundleCacheLifetimeInMinutes") Integer bundleCacheLifetimeInMinutes) { + this.trustDomain = trustDomain; + this.bundleEndpointUrl = bundleEndpointUrl; + setBundleCacheLifetimeInMinutes(bundleCacheLifetimeInMinutes); + } + + @JsonProperty("trustDomain") + public String getTrustDomain() { + return trustDomain; + } + + @JsonProperty("trustDomain") + public void setTrustDomain(String trustDomain) { + this.trustDomain = trustDomain; + } + + @JsonProperty("bundleEndpointUrl") + public String getBundleEndpointUrl() { + return bundleEndpointUrl; + } + + @JsonProperty("bundleEndpointUrl") + public void setBundleEndpointUrl(String bundleEndpointUrl) { + this.bundleEndpointUrl = bundleEndpointUrl; + } + + @JsonProperty("bundleCacheLifetimeInMinutes") + public Integer getBundleCacheLifetimeInMinutes() { + return bundleCacheLifetimeInMinutes; + } + + @JsonProperty("bundleCacheLifetimeInMinutes") + public void setBundleCacheLifetimeInMinutes(Integer bundleCacheLifetimeInMinutes) { + this.bundleCacheLifetimeInMinutes = (bundleCacheLifetimeInMinutes != null && bundleCacheLifetimeInMinutes > 0) + ? bundleCacheLifetimeInMinutes + : DEFAULT_BUNDLE_CACHE_LIFETIME_IN_MINUTES; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SpiffeTrustDomainConfiguration)) return false; + SpiffeTrustDomainConfiguration that = (SpiffeTrustDomainConfiguration) o; + return Objects.equals(trustDomain, that.trustDomain) + && Objects.equals(bundleEndpointUrl, that.bundleEndpointUrl) + && Objects.equals(bundleCacheLifetimeInMinutes, that.bundleCacheLifetimeInMinutes); + } + + @Override + public int hashCode() { + return Objects.hash(trustDomain, bundleEndpointUrl, bundleCacheLifetimeInMinutes); + } + + @Override + public String toString() { + return "SpiffeTrustDomainConfiguration{" + + "trustDomain='" + trustDomain + '\'' + + ", bundleEndpointUrl='" + bundleEndpointUrl + '\'' + + ", bundleCacheLifetimeInMinutes=" + bundleCacheLifetimeInMinutes + + '}'; + } +} From 716966a30fac400772c2a140ed084b652d70a1a1 Mon Sep 17 00:00:00 2001 From: yuriyz Date: Tue, 7 Jul 2026 13:07:17 +0300 Subject: [PATCH 2/8] feat(jans-auth-server): SPIFFE SVID assertion implementation Signed-off-by: YuriyZ --- .../model/token/SpiffeJwtSvidAssertion.java | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 jans-auth-server/server/src/main/java/io/jans/as/server/model/token/SpiffeJwtSvidAssertion.java diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/model/token/SpiffeJwtSvidAssertion.java b/jans-auth-server/server/src/main/java/io/jans/as/server/model/token/SpiffeJwtSvidAssertion.java new file mode 100644 index 00000000000..c26086e6f18 --- /dev/null +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/model/token/SpiffeJwtSvidAssertion.java @@ -0,0 +1,154 @@ +/* + * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text. + * + * Copyright (c) 2026, Janssen Project + */ + +package io.jans.as.server.model.token; + +import io.jans.as.common.model.registration.Client; +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.crypto.AbstractCryptoProvider; +import io.jans.as.model.crypto.signature.SignatureAlgorithm; +import io.jans.as.model.exception.InvalidJwtException; +import io.jans.as.model.jwt.Jwt; +import io.jans.as.model.jwt.JwtClaimName; +import io.jans.as.model.util.SpiffeIdUtil; +import io.jans.as.server.service.ClientIdMetadataService; +import io.jans.as.server.service.ClientService; +import io.jans.as.server.service.SpiffeBundleService; +import io.jans.service.cdi.util.CdiUtil; +import org.apache.commons.lang3.StringUtils; +import org.json.JSONObject; + +import java.util.Date; +import java.util.List; + +/** + * Validates a SPIFFE JWT-SVID client assertion (client_assertion_type = + * urn:ietf:params:oauth:client-assertion-type:jwt-spiffe), per draft-ietf-oauth-spiffe-client-auth. + *

+ * Unlike {@link ClientAssertion} (private_key_jwt/client_secret_jwt), the signature here is + * verified against the SPIFFE trust domain's JWT-SVID signing keys (fetched from the + * admin-configured SPIFFE Bundle Endpoint), not the client's own registered JWKS. + * + * @author Yuriy Zabrovarnyy + */ +public class SpiffeJwtSvidAssertion { + + private final AppConfiguration appConfiguration; + private final AbstractCryptoProvider cryptoProvider; + private final String clientId; + private final String encodedAssertion; + private boolean verified; + + private Jwt jwt; + private Client client; + + public SpiffeJwtSvidAssertion(AppConfiguration appConfiguration, AbstractCryptoProvider cryptoProvider, String clientId, String encodedAssertion) { + this.appConfiguration = appConfiguration; + this.cryptoProvider = cryptoProvider; + this.clientId = clientId; + this.encodedAssertion = encodedAssertion; + this.verified = false; + } + + public String getSubjectIdentifier() throws InvalidJwtException { + assertVerified(); + return jwt.getClaims().getClaimAsString(JwtClaimName.SUBJECT_IDENTIFIER); + } + + public Client getClient() throws InvalidJwtException { + assertVerified(); + return client; + } + + private void assertVerified() throws InvalidJwtException { + if (!verified) { + throw new InvalidJwtException("SPIFFE JWT-SVID assertion is not verified"); + } + } + + public void initAndVerify() throws InvalidJwtException { + try { + initAndVerifyInternally(); + verified = true; + } catch (InvalidJwtException e) { + throw e; + } catch (Exception e) { + throw new InvalidJwtException("Cannot verify SPIFFE JWT-SVID", e); + } + } + + private void initAndVerifyInternally() throws Exception { + if (StringUtils.isBlank(encodedAssertion)) { + throw new InvalidJwtException("The client_assertion is null or empty"); + } + if (StringUtils.isBlank(clientId)) { + throw new InvalidJwtException("client_id is required for SPIFFE JWT-SVID client authentication"); + } + + jwt = Jwt.parse(encodedAssertion); + + final String subject = jwt.getClaims().getClaimAsString(JwtClaimName.SUBJECT_IDENTIFIER); + final List audience = jwt.getClaims().getClaimAsStringList(JwtClaimName.AUDIENCE); + final Date expirationTime = jwt.getClaims().getClaimAsDate(JwtClaimName.EXPIRATION_TIME); + + if (StringUtils.isBlank(subject) || audience == null || audience.isEmpty() || expirationTime == null) { + throw new InvalidJwtException("SPIFFE JWT-SVID must contain sub, aud and exp claims"); + } + if (expirationTime.before(new Date())) { + throw new InvalidJwtException("SPIFFE JWT-SVID has expired"); + } + if (!SpiffeIdUtil.isValidPresentedSpiffeId(subject)) { + throw new InvalidJwtException("Invalid SPIFFE ID in sub claim: " + subject); + } + + // aud MUST contain only the authorization server's issuer identifier as its sole value + final String issuer = appConfiguration.getIssuer(); + if (audience.size() != 1 || !audience.get(0).equals(issuer)) { + throw new InvalidJwtException("Invalid audience for SPIFFE JWT-SVID. It must contain only the server issuer as its sole value. Aud: " + audience); + } + + client = resolveClient(clientId); + if (client == null) { + throw new InvalidJwtException("Invalid client"); + } + + final String registeredSpiffeId = client.getAttributes().getSpiffeId(); + if (StringUtils.isBlank(registeredSpiffeId) || !SpiffeIdUtil.matches(registeredSpiffeId, subject)) { + throw new InvalidJwtException("SPIFFE ID in sub claim does not match client's registered spiffe_id"); + } + + verifySignature(subject); + } + + private Client resolveClient(String clientId) { + final ClientIdMetadataService clientIdMetadataService = CdiUtil.bean(ClientIdMetadataService.class); + if (clientIdMetadataService.isCimdClientId(clientId)) { + try { + return clientIdMetadataService.getClient(clientId); + } catch (RuntimeException e) { + return null; + } + } + final ClientService clientService = CdiUtil.bean(ClientService.class); + return clientService.getClient(clientId); + } + + private void verifySignature(String presentedSpiffeId) throws Exception { + final SpiffeBundleService spiffeBundleService = CdiUtil.bean(SpiffeBundleService.class); + final String trustDomain = SpiffeIdUtil.trustDomainOf(presentedSpiffeId); + final JSONObject jwks = spiffeBundleService.getJwtSvidJwks(trustDomain); + if (jwks == null) { + throw new InvalidJwtException("No SPIFFE JWT-SVID signing keys configured/available for trust domain: " + trustDomain); + } + + final SignatureAlgorithm signatureAlgorithm = jwt.getHeader().getSignatureAlgorithm(); + final String keyId = jwt.getHeader().getKeyId(); + final boolean validSignature = cryptoProvider.verifySignature(jwt.getSigningInput(), jwt.getEncodedSignature(), keyId, jwks, null, signatureAlgorithm); + if (!validSignature) { + throw new InvalidJwtException("Invalid SPIFFE JWT-SVID signature"); + } + } +} From 6b8704c0f4d8294d1b9d83746e65aa66ea3e559a Mon Sep 17 00:00:00 2001 From: yuriyz Date: Thu, 9 Jul 2026 12:47:56 +0300 Subject: [PATCH 3/8] feat(jans-auth-server): added SPIFFE SVID assertion test Signed-off-by: YuriyZ --- .../token/SpiffeJwtSvidAssertionTest.java | 453 ++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 jans-auth-server/server/src/test/java/io/jans/as/server/model/token/SpiffeJwtSvidAssertionTest.java diff --git a/jans-auth-server/server/src/test/java/io/jans/as/server/model/token/SpiffeJwtSvidAssertionTest.java b/jans-auth-server/server/src/test/java/io/jans/as/server/model/token/SpiffeJwtSvidAssertionTest.java new file mode 100644 index 00000000000..ec27062a2b6 --- /dev/null +++ b/jans-auth-server/server/src/test/java/io/jans/as/server/model/token/SpiffeJwtSvidAssertionTest.java @@ -0,0 +1,453 @@ +/* + * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text. + * + * Copyright (c) 2020, Janssen Project + */ + +package io.jans.as.server.model.token; + +import io.jans.as.common.model.registration.Client; +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.crypto.AbstractCryptoProvider; +import io.jans.as.model.crypto.signature.SignatureAlgorithm; +import io.jans.as.model.exception.InvalidJwtException; +import io.jans.as.model.jwt.Jwt; +import io.jans.as.model.jwt.JwtClaimName; +import io.jans.as.model.jwt.JwtType; +import io.jans.as.persistence.model.ClientAttributes; +import io.jans.as.server.service.ClientIdMetadataService; +import io.jans.as.server.service.ClientService; +import io.jans.as.server.service.SpiffeBundleService; +import io.jans.service.cdi.util.CdiUtil; +import org.json.JSONObject; +import org.mockito.MockedStatic; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.TimeZone; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; + +/** + * @author Yuriy Zabrovarnyy + */ +public class SpiffeJwtSvidAssertionTest { + + private static final String ISSUER = "https://server.example.com"; + private static final String SPIFFE_ID = "spiffe://example.org/my-workload"; + private static final String CLIENT_ID = "client-inum-1"; + private static final String KEY_ID = "kid1"; + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_blankAssertion_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, "").initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_blankClientId_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, "", jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_missingSubClaim_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + String jwtSvid = buildJwtSvid(null, ISSUER, fiveMinutesFromNow()); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_missingAudClaim_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + String jwtSvid = buildJwtSvid(SPIFFE_ID, null, fiveMinutesFromNow()); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_missingExpClaim_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, null); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_expiredJwt_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesAgo()); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_invalidSpiffeIdSyntaxInSub_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + String jwtSvid = buildJwtSvid("not-a-spiffe-id", ISSUER, fiveMinutesFromNow()); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_wildcardSubClaim_shouldThrow() throws Exception { + // a presented (concrete) SPIFFE ID must never itself carry the "/*" wildcard suffix + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + String jwtSvid = buildJwtSvid("spiffe://example.org/*", ISSUER, fiveMinutesFromNow()); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_audienceHasMultipleValues_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, Arrays.asList(ISSUER, "https://someone-else.example.com"), fiveMinutesFromNow()); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_audienceNotServerIssuer_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, "https://not-the-issuer.example.com", fiveMinutesFromNow()); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_clientNotFound_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + when(clientIdMetadataService.isCimdClientId(CLIENT_ID)).thenReturn(false); + when(clientService.getClient(CLIENT_ID)).thenReturn(null); + + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_clientHasNoRegisteredSpiffeId_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + when(clientIdMetadataService.isCimdClientId(CLIENT_ID)).thenReturn(false); + when(clientService.getClient(CLIENT_ID)).thenReturn(clientWithSpiffeId(null)); + + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_subDoesNotMatchRegisteredSpiffeId_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + when(clientIdMetadataService.isCimdClientId(CLIENT_ID)).thenReturn(false); + when(clientService.getClient(CLIENT_ID)).thenReturn(clientWithSpiffeId("spiffe://example.org/some-other-workload")); + + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + } + + @Test + public void initAndVerify_noJwtSvidBundleForTrustDomain_shouldThrowWithoutAttemptingSignatureVerification() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + SpiffeBundleService spiffeBundleService = mock(SpiffeBundleService.class); + when(clientIdMetadataService.isCimdClientId(CLIENT_ID)).thenReturn(false); + when(clientService.getClient(CLIENT_ID)).thenReturn(clientWithSpiffeId(SPIFFE_ID)); + when(spiffeBundleService.getJwtSvidJwks("example.org")).thenReturn(null); + + SpiffeJwtSvidAssertion assertion = new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid); + boolean threw = false; + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + cdiUtil.when(() -> CdiUtil.bean(SpiffeBundleService.class)).thenReturn(spiffeBundleService); + + assertion.initAndVerify(); + } catch (InvalidJwtException expected) { + threw = true; + } + + assertTrue(threw, "Expected InvalidJwtException to be thrown"); + verify(cryptoProvider, never()).verifySignature(any(), any(), any(), any(), any(), any()); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_invalidSignature_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + JSONObject jwks = new JSONObject().put("keys", new org.json.JSONArray()); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + SpiffeBundleService spiffeBundleService = mock(SpiffeBundleService.class); + when(clientIdMetadataService.isCimdClientId(CLIENT_ID)).thenReturn(false); + when(clientService.getClient(CLIENT_ID)).thenReturn(clientWithSpiffeId(SPIFFE_ID)); + when(spiffeBundleService.getJwtSvidJwks("example.org")).thenReturn(jwks); + when(cryptoProvider.verifySignature(any(), any(), eq(KEY_ID), eq(jwks), eq(null), eq(SignatureAlgorithm.RS256))).thenReturn(false); + + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + cdiUtil.when(() -> CdiUtil.bean(SpiffeBundleService.class)).thenReturn(spiffeBundleService); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid).initAndVerify(); + } + } + + @Test + public void initAndVerify_validAssertion_shouldSucceedAndExposeSubjectAndClient() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + JSONObject jwks = new JSONObject().put("keys", new org.json.JSONArray()); + Client client = clientWithSpiffeId(SPIFFE_ID); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + SpiffeBundleService spiffeBundleService = mock(SpiffeBundleService.class); + when(clientIdMetadataService.isCimdClientId(CLIENT_ID)).thenReturn(false); + when(clientService.getClient(CLIENT_ID)).thenReturn(client); + when(spiffeBundleService.getJwtSvidJwks("example.org")).thenReturn(jwks); + when(cryptoProvider.verifySignature(any(), any(), eq(KEY_ID), eq(jwks), eq(null), eq(SignatureAlgorithm.RS256))).thenReturn(true); + + SpiffeJwtSvidAssertion assertion = new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid); + + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + cdiUtil.when(() -> CdiUtil.bean(SpiffeBundleService.class)).thenReturn(spiffeBundleService); + + assertion.initAndVerify(); + } + + assertEquals(assertion.getSubjectIdentifier(), SPIFFE_ID); + assertSame(assertion.getClient(), client); + } + + @Test + public void initAndVerify_wildcardRegisteredSpiffeId_shouldMatchConcretePresentedId() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String presentedSpiffeId = "spiffe://example.org/fleet/instance-123"; + String jwtSvid = buildJwtSvid(presentedSpiffeId, ISSUER, fiveMinutesFromNow()); + + JSONObject jwks = new JSONObject().put("keys", new org.json.JSONArray()); + Client client = clientWithSpiffeId("spiffe://example.org/fleet/*"); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + SpiffeBundleService spiffeBundleService = mock(SpiffeBundleService.class); + when(clientIdMetadataService.isCimdClientId(CLIENT_ID)).thenReturn(false); + when(clientService.getClient(CLIENT_ID)).thenReturn(client); + when(spiffeBundleService.getJwtSvidJwks("example.org")).thenReturn(jwks); + when(cryptoProvider.verifySignature(any(), any(), eq(KEY_ID), eq(jwks), eq(null), eq(SignatureAlgorithm.RS256))).thenReturn(true); + + SpiffeJwtSvidAssertion assertion = new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, jwtSvid); + + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + cdiUtil.when(() -> CdiUtil.bean(SpiffeBundleService.class)).thenReturn(spiffeBundleService); + + assertion.initAndVerify(); + } + + assertEquals(assertion.getSubjectIdentifier(), presentedSpiffeId); + } + + @Test + public void initAndVerify_cimdClientId_resolvesViaClientIdMetadataServiceNotClientService() throws Exception { + String cimdClientId = "https://example.com/client-metadata"; + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + JSONObject jwks = new JSONObject().put("keys", new org.json.JSONArray()); + Client cimdClient = clientWithSpiffeId(SPIFFE_ID); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + SpiffeBundleService spiffeBundleService = mock(SpiffeBundleService.class); + when(clientIdMetadataService.isCimdClientId(cimdClientId)).thenReturn(true); + when(clientIdMetadataService.getClient(cimdClientId)).thenReturn(cimdClient); + when(spiffeBundleService.getJwtSvidJwks("example.org")).thenReturn(jwks); + when(cryptoProvider.verifySignature(any(), any(), eq(KEY_ID), eq(jwks), eq(null), eq(SignatureAlgorithm.RS256))).thenReturn(true); + + SpiffeJwtSvidAssertion assertion = new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, cimdClientId, jwtSvid); + + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + cdiUtil.when(() -> CdiUtil.bean(SpiffeBundleService.class)).thenReturn(spiffeBundleService); + + assertion.initAndVerify(); + } + + assertSame(assertion.getClient(), cimdClient); + verify(clientService, never()).getClient(any()); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void initAndVerify_cimdResolutionThrows_shouldTreatAsClientNotFound() throws Exception { + String cimdClientId = "https://example.com/client-metadata"; + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + when(appConfiguration.getIssuer()).thenReturn(ISSUER); + String jwtSvid = buildJwtSvid(SPIFFE_ID, ISSUER, fiveMinutesFromNow()); + + ClientIdMetadataService clientIdMetadataService = mock(ClientIdMetadataService.class); + ClientService clientService = mock(ClientService.class); + when(clientIdMetadataService.isCimdClientId(cimdClientId)).thenReturn(true); + when(clientIdMetadataService.getClient(cimdClientId)).thenThrow(new RuntimeException("fetch failed")); + + try (MockedStatic cdiUtil = mockStatic(CdiUtil.class)) { + cdiUtil.when(() -> CdiUtil.bean(ClientIdMetadataService.class)).thenReturn(clientIdMetadataService); + cdiUtil.when(() -> CdiUtil.bean(ClientService.class)).thenReturn(clientService); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, cimdClientId, jwtSvid).initAndVerify(); + } + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void getSubjectIdentifier_beforeVerify_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, "irrelevant").getSubjectIdentifier(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void getClient_beforeVerify_shouldThrow() throws Exception { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + + new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, "irrelevant").getClient(); + } + + @Test(expectedExceptions = InvalidJwtException.class) + public void getSubjectIdentifier_afterFailedVerify_shouldThrow() throws InvalidJwtException { + AppConfiguration appConfiguration = mock(AppConfiguration.class); + AbstractCryptoProvider cryptoProvider = mock(AbstractCryptoProvider.class); + SpiffeJwtSvidAssertion assertion = new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, CLIENT_ID, ""); + + try { + assertion.initAndVerify(); + } catch (InvalidJwtException ignored) { + // expected - assertion is left unverified because the encoded assertion is blank + } + + // verified is still false, so this must throw regardless of the earlier failure's cause + assertion.getSubjectIdentifier(); + } + + private static String buildJwtSvid(String subject, Object audience, Date expirationTime) { + Jwt jwt = new Jwt(); + jwt.getHeader().setType(JwtType.JWT); + jwt.getHeader().setAlgorithm(SignatureAlgorithm.RS256); + jwt.getHeader().setKeyId(KEY_ID); + + if (subject != null) { + jwt.getClaims().setSubjectIdentifier(subject); + } + if (audience instanceof String) { + jwt.getClaims().setClaim(JwtClaimName.AUDIENCE, (String) audience); + } else if (audience instanceof java.util.List) { + jwt.getClaims().setClaim(JwtClaimName.AUDIENCE, (java.util.List) audience); + } + if (expirationTime != null) { + jwt.getClaims().setExpirationTime(expirationTime); + } + + jwt.setEncodedSignature("fake-signature"); + return jwt.toString(); + } + + private static Date fiveMinutesFromNow() { + GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + calendar.add(Calendar.MINUTE, 5); + return calendar.getTime(); + } + + private static Date fiveMinutesAgo() { + GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + calendar.add(Calendar.MINUTE, -5); + return calendar.getTime(); + } + + private static Client clientWithSpiffeId(String spiffeId) { + Client client = new Client(); + client.setClientId(CLIENT_ID); + ClientAttributes attributes = new ClientAttributes(); + attributes.setSpiffeId(spiffeId); + client.setAttributes(attributes); + return client; + } +} From 1bbc8cf2ef1cd77e5472e0de4bbca8df71ad05a1 Mon Sep 17 00:00:00 2001 From: yuriyz Date: Tue, 14 Jul 2026 13:01:30 +0300 Subject: [PATCH 4/8] feat(jans-auth-server): added SPIFFE bundle service Signed-off-by: YuriyZ --- .../server/service/SpiffeBundleService.java | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 jans-auth-server/server/src/main/java/io/jans/as/server/service/SpiffeBundleService.java diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/service/SpiffeBundleService.java b/jans-auth-server/server/src/main/java/io/jans/as/server/service/SpiffeBundleService.java new file mode 100644 index 00000000000..1b9f5be4010 --- /dev/null +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/service/SpiffeBundleService.java @@ -0,0 +1,185 @@ +/* + * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text. + * + * Copyright (c) 2026, Janssen Project + */ + +package io.jans.as.server.service; + +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.configuration.SpiffeTrustDomainConfiguration; +import io.jans.as.model.util.CertUtils; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.apache.commons.lang3.StringUtils; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; + +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * Fetches and caches SPIFFE Bundle Endpoint documents (a JWKS keyed by trust domain, per + * draft-ietf-oauth-spiffe-client-auth section 6), used to validate X.509-SVID and JWT-SVID + * client credentials during SPIFFE-based client authentication. + *

+ * Trust domains and their bundle endpoint URLs come exclusively from the admin-configured + * {@code spiffeTrustDomains} list in {@link AppConfiguration} - a client-supplied + * {@code spiffe_bundle_endpoint} is never used as a trust anchor source, since trusting it would + * let a client vouch for itself. + * + * @author Yuriy Zabrovarnyy + */ +@ApplicationScoped +public class SpiffeBundleService { + + private static final String USE_X509_SVID = "x509-svid"; + private static final String USE_JWT_SVID = "jwt-svid"; + + @Inject + private Logger log; + + @Inject + private AppConfiguration appConfiguration; + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** + * Returns the X.509 trust anchors (CA certificates tagged {@code use: x509-svid}) configured + * for the given trust domain, for path-validating a presented X.509-SVID. Returns an empty + * set (fail closed) if the trust domain is not configured or its bundle cannot be obtained. + */ + public Set getX509TrustAnchors(String trustDomain) { + final JSONObject bundle = getBundle(trustDomain); + final Set anchors = new HashSet<>(); + if (bundle == null) { + return anchors; + } + + final JSONArray keys = bundle.optJSONArray("keys"); + if (keys == null) { + return anchors; + } + for (int i = 0; i < keys.length(); i++) { + final JSONObject key = keys.optJSONObject(i); + if (key == null || !USE_X509_SVID.equals(key.optString("use"))) { + continue; + } + final JSONArray x5c = key.optJSONArray("x5c"); + if (x5c == null || x5c.isEmpty()) { + continue; + } + final X509Certificate cert = CertUtils.x509CertificateFromPem(x5c.optString(0)); + if (cert != null) { + anchors.add(new TrustAnchor(cert, null)); + } + } + return anchors; + } + + /** + * Returns a JWKS (as a {@link JSONObject}, ready to feed into the crypto provider's signature + * verification) containing only the keys tagged {@code use: jwt-svid} for the given trust + * domain, used to verify a JWT-SVID's signature. Returns null (fail closed) if the trust + * domain is not configured or its bundle cannot be obtained. + */ + public JSONObject getJwtSvidJwks(String trustDomain) { + final JSONObject bundle = getBundle(trustDomain); + if (bundle == null) { + return null; + } + + final JSONArray filtered = new JSONArray(); + final JSONArray keys = bundle.optJSONArray("keys"); + if (keys != null) { + for (int i = 0; i < keys.length(); i++) { + final JSONObject key = keys.optJSONObject(i); + if (key != null && USE_JWT_SVID.equals(key.optString("use"))) { + filtered.put(key); + } + } + } + if (filtered.isEmpty()) { + return null; + } + return new JSONObject().put("keys", filtered); + } + + private SpiffeTrustDomainConfiguration findConfiguration(String trustDomain) { + if (StringUtils.isBlank(trustDomain)) { + return null; + } + for (SpiffeTrustDomainConfiguration config : appConfiguration.getSpiffeTrustDomains()) { + if (trustDomain.equalsIgnoreCase(config.getTrustDomain())) { + return config; + } + } + return null; + } + + private JSONObject getBundle(String trustDomain) { + final SpiffeTrustDomainConfiguration config = findConfiguration(trustDomain); + if (config == null || StringUtils.isBlank(config.getBundleEndpointUrl())) { + log.debug("No SPIFFE trust bundle configured for trust domain: {}", trustDomain); + return null; + } + + final CacheEntry cached = cache.get(trustDomain); + if (cached != null && !cached.isExpired()) { + return cached.jwks; + } + + try { + final JSONObject fetched = fetchBundle(config.getBundleEndpointUrl()); + cache.put(trustDomain, new CacheEntry(fetched, System.currentTimeMillis() + config.getBundleCacheLifetimeInMinutes() * 60_000L)); + return fetched; + } catch (RuntimeException e) { + log.error("Failed to fetch SPIFFE bundle for trust domain: {} from: {}", trustDomain, config.getBundleEndpointUrl(), e); + if (cached != null) { + log.warn("Serving stale cached SPIFFE bundle for trust domain: {} after fetch failure.", trustDomain); + return cached.jwks; + } + return null; + } + } + + protected JSONObject fetchBundle(String bundleEndpointUrl) { + try (Client httpClient = ClientBuilder.newBuilder() + .connectTimeout(5000, TimeUnit.MILLISECONDS) + .readTimeout(10000, TimeUnit.MILLISECONDS) + .build()) { + + final Response response = httpClient.target(bundleEndpointUrl) + .request(MediaType.APPLICATION_JSON) + .get(); + + if (response.getStatus() != 200) { + throw new RuntimeException("Failed to fetch SPIFFE bundle: HTTP " + response.getStatus() + " from " + bundleEndpointUrl); + } + return new JSONObject(response.readEntity(String.class)); + } + } + + private static class CacheEntry { + final JSONObject jwks; + final long expiresAtMillis; + + CacheEntry(JSONObject jwks, long expiresAtMillis) { + this.jwks = jwks; + this.expiresAtMillis = expiresAtMillis; + } + + boolean isExpired() { + return System.currentTimeMillis() > expiresAtMillis; + } + } +} From dbc3b0b56974eb27bbae5c588db096cc320022e1 Mon Sep 17 00:00:00 2001 From: yuriyz Date: Wed, 15 Jul 2026 12:30:59 +0300 Subject: [PATCH 5/8] feat(jans-auth-server): added test for SPIFFE bundle service test Signed-off-by: YuriyZ --- .../service/SpiffeBundleServiceTest.java | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 jans-auth-server/server/src/test/java/io/jans/as/server/service/SpiffeBundleServiceTest.java diff --git a/jans-auth-server/server/src/test/java/io/jans/as/server/service/SpiffeBundleServiceTest.java b/jans-auth-server/server/src/test/java/io/jans/as/server/service/SpiffeBundleServiceTest.java new file mode 100644 index 00000000000..273422c469d --- /dev/null +++ b/jans-auth-server/server/src/test/java/io/jans/as/server/service/SpiffeBundleServiceTest.java @@ -0,0 +1,303 @@ +/* + * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text. + * + * Copyright (c) 2026, Janssen Project + */ + +package io.jans.as.server.service; + +import io.jans.as.model.configuration.AppConfiguration; +import io.jans.as.model.configuration.SpiffeTrustDomainConfiguration; +import org.json.JSONArray; +import org.json.JSONObject; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.testng.MockitoTestNGListener; +import org.slf4j.Logger; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import java.security.cert.TrustAnchor; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for SpiffeBundleService. + * + * @author Yuriy Zabrovarnyy + */ +@Listeners(MockitoTestNGListener.class) +public class SpiffeBundleServiceTest { + + // Raw base64 DER certificate (no PEM markers), exactly the shape of a JWK x5c entry. + // Reused verbatim from CertUtilsTest.TEST_PEM_3 - known to parse successfully. + private static final String CERT_BASE64 = "MIIBBjCBrAIBAjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDDARtdGxzMB4XDTE4MTAxODEyMzcwOVoXDTIyMDUwMjEyMzcwOVowDzENMAsGA1UEAwwEbXRsczBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNcnyxwqV6hY8QnhxxzFQ03C7HKW9OylMbnQZjjJ/Au08/coZwxS7LfA4vOLS9WuneIXhbGGWvsDSb0tH6IxLm8wCgYIKoZIzj0EAwIDSQAwRgIhAP0RC1E+vwJD/D1AGHGzuri+hlV/PpQEKTWUVeORWz83AiEA5x2eXZOVbUlJSGQgjwD5vaUaKlLR50Q2DmFfQj1L+SY="; + + private static final String TRUST_DOMAIN = "example.org"; + private static final String BUNDLE_ENDPOINT_URL = "https://bundle.example.org/keys.json"; + + @InjectMocks + @Spy + private SpiffeBundleService spiffeBundleService; + + @Mock + private Logger log; + + @Mock + private AppConfiguration appConfiguration; + + @Test + public void getX509TrustAnchors_blankTrustDomain_shouldReturnEmptySetWithoutFetching() { + Set result = spiffeBundleService.getX509TrustAnchors(""); + + assertTrue(result.isEmpty()); + verify(spiffeBundleService, never()).fetchBundle(anyString()); + } + + @Test + public void getX509TrustAnchors_nullTrustDomain_shouldReturnEmptySetWithoutFetching() { + Set result = spiffeBundleService.getX509TrustAnchors(null); + + assertTrue(result.isEmpty()); + verify(spiffeBundleService, never()).fetchBundle(anyString()); + } + + @Test + public void getX509TrustAnchors_noConfigForTrustDomain_shouldReturnEmptySet() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor("other.org", BUNDLE_ENDPOINT_URL))); + + Set result = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + assertTrue(result.isEmpty()); + verify(spiffeBundleService, never()).fetchBundle(anyString()); + } + + @Test + public void getX509TrustAnchors_configWithBlankBundleEndpointUrl_shouldReturnEmptySet() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, ""))); + + Set result = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + assertTrue(result.isEmpty()); + verify(spiffeBundleService, never()).fetchBundle(anyString()); + } + + @Test + public void getX509TrustAnchors_caseInsensitiveTrustDomainMatch_shouldFindConfig() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor("Example.ORG", BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(x509SvidKey(CERT_BASE64))).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set result = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + assertEquals(result.size(), 1); + } + + @Test + public void getX509TrustAnchors_bundleWithNoKeysArray_shouldReturnEmptySet() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(new JSONObject()).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set result = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + assertTrue(result.isEmpty()); + } + + @Test + public void getX509TrustAnchors_filtersOutNonX509SvidKeys() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(jwtSvidKey("kid1"), x509SvidKey(CERT_BASE64))).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set result = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + assertEquals(result.size(), 1); + } + + @Test + public void getX509TrustAnchors_keyWithoutX5c_shouldBeSkipped() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(x509SvidKey(null))).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set result = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + assertTrue(result.isEmpty()); + } + + @Test + public void getX509TrustAnchors_multipleX509SvidKeys_shouldReturnAllAsAnchors() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(x509SvidKey(CERT_BASE64), x509SvidKey(CERT_BASE64))).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set result = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + // TrustAnchor has no value-based equals/hashCode, so both entries are added as distinct + // Set elements even though they wrap an identical certificate - this proves both x509-svid + // keys in the bundle were processed rather than the loop stopping after the first. + assertEquals(result.size(), 2); + } + + @Test + public void getX509TrustAnchors_fetchFailsWithNoCache_shouldFailClosedToEmptySet() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doThrow(new RuntimeException("connection refused")).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set result = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + assertTrue(result.isEmpty()); + } + + @Test + public void getX509TrustAnchors_secondCallWithinTtl_shouldNotRefetch() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(x509SvidKey(CERT_BASE64))).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set firstResult = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + assertEquals(firstResult.size(), 1); + + Set secondResult = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + assertEquals(secondResult.size(), 1); + verify(spiffeBundleService, times(1)).fetchBundle(BUNDLE_ENDPOINT_URL); + } + + @Test + public void getX509TrustAnchors_fetchFailsAfterCacheExpired_shouldServeStaleCache() throws Exception { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(x509SvidKey(CERT_BASE64))).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set firstResult = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + assertEquals(firstResult.size(), 1); + + forceCacheEntryExpired(TRUST_DOMAIN); + doThrow(new RuntimeException("bundle endpoint unreachable")).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set secondResult = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + + // fetch was retried (cache had expired) but failed, so the stale-but-last-known-good + // bundle is served rather than failing closed to an empty set. + assertEquals(secondResult.size(), 1); + verify(spiffeBundleService, times(2)).fetchBundle(BUNDLE_ENDPOINT_URL); + } + + @Test + public void getJwtSvidJwks_noConfigForTrustDomain_shouldReturnNull() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.emptyList()); + + JSONObject result = spiffeBundleService.getJwtSvidJwks(TRUST_DOMAIN); + + assertNull(result); + } + + @Test + public void getJwtSvidJwks_bundleWithOnlyX509SvidKeys_shouldReturnNull() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(x509SvidKey(CERT_BASE64))).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + JSONObject result = spiffeBundleService.getJwtSvidJwks(TRUST_DOMAIN); + + assertNull(result); + } + + @Test + public void getJwtSvidJwks_filtersToJwtSvidKeysOnly() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(x509SvidKey(CERT_BASE64), jwtSvidKey("kid1"), jwtSvidKey("kid2"))) + .when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + JSONObject result = spiffeBundleService.getJwtSvidJwks(TRUST_DOMAIN); + + assertEquals(result.getJSONArray("keys").length(), 2); + assertEquals(result.getJSONArray("keys").getJSONObject(0).getString("kid"), "kid1"); + assertEquals(result.getJSONArray("keys").getJSONObject(1).getString("kid"), "kid2"); + } + + @Test + public void getJwtSvidJwks_fetchFailsWithNoCache_shouldReturnNull() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doThrow(new RuntimeException("timeout")).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + JSONObject result = spiffeBundleService.getJwtSvidJwks(TRUST_DOMAIN); + + assertNull(result); + } + + @Test + public void getX509TrustAnchorsAndGetJwtSvidJwks_shareOneCachedFetch() { + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Collections.singletonList(configFor(TRUST_DOMAIN, BUNDLE_ENDPOINT_URL))); + doReturn(bundleWithKeys(x509SvidKey(CERT_BASE64), jwtSvidKey("kid1"))).when(spiffeBundleService).fetchBundle(BUNDLE_ENDPOINT_URL); + + Set anchors = spiffeBundleService.getX509TrustAnchors(TRUST_DOMAIN); + JSONObject jwks = spiffeBundleService.getJwtSvidJwks(TRUST_DOMAIN); + + assertEquals(anchors.size(), 1); + assertEquals(jwks.getJSONArray("keys").length(), 1); + verify(spiffeBundleService, times(1)).fetchBundle(BUNDLE_ENDPOINT_URL); + } + + @Test + public void differentTrustDomains_shouldBeFetchedAndCachedIndependently() { + SpiffeTrustDomainConfiguration configA = configFor("a.org", "https://bundle.a.org/keys.json"); + SpiffeTrustDomainConfiguration configB = configFor("b.org", "https://bundle.b.org/keys.json"); + when(appConfiguration.getSpiffeTrustDomains()).thenReturn(Arrays.asList(configA, configB)); + doReturn(bundleWithKeys(x509SvidKey(CERT_BASE64))).when(spiffeBundleService).fetchBundle("https://bundle.a.org/keys.json"); + doReturn(bundleWithKeys()).when(spiffeBundleService).fetchBundle("https://bundle.b.org/keys.json"); + + Set anchorsA = spiffeBundleService.getX509TrustAnchors("a.org"); + Set anchorsB = spiffeBundleService.getX509TrustAnchors("b.org"); + + assertEquals(anchorsA.size(), 1); + assertTrue(anchorsB.isEmpty()); + verify(spiffeBundleService, times(1)).fetchBundle(eq("https://bundle.a.org/keys.json")); + verify(spiffeBundleService, times(1)).fetchBundle(eq("https://bundle.b.org/keys.json")); + } + + private SpiffeTrustDomainConfiguration configFor(String trustDomain, String url) { + return new SpiffeTrustDomainConfiguration(trustDomain, url, 60); + } + + private JSONObject bundleWithKeys(JSONObject... keys) { + JSONArray array = new JSONArray(); + for (JSONObject key : keys) { + array.put(key); + } + return new JSONObject().put("keys", array); + } + + private JSONObject x509SvidKey(String x5cBase64) { + JSONObject key = new JSONObject().put("use", "x509-svid"); + if (x5cBase64 != null) { + key.put("x5c", new JSONArray().put(x5cBase64)); + } + return key; + } + + private JSONObject jwtSvidKey(String kid) { + return new JSONObject().put("use", "jwt-svid").put("kid", kid).put("kty", "RSA"); + } + + /** + * Reflectively back-dates the cached entry's expiry so the next call is forced to attempt a + * re-fetch, without sleeping past the real (minutes-scale) cache TTL. + */ + private void forceCacheEntryExpired(String trustDomain) throws Exception { + java.lang.reflect.Field cacheField = SpiffeBundleService.class.getDeclaredField("cache"); + cacheField.setAccessible(true); + java.util.concurrent.ConcurrentHashMap cache = (java.util.concurrent.ConcurrentHashMap) cacheField.get(spiffeBundleService); + Object cacheEntry = cache.get(trustDomain); + java.lang.reflect.Field expiresAtField = cacheEntry.getClass().getDeclaredField("expiresAtMillis"); + expiresAtField.setAccessible(true); + expiresAtField.set(cacheEntry, 0L); + } +} From 3d20850f8680f7931e3844eae710ba156b5fc455 Mon Sep 17 00:00:00 2001 From: yuriyz Date: Fri, 17 Jul 2026 17:54:55 +0300 Subject: [PATCH 6/8] feat(jans-auth-server): added SPIFFE utils Signed-off-by: YuriyZ --- .../io/jans/as/model/util/SpiffeIdUtil.java | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 jans-auth-server/model/src/main/java/io/jans/as/model/util/SpiffeIdUtil.java diff --git a/jans-auth-server/model/src/main/java/io/jans/as/model/util/SpiffeIdUtil.java b/jans-auth-server/model/src/main/java/io/jans/as/model/util/SpiffeIdUtil.java new file mode 100644 index 00000000000..90f923225c5 --- /dev/null +++ b/jans-auth-server/model/src/main/java/io/jans/as/model/util/SpiffeIdUtil.java @@ -0,0 +1,147 @@ +/* + * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text. + * + * Copyright (c) 2026, Janssen Project + */ + +package io.jans.as.model.util; + +import org.apache.commons.lang3.StringUtils; + +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Utility for parsing and matching SPIFFE IDs (spiffe://trust-domain/path), used by SPIFFE-based + * client authentication (draft-ietf-oauth-spiffe-client-auth). + * + * @author Yuriy Zabrovarnyy + */ +public class SpiffeIdUtil { + + public static final String SCHEME = "spiffe"; + public static final String WILDCARD_SUFFIX = "/*"; + + private SpiffeIdUtil() { + } + + /** + * Validates the syntax of a SPIFFE ID as presented in a credential (X.509-SVID URI SAN or + * JWT-SVID `sub` claim). Presented SPIFFE IDs must be concrete: they must not carry the + * "/*" wildcard suffix, which is only meaningful in a *registered* client's `spiffe_id` + * metadata. + */ + public static boolean isValidPresentedSpiffeId(String value) { + return isValidSpiffeId(value, false); + } + + /** + * Validates the syntax of a `spiffe_id` value as registered in client metadata, which may + * carry a trailing "/*" for path-segment prefix matching against presented SVIDs. + */ + public static boolean isValidRegisteredSpiffeId(String value) { + return isValidSpiffeId(value, true); + } + + private static boolean isValidSpiffeId(String value, boolean allowWildcard) { + if (StringUtils.isBlank(value)) { + return false; + } + + String toParse = value; + if (value.endsWith(WILDCARD_SUFFIX)) { + if (!allowWildcard) { + return false; + } + toParse = value.substring(0, value.length() - WILDCARD_SUFFIX.length()); + if (toParse.isEmpty()) { + return false; + } + } + + final URI uri; + try { + uri = new URI(toParse); + } catch (URISyntaxException e) { + return false; + } + + if (!SCHEME.equalsIgnoreCase(uri.getScheme())) { + return false; + } + if (StringUtils.isBlank(uri.getHost())) { + return false; + } + if (uri.getQuery() != null || uri.getFragment() != null || uri.getUserInfo() != null) { + return false; + } + + final String path = uri.getPath(); + if (path != null) { + for (String segment : path.split("/")) { + if (".".equals(segment) || "..".equals(segment)) { + return false; + } + } + } + return true; + } + + /** + * Returns the trust domain (authority component, lower-cased) of a SPIFFE ID, e.g. + * "example.org" for "spiffe://example.org/my-workload". Returns null if the value is not a + * syntactically valid SPIFFE ID (wildcard suffix, if present, is ignored for this purpose). + */ + public static String trustDomainOf(String spiffeId) { + if (StringUtils.isBlank(spiffeId)) { + return null; + } + String toParse = spiffeId.endsWith(WILDCARD_SUFFIX) + ? spiffeId.substring(0, spiffeId.length() - WILDCARD_SUFFIX.length()) + : spiffeId; + try { + final URI uri = new URI(toParse); + final String host = uri.getHost(); + return host != null ? host.toLowerCase() : null; + } catch (URISyntaxException e) { + return null; + } + } + + /** + * Matches a presented (concrete) SPIFFE ID against a registered SPIFFE ID pattern. + *

+ * A registered pattern with a trailing "/*" matches any presented ID that shares its trust + * domain and whose path starts with the pattern's path as a full path-segment prefix, e.g. + * "spiffe://example.org/client/*" matches "spiffe://example.org/client/123" but not + * "spiffe://example.org/client123". A registered pattern without a wildcard requires an + * exact match. + */ + public static boolean matches(String registeredSpiffeId, String presentedSpiffeId) { + if (StringUtils.isBlank(registeredSpiffeId) || StringUtils.isBlank(presentedSpiffeId)) { + return false; + } + + if (!registeredSpiffeId.endsWith(WILDCARD_SUFFIX)) { + return registeredSpiffeId.equals(presentedSpiffeId); + } + + final String prefix = registeredSpiffeId.substring(0, registeredSpiffeId.length() - WILDCARD_SUFFIX.length()); + if (!presentedSpiffeId.startsWith(prefix + "/")) { + return false; + } + + // require an exact trust domain match, not merely a string-prefix coincidence + final String registeredTrustDomain = trustDomainOf(registeredSpiffeId); + final String presentedTrustDomain = trustDomainOf(presentedSpiffeId); + return registeredTrustDomain != null && registeredTrustDomain.equals(presentedTrustDomain); + } + + /** + * True if the registered `spiffe_id` metadata value is a wildcard pattern (ends with "/*"), + * meaning multiple concrete SVIDs under that prefix can authenticate as the same client. + */ + public static boolean isWildcard(String registeredSpiffeId) { + return registeredSpiffeId != null && registeredSpiffeId.endsWith(WILDCARD_SUFFIX); + } +} From baa41c62b329843072dd153de06fa3639489685f Mon Sep 17 00:00:00 2001 From: yuriyz Date: Mon, 20 Jul 2026 12:54:38 +0300 Subject: [PATCH 7/8] feat(jans-auth-server): added test for SPIFFE utils Signed-off-by: YuriyZ --- .../jans/as/model/util/SpiffeIdUtilTest.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 jans-auth-server/model/src/test/java/io/jans/as/model/util/SpiffeIdUtilTest.java diff --git a/jans-auth-server/model/src/test/java/io/jans/as/model/util/SpiffeIdUtilTest.java b/jans-auth-server/model/src/test/java/io/jans/as/model/util/SpiffeIdUtilTest.java new file mode 100644 index 00000000000..775d97955c6 --- /dev/null +++ b/jans-auth-server/model/src/test/java/io/jans/as/model/util/SpiffeIdUtilTest.java @@ -0,0 +1,109 @@ +/* + * Janssen Project software is available under the Apache License (2004). See http://www.apache.org/licenses/ for full text. + * + * Copyright (c) 2026, Janssen Project + */ + +package io.jans.as.model.util; + +import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * @author Yuriy Zabrovarnyy + */ +public class SpiffeIdUtilTest { + + @Test + public void isValidPresentedSpiffeId_withValidId_returnsTrue() { + assertTrue(SpiffeIdUtil.isValidPresentedSpiffeId("spiffe://example.org/my-workload")); + } + + @Test + public void isValidPresentedSpiffeId_withWildcard_returnsFalse() { + assertFalse(SpiffeIdUtil.isValidPresentedSpiffeId("spiffe://example.org/client/*")); + } + + @Test + public void isValidPresentedSpiffeId_withWrongScheme_returnsFalse() { + assertFalse(SpiffeIdUtil.isValidPresentedSpiffeId("https://example.org/my-workload")); + } + + @Test + public void isValidPresentedSpiffeId_withNoTrustDomain_returnsFalse() { + assertFalse(SpiffeIdUtil.isValidPresentedSpiffeId("spiffe:///my-workload")); + } + + @Test + public void isValidPresentedSpiffeId_withDotDotSegment_returnsFalse() { + assertFalse(SpiffeIdUtil.isValidPresentedSpiffeId("spiffe://example.org/../my-workload")); + } + + @Test + public void isValidPresentedSpiffeId_withBlank_returnsFalse() { + assertFalse(SpiffeIdUtil.isValidPresentedSpiffeId("")); + assertFalse(SpiffeIdUtil.isValidPresentedSpiffeId(null)); + } + + @Test + public void isValidRegisteredSpiffeId_withWildcard_returnsTrue() { + assertTrue(SpiffeIdUtil.isValidRegisteredSpiffeId("spiffe://example.org/client/*")); + } + + @Test + public void isValidRegisteredSpiffeId_withBareWildcard_matchesEntireTrustDomain() { + // "spiffe://example.org/*" is well-formed: an empty prefix matches any path under the trust domain. + assertTrue(SpiffeIdUtil.isValidRegisteredSpiffeId("spiffe://example.org/*")); + assertTrue(SpiffeIdUtil.matches("spiffe://example.org/*", "spiffe://example.org/anything")); + } + + @Test + public void trustDomainOf_withExactId_returnsTrustDomain() { + assertEquals(SpiffeIdUtil.trustDomainOf("spiffe://Example.ORG/my-workload"), "example.org"); + } + + @Test + public void trustDomainOf_withWildcardId_returnsTrustDomain() { + assertEquals(SpiffeIdUtil.trustDomainOf("spiffe://example.org/client/*"), "example.org"); + } + + @Test + public void trustDomainOf_withInvalidId_returnsNull() { + assertNull(SpiffeIdUtil.trustDomainOf("not-a-spiffe-id")); + assertNull(SpiffeIdUtil.trustDomainOf("")); + } + + @Test + public void matches_withExactRegisteredId_requiresExactMatch() { + assertTrue(SpiffeIdUtil.matches("spiffe://example.org/client", "spiffe://example.org/client")); + assertFalse(SpiffeIdUtil.matches("spiffe://example.org/client", "spiffe://example.org/client/123")); + } + + @Test + public void matches_withWildcardRegisteredId_matchesPathSegmentPrefix() { + assertTrue(SpiffeIdUtil.matches("spiffe://example.org/client/*", "spiffe://example.org/client/123")); + assertFalse(SpiffeIdUtil.matches("spiffe://example.org/client/*", "spiffe://example.org/client123")); + } + + @Test + public void matches_withWildcardRegisteredId_requiresSameTrustDomain() { + assertFalse(SpiffeIdUtil.matches("spiffe://example.org/client/*", "spiffe://evil.org/client/123")); + } + + @Test + public void matches_withBlankValues_returnsFalse() { + assertFalse(SpiffeIdUtil.matches(null, "spiffe://example.org/client")); + assertFalse(SpiffeIdUtil.matches("spiffe://example.org/client", null)); + } + + @Test + public void isWildcard_detectsTrailingWildcardSuffix() { + assertTrue(SpiffeIdUtil.isWildcard("spiffe://example.org/client/*")); + assertFalse(SpiffeIdUtil.isWildcard("spiffe://example.org/client")); + assertFalse(SpiffeIdUtil.isWildcard(null)); + } +} From 170bbe52a29ffacb9a40549bb6742bbb978e41bd Mon Sep 17 00:00:00 2001 From: yuriyz Date: Tue, 21 Jul 2026 17:00:45 +0300 Subject: [PATCH 8/8] feat(jans-auth-server): added authentication by SPIFFE Signed-off-by: YuriyZ --- .../as/server/auth/AuthenticationFilter.java | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/jans-auth-server/server/src/main/java/io/jans/as/server/auth/AuthenticationFilter.java b/jans-auth-server/server/src/main/java/io/jans/as/server/auth/AuthenticationFilter.java index c0fd71a3006..94616fd80b1 100644 --- a/jans-auth-server/server/src/main/java/io/jans/as/server/auth/AuthenticationFilter.java +++ b/jans-auth-server/server/src/main/java/io/jans/as/server/auth/AuthenticationFilter.java @@ -12,6 +12,7 @@ import io.jans.as.model.authorize.AuthorizeRequestParam; import io.jans.as.model.common.AuthenticationMethod; import io.jans.as.model.common.ExchangeTokenType; +import io.jans.as.model.common.FeatureFlagType; import io.jans.as.model.common.GrantType; import io.jans.as.model.common.SubjectTokenType; import io.jans.as.model.config.Constants; @@ -30,6 +31,7 @@ import io.jans.as.server.model.common.AuthorizationGrant; import io.jans.as.server.model.common.AuthorizationGrantList; import io.jans.as.server.model.token.ClientAssertion; +import io.jans.as.server.model.token.SpiffeJwtSvidAssertion; import io.jans.as.server.model.token.HttpAuthTokenType; import io.jans.as.server.service.*; import io.jans.as.server.service.external.ExternalClientAuthnService; @@ -107,6 +109,9 @@ public class AuthenticationFilter implements Filter { @Inject private ClientService clientService; + @Inject + private ClientIdMetadataService clientIdMetadataService; + @Inject private ClientFilterService clientFilterService; @@ -328,7 +333,7 @@ private boolean processMTLS(HttpServletRequest httpRequest, HttpServletResponse final String clientId = httpRequest.getParameter(Constants.CLIENT_ID); if (StringUtils.isNotBlank(clientId)) { - final Client client = clientService.getClient(clientId); + final Client client = resolveClientForTokenEndpointAuthn(clientId); if (client != null && (client.hasAuthenticationMethod(AuthenticationMethod.TLS_CLIENT_AUTH) || client.hasAuthenticationMethod(AuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH))) { @@ -345,6 +350,28 @@ private boolean processMTLS(HttpServletRequest httpRequest, HttpServletResponse return false; } + /** + * Resolves a client for token-endpoint client authentication (mTLS, JWT assertion), also + * consulting Client ID Metadata Document (CIMD) resolution for URL-shaped client_ids - + * {@link #clientService}'s direct DN lookup alone cannot resolve those. This matters for + * SPIFFE-based client authentication when the client_id is a CIMD URL per + * draft-ietf-oauth-spiffe-client-auth section 7. + */ + private Client resolveClientForTokenEndpointAuthn(String clientId) { + if (StringUtils.isBlank(clientId)) { + return null; + } + if (clientIdMetadataService.isCimdClientId(clientId)) { + try { + return clientIdMetadataService.getClient(clientId); + } catch (RuntimeException e) { + log.debug("Failed to resolve CIMD client_id: {}", clientId, e); + return null; + } + } + return clientService.getClient(clientId); + } + private void processAuthByAccessToken(String accessToken, HttpServletRequest httpRequest, HttpServletResponse httpResponse, FilterChain filterChain) { try { log.trace("Authenticating client by access token {} ...", accessToken); @@ -568,6 +595,21 @@ private void processJwtAuth(HttpServletRequest servletRequest, HttpServletRespon identity.getCredentials().setUsername(username); identity.getCredentials().setPassword(password); + authenticator.authenticateClient(servletRequest, true); + authorized = true; + } + } else if (clientAssertionType == ClientAssertionType.SPIFFE_JWT + && appConfiguration.isFeatureEnabled(FeatureFlagType.SPIFFE_CLIENT_AUTH)) { + SpiffeJwtSvidAssertion spiffeAssertion = new SpiffeJwtSvidAssertion(appConfiguration, cryptoProvider, clientId, encodedAssertion); + spiffeAssertion.initAndVerify(); + + String username = spiffeAssertion.getClient().getClientId(); + + if (!username.equals(identity.getCredentials().getUsername()) || !identity.isLoggedIn()) { + identity.logout(); + identity.getCredentials().setUsername(username); + identity.getCredentials().setPassword(null); + authenticator.authenticateClient(servletRequest, true); authorized = true; }