diff --git a/core/src/main/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuer.java b/core/src/main/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuer.java index e97d238b200..6b68983fcd0 100644 --- a/core/src/main/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuer.java +++ b/core/src/main/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuer.java @@ -17,6 +17,8 @@ package org.keycloak.sdjwt.consumer; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -158,11 +160,9 @@ public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer { } private List fetchIssuerMetadataJwks(String issuerUri) throws VerificationException { - // Build full URL to JWT VC metadata endpoint - - issuerUri = normalizeUri(issuerUri); - String jwtVcIssuerUri = issuerUri - .concat(JWT_VC_ISSUER_END_POINT); // Append well-known path + // Build full URL to JWT VC metadata endpoint according to draft-ietf-oauth-sd-jwt-vc-13 + String normalizedIssuerUri = normalizeUri(issuerUri); + String jwtVcIssuerUri = buildJwtVcIssuerMetadataUri(normalizedIssuerUri); // Fetch and parse metadata @@ -179,10 +179,10 @@ public class JwtVcMetadataTrustedSdJwtIssuer implements TrustedSdJwtIssuer { String exposedIssuerUri = normalizeUri(issuerMetadata.getIssuer()); - if (!issuerUri.equals(exposedIssuerUri)) { + if (!normalizedIssuerUri.equals(exposedIssuerUri)) { throw new VerificationException(String.format( "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()); } + 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 { try { 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 return uri.replaceAll("/$", ""); } diff --git a/core/src/test/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuerTest.java b/core/src/test/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuerTest.java index 221e6ddea9d..3566d5912aa 100644 --- a/core/src/test/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuerTest.java +++ b/core/src/test/java/org/keycloak/sdjwt/consumer/JwtVcMetadataTrustedSdJwtIssuerTest.java @@ -37,7 +37,6 @@ import org.junit.ClassRule; import org.junit.Test; 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.MatcherAssert.assertThat; @@ -67,6 +66,24 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest { 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 keys = trustedIssuer + .resolveIssuerVerifyingKeys(issuerSignedJWT); + + assertEquals(3, keys.size()); + } + @Test public void shouldResolveKeys_WhenIssuerTrustedOnRegexPattern() throws Exception { Pattern issuerUriRegex = Pattern.compile("https://.*\\.example\\.com"); @@ -405,12 +422,16 @@ public abstract class JwtVcMetadataTrustedSdJwtIssuerTest { private HttpDataFetcher mockHttpDataFetcherWithMetadataAndJwks( String issuer, JsonNode metadata, JsonNode jwks ) { - return uri -> { - if (!uri.startsWith(issuer)) { - throw new UnknownHostException("Unavailable URI"); - } + String normalizedIssuer = issuer.replaceAll("/$", ""); + String metadataUri; + 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; } else if (uri.endsWith("/api/vci/jwks")) { return jwks; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProvider.java index 9c45008b92b..b7a6d692819 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProvider.java @@ -18,15 +18,19 @@ package org.keycloak.protocol.oid4vc.issuance; import jakarta.ws.rs.core.UriInfo; +import org.keycloak.http.HttpResponse; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oid4vc.model.JWTVCIssuerMetadata; import org.keycloak.protocol.oidc.utils.JWKSServerUtils; import org.keycloak.services.Urls; +import org.keycloak.services.resources.ServerMetadataResource; import org.keycloak.urls.UrlType; 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 *

@@ -35,6 +39,7 @@ import org.keycloak.wellknown.WellKnownProvider; * @author Francis Pouatcha */ public class JWTVCIssuerWellKnownProvider implements WellKnownProvider { + private static final Logger LOGGER = Logger.getLogger(JWTVCIssuerWellKnownProvider.class); private final KeycloakSession session; public JWTVCIssuerWellKnownProvider(KeycloakSession session) { @@ -51,6 +56,8 @@ public class JWTVCIssuerWellKnownProvider implements WellKnownProvider { UriInfo frontendUriInfo = session.getContext().getUri(UrlType.FRONTEND); RealmModel realm = session.getContext().getRealm(); + addDeprecationHeadersIfOldRoute(); + JWTVCIssuerMetadata config = new JWTVCIssuerMetadata(); config.setIssuer(Urls.realmIssuer(frontendUriInfo.getBaseUri(), realm.getName())); @@ -59,4 +66,39 @@ public class JWTVCIssuerWellKnownProvider implements WellKnownProvider { 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); + } } diff --git a/services/src/main/java/org/keycloak/services/resources/ServerMetadataResource.java b/services/src/main/java/org/keycloak/services/resources/ServerMetadataResource.java index e0a0a18ed66..b86c48bdd4f 100644 --- a/services/src/main/java/org/keycloak/services/resources/ServerMetadataResource.java +++ b/services/src/main/java/org/keycloak/services/resources/ServerMetadataResource.java @@ -30,6 +30,7 @@ import jakarta.ws.rs.ext.Provider; import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory; +import org.keycloak.protocol.oid4vc.issuance.JWTVCIssuerWellKnownProviderFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory; 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). if (OAuth2WellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true; if (OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true; + if (JWTVCIssuerWellKnownProviderFactory.PROVIDER_ID.equals(providerName)) return true; return false; } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index b6b03e9b935..27380f48107 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -70,6 +70,7 @@ import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserSessionModel; 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.OID4VCIssuerEndpoint; 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; } + 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) { return getCredentialOfferUriUrl(configId, true, "john"); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java index 552d94bd4f8..757f0bab175 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerWellKnownProviderTest.java @@ -59,6 +59,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata; import org.keycloak.protocol.oid4vc.model.DisplayObject; 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.ProofTypesSupported; 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 public void testUnsignedMetadataWhenSignedDisabled() { try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {