mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 12:05:49 -06:00
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:
committed by
Marek Posolda
parent
741c0ad959
commit
a1bffa3ddc
@@ -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<JWK> 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("/$", "");
|
||||
}
|
||||
|
||||
@@ -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<SignatureVerifierContext> 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;
|
||||
|
||||
@@ -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
|
||||
* <p/>
|
||||
@@ -35,6 +39,7 @@ import org.keycloak.wellknown.WellKnownProvider;
|
||||
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user