[OID4VCI] Adjust Credential Issuer Metadata endpoint, return issuer metadata at /.well-known/openid-credential-issuer/realms/{realm} (#42577)

Closes #41589

Signed-off-by: Awambeng <awambengrodrick@gmail.com>
This commit is contained in:
Awambeng 2025-09-16 09:24:44 +01:00 committed by GitHub
parent 8a94bd90f9
commit 20f9306b78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 105 additions and 2 deletions

View File

@ -17,6 +17,7 @@
package org.keycloak.protocol.oid4vc.issuance;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.apache.http.HttpHeaders;
import org.keycloak.common.util.Time;
@ -26,6 +27,7 @@ import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.http.HttpResponse;
import org.keycloak.jose.jwe.JWEConstants;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
@ -38,13 +40,15 @@ import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import java.net.URI;
import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.CredentialResponseEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.CredentialRequestEncryptionMetadata;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.JsonWebToken;
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.util.JsonSerialization;
import org.keycloak.utils.MediaType;
@ -129,6 +133,9 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
}
}
// Add deprecation headers/logs if the old realm-scoped route was used
addDeprecationHeadersIfOldRoute(keycloakSession);
return new CredentialIssuer()
.setCredentialIssuer(getIssuer(context))
.setCredentialEndpoint(getCredentialsEndpoint(context))
@ -155,6 +162,7 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
LOGGER.debugf("Falling back to JSON response due to signed metadata failure for realm: %s", realm.getName());
}
}
return issuer;
}
@ -523,4 +531,36 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
return CryptoUtils.getSupportedAsymmetricSignatureAlgorithms(session);
}
/**
* Attach OID4VCI-specific deprecation headers (and a server WARN) when the old
* realm-scoped route is used.
* old: /realms/{realm}/.well-known/openid-credential-issuer
* new: /.well-known/openid-credential-issuer/realms/{realm}
*/
private void addDeprecationHeadersIfOldRoute(KeycloakSession session) {
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;
}
UriBuilder base = session.getContext().getUri().getBaseUriBuilder();
String logKey = session.getContext().getRealm().getName();
URI successor = ServerMetadataResource.wellKnownOAuthProviderUrl(base)
.build(Oid4VciConstants.WELL_KNOWN_OPENID_CREDENTIAL_ISSUER, logKey);
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 OID4VCI in realm '%s'. Use %s instead.", logKey, successor);
}
}

View File

@ -29,8 +29,10 @@ import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory;
import org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory;
import org.keycloak.services.cors.Cors;
import static org.keycloak.utils.MediaType.APPLICATION_JWT;
import java.util.List;
@ -54,7 +56,7 @@ public class ServerMetadataResource {
@GET
@Path("{provider}/realms/{realm}")
@Produces(MediaType.APPLICATION_JSON)
@Produces({MediaType.APPLICATION_JSON, APPLICATION_JWT})
public Response getOAuth2AuthorizationServerWellKnown(final @PathParam("provider") String providerName,
final @PathParam("realm") String name) {
if (!isValidProvider(providerName)) throw new NotFoundException();
@ -68,6 +70,7 @@ public class ServerMetadataResource {
private boolean isValidProvider(String providerName) {
// 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;
return false;
}
}

View File

@ -23,6 +23,7 @@ import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.apache.http.Header;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.CloseableHttpResponse;
@ -702,6 +703,65 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
testBatchSizeValidation(testingClient, "invalid", false, null);
}
@Test
public void testOldOidcDiscoveryCompliantWellKnownUrlWithDeprecationHeaders() {
try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {
// Old OIDC Discovery compliant URL
String oldWellKnownUri = OAuthClient.AUTH_SERVER_ROOT + "/realms/" + TEST_REALM_NAME + "/.well-known/openid-credential-issuer";
String expectedIssuer = getRealmPath(TEST_REALM_NAME);
HttpGet getMetadata = new HttpGet(oldWellKnownUri);
getMetadata.addHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
try (CloseableHttpResponse response = httpClient.execute(getMetadata)) {
// Status & Content-Type
assertEquals("Old well-known URL should return 200 OK",
HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String contentType = response.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue();
assertTrue("Content-Type should be application/json",
contentType.startsWith(MediaType.APPLICATION_JSON));
// Headers
Header warning = response.getFirstHeader("Warning");
Header deprecation = response.getFirstHeader("Deprecation");
Header link = response.getFirstHeader("Link");
assertNotNull("Should have deprecation warning header", warning);
assertTrue("Warning header should contain deprecation message",
warning.getValue().contains("Deprecated endpoint"));
assertNotNull("Should have deprecation header", deprecation);
assertEquals("Deprecation header should be 'true'", "true", deprecation.getValue());
assertNotNull("Should have successor link header", link);
assertTrue("Link header should contain successor-version",
link.getValue().contains("successor-version"));
// Response body
String json = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
CredentialIssuer issuer = JsonSerialization.readValue(json, CredentialIssuer.class);
assertNotNull("Response should be a CredentialIssuer object", issuer);
assertEquals("credential_issuer should be set",
expectedIssuer, issuer.getCredentialIssuer());
assertEquals("credential_endpoint should be correct",
expectedIssuer + "/protocol/oid4vc/credential", issuer.getCredentialEndpoint());
assertEquals("nonce_endpoint should be correct",
expectedIssuer + "/protocol/oid4vc/nonce", issuer.getNonceEndpoint());
assertEquals("deferred_credential_endpoint should be correct",
expectedIssuer + "/protocol/oid4vc/deferred_credential", issuer.getDeferredCredentialEndpoint());
assertNotNull("authorization_servers should be present", issuer.getAuthorizationServers());
assertNotNull("credential_response_encryption should be present", issuer.getCredentialResponseEncryption());
assertNotNull("batch_credential_issuance should be present", issuer.getBatchCredentialIssuance());
}
} catch (Exception e) {
throw new RuntimeException("Failed to process old well-known URL response: " + e.getMessage(), e);
}
}
private void testBatchSizeValidation(KeycloakTestingClient testingClient, String batchSize, boolean shouldBePresent, Integer expectedValue) {
testingClient
.server(TEST_REALM_NAME)