VC issuance in Authz Code flow with considering scope parameter

closes #29725

Signed-off-by: Takashi Norimatsu <takashi.norimatsu.ws@hitachi.com>
This commit is contained in:
Takashi Norimatsu 2024-06-12 15:28:19 +09:00 committed by Marek Posolda
parent 3156f264b7
commit b0aac487a3
3 changed files with 245 additions and 40 deletions

View file

@ -39,6 +39,7 @@ import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.apache.http.HttpStatus;
import org.jboss.logging.Logger;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
@ -65,6 +66,10 @@ 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.grants.PreAuthorizedCodeGrantType;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.utils.MediaType;
@ -76,6 +81,7 @@ import java.net.URI;
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;
@ -95,6 +101,8 @@ public class OID4VCIssuerEndpoint {
private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerEndpoint.class);
private Cors cors;
public static final String CREDENTIAL_PATH = "credential";
public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
public static final String RESPONSE_TYPE_IMG_PNG = "image/png";
@ -109,6 +117,8 @@ public class OID4VCIssuerEndpoint {
private final Map<Format, VerifiableCredentialsSigningService> signingServices;
private final boolean isIgnoreScopeCheck;
public OID4VCIssuerEndpoint(KeycloakSession session,
String issuerDid,
Map<Format, VerifiableCredentialsSigningService> signingServices,
@ -121,9 +131,24 @@ public class OID4VCIssuerEndpoint {
this.issuerDid = issuerDid;
this.signingServices = signingServices;
this.preAuthorizedCodeLifeSpan = preAuthorizedCodeLifeSpan;
this.isIgnoreScopeCheck = false;
}
public OID4VCIssuerEndpoint(KeycloakSession session,
String issuerDid,
Map<Format, VerifiableCredentialsSigningService> 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 +263,26 @@ public class OID4VCIssuerEndpoint {
.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 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());
}
} else {
clientSession.removeNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
}
}
/**
* Returns a verifiable credential
*/
@ -249,9 +294,15 @@ public class OID4VCIssuerEndpoint {
CredentialRequest credentialRequestVO) {
LOGGER.debugf("Received credentials request %s.", credentialRequestVO);
cors = Cors.builder().auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
// do first to fail fast on auth
UserSessionModel userSessionModel = getUserSessionModel();
if (!isIgnoreScopeCheck) {
checkScope(credentialRequestVO);
}
Format requestedFormat = credentialRequestVO.getFormat();
String requestedCredential = credentialRequestVO.getCredentialIdentifier();
@ -426,4 +477,4 @@ public class OID4VCIssuerEndpoint {
LOGGER.debugf("The credential to sign is: %s", vc);
return vc;
}
}
}

View file

@ -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 class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
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 class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
authenticatedClientSession.getUserSession().getId());
return OAuth2CodeParser.persistCode(session, authenticatedClientSession, oAuth2Code);
}
}
}

View file

@ -41,6 +41,9 @@ import org.junit.Before;
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.admin.client.resource.UserResource;
import org.keycloak.common.VerificationException;
import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.util.MultivaluedHashMap;
@ -48,6 +51,7 @@ import org.keycloak.common.util.SecretGenerator;
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 +66,18 @@ import org.keycloak.protocol.oid4vc.model.OfferUriType;
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 +89,10 @@ import java.nio.charset.StandardCharsets;
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 +379,6 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
}));
}
// Tests the complete flow from
// 1. Retrieving the credential-offer-uri
// 2. Using the uri to get the actual credential offer
@ -442,45 +453,157 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
});
}
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 logoutUser(String clientId, String username) {
UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(TEST_REALM_NAME), username);
user.logout();
}
private void testCredentialIssuanceWithAuthZCodeFlow(Consumer<Map<String, String>> 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
logoutUser(testClientId, "john");
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<String, String, String> f, Consumer<Map<String, Object>> 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 {
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 (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))) {
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 +614,39 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
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 testCredentialIssuanceWithAuthZCodeWithScopeUnmatched() 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 testCredentialIssuanceWithAuthZCodeSWithoutScope() 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 +671,8 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
authenticator,
new ObjectMapper(),
TIME_PROVIDER,
30);
30,
true);
}
private String getBasePath(String realm) {
@ -605,5 +755,6 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
throw e;
}
}
}