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:
parent
3156f264b7
commit
b0aac487a3
3 changed files with 245 additions and 40 deletions
|
@ -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();
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,14 +453,104 @@ 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
|
||||
// 3. Get the credential configuration id from issuer metadata at .wellKnown
|
||||
// 4. With the access token, get the credential
|
||||
@Test
|
||||
public void testCredentialIssuanceWithAuthZCode() throws Exception {
|
||||
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);
|
||||
|
@ -458,7 +559,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
|
|||
|
||||
// 1. Get authoriZation code without scope specified by wallet
|
||||
// 2. Using the code to get accesstoken
|
||||
String token = getBearerToken(oauth.openid(false).scope(null));
|
||||
String token = f.apply(testClientId, testScope);
|
||||
|
||||
// 3. Get the credential configuration id from issuer metadata at .wellKnown
|
||||
try (Response discoveryResponse = oid4vciDiscoveryTarget.request().get()) {
|
||||
|
@ -474,13 +575,35 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
|
|||
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());
|
||||
request.setFormat(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat());
|
||||
request.setCredentialIdentifier(oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId());
|
||||
|
||||
assertEquals("jwt_vc", oid4vciIssuerConfig.getCredentialsSupported().get("test-credential").getFormat().toString());
|
||||
assertEquals("test-credential", oid4vciIssuerConfig.getCredentialsSupported().get("test-credential").getId());
|
||||
assertEquals(testFormat, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getFormat().toString());
|
||||
assertEquals(testCredentialConfigurationId, oid4vciIssuerConfig.getCredentialsSupported().get(testCredentialConfigurationId).getId());
|
||||
|
||||
try (Response response = credentialTarget.request().header(HttpHeaders.AUTHORIZATION, "bearer " + token).post(Entity.json(request))) {
|
||||
c.accept(Map.of(
|
||||
"accessToken", token,
|
||||
"credentialTarget", credentialTarget,
|
||||
"credentialRequest", request
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Assert.fail();
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
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 +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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue