[OID4VCI] Credential Offer must be created by Issuer not Holder (#44255)

closes #44116


Signed-off-by: Thomas Diesler <tdiesler@ibm.com>
This commit is contained in:
Thomas Diesler
2025-11-27 16:07:10 +01:00
committed by GitHub
parent bf23259c0f
commit 54bf9206b2
59 changed files with 1689 additions and 587 deletions

View File

@@ -38,7 +38,9 @@ import org.keycloak.provider.ProviderConfigProperty;
public class PassThroughClientAuthenticator extends AbstractClientAuthenticator {
public static final String PROVIDER_ID = "testsuite-client-passthrough";
public static String clientId = "test-app";
public static String namedClientId = "named-test-app";
// If this parameter is present in the HTTP request, the error will be thrown during authentication
public static final String TEST_ERROR_PARAM = "test_error_param";

View File

@@ -72,7 +72,6 @@ import org.keycloak.events.admin.AuthDetails;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.email.EmailEventListenerProviderFactory;
import org.keycloak.http.HttpRequest;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.FederatedIdentityModel;
@@ -85,9 +84,14 @@ import org.keycloak.models.UserProvider;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.models.utils.ResetTimeOffsetEvent;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.idm.AdminEventRepresentation;
@@ -1121,18 +1125,24 @@ public class TestingResourceProvider implements RealmResourceProvider {
@NoCache
public String getPreAuthorizedCode(@QueryParam("realm") final String realmName, @QueryParam("userSessionId") final String userSessionId, @QueryParam("clientId") final String clientId, @QueryParam("expiration") final int expiration) {
RealmModel realm = getRealmByName(realmName);
AuthenticatedClientSessionModel ascm = session.sessions()
.getUserSession(realm, userSessionId)
.getAuthenticatedClientSessions()
.values()
.stream().filter(acsm -> acsm.getClient().getClientId().equals(clientId))
.findFirst()
.orElseThrow(() -> new RuntimeException("No authenticatedClientSession found."));
return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, ascm, expiration);
UserSessionModel userSession = session.sessions().getUserSession(realm, userSessionId);
String code = "urn:oid4vci:code:" + UUID.randomUUID();
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()))
.setCredentialConfigurationIds(List.of("oid4vc_natural_person"))
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(
new PreAuthorizedCode().setPreAuthorizedCode(code)));
String userId = userSession.getUser().getUsername();
var offerStorage = session.getProvider(CredentialOfferStorage.class);
offerStorage.putOfferState(session, new CredentialOfferState(credOffer, clientId, userId, expiration));
return code;
}
@POST
@Path("/email-event-litener-provide/add-events")
@Path("/email-event-listener-provide/add-events")
@Consumes(MediaType.APPLICATION_JSON)
public void addEventsToEmailEventListenerProvider(List<EventType> events) {
if (events != null && !events.isEmpty()) {
@@ -1143,7 +1153,7 @@ public class TestingResourceProvider implements RealmResourceProvider {
}
@POST
@Path("/email-event-litener-provide/remove-events")
@Path("/email-event-listener-provide/remove-events")
@Consumes(MediaType.APPLICATION_JSON)
public void removeEventsToEmailEventListenerProvider(List<EventType> events) {
if (events != null && !events.isEmpty()) {

View File

@@ -457,7 +457,7 @@ public interface TestingResource {
* @param events The events to be included
*/
@POST
@Path("/email-event-litener-provide/add-events")
@Path("/email-event-listener-provide/add-events")
@Consumes(MediaType.APPLICATION_JSON)
public void addEventsToEmailEventListenerProvider(List<EventType> events);
@@ -466,7 +466,7 @@ public interface TestingResource {
* @param events The events to be removed
*/
@POST
@Path("/email-event-litener-provide/remove-events")
@Path("/email-event-listener-provide/remove-events")
@Consumes(MediaType.APPLICATION_JSON)
public void removeEventsToEmailEventListenerProvider(List<EventType> events);

View File

@@ -65,7 +65,6 @@ public class PreAuthorizedGrantTest extends AbstractTestRealmKeycloakTest {
AccessTokenResponse accessTokenResponse = postCode(preAuthorizedCode);
assertEquals("An access token should have successfully been returned.", HttpStatus.SC_OK, accessTokenResponse.getStatusCode());
assertEquals("The correct session should have been used for the pre-authorized code.", userSessionId, accessTokenResponse.getSessionState());
}
@Test

View File

@@ -27,7 +27,7 @@ import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint;
import org.junit.Assert;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
public class ParEndpointTest {
@@ -35,12 +35,12 @@ public class ParEndpointTest {
public void testFlattenDecodedFormParametersRetainAuthorizationDetails() {
var decodedFormParameters = new MultivaluedHashMap<String, String>();
String authorizationDetails = "[{\"type\": \"urn:openfinanceuae:account-access-consent:v1.0\",\"foo\":\"bar\"},{\"type\": \"urn:openfinanceuae:account-access-consent:v1.0\",\"gugu\":\"gaga\"}]";
decodedFormParameters.put(AUTHORIZATION_DETAILS_PARAM, List.of(authorizationDetails));
decodedFormParameters.put(AUTHORIZATION_DETAILS, List.of(authorizationDetails));
var params = new HashMap<String, String>();
ParEndpoint.flattenDecodedFormParametersToParamsMap(decodedFormParameters, params);
Assert.assertEquals(authorizationDetails, params.get(AUTHORIZATION_DETAILS_PARAM));
Assert.assertEquals(authorizationDetails, params.get(AUTHORIZATION_DETAILS));
}
@Test

View File

@@ -0,0 +1,460 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.oid4vc.issuance;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
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.ClientRepresentation;
import org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
import org.apache.commons.io.IOUtils;
import org.apache.directory.api.util.Strings;
import org.apache.http.HttpEntity;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
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.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.CREDENTIAL_IDENTIFIERS;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST;
import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* Credential Offer Validity Matrix
* <p>
* +----------+-----------+---------+---------+------------------------------------------------------+
* | pre-auth | clientId | userId | Valid | Notes |
* +----------+-----------+---------+---------+------------------------------------------------------+
* | no | no | no | yes | Generic offer; any logged-in user may redeem. |
* | no | no | yes | yes | Offer restricted to a specific user. |
* | no | yes | no | yes | Bound to client; user determined at login. |
* | no | yes | yes | yes | Bound to both client and user. |
* +----------+-----------+---------+---------+------------------------------------------------------+
* | yes | no | no | no | Pre-auth requires a user subject; missing userId. |
* | yes | no | yes | yes | Pre-auth for a specific user; client issuer defined. |
* | yes | yes | no | no | Same as above; userId required. |
* | yes | yes | yes | yes | Fully constrained: user + client. |
* +----------+-----------+---------+---------+------------------------------------------------------+
*/
public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
String issUserId = "john";
String issClientId = clientId;
String namedUserId = "alice";
String credScopeName = jwtTypeCredentialScopeName;
String credConfigId = jwtTypeCredentialConfigurationIdName;
static class OfferTestContext {
boolean preAuthorized;
String issUser;
String issClient;
String appUser;
String appClient;
CredentialIssuer issuerMetadata;
OIDCConfigurationRepresentation authorizationMetadata;
SupportedCredentialConfiguration supportedCredentialConfiguration;
}
OfferTestContext newTestContext(boolean preAuth, String appClient, String appUser) {
var ctx = new OfferTestContext();
ctx.preAuthorized = preAuth;
ctx.issUser = issUserId;
ctx.issClient = issClientId;
ctx.appUser = appUser;
ctx.appClient = appClient;
ctx.issuerMetadata = getCredentialIssuerMetadata();
ctx.authorizationMetadata = getAuthorizationMetadata(ctx.issuerMetadata.getAuthorizationServers().get(0));
ctx.supportedCredentialConfiguration = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
return ctx;
}
@Test
public void testVariousLogins() {
assertNotNull(getBearerTokenAndLogout(issClientId, issUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(issClientId, namedUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(namedClientId, issUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(namedClientId, namedUserId, "openid"));
}
@Test
public void testCredentialWithoutOffer() throws Exception {
var ctx = newTestContext(false, null, namedUserId);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(ctx.issuerMetadata.getCredentialIssuer()));
// [TODO] Requires Credential scope in AuthorizationRequest although already given in AuthorizationDetails
// https://github.com/keycloak/keycloak/issues/44320
String accessToken = getBearerToken(issClientId, ctx.appUser, credScopeName, authDetail);
CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken, authDetail);
verifyCredentialResponse(ctx, credResponse);
}
@Test
public void testCredentialOffer_noPreAuth_noClientId_noUserId() throws Exception {
runCredentialOfferTest(newTestContext(false, null, null));
}
@Test
public void testCredentialOffer_noPreAuth_noClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(false, null, namedUserId));
}
@Test
public void testCredentialOffer_noPreAuth_ClientId_noUserId() throws Exception {
runCredentialOfferTest(newTestContext(false, namedClientId, null));
}
@Test
public void testCredentialOffer_noPreAuth_ClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(false, namedClientId, namedUserId));
}
// Pre Authorized --------------------------------------------------------------------------------------------------
@Test
public void testCredentialOffer_PreAuth_noClientId_noUserId() throws Exception {
try {
runCredentialOfferTest(newTestContext(true, null, null));
fail("Expected " + INVALID_CREDENTIAL_OFFER_REQUEST.name());
} catch (RuntimeException ex) {
List.of(INVALID_CREDENTIAL_OFFER_REQUEST.name(), "Pre-Authorized credential offer requires a target user")
.forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it)));
}
}
@Test
public void testCredentialOffer_PreAuth_noClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(true, null, namedUserId));
}
@Test
public void testCredentialOffer_PreAuth_ClientId_noUserId() throws Exception {
try {
runCredentialOfferTest(newTestContext(true, namedClientId, null));
fail("Expected " + INVALID_CREDENTIAL_OFFER_REQUEST.name());
} catch (RuntimeException ex) {
List.of(INVALID_CREDENTIAL_OFFER_REQUEST.name(), "Pre-Authorized credential offer requires a target user")
.forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it)));
}
}
@Test
public void testCredentialOffer_PreAuth_ClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(true, namedClientId, namedUserId));
}
void runCredentialOfferTest(OfferTestContext ctx) throws Exception {
// Issuer login
//
String issToken = getBearerToken(ctx.issClient, ctx.issUser, "openid");
// Exclude scope: <credScope>
// Require role: credential-offer-create
verifyTokenJwt(ctx, issToken,
List.of(), List.of(ctx.supportedCredentialConfiguration.getScope()),
List.of(CREDENTIAL_OFFER_CREATE.getName()), List.of());
// Retrieving the credential-offer-uri
//
String offerUri = getCredentialOfferUriUrl(ctx, issToken);
// Issuer logout in order to remove unwanted session state
//
logout(ctx.issUser);
try {
// Using the uri to get the actual credential offer
//
CredentialsOffer credOffer = getCredentialsOffer(ctx, offerUri);
if (credOffer.getCredentialConfigurationIds().size() > 1)
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
if (ctx.preAuthorized) {
// Get an access token for the pre-authorized code (PAC)
//
// For a PAC access token, we treat all scopes and all roles as non-meaningful.
// The access token:
// 1. has no authenticated user, and therefore cannot carry any user roles
// 2. does not perform authorization-based scope filtering
// 3. does not derive scopes from the client configuration
// 4. does not reflect anything from the credential offer
//
AccessTokenResponse accessToken = getPreAuthorizedAccessTokenResponse(ctx, credOffer);
List<AuthorizationDetail> authDetails = accessToken.getAuthorizationDetails();
if (authDetails == null)
throw new IllegalStateException("No authorization_details in token response");
if (authDetails.size() > 1)
throw new IllegalStateException("Multiple authorization_details in token response");
// Get the credential and verify
//
CredentialResponse credResponse = getCredentialByAuthDetail(ctx, accessToken.getAccessToken(), authDetails.get(0));
verifyCredentialResponse(ctx, credResponse);
} else {
String clientId = ctx.appClient != null ? ctx.appClient : namedClientId;
String userId = ctx.appUser != null ? ctx.appUser : namedUserId;
String credConfigId = credOffer.getCredentialConfigurationIds().get(0);
SupportedCredentialConfiguration credConfig = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
String scope = credConfig.getScope();
String accessToken = getBearerToken(clientId, userId, scope);
// Get the credential and verify
//
CredentialResponse credResponse = getCredentialByOffer(ctx, accessToken, credOffer);
verifyCredentialResponse(ctx, credResponse);
}
} finally {
if (ctx.appUser != null) {
logout(ctx.appUser);
}
}
}
// Private ---------------------------------------------------------------------------------------------------------
private String getBearerToken(String clientId, String username, String scope) {
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
if (client.isDirectAccessGrantsEnabled()) {
return getBearerTokenDirectAccess(oauth, client, username, scope).getAccessToken();
} else {
return getBearerTokenCodeFlow(oauth, client, username, scope).getAccessToken();
}
}
private String getBearerToken(String clientId, String username, String scope, AuthorizationDetail... authDetail) {
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
String authCode = getAuthorizationCode(oauth, client, username, scope);
return getBearerToken(oauth, authCode, authDetail).getAccessToken();
}
private String getBearerTokenAndLogout(String clientId, String userId, String scope) {
String token = getBearerToken(clientId, userId, scope);
logout(userId);
return token;
}
private void logout(String userId) {
findUserByUsernameId(testRealm(), userId).logout();
}
private String getCredentialOfferUriUrl(OfferTestContext ctx, String token) throws Exception {
CredentialOfferURI offerURI = getCredentialOfferUri(ctx, token);
return offerURI.getIssuer() + offerURI.getNonce();
}
private CredentialOfferURI getCredentialOfferUri(OfferTestContext ctx, String token) throws Exception {
String credConfigId = ctx.supportedCredentialConfiguration.getId();
String credOfferUriUrl = getCredentialOfferUriUrl(credConfigId, ctx.preAuthorized, ctx.appUser, ctx.appClient);
HttpGet getCredentialOfferURI = new HttpGet(credOfferUriUrl);
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
int statusCode = credentialOfferURIResponse.getStatusLine().getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
HttpEntity entity = credentialOfferURIResponse.getEntity();
throw new IllegalStateException(EntityUtils.toString(entity));
}
String s = IOUtils.toString(credentialOfferURIResponse.getEntity().getContent(), StandardCharsets.UTF_8);
CredentialOfferURI credentialOfferURI = JsonSerialization.valueFromString(s, CredentialOfferURI.class);
assertTrue(credentialOfferURI.getIssuer().startsWith(ctx.issuerMetadata.getCredentialIssuer()));
assertTrue(Strings.isNotEmpty(credentialOfferURI.getNonce()));
return credentialOfferURI;
}
private CredentialsOffer getCredentialsOffer(OfferTestContext ctx, String offerUri) throws Exception {
HttpGet getCredentialOffer = new HttpGet(offerUri);
CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer);
int statusCode = credentialOfferResponse.getStatusLine().getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
HttpEntity entity = credentialOfferResponse.getEntity();
throw new IllegalStateException(EntityUtils.toString(entity));
}
String s = IOUtils.toString(credentialOfferResponse.getEntity().getContent(), StandardCharsets.UTF_8);
CredentialsOffer credOffer = JsonSerialization.valueFromString(s, CredentialsOffer.class);
assertEquals(List.of(ctx.supportedCredentialConfiguration.getId()), credOffer.getCredentialConfigurationIds());
return credOffer;
}
private AccessTokenResponse getPreAuthorizedAccessTokenResponse(OID4VCICredentialOfferMatrixTest.OfferTestContext ctx, CredentialsOffer credOffer) throws Exception {
PreAuthorizedCode preAuthorizedCode = credOffer.getGrants().getPreAuthorizedCode();
HttpPost postPreAuthorizedCode = new HttpPost(ctx.authorizationMetadata.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode.getPreAuthorizedCode()));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
CloseableHttpResponse accessTokenResponse = httpClient.execute(postPreAuthorizedCode);
int statusCode = accessTokenResponse.getStatusLine().getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
HttpEntity entity = accessTokenResponse.getEntity();
throw new IllegalStateException(EntityUtils.toString(entity));
}
return new AccessTokenResponse(accessTokenResponse);
}
private CredentialResponse getCredentialByAuthDetail(OfferTestContext ctx, String accessToken, AuthorizationDetail authDetail) throws Exception {
@SuppressWarnings("unchecked")
List<String> credIdentifiers = (List<String>) authDetail.getAdditionalFields().get(CREDENTIAL_IDENTIFIERS);
var credentialRequest = new CredentialRequest();
if (credIdentifiers != null) {
if (credIdentifiers.size() > 1)
throw new IllegalStateException("Multiple credential ids not supported");
credentialRequest.setCredentialIdentifier(credIdentifiers.get(0));
} else {
if (authDetail.getCredentialConfigurationId() == null)
throw new IllegalStateException("No credential_configuration_id in: " + JsonSerialization.valueAsString(authDetail));
credentialRequest.setCredentialConfigurationId(authDetail.getCredentialConfigurationId());
}
return sendCredentialRequest(ctx, accessToken, credentialRequest);
}
private CredentialResponse getCredentialByOffer(OfferTestContext ctx, String accessToken, CredentialsOffer credOffer) throws Exception {
List<String> credConfigIds = credOffer.getCredentialConfigurationIds();
if (credConfigIds.size() > 1)
throw new IllegalStateException("Multiple credential configuration ids not supported in: " + JsonSerialization.valueAsString(credOffer));
var credentialRequest = new CredentialRequest();
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
return sendCredentialRequest(ctx, accessToken, credentialRequest);
}
private CredentialResponse sendCredentialRequest(OfferTestContext ctx, String accessToken, CredentialRequest credentialRequest) throws Exception {
HttpPost postCredential = new HttpPost(ctx.issuerMetadata.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
StringEntity stringEntity = new StringEntity(JsonSerialization.valueAsString(credentialRequest), ContentType.APPLICATION_JSON);
postCredential.setEntity(stringEntity);
CloseableHttpResponse credentialRequestResponse = httpClient.execute(postCredential);
int statusCode = credentialRequestResponse.getStatusLine().getStatusCode();
if (HttpStatus.SC_OK != statusCode) {
HttpEntity entity = credentialRequestResponse.getEntity();
throw new IllegalStateException(EntityUtils.toString(entity));
}
String s = IOUtils.toString(credentialRequestResponse.getEntity().getContent(), StandardCharsets.UTF_8);
CredentialResponse credentialResponse = JsonSerialization.valueFromString(s, CredentialResponse.class);
assertNotNull("The credentials array should be present in the response", credentialResponse.getCredentials());
assertFalse("The credentials array should not be empty", credentialResponse.getCredentials().isEmpty());
return credentialResponse;
}
private void verifyCredentialResponse(OfferTestContext ctx, CredentialResponse credResponse) throws Exception {
String scope = ctx.supportedCredentialConfiguration.getScope();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull("The first credential in the array should not be null", credentialObj);
String expUsername = ctx.appUser != null ? ctx.appUser : namedUserId;
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
assertEquals("did:web:test.org", jsonWebToken.getIssuer());
Object vc = jsonWebToken.getOtherClaims().get("vc");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
assertEquals(List.of(scope), credential.getType());
assertEquals(URI.create("did:web:test.org"), credential.getIssuer());
assertEquals(expUsername + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}
private void verifyTokenJwt(
OfferTestContext ctx,
String token,
List<String> includeScopes,
List<String> excludeScopes,
List<String> includeRoles,
List<String> excludeRoles
) throws Exception {
JsonWebToken jwt = JsonSerialization.readValue(new JWSInput(token).getContent(), JsonWebToken.class);
List<String> wasScopes = Arrays.stream(((String) jwt.getOtherClaims().get("scope")).split("\\s")).toList();
includeScopes.forEach(it -> assertTrue("Missing scope: " + it, wasScopes.contains(it)));
excludeScopes.forEach(it -> assertFalse("Invalid scope: " + it, wasScopes.contains(it)));
List<String> allRoles = new ArrayList<>();
Object realmAccess = jwt.getOtherClaims().get("realm_access");
if (realmAccess != null) {
@SuppressWarnings("unchecked")
var realmRoles = ((Map<String, List<String>>) realmAccess).get("roles");
allRoles.addAll(realmRoles);
}
Object resourceAccess = jwt.getOtherClaims().get("resource_access");
if (resourceAccess != null) {
@SuppressWarnings("unchecked")
var resourceAccessMapping = (Map<String, Map<String, List<String>>>) resourceAccess;
resourceAccessMapping.forEach((k, v) -> {
allRoles.addAll(v.get("roles"));
});
}
includeRoles.forEach(it -> assertTrue("Missing role: " + it, allRoles.contains(it)));
excludeRoles.forEach(it -> assertFalse("Invalid role: " + it, allRoles.contains(it)));
}
}

View File

@@ -115,15 +115,12 @@ public class OID4VCTargetRoleMapperTest extends OID4VCTest {
return mergedRoles;
}
);
} else {
testRealm.getRoles()
.setClient(Map.of(existingClient.getClientId(),
List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
}
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
.orElse(new ArrayList<>());
realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"), "newClient", List.of("newRole"))));
realmUsers.add(getUserRepresentation("John Doe", List.of(),
Map.of(clientId, List.of("testRole"), "newClient", List.of("newRole"))));
testRealm.setUsers(realmUsers);
}
}

View File

@@ -52,6 +52,8 @@ import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@@ -63,8 +65,6 @@ import static org.junit.Assert.assertNotNull;
*/
public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEndpointTest {
public static final String OPENID_CREDENTIAL_TYPE = "openid_credential";
/**
* Test context for OID4VC tests
*/
@@ -140,7 +140,7 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -176,8 +176,8 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
String credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
String credentialConfigurationId = authDetailResponse.getCredentialConfigurationId();
assertNotNull("Credential configuration id should not be null", credentialConfigurationId);
// Request the actual credential using the identifier
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
@@ -185,7 +185,7 @@ public abstract class OID4VCAuthorizationCodeFlowTestBase extends OID4VCIssuerEn
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));

View File

@@ -53,7 +53,7 @@ import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -131,7 +131,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credentialConfigurationId);
authDetail.setClaims(List.of(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -198,7 +198,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
assertEquals("Should have exactly one authorization detail", 1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals("Credential configuration ID should match", credentialConfigurationId, authDetailResponse.getCredentialConfigurationId());
// Verify claims are preserved
@@ -229,7 +229,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
@@ -263,7 +263,7 @@ public class OID4VCAuthorizationCodeFlowWithPARTest extends OID4VCIssuerEndpoint
// Step 1: Create PAR request with INVALID authorization_details
// Create authorization details with INVALID credential configuration ID
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId("INVALID_CONFIG_ID"); // This should cause failure
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));

View File

@@ -39,6 +39,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
@@ -56,7 +57,7 @@ import org.apache.http.entity.StringEntity;
import org.apache.http.message.BasicNameValuePair;
import org.junit.Test;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -103,7 +104,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcTestContext ctx = new Oid4vcTestContext();
String credentialConfigurationId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) + "credential-offer-uri?credential_configuration_id=" + credentialConfigurationId);
HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId));
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CredentialOfferURI credentialOfferURI;
@@ -144,7 +145,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -166,7 +167,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId());
assertNotNull(authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
@@ -202,7 +203,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -225,7 +226,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID), authDetailResponse.getCredentialConfigurationId());
assertNotNull(authDetailResponse.getClaims());
assertEquals(1, authDetailResponse.getClaims().size());
@@ -257,7 +258,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -293,7 +294,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -329,7 +330,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -369,7 +370,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
// Missing credential_configuration_id - should fail
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -403,7 +404,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(false);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
@@ -481,7 +482,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i);
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(i);
assertEquals(OPENID_CREDENTIAL_TYPE, authDetailResponse.getType());
assertEquals(OPENID_CREDENTIAL, authDetailResponse.getType());
assertEquals("Credential configuration ID should match the one from the offer",
expectedConfigId, authDetailResponse.getCredentialConfigurationId());
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
@@ -502,18 +503,22 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
@Test
public void testCompleteFlowWithCredentialOfferBasedAuthorizationDetails() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
PreAuthorizedCode preAuthorizedCode = ctx.credentialsOffer.getGrants().getPreAuthorizedCode();
// Step 1: Request token without authorization_details parameter (no scope needed)
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, ctx.credentialsOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode()));
parameters.add(new BasicNameValuePair(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthorizedCode.getPreAuthorizedCode()));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
postPreAuthorizedCode.setEntity(formEntity);
String credentialIdentifier = null;
String credentialIdentifier;
String credentialConfigurationId;
OID4VCAuthorizationDetailsResponse authDetailResponse;
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
@@ -524,46 +529,94 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
ctx.credentialsOffer.getCredentialConfigurationIds().size(), authDetailsResponse.size());
// Use the first authorization detail for credential request
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
authDetailResponse = authDetailsResponse.get(0);
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
credentialConfigurationId = authDetailResponse.getCredentialConfigurationId();
assertNotNull("Credential configuration id should not be null", credentialConfigurationId);
}
// Step 2: Request the actual credential using ONLY the identifier (no credential_configuration_id)
// This tests that the mapping from credential identifier to credential configuration ID works correctly
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
// This tests that the mapping from credential identifier to credential configuration ID works as expected.
//
// The Pre-Authorized code flow is treated as a separate authentication event.
// Even if the underlying user and client match an existing session.
// A new user session is created because:
// * The pre-auth code is defined as a standalone authentication mechanism.
// * It does not assume the caller already has an authenticated session.
// * It must guarantee isolation of state tied to the VC issuance flow.
{
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialIdentifier(credentialIdentifier);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response
CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class);
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
// Parse the credential response
CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class);
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
// Step 3: Verify that the issued credential structure is valid
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
// Step 3: Verify that the issued credential structure is valid
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// Verify the credential structure based on format
verifyCredentialStructure(credentialObj);
// Verify the credential structure based on format
verifyCredentialStructure(credentialObj);
}
}
// Step 3: Request a credential using the credentialConfigurationId
//
{
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
CredentialRequest credentialRequest = new CredentialRequest();
credentialRequest.setCredentialConfigurationId(credentialConfigurationId);
String requestBody = JsonSerialization.writeValueAsString(credentialRequest);
postCredential.setEntity(new StringEntity(requestBody, StandardCharsets.UTF_8));
try (CloseableHttpResponse credentialResponse = httpClient.execute(postCredential)) {
assertEquals(HttpStatus.SC_OK, credentialResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(credentialResponse.getEntity().getContent(), StandardCharsets.UTF_8);
// Parse the credential response
CredentialResponse parsedResponse = JsonSerialization.readValue(responseBody, CredentialResponse.class);
assertNotNull("Credential response should not be null", parsedResponse);
assertNotNull("Credentials should be present", parsedResponse.getCredentials());
assertEquals("Should have exactly one credential", 1, parsedResponse.getCredentials().size());
// Step 3: Verify that the issued credential structure is valid
CredentialResponse.Credential credentialWrapper = parsedResponse.getCredentials().get(0);
assertNotNull("Credential wrapper should not be null", credentialWrapper);
// The credential is stored as Object, so we need to cast it
Object credentialObj = credentialWrapper.getCredential();
assertNotNull("Credential object should not be null", credentialObj);
// Verify the credential structure based on format
verifyCredentialStructure(credentialObj);
}
}
}
@@ -603,7 +656,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
String expectedConfigId = ctx.credentialsOffer.getCredentialConfigurationIds().get(i);
// Verify structure
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL_TYPE, authDetail.getType());
assertEquals("Type should be openid_credential", OPENID_CREDENTIAL, authDetail.getType());
assertEquals("Credential configuration ID should match the one from the offer",
expectedConfigId, authDetail.getCredentialConfigurationId());
assertNotNull("Credential identifiers should be present", authDetail.getCredentialIdentifiers());
@@ -633,6 +686,7 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
@Test
public void testCompleteFlowWithClaimsValidation() throws Exception {
String token = getBearerToken(oauth, client, getCredentialClientScope().getName());
String credConfigId = getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
Oid4vcTestContext ctx = prepareOid4vcTestContext(token);
// Step 1: Request token with authorization details containing specific claims
@@ -650,13 +704,13 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
claim.setMandatory(true);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL_TYPE);
authDetail.setCredentialConfigurationId(getCredentialClientScope().getAttributes().get(CredentialScopeModel.CONFIGURATION_ID));
authDetail.setClaims(Arrays.asList(claim));
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(Collections.singletonList(ctx.credentialIssuer.getCredentialIssuer()));
authDetail.setClaims(List.of(claim));
List<AuthorizationDetail> authDetails = List.of(authDetail);
String authDetailsJson = JsonSerialization.writeValueAsString(authDetails);
String authDetailsJson = JsonSerialization.valueAsString(authDetails);
HttpPost postPreAuthorizedCode = new HttpPost(ctx.openidConfig.getTokenEndpoint());
List<NameValuePair> parameters = new LinkedList<>();
@@ -667,6 +721,8 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
postPreAuthorizedCode.setEntity(formEntity);
String credentialIdentifier;
String credentialConfigurationId;
OID4VCAuthorizationDetailsResponse authDetailResponse;
try (CloseableHttpResponse tokenResponse = httpClient.execute(postPreAuthorizedCode)) {
assertEquals(HttpStatus.SC_OK, tokenResponse.getStatusLine().getStatusCode());
String responseBody = IOUtils.toString(tokenResponse.getEntity().getContent(), StandardCharsets.UTF_8);
@@ -674,15 +730,18 @@ public abstract class OID4VCAuthorizationDetailsFlowTestBase extends OID4VCIssue
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertEquals(1, authDetailsResponse.size());
OID4VCAuthorizationDetailsResponse authDetailResponse = authDetailsResponse.get(0);
authDetailResponse = authDetailsResponse.get(0);
assertNotNull("Credential identifiers should be present", authDetailResponse.getCredentialIdentifiers());
assertEquals(1, authDetailResponse.getCredentialIdentifiers().size());
credentialIdentifier = authDetailResponse.getCredentialIdentifiers().get(0);
assertNotNull("Credential identifier should not be null", credentialIdentifier);
credentialConfigurationId = authDetailResponse.getCredentialConfigurationId();
assertNotNull("Credential configuration id should not be null", credentialConfigurationId);
}
// Step 2: Request the actual credential using the identifier
// Step 2: Request the actual credential using the identifier and config id
HttpPost postCredential = new HttpPost(ctx.credentialIssuer.getCredentialEndpoint());
postCredential.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
postCredential.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");

View File

@@ -23,7 +23,6 @@ import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.core.Response;
import org.keycloak.common.Profile;
import org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@@ -34,7 +33,7 @@ import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.junit.Before;
import org.junit.Test;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE;
import static org.keycloak.OAuth2Constants.OPENID_CREDENTIAL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -74,7 +73,7 @@ public class OID4VCAuthorizationDetailsTypesSupportedTest extends OID4VCIssuerEn
// Verify that it contains openid_credential
List<String> supportedTypes = oauthConfig.getAuthorizationDetailsTypesSupported();
assertTrue("authorization_details_types_supported should contain openid_credential",
supportedTypes.contains(OID4VCAuthorizationDetailsProcessor.OPENID_CREDENTIAL_TYPE));
supportedTypes.contains(OPENID_CREDENTIAL));
}
}
@@ -94,7 +93,7 @@ public class OID4VCAuthorizationDetailsTypesSupportedTest extends OID4VCIssuerEn
assertNotNull("Authorization server should support authorization_details_types_supported",
authServerConfig.getAuthorizationDetailsTypesSupported());
assertTrue("Authorization server should support openid_credential",
authServerConfig.getAuthorizationDetailsTypesSupported().contains(OPENID_CREDENTIAL_TYPE));
authServerConfig.getAuthorizationDetailsTypesSupported().contains(OPENID_CREDENTIAL));
}
}

View File

@@ -255,11 +255,7 @@ public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
}
private String getCredentialOfferUriUrl() {
return getBasePath("test") + "credential-offer-uri?credential_configuration_id=" + jwtTypeCredentialConfigurationIdName;
}
private String getCredentialOfferUrl(String sessionCode) {
return getBasePath("test") + "credential-offer/" + sessionCode;
return getCredentialOfferUriUrl(jwtTypeCredentialConfigurationIdName);
}
private String getSessionCodeFromOfferUri(String accessToken) throws Exception {

View File

@@ -38,6 +38,7 @@ import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.models.KeyManager;
import org.keycloak.models.RealmModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
@@ -75,6 +76,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testRequestCredentialWithEncryption() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient
.server(TEST_REALM_NAME)
@@ -93,7 +95,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
PrivateKey privateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@@ -169,6 +171,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testEncryptedCredentialRequest() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
@@ -194,7 +197,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
PrivateKey responsePrivateKey = (PrivateKey) jwkPair.get("privateKey");
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@@ -234,6 +237,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testEncryptedCredentialRequestWithCompression() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
@@ -262,7 +266,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
// Create credential request with response encryption parameters
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc(A256GCM)
@@ -474,6 +478,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
@Test
public void testRequestCredentialWithInvalidJWK() throws Throwable {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
@@ -483,7 +488,7 @@ public class OID4VCIssuerEndpointEncryptionTest extends OID4VCIssuerEndpointTest
// Invalid JWK (missing modulus but WITH alg parameter)
JWK jwk = JWKParser.create().parse("{\"kty\":\"RSA\",\"alg\":\"RSA-OAEP-256\",\"e\":\"AQAB\"}").getJwk();
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setCredentialResponseEncryption(
new CredentialResponseEncryption()
.setEnc("A256GCM")

View File

@@ -35,6 +35,8 @@ import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
@@ -54,7 +56,7 @@ import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwe.JWE;
import org.keycloak.jose.jwe.JWEException;
@@ -79,6 +81,7 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.representations.JsonWebToken;
@@ -88,6 +91,7 @@ import org.keycloak.representations.idm.ComponentExportRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.RolesRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
@@ -110,12 +114,14 @@ import org.apache.http.impl.client.HttpClientBuilder;
import org.jboss.logging.Logger;
import org.junit.Before;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_URI_CODE_SCOPE;
import static org.keycloak.protocol.oid4vc.model.ProofType.JWT;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.namedClientId;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -139,33 +145,31 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
protected static ClientScopeRepresentation minimalJwtTypeCredentialClientScope;
protected CloseableHttpClient httpClient;
protected ClientRepresentation client;
protected ClientRepresentation namedClient;
record OAuth2CodeEntry(String key, OAuth2Code code) {}
protected boolean shouldEnableOid4vci() {
return true;
}
protected static String prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) {
protected static OAuth2CodeEntry prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) {
AuthenticationManager.AuthResult authResult = authenticator.authenticate();
UserSessionModel userSessionModel = authResult.session();
AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient(
authResult.client().getId());
String codeId = SecretGenerator.getInstance().randomString();
String nonce = SecretGenerator.getInstance().randomString();
OAuth2Code oAuth2Code = new OAuth2Code(codeId,
OAuth2Code oauth2Code = new OAuth2Code(
SecretGenerator.getInstance().randomString(),
Time.currentTime() + 6000,
nonce,
SecretGenerator.getInstance().randomString(),
CREDENTIAL_OFFER_URI_CODE_SCOPE,
null,
null,
null,
null,
authenticatedClientSessionModel.getUserSession().getId());
String oauthCode = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oAuth2Code);
authenticatedClientSessionModel.setNote(oauthCode, note);
return oauthCode;
String nonce = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oauth2Code);
authenticatedClientSessionModel.setNote(nonce, note);
return new OAuth2CodeEntry(nonce, oauth2Code);
}
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session,
@@ -201,6 +205,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
CryptoIntegration.init(this.getClass().getClassLoader());
httpClient = HttpClientBuilder.create().build();
client = testRealm().clients().findByClientId(clientId).get(0);
namedClient = testRealm().clients().findByClientId(namedClientId).get(0);
// Lookup the pre-installed oid4vc_natural_person client scope
sdJwtTypeNaturalPersonClientScope = requireExistingClientScope(sdJwtTypeNaturalPersonScopeName);
@@ -228,13 +233,23 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
null,
null);
// Assign the registered optional client scopes to the client
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), client.getClientId());
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), client.getClientId());
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), client.getClientId());
List.of(client, namedClient).forEach(client -> {
String clientId = client.getClientId();
// Enable OID4VCI for the client by default, but allow tests to override
setClientOid4vciEnabled(clientId, shouldEnableOid4vci());
// Assign the registered optional client scopes to the client
assignOptionalClientScopeToClient(sdJwtTypeNaturalPersonClientScope.getId(), clientId);
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(sdJwtTypeNaturalPersonClientScope.getId(), clientId);
assignOptionalClientScopeToClient(sdJwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(jwtTypeCredentialClientScope.getId(), clientId);
assignOptionalClientScopeToClient(minimalJwtTypeCredentialClientScope.getId(), clientId);
// Enable OID4VCI for the client by default, but allow tests to override
setClientOid4vciEnabled(clientId, shouldEnableOid4vci());
});
}
private ClientResource findClientByClientId(RealmResource realm, String clientId) {
@@ -264,7 +279,7 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
// Create a new ClientScope if not found
ClientScopeRepresentation clientScope = new ClientScopeRepresentation();
clientScope.setName(scopeName);
clientScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
clientScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
Map<String, String> attributes =
new HashMap<>(Map.of(ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE, "true",
CredentialScopeModel.EXPIRY_IN_SECONDS, "15"));
@@ -531,6 +546,29 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return contextRoot + "/auth/.well-known/openid-credential-issuer/realms/" + realm;
}
protected String getCredentialOfferUriUrl(String configId) {
return getCredentialOfferUriUrl(configId, true, "john");
}
protected String getCredentialOfferUriUrl(String configId, boolean preAuthorized, String targetUser) {
return getCredentialOfferUriUrl(configId, preAuthorized, targetUser, null);
}
protected String getCredentialOfferUriUrl(String configId, Boolean preAuthorized, String appUserId, String appClientId) {
String res = getBasePath("test") + "credential-offer-uri?credential_configuration_id=" + configId;
if (preAuthorized != null)
res += "&pre_authorized=" + preAuthorized;
if (appClientId != null)
res += "&client_id=" + appClientId;
if (appUserId != null)
res += "&user_id=" + appUserId;
return res;
}
protected String getCredentialOfferUrl(String nonce) {
return getBasePath("test") + "credential-offer/" + nonce;
}
protected void requestCredential(String token,
String credentialEndpoint,
SupportedCredentialConfiguration offeredCredential,
@@ -569,6 +607,24 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
}
}
public OIDCConfigurationRepresentation getAuthorizationMetadata(String authServerUrl) {
HttpGet getOpenidConfiguration = new HttpGet(authServerUrl + "/.well-known/openid-configuration");
try (CloseableHttpResponse response = httpClient.execute(getOpenidConfiguration)) {
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
String s = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8);
return JsonSerialization.readValue(s, OIDCConfigurationRepresentation.class);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
public SupportedCredentialConfiguration getSupportedCredentialConfigurationByScope(CredentialIssuer metadata, String scope) {
SupportedCredentialConfiguration result = metadata.getCredentialsSupported().values().stream()
.filter(it -> it.getScope().equals(scope))
.findFirst().orElse(null);
return result;
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
if (testRealm.getComponents() == null) {
@@ -583,15 +639,15 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
getRsaEncKeyProvider(RSA_OAEP, "enc-key-oaep", 101));
// Find existing client representation
ClientRepresentation existingClient = testRealm.getClients().stream()
.filter(client -> client.getClientId().equals(clientId))
.findFirst()
Map<String, ClientRepresentation> realmClients = testRealm.getClients().stream()
.collect(Collectors.toMap(ClientRepresentation::getClientId, Function.identity()));
ClientRepresentation existingClient = Optional.ofNullable(realmClients.get(clientId))
.orElseThrow(() -> new IllegalStateException("Client with ID " + clientId + " not found in realm"));
// Add a role to an existing client
if (testRealm.getRoles() != null) {
Map<String, List<RoleRepresentation>> clientRoles = testRealm.getRoles().getClient();
clientRoles.merge(
RolesRepresentation realmRoles = testRealm.getRoles();
if (realmRoles != null) {
realmRoles.getClient().merge(
existingClient.getClientId(),
List.of(getRoleRepresentation("testRole", existingClient.getClientId())),
(existingRoles, newRoles) -> {
@@ -600,15 +656,12 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return mergedRoles;
}
);
} else {
testRealm.getRoles()
.setClient(Map.of(existingClient.getClientId(),
List.of(getRoleRepresentation("testRole", existingClient.getClientId()))));
}
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new)
.orElse(new ArrayList<>());
realmUsers.add(getUserRepresentation(Map.of(existingClient.getClientId(), List.of("testRole"))));
Map<String, List<String>> clientRoles = Map.of(clientId, List.of("testRole"));
List<UserRepresentation> realmUsers = Optional.ofNullable(testRealm.getUsers()).map(ArrayList::new).orElse(new ArrayList<>());
realmUsers.add(getUserRepresentation("John Doe", List.of(CREDENTIAL_OFFER_CREATE.getName()), clientRoles));
realmUsers.add(getUserRepresentation("Alice Wonderland", List.of(), Map.of()));
testRealm.setUsers(realmUsers);
}

View File

@@ -61,7 +61,6 @@ import org.keycloak.protocol.oid4vc.model.DisplayObject;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.ProofTypesSupported;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@@ -88,7 +87,7 @@ import org.hamcrest.Matchers;
import org.junit.Test;
import static org.keycloak.OID4VCConstants.SIGNED_METADATA_JWT_TYPE;
import static org.keycloak.constants.Oid4VciConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.constants.OID4VCIConstants.BATCH_CREDENTIAL_ISSUANCE_BATCH_SIZE;
import static org.keycloak.jose.jwe.JWEConstants.A256GCM;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
@@ -783,31 +782,4 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerEndpointTest
}
});
}
public static void extendConfigureTestRealm(RealmRepresentation testRealm, ClientRepresentation clientRepresentation) {
if (testRealm.getComponents() == null) {
testRealm.setComponents(new MultivaluedHashMap<>());
}
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY));
testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.credentialbuilder.CredentialBuilder", getCredentialBuilderProvider(Format.JWT_VC));
if (testRealm.getClients() != null) {
testRealm.getClients().add(clientRepresentation);
} else {
testRealm.setClients(new ArrayList<>(List.of(clientRepresentation)));
}
if (testRealm.getUsers() != null) {
testRealm.getUsers().add(OID4VCTest.getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole"))));
} else {
testRealm.setUsers(new ArrayList<>(List.of(OID4VCTest.getUserRepresentation(Map.of(clientRepresentation.getClientId(), List.of("testRole"))))));
}
if (testRealm.getAttributes() != null) {
testRealm.getAttributes().put("issuerDid", TEST_DID.toString());
} else {
testRealm.setAttributes(new HashMap<>(Map.of("issuerDid", TEST_DID.toString())));
}
}
}

View File

@@ -6,7 +6,6 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
@@ -36,7 +35,7 @@ public class OID4VCJWTIssuerEndpointDisabledTest extends OID4VCIssuerEndpointTes
// Test getCredentialOfferURI
CorsErrorResponseException offerUriException = Assert.assertThrows(CorsErrorResponseException.class, () ->
issuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
issuerEndpoint.getCredentialOfferURI("test-credential")
);
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus());

View File

@@ -35,9 +35,12 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage.CredentialOfferState;
import org.keycloak.protocol.oid4vc.model.Claim;
import org.keycloak.protocol.oid4vc.model.ClaimDisplay;
import org.keycloak.protocol.oid4vc.model.Claims;
@@ -50,7 +53,6 @@ import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.JwtProof;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.protocol.oid4vc.model.Proofs;
@@ -59,9 +61,8 @@ 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.sdjwt.vp.SdJwtVP;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
@@ -90,19 +91,20 @@ import static org.junit.Assert.fail;
* Test from org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest
*/
public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// ----- getCredentialOfferUri
@Test
public void testGetCredentialOfferUriUnsupportedCredential() {
String token = getBearerToken(oauth);
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0)
oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id")
);
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
exception.getResponse().getStatus());
@@ -112,12 +114,12 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testGetCredentialOfferUriUnauthorized() {
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", true, "john")
);
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
exception.getResponse().getStatus());
@@ -127,12 +129,12 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testGetCredentialOfferUriInvalidToken() {
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString("invalid-token");
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", true, "john")
);
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
exception.getResponse().getStatus());
@@ -148,15 +150,11 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient.server(TEST_REALM_NAME).run((session) -> {
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(
session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId,
OfferUriType.URI,
0,
0);
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI(credentialConfigurationId);
assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus());
CredentialOfferURI credentialOfferURI = JsonSerialization.mapper.convertValue(response.getEntity(),
@@ -177,7 +175,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session) -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response response = issuerEndpoint.getCredentialOffer("nonce");
@@ -193,7 +191,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.getCredentialOffer(null);
@@ -208,7 +206,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.getCredentialOffer("unpreparedNonce");
@@ -223,11 +221,11 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
String sessionCode = prepareSessionCode(session, authenticator, "invalidNote");
String nonce = prepareSessionCode(session, authenticator, "invalidNote").key();
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
issuerEndpoint.getCredentialOffer(sessionCode);
issuerEndpoint.getCredentialOffer(nonce);
}));
});
}
@@ -238,25 +236,28 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
testingClient
.server(TEST_REALM_NAME)
.run((session) -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
CredentialsOffer credentialsOffer = new CredentialsOffer()
CredentialsOffer credOffer = new CredentialsOffer()
.setCredentialIssuer("the-issuer")
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(new PreAuthorizedCode().setPreAuthorizedCode("the-code")))
.setCredentialConfigurationIds(List.of("credential-configuration-id"));
String sessionCode = prepareSessionCode(session, authenticator, JsonSerialization.writeValueAsString(credentialsOffer));
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
CredentialOfferState offerState = new CredentialOfferState(credOffer, null, null, Time.currentTime() + 60);
offerStorage.putOfferState(session, offerState);
// The cache transactions need to be committed explicitly in the test. Without that, the OAuth2Code will only be committed to
// the cache after .run((session)-> ...)
session.getTransactionManager().commit();
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(sessionCode);
Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(offerState.getNonce());
assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus());
Object credentialOfferEntity = credentialOfferResponse.getEntity();
assertNotNull("An actual offer should be in the response.", credentialOfferEntity);
CredentialsOffer retrievedCredentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class);
assertEquals("The offer should be the one prepared with for the session.", credentialsOffer, retrievedCredentialsOffer);
assertEquals("The offer should be the one prepared with for the session.", credOffer, retrievedCredentialsOffer);
});
}
@@ -265,7 +266,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredentialUnauthorized() {
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
@@ -285,7 +286,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredentialInvalidToken() {
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString("token");
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
@@ -311,8 +312,8 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
try {
withCausePropagation(() -> {
testingClient.server(TEST_REALM_NAME).run((session -> {
AppAuthManager.BearerTokenAuthenticator authenticator =
new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator =
new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
// Prepare the issue endpoint with no credential builders.
@@ -337,7 +338,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
String token = getBearerToken(oauth);
withCausePropagation(() -> {
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
@@ -352,14 +353,15 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredential() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
.setCredentialConfigurationId(credConfigId);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
@@ -392,27 +394,22 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
}
@Test
public void testRequestCredentialWithConfigurationIdNotSet() {
public void testRequestCredentialWithNeitherIdSet() {
final String scopeName = minimalJwtTypeCredentialClientScope.getName();
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response credentialResponse = issuerEndpoint.requestCredential(requestPayload);
assertEquals("The credential request should be answered successfully.",
HttpStatus.SC_OK, credentialResponse.getStatus());
assertNotNull("A credential should be responded.", credentialResponse.getEntity());
CredentialResponse credentialResponseVO = JsonSerialization.mapper
.convertValue(credentialResponse.getEntity(), CredentialResponse.class);
String credentialString = (String) credentialResponseVO.getCredentials().get(0).getCredential();
SdJwtVP sdJwtVP = SdJwtVP.of(credentialString);
assertNotNull("A valid credential string should have been responded", sdJwtVP);
CredentialRequest credentialRequest = new CredentialRequest();
try {
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
issuerEndpoint.requestCredential(requestPayload);
Assert.fail("Expected BadRequestException due to unknown credential identifier");
} catch (BadRequestException e) {
ErrorResponse error = (ErrorResponse) e.getResponse().getEntity();
assertEquals(ErrorType.MISSING_CREDENTIAL_IDENTIFIER_AND_CONFIGURATION_ID, error.getError());
}
});
}
@@ -430,9 +427,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
// 1. Retrieving the credential-offer-uri
final String credentialConfigurationId = jwtTypeCredentialClientScope.getAttributes()
.get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME)
+ "credential-offer-uri?credential_configuration_id="
+ credentialConfigurationId);
HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId));
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
@@ -602,15 +597,16 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestCredentialWithNotificationId() {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run((session) -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
.setCredentialConfigurationId(credConfigId);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
@@ -639,12 +635,13 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
@Test
public void testRequestMultipleCredentialsWithProofs() {
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
String cNonce = getCNonce();
testingClient.server(TEST_REALM_NAME).run(session -> {
try {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
String issuer = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
@@ -654,7 +651,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
CredentialRequest request = new CredentialRequest()
.setCredentialIdentifier(scopeName)
.setCredentialConfigurationId(credConfigId)
.setProofs(proofs);
OID4VCIssuerEndpoint endpoint = prepareIssuerEndpoint(session, authenticator);
@@ -874,7 +871,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
@@ -901,7 +898,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
@@ -931,7 +928,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
String token = getBearerToken(oauth, client, jwtTypeCredentialClientScope.getName());
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
// Prepare endpoint with no credential builders to simulate missing builder for the configured format
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator, Map.of());
@@ -963,7 +960,7 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
.get(CredentialScopeModel.CONFIGURATION_ID);
testingClient.server(TEST_REALM_NAME).run(session -> {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
BearerTokenAuthenticator authenticator = new BearerTokenAuthenticator(session);
authenticator.setTokenString(token);
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);

View File

@@ -6,7 +6,6 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.Assert;
@@ -36,7 +35,7 @@ public class OID4VCSdJwtIssuingEndpointDisabledTest extends OID4VCIssuerEndpoint
// Test getCredentialOfferURI
CorsErrorResponseException offerUriException = Assert.assertThrows(CorsErrorResponseException.class, () ->
issuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
issuerEndpoint.getCredentialOfferURI("test-credential")
);
assertEquals("Should fail with 403 Forbidden when client is not OID4VCI-enabled",
Response.Status.FORBIDDEN.getStatusCode(), offerUriException.getResponse().getStatus());

View File

@@ -32,7 +32,7 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Base64Url;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@@ -231,7 +231,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
final String nonceEndpoint = OID4VCIssuerWellKnownProvider.getNonceEndpoint(session.getContext());
try {
// make the exp-value negative to set the exp-time in the past
session.getContext().getRealm().setAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS, -1);
session.getContext().getRealm().setAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS, -1);
String cNonce = cNonceHandler.buildCNonce(List.of(credentialsEndpoint),
Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, nonceEndpoint));
Proofs proof = new Proofs().setJwt(List.of(generateJwtProof(getCredentialIssuer(session), cNonce)));
@@ -241,7 +241,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
testRequestTestCredential(session, clientScope, token, proof);
} finally {
// make sure other tests are not affected by the changed realm-attribute
session.getContext().getRealm().removeAttribute(Oid4VciConstants.C_NONCE_LIFETIME_IN_SECONDS);
session.getContext().getRealm().removeAttribute(OID4VCIConstants.C_NONCE_LIFETIME_IN_SECONDS);
}
})));
Assert.fail("Should have thrown an exception");
@@ -303,9 +303,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
// 1. Retrieving the credential-offer-uri
final String credentialConfigurationId = clientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
HttpGet getCredentialOfferURI = new HttpGet(getBasePath(TEST_REALM_NAME) +
"credential-offer-uri?credential_configuration_id=" +
credentialConfigurationId);
HttpGet getCredentialOfferURI = new HttpGet(getCredentialOfferUriUrl(credentialConfigurationId));
getCredentialOfferURI.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
CloseableHttpResponse credentialOfferURIResponse = httpClient.execute(getCredentialOfferURI);
@@ -515,7 +513,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
public static ProtocolMapperRepresentation getJtiGeneratedIdMapper() {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("generated-id-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-generated-id-mapper");
protocolMapperRepresentation.setConfig(Map.of(
@@ -530,7 +528,7 @@ public class OID4VCSdJwtIssuingEndpointTest extends OID4VCIssuerEndpointTest {
.addClientScope(realmModel, jwtTypeCredentialScopeName);
credentialScope.setAttribute(CredentialScopeModel.CREDENTIAL_IDENTIFIER,
jwtTypeCredentialScopeName);
credentialScope.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
credentialScope.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
return credentialScope;
}

View File

@@ -29,6 +29,7 @@ import java.security.Security;
import java.security.cert.Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
@@ -53,7 +54,7 @@ import org.keycloak.common.util.CertificateUtils;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.PemUtils;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.crypto.ECDSASignatureSignerContext;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
@@ -70,6 +71,7 @@ import org.keycloak.protocol.oid4vc.issuance.VCIssuanceContext;
import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtProofValidator;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
import org.keycloak.protocol.oid4vc.model.Format;
@@ -91,7 +93,8 @@ import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.AccessTokenRequest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.util.JsonSerialization;
@@ -336,7 +339,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("role-mapper");
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocolMapper("oid4vc-target-role-mapper");
protocolMapperRepresentation.setConfig(
Map.of("claim.name", "roles", "clientId", clientId)
@@ -347,7 +350,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
public static ProtocolMapperRepresentation getIdMapper() {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName("id-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-subject-id-mapper");
protocolMapperRepresentation.setConfig(Map.of());
@@ -357,7 +360,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
public static ProtocolMapperRepresentation getStaticClaimMapper(String scopeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-static-claim-mapper");
protocolMapperRepresentation.setConfig(
@@ -394,25 +397,36 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
return componentExportRepresentation;
}
public static UserRepresentation getUserRepresentation(Map<String, List<String>> clientRoles) {
UserBuilder userBuilder = UserBuilder.create()
.id(KeycloakModelUtils.generateId())
.username("john")
.enabled(true)
.email("john@email.cz")
.emailVerified(true)
.firstName("John")
.lastName("Doe")
.password("password")
.role("account", "manage-account")
.role("account", "view-profile");
public static UserRepresentation getUserRepresentation(
String fullName,
List<String> realmRoles,
Map<String, List<String>> clientRoles
) {
String[] nameToks = fullName.split("\\s");
String firstName = nameToks[0];
String lastName = nameToks[1];
String username = firstName.toLowerCase();
UserBuilder userBuilder = UserBuilder.create()
.id(KeycloakModelUtils.generateId())
.username(username)
.enabled(true)
.email(username + "@email.cz")
.emailVerified(true)
.firstName(firstName)
.lastName(lastName)
.password("password")
.role("account", "manage-account")
.role("account", "view-profile");
clientRoles.entrySet().forEach(entry -> {
entry.getValue().forEach(role -> userBuilder.role(entry.getKey(), role));
});
return userBuilder.build();
}
// When Keycloak issues a token for a user and client:
//
// 1. It looks up all effective realm roles and all effective client roles assigned to the user.
// 2. The token includes only those roles that the user actually has.
//
realmRoles.forEach(userBuilder::addRoles);
clientRoles.forEach((cid, roles) -> roles.forEach(role -> userBuilder.role(cid, role)));
return userBuilder.build();
}
public static RoleRepresentation getRoleRepresentation(String roleName, String clientId) {
@@ -423,27 +437,62 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
return role;
}
protected String getBearerToken(OAuthClient oAuthClient) {
return getBearerToken(oAuthClient, null);
protected String getAuthorizationCode(OAuthClient oAuthClient, ClientRepresentation client, String username, String scope) {
if (client != null) {
oAuthClient.client(client.getClientId(), client.getSecret());
}
if (scope != null) {
oAuthClient.scope(scope);
}
var authorizationEndpointResponse = oAuthClient.doLogin(username,"password");
return authorizationEndpointResponse.getCode();
}
protected String getBearerToken(OAuthClient oauthClient) {
return getBearerToken(oauthClient, null);
}
protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client) {
return getBearerToken(oAuthClient, client, null);
protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client) {
return getBearerToken(oauthClient, client, null);
}
protected String getBearerToken(OAuthClient oAuthClient, ClientRepresentation client, String credentialScopeName) {
if (client != null) {
oAuthClient.client(client.getClientId(), client.getSecret());
}
if (credentialScopeName != null) {
oAuthClient.scope(credentialScopeName);
}
AuthorizationEndpointResponse authorizationEndpointResponse = oAuthClient.doLogin("john",
"password");
return oAuthClient.doAccessTokenRequest(authorizationEndpointResponse.getCode()).getAccessToken();
protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client, String scope) {
return getBearerToken(oauthClient, client, "john", scope);
}
public static class StaticTimeProvider implements TimeProvider {
protected String getBearerToken(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) {
return getBearerTokenCodeFlow(oauthClient, client, username, scope).getAccessToken();
}
protected AccessTokenResponse getBearerToken(OAuthClient oauthClient, String authCode, AuthorizationDetail... authDetail) {
AccessTokenRequest accessTokenRequest = oauthClient.accessTokenRequest(authCode);
if (authDetail != null) {
accessTokenRequest.authorizationDetails(Arrays.asList(authDetail));
}
AccessTokenResponse tokenResponse = accessTokenRequest.send();
if (!tokenResponse.isSuccess()) {
throw new IllegalStateException(tokenResponse.getErrorDescription());
}
return tokenResponse;
}
protected AccessTokenResponse getBearerTokenCodeFlow(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) {
var authCode = getAuthorizationCode(oauthClient, client, username, scope);
return oauthClient.accessTokenRequest(authCode).send();
}
protected AccessTokenResponse getBearerTokenDirectAccess(OAuthClient oauthClient, ClientRepresentation client, String username, String scope) {
if (client != null) {
oauthClient.client(client.getClientId(), client.getSecret());
}
if (scope != null) {
oauthClient.scope(scope);
}
var accessTokenResponse = oauthClient.doPasswordGrantRequest(username, "password");
return accessTokenResponse;
}
public static class StaticTimeProvider implements TimeProvider {
private final int currentTimeInS;
public StaticTimeProvider(int currentTimeInS) {
@@ -464,7 +513,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
protected ProtocolMapperRepresentation getUserAttributeMapper(String subjectProperty, String attributeName) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(attributeName + "-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-user-attribute-mapper");
protocolMapperRepresentation.setConfig(
@@ -478,7 +527,7 @@ public abstract class OID4VCTest extends AbstractTestRealmKeycloakTest {
protected ProtocolMapperRepresentation getIssuedAtTimeMapper(String subjectProperty, String truncateToTimeUnit, String valueSource) {
ProtocolMapperRepresentation protocolMapperRepresentation = new ProtocolMapperRepresentation();
protocolMapperRepresentation.setName(subjectProperty + "-oid4vc-issued-at-time-claim-mapper");
protocolMapperRepresentation.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
protocolMapperRepresentation.setId(UUID.randomUUID().toString());
protocolMapperRepresentation.setProtocolMapper("oid4vc-issued-at-time-claim-mapper");

View File

@@ -24,7 +24,7 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.constants.Oid4VciConstants;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCIssuedAtTimeClaimMapper;
@@ -68,7 +68,7 @@ public class OID4VCTimeNormalizationSdJwtTest extends OID4VCSdJwtIssuingEndpoint
ClientScopeRepresentation clientScope = fromJsonString(clientScopeString, ClientScopeRepresentation.class);
ProtocolMapperRepresentation pr = new ProtocolMapperRepresentation();
pr.setName("iat-from-vc");
pr.setProtocol(Oid4VciConstants.OID4VC_PROTOCOL);
pr.setProtocol(OID4VCIConstants.OID4VC_PROTOCOL);
pr.setProtocolMapper(OID4VCIssuedAtTimeClaimMapper.MAPPER_ID);
pr.setConfig(Map.of(
OID4VCIssuedAtTimeClaimMapper.CLAIM_NAME, "iat",

View File

@@ -24,6 +24,7 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
@@ -55,6 +56,7 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest {
});
final String scopeName = jwtTypeCredentialClientScope.getName();
String credConfigId = jwtTypeCredentialClientScope.getAttributes().get(CredentialScopeModel.CONFIGURATION_ID);
String token = getBearerToken(oauth, client, scopeName);
testingClient.server(TEST_REALM_NAME).run(session -> {
@@ -64,7 +66,7 @@ public class OID4VCTimeNormalizationTest extends OID4VCJWTIssuerEndpointTest {
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
CredentialRequest credentialRequest = new CredentialRequest()
.setCredentialIdentifier(scopeName);
.setCredentialConfigurationId(credConfigId);
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
Response response = issuerEndpoint.requestCredential(requestPayload);

View File

@@ -405,6 +405,7 @@
"clientId": "named-test-app",
"name": "My Named Test App",
"enabled": true,
"directAccessGrantsEnabled": true,
"baseUrl": "http://localhost:8180/namedapp/base",
"redirectUris": [
"http://localhost:8180/namedapp/base/*",