Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpiffeTrustDomainConfiguration> spiffeTrustDomains;

public Boolean getUseOpenidSubAttributeValueForPairwiseLocalAccountId() {
if (useOpenidSubAttributeValueForPairwiseLocalAccountId == null) useOpenidSubAttributeValueForPairwiseLocalAccountId = false;
return useOpenidSubAttributeValueForPairwiseLocalAccountId;
Expand Down Expand Up @@ -3986,6 +3990,15 @@ public void setCimdMaxTtlMinutes(Integer cimdMaxTtlMinutes) {
this.cimdMaxTtlMinutes = cimdMaxTtlMinutes;
}

public List<SpiffeTrustDomainConfiguration> getSpiffeTrustDomains() {
if (spiffeTrustDomains == null) spiffeTrustDomains = new ArrayList<>();
return spiffeTrustDomains;
}

public void setSpiffeTrustDomains(List<SpiffeTrustDomainConfiguration> spiffeTrustDomains) {
this.spiffeTrustDomains = spiffeTrustDomains;
}

public Map<String, TrustedIssuerConfig> getIdJagTrustedIdpIssuers() {
if (idJagTrustedIdpIssuers == null) idJagTrustedIdpIssuers = new HashMap<>();
return idJagTrustedIdpIssuers;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading