From 13cf752793dd088adf20e2d01566c7c4e8c2172e Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Wed, 12 Jun 2024 15:28:19 +0900 Subject: [PATCH] VC issuance in Authz Code flow with considering scope parameter closes #29725 Signed-off-by: Takashi Norimatsu --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 48 +++- .../grants/PreAuthorizedCodeGrantType.java | 5 +- .../signing/OID4VCIssuerEndpointTest.java | 220 +++++++++++++++--- 3 files changed, 232 insertions(+), 41 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 344a6e3d7578..3a444646318f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -65,6 +65,8 @@ import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; +import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.representations.AccessToken; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.utils.MediaType; @@ -76,6 +78,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.Arrays; import java.util.Base64; import java.util.Date; import java.util.HashMap; @@ -109,6 +112,8 @@ public class OID4VCIssuerEndpoint { private final Map signingServices; + private final boolean isIgnoreScopeCheck; + public OID4VCIssuerEndpoint(KeycloakSession session, String issuerDid, Map signingServices, @@ -121,9 +126,24 @@ public OID4VCIssuerEndpoint(KeycloakSession session, this.issuerDid = issuerDid; this.signingServices = signingServices; this.preAuthorizedCodeLifeSpan = preAuthorizedCodeLifeSpan; - + this.isIgnoreScopeCheck = false; } + public OID4VCIssuerEndpoint(KeycloakSession session, + String issuerDid, + Map signingServices, + AppAuthManager.BearerTokenAuthenticator authenticator, + ObjectMapper objectMapper, TimeProvider timeProvider, int preAuthorizedCodeLifeSpan, + boolean isIgnoreScopeCheck) { + this.session = session; + this.bearerTokenAuthenticator = authenticator; + this.objectMapper = objectMapper; + this.timeProvider = timeProvider; + this.issuerDid = issuerDid; + this.signingServices = signingServices; + this.preAuthorizedCodeLifeSpan = preAuthorizedCodeLifeSpan; + this.isIgnoreScopeCheck = isIgnoreScopeCheck; + } /** * Provides the URI to the OID4VCI compliant credentials offer @@ -238,6 +258,26 @@ public Response getCredentialOffer(@PathParam("nonce") String nonce) { .build(); } + 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(); + String credentialIdentifier = credentialRequestVO.getCredentialIdentifier(); + String scope = client.getAttributes().get("vc." + credentialIdentifier + ".scope"); // following credential identifier in client attribute + 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 BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); + } else { + 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 */ @@ -252,6 +292,10 @@ public Response requestCredential( // do first to fail fast on auth UserSessionModel userSessionModel = getUserSessionModel(); + if (!isIgnoreScopeCheck) { + checkScope(credentialRequestVO); + } + Format requestedFormat = credentialRequestVO.getFormat(); String requestedCredential = credentialRequestVO.getCredentialIdentifier(); @@ -426,4 +470,4 @@ private VerifiableCredential getVCToSign(List protocolMappers, Str LOGGER.debugf("The credential to sign is: %s", vc); return vc; } -} \ No newline at end of file +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java index 60534a08f1a5..d0ff1ff11a9c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java @@ -45,6 +45,8 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase { private static final Logger LOGGER = Logger.getLogger(PreAuthorizedCodeGrantType.class); + public static final String VC_ISSUANCE_FLOW = "VC-Issuance-Flow"; + @Override public Response process(Context context) { LOGGER.debug("Process grant request for preauthorized."); @@ -73,6 +75,7 @@ public Response process(Context context) { AuthenticatedClientSessionModel clientSession = result.getClientSession(); ClientSessionContext sessionContext = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, OAuth2Constants.SCOPE_OPENID, session); + clientSession.setNote(VC_ISSUANCE_FLOW, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE); // set the client as retrieved from the pre-authorized session @@ -119,4 +122,4 @@ public static String getPreAuthorizedCode(KeycloakSession session, Authenticated authenticatedClientSession.getUserSession().getId()); return OAuth2CodeParser.persistCode(session, authenticatedClientSession, oAuth2Code); } -} \ No newline at end of file +} 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 72367c0b41c1..5e69054eec6e 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 @@ -41,6 +41,8 @@ import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.common.VerificationException; import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.util.MultivaluedHashMap; @@ -48,6 +50,7 @@ import org.keycloak.crypto.Algorithm; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory; import org.keycloak.protocol.oid4vc.issuance.TimeProvider; @@ -62,14 +65,18 @@ import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.resources.RealmsResource; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.runonserver.RunOnServerException; import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.OAuthClient; @@ -81,6 +88,10 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -367,7 +378,6 @@ public void testRequestCredential() { })); } - // Tests the complete flow from // 1. Retrieving the credential-offer-uri // 2. Using the uri to get the actual credential offer @@ -442,45 +452,152 @@ public void testCredentialIssuance() throws Exception { }); } + private ClientResource findClientByClientId(RealmResource realm, String clientId) { + for (ClientRepresentation c : realm.clients().findAll()) { + if (clientId.equals(c.getClientId())) { + return realm.clients().get(c.getId()); + } + } + return null; + } + + private String registerOptionalClientScope(String scopeName) { + ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); + clientScope.setName(scopeName); + clientScope.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Response res = testRealm().clientScopes().create(clientScope); + String scopeId = ApiUtil.getCreatedId(res); + getCleanup().addClientScopeId(scopeId); // automatically removed when a test method is finished. + res.close(); + return scopeId; + } + + private void assignOptionalClientScopeToClient(String scopeId, String clientId) { + ClientResource clientResource = findClientByClientId(testRealm(), clientId); + clientResource.addOptionalClientScope(scopeId); + } + + private void addCredentialConfigurationIdToClient(String clientId, String credentialConfigurationId, String format, String scope) { + ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0); + ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId()); + + clientRepresentation.setAttributes(Map.of( + "vc." + credentialConfigurationId + ".format", format, + "vc." + credentialConfigurationId + ".scope", scope)); + clientRepresentation.setProtocolMappers( + List.of( + getRoleMapper(clientId), + getEmailMapper(), + getIdMapper(), + getStaticClaimMapper(scope), + getStaticClaimMapper("AnotherCredentialType") + ) + ); + + clientResource.update(clientRepresentation); + } + + private void removeCredentialConfigurationIdToClient(String clientId) { + ClientRepresentation clientRepresentation = adminClient.realm(TEST_REALM_NAME).clients().findByClientId(clientId).get(0); + ClientResource clientResource = adminClient.realm(TEST_REALM_NAME).clients().get(clientRepresentation.getId()); + clientRepresentation.setAttributes(Map.of()); + clientResource.update(clientRepresentation); + } + + private void testCredentialIssuanceWithAuthZCodeFlow(Consumer> c) throws Exception { + // use pre-registered client for this test class whose clientId is "test-app" defined in testrealm.json + String testClientId = "test-app"; + + // use supported values by Credential Issuer Metadata + String testCredentialConfigurationId = "test-credential"; + String testScope = "VerifiableCredential"; + String testFormat = Format.JWT_VC.toString(); + + // register optional client scope + String scopeId = registerOptionalClientScope(testScope); + + // assign registered optional client scope + assignOptionalClientScopeToClient(scopeId, testClientId); // pre-registered client for this test class + + // add credential configuration id to a client as client attributes + addCredentialConfigurationIdToClient(testClientId, testCredentialConfigurationId, testFormat, testScope); + + c.accept(Map.of( + "clientId", testClientId, + "credentialConfigurationId", testCredentialConfigurationId, + "scope", testScope, + "format", testFormat) + ); + + // clean-up + removeCredentialConfigurationIdToClient(testClientId); + oauth.clientId(null); + } // Tests the AuthZCode complete flow without scope from // 1. Get authorization code without scope specified by wallet - // 2. Using the code to get access token + // 2. Using the code to get access token // 3. Get the credential configuration id from issuer metadata at .wellKnown // 4. With the access token, get the credential + private void testCredentialIssuanceWithAuthZCodeFlow(BiFunction f, Consumer> c) throws Exception { + testCredentialIssuanceWithAuthZCodeFlow(m->{ + String testClientId = m.get("clientId"); + String testScope = m.get("scope"); + String testFormat = m.get("format"); + String testCredentialConfigurationId = m.get("credentialConfigurationId"); + + try (Client client = AdminClientUtil.createResteasyClient()) { + UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); + URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder).build(TEST_REALM_NAME, OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID); + WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri); + + // 1. Get authoriZation code without scope specified by wallet + // 2. Using the code to get accesstoken + String token = f.apply(testClientId, testScope); + + // 3. Get the credential configuration id from issuer metadata at .wellKnown + try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) { + CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class), CredentialIssuer.class); + assertEquals(200, discoveryResponse.getStatus()); + assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer()); + assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint()); + + // 4. With the access token, get the credential + try (Client clientForCredentialRequest = AdminClientUtil.createResteasyClient()) { + UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint()); + URI credentialUri = credentialUriBuilder.build(); + WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri); + + CredentialRequest request = new CredentialRequest(); + request.setFormat(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat()); + request.setCredentialIdentifier(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId()); + + assertEquals(testFormat, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat().toString()); + assertEquals(testCredentialConfigurationId, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId()); + + c.accept(Map.of( + "accessToken", token, + "credentialTarget", credentialTarget, + "credentialRequest", request + )); + } + } + } catch (IOException e) { + Assert.fail(); + } + + }); + } + @Test - public void testCredentialIssuanceWithAuthZCode() throws Exception { - - try (Client client = AdminClientUtil.createResteasyClient()) { - UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); - URI oid4vciDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder).build(TEST_REALM_NAME, OID4VCIssuerWellKnownProviderFactory.PROVIDER_ID); - WebTarget oid4vciDiscoveryTarget = client.target(oid4vciDiscoveryUri); - - // 1. Get authoriZation code without scope specified by wallet - // 2. Using the code to get accesstoken - String token = getBearerToken(oauth.openid(false).scope(null)); - - // 3. Get the credential configuration id from issuer metadata at .wellKnown - try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) { - CredentialIssuer oid4vciIssuerConfig = JsonSerialization.readValue(discoveryResponse.readEntity(String.class), CredentialIssuer.class); - assertEquals(200, discoveryResponse.getStatus()); - assertEquals(getRealmPath(TEST_REALM_NAME), oid4vciIssuerConfig.getCredentialIssuer()); - assertEquals(getBasePath(TEST_REALM_NAME) + "credential", oid4vciIssuerConfig.getCredentialEndpoint()); - - // 4. With the access token, get the credential - try (Client clientForCredentialRequest = AdminClientUtil.createResteasyClient()) { - UriBuilder credentialUriBuilder = UriBuilder.fromUri(oid4vciIssuerConfig.getCredentialEndpoint()); - URI credentialUri = credentialUriBuilder.build(); - WebTarget credentialTarget = clientForCredentialRequest.target(credentialUri); - - CredentialRequest request = new CredentialRequest(); - request.setFormat(oid4vciIssuerConfig.getCredentialsSupported().get("test-credential").getFormat()); - request.setCredentialIdentifier(oid4vciIssuerConfig.getCredentialsSupported().get("test-credential").getId()); - - assertEquals("jwt_vc", oid4vciIssuerConfig.getCredentialsSupported().get("test-credential").getFormat().toString()); - assertEquals("test-credential", oid4vciIssuerConfig.getCredentialsSupported().get("test-credential").getId()); - - try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + token).post(Entity.json(request))) { + public void testCredentialIssuanceWithAuthZCodeWithScopeMatched() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(testScope)), + 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))) { CredentialResponse credentialResponse = JsonSerialization.readValue(response.readEntity(String.class),CredentialResponse.class); assertEquals(200, response.getStatus()); @@ -491,13 +608,39 @@ public void testCredentialIssuanceWithAuthZCode() throws Exception { assertEquals(TEST_TYPES, credential.getType()); assertEquals(TEST_DID, credential.getIssuer()); assertEquals("john@email.cz", credential.getCredentialSubject().getClaims().get("email")); + } catch (IOException | VerificationException e) { + Assert.fail(); } - } - } - } + }); } + @Test + public void testCredentialIssuanceWithAuthZCodeScopeUnmatched() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope("email")), // set registered different scope + 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()); + } + }); + } + @Test + public void testCredentialIssuanceWithoutScope() throws Exception { + testCredentialIssuanceWithAuthZCodeFlow((testClientId, testScope) -> getBearerToken(oauth.clientId(testClientId).openid(false).scope(null)), // no scope + 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()); + } + }); + } private static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { String nonce = SecretGenerator.getInstance().randomString(); @@ -522,7 +665,8 @@ private static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession sessio authenticator, new ObjectMapper(), TIME_PROVIDER, - 30); + 30, + true); } private String getBasePath(String realm) {