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

@@ -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");
}

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.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()) {