Fix scope validation for realm-level credential definitions in Authorization Code flow (#39148)

Closes #39130

Signed-off-by: Awambeng Rodrick <awambengrodrick@gmail.com>
(cherry picked from commit ca3859b0f821587dfc4be31daef77c3a3e273e77)
This commit is contained in:
Awambeng 2025-05-07 12:56:06 +01:00 committed by Marek Posolda
parent 28ea8bc221
commit 60445d2a9f
2 changed files with 94 additions and 11 deletions

View File

@ -292,23 +292,33 @@ public class OID4VCIssuerEndpoint {
private void checkScope(CredentialRequest credentialRequestVO) {
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
String vcIssuanceFlow = clientSession.getNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
if (vcIssuanceFlow == null || !vcIssuanceFlow.equals(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)) {
// authz code flow
ClientModel client = clientSession.getClient();
// Authorization Code Flow
RealmModel realm = session.getContext().getRealm();
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
String scope = client.getAttributes().get("vc." + credentialIdentifier + ".scope"); // following credential identifier in client attribute
String scope = realm.getAttribute("vc." + credentialIdentifier + ".scope");
AccessToken accessToken = bearerTokenAuthenticator.authenticate().getToken();
if (Arrays.stream(accessToken.getScope().split(" ")).sequential().noneMatch(i -> i.equals(scope))) {
LOGGER.debugf("Scope check failure: credentialIdentifier = %s, required scope = %s, scope in access token = %s.", credentialIdentifier, scope, accessToken.getScope());
throw new CorsErrorResponseException(cors, ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(), "Scope check failure", Response.Status.BAD_REQUEST);
if (scope == null || Arrays.stream(accessToken.getScope().split(" "))
.noneMatch(tokenScope -> tokenScope.equals(scope))) {
LOGGER.debugf("Scope check failure: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
credentialIdentifier, scope, accessToken.getScope());
throw new CorsErrorResponseException(cors,
ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(),
"Scope check failure",
Response.Status.BAD_REQUEST);
} else {
LOGGER.debugf("Scope check success: credentialIdentifier = %s, required scope = %s, scope in access token = %s.", credentialIdentifier, scope, accessToken.getScope());
LOGGER.debugf("Scope check success: credentialIdentifier = %s, required scope = %s, scope in access token = %s.",
credentialIdentifier, scope, accessToken.getScope());
}
} else {
clientSession.removeNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
}
}
/**
* Returns a verifiable credential
*/

View File

@ -30,9 +30,11 @@ import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.VerificationException;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
@ -48,13 +50,14 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@ -415,16 +418,30 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception {
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(testScope)),
// Set the realm attribute for the required scope
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
RealmRepresentation rep = realm.toRepresentation();
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
attributes.put("vc.test-credential.scope", "VerifiableCredential");
rep.setAttributes(attributes);
realm.update(rep);
testCredentialIssuanceWithAuthZCodeFlow(
(testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("VerifiableCredential")),
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
assertEquals("Credential identifier should match", "test-credential", credentialRequest.getCredentialIdentifier());
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
if (response.getStatus() != 200) {
String errorBody = response.readEntity(String.class);
System.out.println("Error Response: " + errorBody);
}
assertEquals(200, response.getStatus());
CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class), CredentialResponse.class);
assertEquals(200, response.getStatus());
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialResponse.getCredential(), JsonWebToken.class).getToken();
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
@ -433,7 +450,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
assertEquals(TEST_DID, credential.getIssuer());
assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email"));
} catch (IOException | VerificationException e) {
Assert.fail();
Assert.fail("Failed to process credential response: " + e.getMessage());
}
});
}
@ -466,4 +483,60 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
});
}
@Test
public void testCredentialIssuanceWithRealmScopeUnmatched() throws Exception {
// Set the realm attribute for the required scope
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
RealmRepresentation rep = realm.toRepresentation();
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
attributes.put("vc.test-credential.scope", "VerifiableCredential");
rep.setAttributes(attributes);
realm.update(rep);
// Run the flow with a non-matching scope
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")),
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
assertEquals(400, response.getStatus());
String errorJson = response.readEntity(String.class);
assertNotNull("Error response should not be null", errorJson);
assertTrue("Error response should mention UNSUPPORTED_CREDENTIAL_TYPE or scope",
errorJson.contains("UNSUPPORTED_CREDENTIAL_TYPE") || errorJson.contains("scope"));
}
});
}
@Test
public void testCredentialIssuanceWithRealmScopeMissing() throws Exception {
// Remove the realm attribute for the required scope
RealmResource realm = adminClient.realm(TEST_REALM_NAME);
RealmRepresentation rep = realm.toRepresentation();
Map<String, String> attributes = rep.getAttributes() != null ? new HashMap<>(rep.getAttributes()) : new HashMap<>();
attributes.remove("vc.test-credential.scope");
rep.setAttributes(attributes);
realm.update(rep);
// Run the flow with a scope in the access token, but no realm attribute
testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("VerifiableCredential")),
m -> {
String accessToken = (String) m.get("accessToken");
WebTarget credentialTarget = (WebTarget) m.get("credentialTarget");
CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest");
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken).post(Entity.json(credentialRequest))) {
assertEquals(400, response.getStatus());
String errorJson = response.readEntity(String.class);
Map<String, Object> errorMap = JsonSerialization.readValue(errorJson, Map.class);
assertTrue("Error should contain 'error' field", errorMap.containsKey("error"));
assertEquals("UNSUPPORTED_CREDENTIAL_TYPE", errorMap.get("error"));
assertEquals("Scope check failure", errorMap.get("error_description"));
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
}