Add spec-compliant jwt vc issuer well-known endpoint

- expose /.well-known/jwt-vc-issuer/realms/{realm} and keep legacy route with deprecation headers
- build consumer metadata URL per draft-ietf-oauth-sd-jwt-vc-13 and add realm-path coverage
- add integration test for new path plus deprecation headers on legacy endpoint

Closes #44256

Signed-off-by: Awambeng Rodrick <awambengrodrick@gmail.com>
Signed-off-by: Awambeng <awambengrodrick@gmail.com>
This commit is contained in:
Awambeng Rodrick
2025-12-12 12:23:46 +01:00
committed by Marek Posolda
parent 741c0ad959
commit a1bffa3ddc
6 changed files with 163 additions and 14 deletions

View File

@@ -17,6 +17,8 @@
package org.keycloak.sdjwt.consumer; package org.keycloak.sdjwt.consumer;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
@@ -158,11 +160,9 @@ public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
} }
private List<JWK> fetchIssuerMetadataJwks(String issuerUri) throws VerificationException { private List<JWK> fetchIssuerMetadataJwks(String issuerUri) throws VerificationException {
// Build full URL to JWT VC metadata endpoint // Build full URL to JWT VC metadata endpoint according to draft-ietf-oauth-sd-jwt-vc-13
String normalizedIssuerUri = normalizeUri(issuerUri);
issuerUri = normalizeUri(issuerUri); String jwtVcIssuerUri = buildJwtVcIssuerMetadataUri(normalizedIssuerUri);
String jwtVcIssuerUri = issuerUri
.concat(JWT_VC_ISSUER_END_POINT); // Append well-known path
// Fetch and parse metadata // Fetch and parse metadata
@@ -179,10 +179,10 @@ public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
String exposedIssuerUri = normalizeUri(issuerMetadata.getIssuer()); String exposedIssuerUri = normalizeUri(issuerMetadata.getIssuer());
if (!issuerUri.equals(exposedIssuerUri)) { if (!normalizedIssuerUri.equals(exposedIssuerUri)) {
throw new VerificationException(String.format( throw new VerificationException(String.format(
"Unexpected metadata's issuer. Expected=%s, Got=%s", "Unexpected metadata's issuer. Expected=%s, Got=%s",
issuerUri, exposedIssuerUri normalizedIssuerUri, exposedIssuerUri
)); ));
} }
@@ -211,6 +211,26 @@ public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
return Arrays.asList(jwks.getKeys()); return Arrays.asList(jwks.getKeys());
} }
static String buildJwtVcIssuerMetadataUri(String issuerUri) throws VerificationException {
try {
URI parsedIssuer = URI.create(issuerUri);
String issuerPath = Optional.ofNullable(parsedIssuer.getRawPath()).orElse("");
String metadataPath = JWT_VC_ISSUER_END_POINT + issuerPath;
URI metadata = new URI(
parsedIssuer.getScheme(),
parsedIssuer.getAuthority(),
metadataPath,
null,
null
);
return metadata.toString();
} catch (IllegalArgumentException | URISyntaxException ex) {
throw new VerificationException("Invalid issuer URI", ex);
}
}
private JsonNode fetchData(String uri) throws VerificationException { private JsonNode fetchData(String uri) throws VerificationException {
try { try {
return Objects.requireNonNull(httpDataFetcher.fetchJsonData(uri)); return Objects.requireNonNull(httpDataFetcher.fetchJsonData(uri));
@@ -222,7 +242,7 @@ public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer {
} }
} }
private String normalizeUri(String uri) { private static String normalizeUri(String uri) {
// Remove any trailing slash // Remove any trailing slash
return uri.replaceAll("/$", ""); return uri.replaceAll("/$", "");
} }

View File

@@ -37,7 +37,6 @@ import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import static org.keycloak.OID4VCConstants.CLAIM_NAME_ISSUER; import static org.keycloak.OID4VCConstants.CLAIM_NAME_ISSUER;
import static org.keycloak.OID4VCConstants.JWT_VC_ISSUER_END_POINT;
import static org.hamcrest.CoreMatchers.endsWith; import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
@@ -67,6 +66,24 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest {
assertEquals(3, keys.size()); assertEquals(3, keys.size());
} }
@Test
public void shouldResolveIssuerVerifyingKeysWithRealmPath() throws Exception {
String issuerUri = "https://issuer.example.com/realms/test-realm";
ObjectNode metadata = SdJwtUtils.mapper.createObjectNode();
metadata.put("issuer", issuerUri);
metadata.set("jwks", exampleJwks());
TrustedSdJwtIssuer trustedIssuer = new JwtVcMetadataTrustedSdJwtIssuer(
issuerUri, mockHttpDataFetcherWithMetadataAndJwks(issuerUri, metadata, exampleJwks()));
IssuerSignedJWT issuerSignedJWT = exampleIssuerSignedJwtWithIssuer(issuerUri);
List<SignatureVerifierContext> keys = trustedIssuer
.resolveIssuerVerifyingKeys(issuerSignedJWT);
assertEquals(3, keys.size());
}
@Test @Test
public void shouldResolveKeys_WhenIssuerTrustedOnRegexPattern() throws Exception { public void shouldResolveKeys_WhenIssuerTrustedOnRegexPattern() throws Exception {
Pattern issuerUriRegex = Pattern.compile("https://.*\\.example\\.com"); Pattern issuerUriRegex = Pattern.compile("https://.*\\.example\\.com");
@@ -405,12 +422,16 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest {
private HttpDataFetcher mockHttpDataFetcherWithMetadataAndJwks( private HttpDataFetcher mockHttpDataFetcherWithMetadataAndJwks(
String issuer, JsonNode metadata, JsonNode jwks String issuer, JsonNode metadata, JsonNode jwks
) { ) {
return uri -> { String normalizedIssuer = issuer.replaceAll("/$", "");
if (!uri.startsWith(issuer)) { String metadataUri;
throw new UnknownHostException("Unavailable URI"); try {
} metadataUri = JwtVcMetadataTrustedSdJwtIssuer.buildJwtVcIssuerMetadataUri(normalizedIssuer);
} catch (VerificationException e) {
throw new IllegalArgumentException(e);
}
if (uri.endsWith(JWT_VC_ISSUER_END_POINT)) { return uri -> {
if (uri.equals(metadataUri)) {
return metadata; return metadata;
} else if (uri.endsWith("/api/vci/jwks")) { } else if (uri.endsWith("/api/vci/jwks")) {
return jwks; return jwks;

View File

@@ -18,15 +18,19 @@ package org.keycloak.protocol.oid4vc.issuance;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import org.keycloak.http.HttpResponse;
import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oid4vc.model.JWTVCIssuerMetadata; import org.keycloak.protocol.oid4vc.model.JWTVCIssuerMetadata;
import org.keycloak.protocol.oidc.utils.JWKSServerUtils; import org.keycloak.protocol.oidc.utils.JWKSServerUtils;
import org.keycloak.services.Urls; import org.keycloak.services.Urls;
import org.keycloak.services.resources.ServerMetadataResource;
import org.keycloak.urls.UrlType; import org.keycloak.urls.UrlType;
import org.keycloak.wellknown.WellKnownProvider; import org.keycloak.wellknown.WellKnownProvider;
import org.jboss.logging.Logger;
/** /**
* {@link WellKnownProvider} implementation for JWT VC Issuer metadata at endpoint /.well-known/jwt-vc-issuer * {@link WellKnownProvider} implementation for JWT VC Issuer metadata at endpoint /.well-known/jwt-vc-issuer
* <p/> * <p/>
@@ -35,6 +39,7 @@ import org.keycloak.wellknown.WellKnownProvider;
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a> * @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/ */
public class JWTVCIssuerWellKnownProvider implements WellKnownProvider { public class JWTVCIssuerWellKnownProvider implements WellKnownProvider {
private static final Logger LOGGER = Logger.getLogger(JWTVCIssuerWellKnownProvider.class);
private final KeycloakSession session; private final KeycloakSession session;
public JWTVCIssuerWellKnownProvider(KeycloakSession session) { public JWTVCIssuerWellKnownProvider(KeycloakSession session) {
@@ -51,6 +56,8 @@ public class JWTVCIssuerWellKnownProvider implements WellKnownProvider {
UriInfo frontendUriInfo = session.getContext().getUri(UrlType.FRONTEND); UriInfo frontendUriInfo = session.getContext().getUri(UrlType.FRONTEND);
RealmModel realm = session.getContext().getRealm(); RealmModel realm = session.getContext().getRealm();
addDeprecationHeadersIfOldRoute();
JWTVCIssuerMetadata config = new JWTVCIssuerMetadata(); JWTVCIssuerMetadata config = new JWTVCIssuerMetadata();
config.setIssuer(Urls.realmIssuer(frontendUriInfo.getBaseUri(), realm.getName())); config.setIssuer(Urls.realmIssuer(frontendUriInfo.getBaseUri(), realm.getName()));
@@ -59,4 +66,39 @@ public class JWTVCIssuerWellKnownProvider implements WellKnownProvider {
return config; return config;
} }
/**
* Attach deprecation headers/log for the legacy realm-scoped route:
* old: /realms/{realm}/.well-known/jwt-vc-issuer
* new: /.well-known/jwt-vc-issuer/realms/{realm}
*/
private void addDeprecationHeadersIfOldRoute() {
String requestPath = session.getContext().getUri().getRequestUri().getPath();
if (requestPath == null) {
return;
}
int idxRealms = requestPath.indexOf("/realms/");
int idxWellKnown = requestPath.indexOf("/.well-known/");
boolean isOldRoute = idxRealms >= 0 && idxWellKnown > idxRealms;
if (!isOldRoute) {
return;
}
var realm = session.getContext().getRealm();
if (realm == null) {
return;
}
var base = session.getContext().getUri().getBaseUriBuilder();
var successor = ServerMetadataResource.wellKnownOAuthProviderUrl(base)
.build(JWTVCIssuerWellKnownProviderFactory.PROVIDER_ID, realm.getName());
HttpResponse httpResponse = session.getContext().getHttpResponse();
httpResponse.setHeader("Warning", "299 - \"Deprecated endpoint; use " + successor + "\"");
httpResponse.setHeader("Deprecation", "true");
httpResponse.setHeader("Link", "<" + successor + ">; rel=\"successor-version\"");
LOGGER.warnf("Deprecated realm-scoped well-known endpoint accessed for JWT VC issuer in realm '%s'. Use %s instead.", realm.getName(), successor);
}
} }

View File

@@ -30,6 +30,7 @@ import jakarta.ws.rs.ext.Provider;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory; import org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory;
import org.keycloak.protocol.oid4vc.issuance.JWTVCIssuerWellKnownProviderFactory;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory;
import org.keycloak.services.cors.Cors; import org.keycloak.services.cors.Cors;
@@ -73,6 +74,7 @@ public class ServerMetadataResource {
// you can add codes here considering the current status of the implementation (preview, experimental). // you can add codes here considering the current status of the implementation (preview, experimental).
if (OAuth2WellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true; if (OAuth2WellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true;
if (OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true; if (OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true;
if (JWTVCIssuerWellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true;
return false; return false;
} }
} }

View File

@@ -70,6 +70,7 @@ import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.JWTVCIssuerWellKnownProviderFactory;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse; import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsResponse;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider; import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
@@ -561,6 +562,17 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return contextRoot + "/auth/.well-known/openid-credential-issuer/realms/" + realm; return contextRoot + "/auth/.well-known/openid-credential-issuer/realms/" + realm;
} }
protected String getSpecCompliantRealmMetadataPath(String realm) {
var contextRoot = suiteContext.getAuthServerInfo().getContextRoot();
// [TODO] This should be contextRoot/.well-known/jwt-vc-issuer/auth/realms/...
return contextRoot + "/auth/.well-known/" + JWTVCIssuerWellKnownProviderFactory.PROVIDER_ID + "/realms/" + realm;
}
protected String getLegacyJwtVcRealmMetadataPath(String realm) {
var contextRoot = suiteContext.getAuthServerInfo().getContextRoot();
return contextRoot + "/auth/realms/" + realm + "/.well-known/" + JWTVCIssuerWellKnownProviderFactory.PROVIDER_ID;
}
protected String getCredentialOfferUriUrl(String configId) { protected String getCredentialOfferUriUrl(String configId) {
return getCredentialOfferUriUrl(configId, true, "john"); return getCredentialOfferUriUrl(configId, true, "john");
} }

View File

@@ -59,6 +59,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.DisplayObject; import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.JWTVCIssuerMetadata;
import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired; import org.keycloak.protocol.oid4vc.model.KeyAttestationsRequired;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported; import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
@@ -239,6 +240,57 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
} }
} }
@Test
public void shouldServeJwtVcMetadataAtSpecCompliantEndpoint() {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
String realm = TEST_REALM_NAME;
String wellKnownUri = getSpecCompliantRealmMetadataPath(realm);
String expectedIssuer = getRealmPath(realm);
HttpGet get = new HttpGet(wellKnownUri);
get.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
try (CloseableHttpResponse response = httpClient.execute(get)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
JWTVCIssuerMetadata metadata = JsonSerialization.readValue(json, JWTVCIssuerMetadata.class);
assertNotNull(metadata);
assertEquals(expectedIssuer, metadata.getIssuer());
assertNotNull("JWKS must be present", metadata.getJwks());
}
} catch (Exception e) {
throw new RuntimeException("Failed to process spec-compliant JWT VC issuer metadata response: " + e.getMessage(), e);
}
}
@Test
public void shouldKeepLegacyJwtVcEndpointWithDeprecationHeaders() {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
String realm = TEST_REALM_NAME;
String wellKnownUri = getLegacyJwtVcRealmMetadataPath(realm); // legacy JWT VC path
HttpGet get = new HttpGet(wellKnownUri);
get.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
try (CloseableHttpResponse response = httpClient.execute(get)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
Header warning = response.getFirstHeader("Warning");
Header deprecation = response.getFirstHeader("Deprecation");
Header link = response.getFirstHeader("Link");
assertNotNull("Warning header should be present", warning);
assertTrue("Warning header should mention deprecated endpoint", warning.getValue().contains("Deprecated endpoint"));
assertNotNull("Deprecation header should be present", deprecation);
assertEquals("true", deprecation.getValue());
assertNotNull("Link header should point to successor", link);
assertTrue("Link header should reference spec-compliant endpoint",
link.getValue().contains(getSpecCompliantRealmMetadataPath(realm)));
}
} catch (Exception e) {
throw new RuntimeException("Failed to process legacy JWT VC issuer metadata response: " + e.getMessage(), e);
}
}
@Test @Test
public void testUnsignedMetadataWhenSignedDisabled() { public void testUnsignedMetadataWhenSignedDisabled() {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {