[OID4VCI] Migrate OID4VCCredentialOfferMatrixTest (#46946)

closes #46971


Signed-off-by: Thomas Diesler <tdiesler@ibm.com>
This commit is contained in:
Thomas Diesler 2026-03-09 08:27:32 +01:00 committed by GitHub
parent 014267dc0e
commit b2dbdd3866
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 738 additions and 527 deletions

View file

@ -360,7 +360,7 @@ public class OID4VCIssuerEndpoint {
* Creates a Credential Offer that is bound to a specific user.
*/
public Response createCredentialOffer(String credConfigId, boolean preAuthorized, String targetUser) {
return createCredentialOffer(credConfigId, preAuthorized, false, targetUser, null, OfferResponseType.URI, 0, 0);
return createCredentialOffer(credConfigId, preAuthorized, targetUser, null, OfferResponseType.URI, 0, 0);
}
/**
@ -417,7 +417,6 @@ public class OID4VCIssuerEndpoint {
* @param credConfigId A valid credential configuration id
* @param preAuthorized A flag whether the offer should be pre-authorized
* @param targetUser The username that the offer is authorized for
* @param withTxCode A flag whether a tx_code should be generated for a pre-auth offer
* @param expireAt The date/time when the offer expires (in Unix timestamp seconds)
* @param responseType The response type, which can be 'uri', 'qr' or 'uri+qr'
* @param width The width of the QR code image
@ -429,7 +428,6 @@ public class OID4VCIssuerEndpoint {
public Response createCredentialOffer(
@QueryParam("credential_configuration_id") String credConfigId,
@QueryParam("pre_authorized") @DefaultValue("true") Boolean preAuthorized,
@QueryParam("tx_code") @DefaultValue("false") Boolean withTxCode,
@QueryParam("target_user") String targetUser,
@QueryParam("expire") Integer expireAt,
@QueryParam("type") @DefaultValue("uri") OfferResponseType responseType,
@ -546,12 +544,6 @@ public class OID4VCIssuerEndpoint {
String targetUserId = Optional.ofNullable(targetUserModel).map(UserModel::getId).orElse(null);
CredentialOfferState offerState = new CredentialOfferState(credOffer, targetClientId, targetUserId, expireAt);
// Generate the TxCode
//
if (preAuthorized && withTxCode) {
offerState.generateTxCode();
}
// Store the CredentialOfferState
//
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
@ -567,11 +559,9 @@ public class OID4VCIssuerEndpoint {
.detail(Details.VERIFIABLE_CREDENTIAL_TARGET_USER_ID, targetUserId)
.success();
String redactedTxCode = !Strings.isEmpty(offerState.getTxCode()) ? "******" : null;
CredentialOfferURI credOfferURI = new CredentialOfferURI()
.setIssuer(credOffer.getCredentialIssuer() + "/protocol/" + OID4VC_PROTOCOL + "/" + CREDENTIAL_OFFER_PATH)
.setNonce(offerState.getNonce())
.setTxCode(redactedTxCode);
.setNonce(offerState.getNonce());
// Respond with QR-Code as 'image/png'
if (responseType == OfferResponseType.QR) {
@ -580,7 +570,7 @@ public class OID4VCIssuerEndpoint {
}
// Respond with URI + QR-Code as 'application/json'
if (responseType == OfferResponseType.URI_AND_QR) {
if (responseType == OfferResponseType.URI_QR) {
byte[] qrBytes = generateQrCode(credOfferURI, width, height);
String encodedBytes = Arrays.toString(Base64.getEncoder().encode(qrBytes));
credOfferURI.setQrCode("data:image/png;base64," + encodedBytes);

View file

@ -35,9 +35,6 @@ public class CredentialOfferURI {
private String issuer;
private String nonce;
@JsonProperty("tx_code")
private String txCode;
@JsonProperty("qr_code")
private String qrCode;
@ -59,15 +56,6 @@ public class CredentialOfferURI {
return this;
}
public String getTxCode() {
return txCode;
}
public CredentialOfferURI setTxCode(String txCode) {
this.txCode = txCode;
return this;
}
public String getQrCode() {
return qrCode;
}

View file

@ -30,7 +30,7 @@ public enum OfferResponseType {
QR("qr"),
URI("uri"),
URI_AND_QR("uri+qr");
URI_QR("uri_qr");
private final String value;
@ -50,8 +50,8 @@ public enum OfferResponseType {
return QR;
} else if (v.equals(URI.getValue())) {
return URI;
} else if (v.equals(URI_AND_QR.getValue())) {
return URI_AND_QR;
} else if (v.equals(URI_QR.getValue())) {
return URI_QR;
} else return null;
})
.orElseThrow(() -> new IllegalArgumentException(String.format("%s is not a supported OfferUriType.", value)));

View file

@ -0,0 +1,340 @@
package org.keycloak.tests.oid4vc;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
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.CredentialResponse.Credential;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testsuite.util.oauth.AbstractOAuthClient;
import org.keycloak.testsuite.util.oauth.AccessTokenRequest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.LoginUrlBuilder;
import org.keycloak.testsuite.util.oauth.PkceGenerator;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferRequest;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriRequest;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialRequest;
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.PreAuthorizedCodeGrantRequest;
import org.keycloak.util.JsonSerialization;
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestRealmConfig.TEST_REALM_NAME;
import static org.keycloak.tests.oid4vc.OID4VCTestContext.ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY;
import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_OFFER_ATTACHMENT_KEY;
import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIAL_OFFER_URI_ATTACHMENT_KEY;
import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIAL_RESPONSE_ATTACHMENT_KEY;
import static org.keycloak.tests.oid4vc.OID4VCTestContext.ISSUER_METADATA_ATTACHMENT_KEY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A basic Wallet to exercise various OID4VCI message flows.
*
* Wallet state between messages is maintained in {@code OID4VCTestContext}.
*
* @author <a href="mailto:tdiesler@ibm.com">Thomas Diesler</a>
*/
public class OID4VCBasicWallet {
final Keycloak keycloak;
final OAuthClient oauth;
final Set<String> loginUsers = new HashSet<>();
public OID4VCBasicWallet(Keycloak keycloak, OAuthClient oauth) {
this.keycloak = keycloak;
this.oauth = oauth;
}
// Composite Actions -----------------------------------------------------------------------------------------------
public CredentialsOffer createPreAuthCredentialOffer(OID4VCTestContext ctx, String targetUser) throws Exception {
// Get Issuer AccessToken
//
AccessTokenResponse issTokenResponse = getIssuerAccessToken(ctx.issuer);
assertNotNull(issTokenResponse.getAccessToken(), "No accessToken");
// Exclude scope: <credScope>
// Require role: credential-offer-create
String issToken = validateIssuerAccessToken(issTokenResponse,
List.of(), List.of(ctx.credScopeName),
List.of(CREDENTIAL_OFFER_CREATE.getName()), List.of());
// Create Pre-Authorized CredentialOffer
//
CredentialOfferURI credOfferUri;
try {
credOfferUri = createCredentialOffer(ctx, ctx.credConfigId)
.preAuthorized(true)
.targetUser(targetUser)
.bearerToken(issToken)
.send().getCredentialOfferURI();
} finally {
logout(ctx.issuer);
}
// Fetch the CredentialsOffer
//
CredentialsOffer credOffer = getCredentialOffer(ctx, credOfferUri)
.send().getCredentialsOffer();
String preAuthCode = credOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode();
assertNotNull(preAuthCode, "No PreAuth Code");
return credOffer;
}
// Low Level Messages ----------------------------------------------------------------------------------------------
public CredentialOfferUriRequest createCredentialOffer(OID4VCTestContext ctx, String credConfigId) {
CredentialOfferUriRequest request = new CredentialOfferUriRequest(oauth, credConfigId) {
public CredentialOfferUriResponse send() {
CredentialOfferUriResponse response = super.send();
ctx.putAttachment(CREDENTIAL_OFFER_URI_ATTACHMENT_KEY, response.getCredentialOfferURI());
return response;
}
};
return request;
}
public CredentialOfferRequest getCredentialOffer(OID4VCTestContext ctx, CredentialOfferURI credOfferUri) {
CredentialOfferRequest request = new CredentialOfferRequest(oauth, credOfferUri) {
public CredentialOfferResponse send() {
CredentialOfferResponse response = super.send();
ctx.putAttachment(CREDENTIALS_OFFER_ATTACHMENT_KEY, response.getCredentialsOffer());
return response;
}
};
return request;
}
public AuthorizationEndpointRequest authorizationRequest() {
AuthorizationEndpointRequest request = new AuthorizationEndpointRequest(oauth) {
public AuthorizationEndpointResponse send(String username, String password) {
loginUsers.add(username);
return super.send(username, password);
}
};
return request;
}
public AccessTokenRequest accessTokenRequest(OID4VCTestContext ctx, String authCode) {
AccessTokenRequest request = new AccessTokenRequest(oauth, authCode) {
public AccessTokenResponse send() {
AccessTokenResponse response = super.send();
ctx.putAttachment(ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY, response);
return response;
}
};
return request;
}
public PreAuthorizedCodeGrantRequest preAuthAccessTokenRequest(OID4VCTestContext ctx, String preAuthCode) {
PreAuthorizedCodeGrantRequest request = new PreAuthorizedCodeGrantRequest(oauth, preAuthCode) {
public AccessTokenResponse send() {
AccessTokenResponse response = super.send();
ctx.putAttachment(ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY, response);
return response;
}
};
return request;
}
public Oid4vcCredentialRequest credentialRequest(OID4VCTestContext ctx, String accessToken) {
Oid4vcCredentialRequest request = new Oid4vcCredentialRequest(oauth, new CredentialRequest()) {
public Oid4vcCredentialResponse send() {
Oid4vcCredentialResponse response = super.send();
CredentialResponse credentialResponse = response.getCredentialResponse();
ctx.putAttachment(CREDENTIAL_RESPONSE_ATTACHMENT_KEY, credentialResponse);
return response;
}
}.bearerToken(accessToken);
return request;
}
public AccessTokenResponse getIssuerAccessToken(String username) {
PkceGenerator pkce = PkceGenerator.s256();
AuthorizationEndpointResponse authResponse = authorizationRequest()
.codeChallenge(pkce)
.send(username, "password");
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");
AccessTokenResponse tokenResponse = oauth.accessTokenRequest(authCode)
.codeVerifier(pkce)
.send();
assertNotNull(tokenResponse.getAccessToken(), "No AccessToken");
return tokenResponse;
}
public CredentialIssuer getIssuerMetadata(OID4VCTestContext ctx) {
CredentialIssuer issuerMetadata = Optional.ofNullable(ctx.getAttachment(ISSUER_METADATA_ATTACHMENT_KEY))
.orElse(oauth.oid4vc().doIssuerMetadataRequest().getMetadata());
ctx.putAttachment(ISSUER_METADATA_ATTACHMENT_KEY, issuerMetadata);
return issuerMetadata;
}
public void logout() {
for (String user : loginUsers) {
logout(user);
}
loginUsers.clear();
}
public void logout(String username) {
RealmResource realm = keycloak.realm(TEST_REALM_NAME);
UserRepresentation userRep = realm.users().search(username).get(0);
UserResource userResource = realm.users().get(userRep.getId());
userResource.logout();
}
// State Validation ------------------------------------------------------------------------------------------------
public void verifyCredentialsSignature(CredentialResponse credResponse, String algorithm) throws Exception {
for (Credential credEntry : credResponse.getCredentials()) {
String encodedCredential = credEntry.getCredential().toString();
JWSInput jwsInput = new JWSInput(encodedCredential);
JWSHeader header = jwsInput.getHeader();
assertEquals(algorithm, header.getRawAlgorithm());
oauth.verifyToken(encodedCredential, JsonWebToken.class);
}
}
public String validateIssuerAccessToken(
AccessTokenResponse tokenResponse,
List<String> includeScopes, List<String> excludeScopes,
List<String> includeRoles, List<String> excludeRoles
) throws Exception {
String accessToken = tokenResponse.getAccessToken();
JsonWebToken jwt = JsonSerialization.readValue(new JWSInput(accessToken).getContent(), JsonWebToken.class);
List<String> wasScopes = Arrays.stream(((String) jwt.getOtherClaims().get("scope")).split("\\s")).toList();
includeScopes.forEach(it -> assertTrue(wasScopes.contains(it), "Missing scope: " + it));
excludeScopes.forEach(it -> assertFalse(wasScopes.contains(it), "Invalid scope: " + 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(allRoles.contains(it), "Missing role: " + it));
excludeRoles.forEach(it -> assertFalse(allRoles.contains(it), "Invalid role: " + it));
return accessToken;
}
public String validateHolderAccessToken(OID4VCTestContext ctx, AccessTokenResponse tokenResponse) throws Exception {
// Check that we can extract the AccessToken
if (!tokenResponse.isSuccess()) {
fail("Error in AccessToken response: " + tokenResponse.getErrorDescription());
}
String accessToken = tokenResponse.getAccessToken();
assertNotNull(accessToken, "No AccessToken");
// Extract authorization_details from AccessTokenResponse
//
List<OID4VCAuthorizationDetail> tokenAuthDetails = tokenResponse.getOID4VCAuthorizationDetails();
assertTrue(tokenAuthDetails != null && !tokenAuthDetails.isEmpty(), "No authorization_details in AccessTokenResponse");
// Extract authorization_details from AccessToken (JWT)
//
JsonWebToken jwt = new JWSInput(tokenResponse.getAccessToken()).readJsonContent(JsonWebToken.class);
Object authDetailsClaim = jwt.getOtherClaims().get(AUTHORIZATION_DETAILS);
String authDetailsJson = Optional.ofNullable(authDetailsClaim)
.map(JsonSerialization::valueAsString)
.orElse(null);
List<OID4VCAuthorizationDetail> jwtAuthDetails = Optional.ofNullable(authDetailsJson)
.map(it -> JsonSerialization.valueFromString(it, OID4VCAuthorizationDetail[].class))
.map(Arrays::asList)
.orElse(null);
assertTrue(jwtAuthDetails != null && !jwtAuthDetails.isEmpty(), "No authorization_details in AccessTokenJWT");
assertEquals(1, tokenAuthDetails.size(), "Expected one authorization_details entry");
var tokenAuthDetail = tokenAuthDetails.get(0);
assertEquals(1, jwtAuthDetails.size(), "Expected one authorization_details entry");
var jwtAuthDetail = jwtAuthDetails.get(0);
assertEquals(ctx.credConfigId, tokenAuthDetail.getCredentialConfigurationId());
assertEquals(tokenAuthDetail, jwtAuthDetail);
return accessToken;
}
public static class AuthorizationEndpointRequest {
protected final AbstractOAuthClient<?> client;
protected final LoginUrlBuilder loginForm;
public AuthorizationEndpointRequest(AbstractOAuthClient<?> client) {
this.client = client;
this.loginForm = client.loginForm();
}
public AuthorizationEndpointRequest authorizationDetails(OID4VCAuthorizationDetail authDetail) {
loginForm.authorizationDetails(List.of(authDetail));
return this;
}
public AuthorizationEndpointRequest codeChallenge(PkceGenerator pkce) {
loginForm.codeChallenge(pkce);
return this;
}
public AuthorizationEndpointRequest request(String request) {
loginForm.request(request);
return this;
}
public AuthorizationEndpointRequest scope(String... scopes) {
loginForm.scope(scopes);
return this;
}
public AuthorizationEndpointResponse send(String username, String password) {
loginForm.open();
client.fillLoginForm(username, password);
return client.parseLoginResponse();
}
}
}

View file

@ -0,0 +1,236 @@
package org.keycloak.tests.oid4vc;
import java.net.URI;
import java.util.List;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestServerConfig;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.util.JsonSerialization;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Credential Offer Validity Matrix
* <p>
* +----------+----------+---------+------------------------------------------------------+
* | Pre-Auth | Username | Valid | Notes |
* +----------+----------+---------+------------------------------------------------------+
* | no | no | yes | Anonymous offer; any logged-in user may redeem. |
* | no | yes | yes | Offer restricted to a specific user. |
* +----------+----------+---------+------------------------------------------------------+
* | yes | no | no | Pre-auth requires a target user. |
* | yes | yes | yes | Pre-auth for a specific target user. |
* +----------+----------+---------+------------------------------------------------------+
*/
@KeycloakIntegrationTest(config = VCTestServerConfig.class)
public class OID4VCCredentialOfferMatrixTest extends OID4VCIssuerTestBase {
OID4VCBasicWallet wallet;
@BeforeEach
void beforeEach() {
wallet = new OID4VCBasicWallet(keycloak, oauth);
}
@AfterEach
void afterEach() {
wallet.logout();
}
@Test
public void testRealmSetup() {
RealmRepresentation realmRep = testRealm.admin().toRepresentation();
assertEquals(shouldEnableOid4vci(realmRep), realmRep.isVerifiableCredentialsEnabled());
assertEquals(shouldEnableOid4vci(client), isOid4vciEnabled(client));
}
@Test
public void testWithoutOffer_Scope() throws Exception {
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
// Send AuthorizationRequest
//
AuthorizationEndpointResponse authResponse = wallet
.authorizationRequest()
.scope(ctx.credScopeName)
.send(ctx.holder, "password");
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");
// Build and send AccessTokenRequest
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");
String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier,"No authorized credential identifier");
// Send the CredentialRequest
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.send().getCredentialResponse();
verifyCredentialResponse(ctx, ctx.holder, credResponse);
}
@Test
public void testWithoutOffer_Scope_AuthDetails() throws Exception {
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
CredentialIssuer issuerMetadata = wallet.getIssuerMetadata(ctx);
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(ctx.credConfigId);
authDetail.setLocations(List.of(issuerMetadata.getCredentialIssuer()));
// Send AuthorizationRequest
//
AuthorizationEndpointResponse authResponse = wallet
.authorizationRequest()
.scope(ctx.credScopeName)
.authorizationDetails(authDetail)
.send(ctx.holder, "password");
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");
// Build and send AccessTokenRequest
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode).send();
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");
String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier,"No authorized credential identifier");
// Send the CredentialRequest
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.send().getCredentialResponse();
verifyCredentialResponse(ctx, ctx.holder, credResponse);
}
@Test
public void testPreAuthOffer_DisabledUser() {
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
// Disable user
UserRepresentation userRep = testRealm.admin().users().search(ctx.holder).get(0);
UserResource userResource = testRealm.admin().users().get(userRep.getId());
userRep.setEnabled(false);
userResource.update(userRep);
try {
IllegalStateException error = assertThrows(IllegalStateException.class,
() -> wallet.createPreAuthCredentialOffer(ctx, ctx.holder));
assertTrue(error.getMessage().contains("User 'alice' disabled"), error.getMessage());
} finally {
userRep.setEnabled(true);
userResource.update(userRep);
}
}
@Test
public void testPreAuthOffer_SelfIssued() throws Exception {
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
// Create Pre-Authorized CredentialOffer
//
CredentialsOffer credOffer = wallet.createPreAuthCredentialOffer(ctx, null);
String preAuthCode = credOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode();
// Redeem Pre-Authorized Code for AccessToken
//
AccessTokenResponse tokenResponse = wallet.preAuthAccessTokenRequest(ctx, preAuthCode).send();
assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription());
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken,"No accessToken");
String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier,"No authorized credential identifier");
// Send the CredentialRequest
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.send().getCredentialResponse();
verifyCredentialResponse(ctx, ctx.issuer, credResponse);
}
@Test
public void testPreAuthOffer_Targeted() throws Exception {
var ctx = new OID4VCTestContext(client, jwtTypeCredentialScope);
// Create Pre-Authorized CredentialOffer
//
CredentialsOffer credOffer = wallet.createPreAuthCredentialOffer(ctx, ctx.holder);
String preAuthCode = credOffer.getGrants().getPreAuthorizedCode().getPreAuthorizedCode();
// Redeem Pre-Authorized Code for AccessToken
//
AccessTokenResponse tokenResponse = wallet.preAuthAccessTokenRequest(ctx, preAuthCode).send();
assertTrue(tokenResponse.isSuccess(), tokenResponse.getErrorDescription());
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");
String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier, "No authorized credential identifier");
// Send the CredentialRequest
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.send().getCredentialResponse();
verifyCredentialResponse(ctx, ctx.holder, credResponse);
}
// Private ---------------------------------------------------------------------------------------------------------
private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception {
String scope = ctx.credentialScope.getName();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull(credentialObj, "The first credential in the array should not be null");
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(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}
}

View file

@ -0,0 +1,136 @@
package org.keycloak.tests.oid4vc;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
/**
* A context that can maintain state across OID4VCI message flows.
*
* It uses a typed in-memory value store based on attachment keys.
* Values can be accessed by type and when there are multiple values - by name+type.
*
* @author <a href="mailto:tdiesler@ibm.com">Thomas Diesler</a>
*/
public class OID4VCTestContext {
static final AttachmentKey<CredentialIssuer> ISSUER_METADATA_ATTACHMENT_KEY = new AttachmentKey<>(CredentialIssuer.class);
static final AttachmentKey<CredentialOfferURI> CREDENTIAL_OFFER_URI_ATTACHMENT_KEY = new AttachmentKey<>(CredentialOfferURI.class);
static final AttachmentKey<CredentialsOffer> CREDENTIALS_OFFER_ATTACHMENT_KEY = new AttachmentKey<>(CredentialsOffer.class);
static final AttachmentKey<AccessTokenResponse> ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(AccessTokenResponse.class);
static final AttachmentKey<CredentialResponse> CREDENTIAL_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(CredentialResponse.class);
ClientRepresentation client;
String clientId;
String issuer; // Issuing username (i.e. agent who creates credential offers)
String holder; // Holder who requests the credential
String credConfigId;
String credScopeName;
CredentialScopeRepresentation credentialScope;
Map<AttachmentKey<?>, Object> attachments = new HashMap<>();
public OID4VCTestContext(ClientRepresentation client, CredentialScopeRepresentation credentialScope) {
this.client = client;
this.clientId = client.getClientId();
this.issuer = "john";
this.holder = "alice";
this.credentialScope = credentialScope;
this.credScopeName = credentialScope.getName();
this.credConfigId = credentialScope.getCredentialConfigurationId();
}
public List<String> getAuthorizedCredentialIdentifiers() {
OID4VCAuthorizationDetail tokenAuthDetails = getOID4VCAuthorizationDetail();
return Optional.ofNullable(tokenAuthDetails)
.map(OID4VCAuthorizationDetail::getCredentialIdentifiers)
.orElse(Collections.emptyList());
}
public String getAuthorizedCredentialIdentifier() {
List<String> authorizedIdentifiers = getAuthorizedCredentialIdentifiers();
return authorizedIdentifiers.size() == 1 ? authorizedIdentifiers.get(0) : null;
}
public String getAuthorizedCredentialConfigurationId() {
OID4VCAuthorizationDetail tokenAuthDetails = getOID4VCAuthorizationDetail();
return Optional.ofNullable(tokenAuthDetails)
.map(OID4VCAuthorizationDetail::getCredentialConfigurationId)
.orElse(null);
}
public List<OID4VCAuthorizationDetail> getOID4VCAuthorizationDetails() {
AccessTokenResponse response = assertAttachment(ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY);
return Optional.ofNullable(response)
.map(AccessTokenResponse::getOID4VCAuthorizationDetails)
.orElse(Collections.emptyList());
}
public OID4VCAuthorizationDetail getOID4VCAuthorizationDetail() {
List<OID4VCAuthorizationDetail> tokenAuthDetails = getOID4VCAuthorizationDetails();
return tokenAuthDetails.size() == 1 ? tokenAuthDetails.get(0) : null;
}
// Attachment Support ----------------------------------------------------------------------------------------------
<T> void putAttachment(AttachmentKey<T> key, T value) {
if (value != null) {
attachments.put(key, value);
} else {
attachments.remove(key, value);
}
}
<T> T assertAttachment(AttachmentKey<T> key) {
return Optional.of(getAttachment(key)).get();
}
@SuppressWarnings("unchecked")
<T> T getAttachment(AttachmentKey<T> key) {
return (T) attachments.get(key);
}
@SuppressWarnings("unchecked")
<T> T removeAttachment(AttachmentKey<T> key) {
return (T) attachments.remove(key);
}
static class AttachmentKey<T> {
private final String name;
private final Class<T> type;
AttachmentKey(Class<T> type) {
this(null, type);
}
AttachmentKey(String name, Class<T> type) {
this.name = Optional.ofNullable(name).orElse("");
this.type = Optional.of(type).get();
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
AttachmentKey<?> that = (AttachmentKey<?>) o;
return Objects.equals(name, that.name) && Objects.equals(type, that.type);
}
@Override
public int hashCode() {
return Objects.hash(name, type);
}
}
}

View file

@ -1,10 +1,12 @@
package org.keycloak.testsuite.util.oauth;
import java.util.Arrays;
import java.util.List;
import org.keycloak.OAuth2Constants;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.AuthorizationDetailsJSONRepresentation;
import org.keycloak.representations.ClaimsRepresentation;
public class LoginUrlBuilder extends AbstractUrlBuilder {
@ -29,6 +31,16 @@ public class LoginUrlBuilder extends AbstractUrlBuilder {
return this;
}
public LoginUrlBuilder authorizationDetails(AuthorizationDetailsJSONRepresentation authDetail) {
parameter(OAuth2Constants.AUTHORIZATION_DETAILS, authDetail != null ? List.of(authDetail) : List.of());
return this;
}
public LoginUrlBuilder authorizationDetails(List<AuthorizationDetailsJSONRepresentation> authDetails) {
parameter(OAuth2Constants.AUTHORIZATION_DETAILS, authDetails);
return this;
}
public LoginUrlBuilder state(String state) {
parameter(OIDCLoginProtocol.STATE_PARAM, state);
return this;
@ -103,8 +115,9 @@ public class LoginUrlBuilder extends AbstractUrlBuilder {
parameter(OIDCLoginProtocol.RESPONSE_MODE_PARAM, client.config().getResponseMode());
parameter(OAuth2Constants.CLIENT_ID, client.config().getClientId());
parameter(OAuth2Constants.REDIRECT_URI, client.config().getRedirectUri());
parameter(OAuth2Constants.SCOPE, client.config().getScope());
if (!params.containsKey(OAuth2Constants.SCOPE)) {
parameter(OAuth2Constants.SCOPE, client.config().getScope());
}
}
public AuthorizationEndpointResponse doLogin(String username, String password) {

View file

@ -13,12 +13,12 @@ public class CredentialOfferRequest extends AbstractHttpGetRequest<CredentialOff
private final CredentialOfferURI credOfferURI;
CredentialOfferRequest(AbstractOAuthClient<?> client, CredentialOfferURI credOfferUri) {
public CredentialOfferRequest(AbstractOAuthClient<?> client, CredentialOfferURI credOfferUri) {
super(client);
this.credOfferURI = credOfferUri;
}
CredentialOfferRequest(AbstractOAuthClient<?> client, String nonce) {
public CredentialOfferRequest(AbstractOAuthClient<?> client, String nonce) {
super(client);
credOfferURI = new CredentialOfferURI();
credOfferURI.setIssuer(client.getEndpoints().getOid4vcCredentialOffer());

View file

@ -15,12 +15,11 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest<Credential
private final String credConfigId;
private Boolean preAuthorized;
private Boolean txCode;
private String targetUser;
private Integer expireAt;
private OfferResponseType responseType;
CredentialOfferUriRequest(AbstractOAuthClient<?> client, String credConfigId) {
public CredentialOfferUriRequest(AbstractOAuthClient<?> client, String credConfigId) {
super(client);
this.credConfigId = credConfigId;
}
@ -30,11 +29,6 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest<Credential
return this;
}
public CredentialOfferUriRequest txCode(Boolean txCode) {
this.txCode = txCode;
return this;
}
public CredentialOfferUriRequest targetUser(String targetUser) {
this.targetUser = targetUser;
return this;
@ -55,7 +49,6 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest<Credential
UriBuilder builder = UriBuilder.fromUri(client.getEndpoints().getOid4vcCredentialOfferUri());
if (!Strings.isEmpty(credConfigId)) builder.queryParam("credential_configuration_id", credConfigId);
if (preAuthorized != null) builder.queryParam("pre_authorized", preAuthorized);
if (txCode != null) builder.queryParam("tx_code", txCode);
if (!Strings.isEmpty(targetUser)) builder.queryParam("target_user", targetUser);
if (expireAt != null) builder.queryParam("expire", expireAt);
if (responseType != null) builder.queryParam("type", responseType.getValue());

View file

@ -16,7 +16,7 @@ public class Oid4vcCredentialRequest extends AbstractHttpPostRequest<Oid4vcCrede
private final CredentialRequest credRequest;
Oid4vcCredentialRequest(AbstractOAuthClient<?> client, CredentialRequest credRequest) {
public Oid4vcCredentialRequest(AbstractOAuthClient<?> client, CredentialRequest credRequest) {
super(client);
this.credRequest = credRequest;
}

View file

@ -16,9 +16,8 @@ import org.apache.http.client.methods.CloseableHttpResponse;
public class PreAuthorizedCodeGrantRequest extends AbstractHttpPostRequest<PreAuthorizedCodeGrantRequest, AccessTokenResponse> {
private final String preAuthCode;
private String txCode;
PreAuthorizedCodeGrantRequest(AbstractOAuthClient<?> client, String preAuthorizedCode) {
public PreAuthorizedCodeGrantRequest(AbstractOAuthClient<?> client, String preAuthorizedCode) {
super(client);
this.preAuthCode = preAuthorizedCode;
}
@ -28,11 +27,6 @@ public class PreAuthorizedCodeGrantRequest extends AbstractHttpPostRequest<PreAu
return this;
}
public PreAuthorizedCodeGrantRequest txCode(String txCode) {
this.txCode = txCode;
return this;
}
@Override
protected String getEndpoint() {
return client.getEndpoints().getToken();
@ -42,7 +36,6 @@ public class PreAuthorizedCodeGrantRequest extends AbstractHttpPostRequest<PreAu
protected void initRequest() {
parameter(OAuth2Constants.GRANT_TYPE, PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE);
parameter(PreAuthorizedCodeGrantTypeFactory.CODE_REQUEST_PARAM, preAuthCode);
parameter(PreAuthorizedCodeGrantTypeFactory.TX_CODE_PARAM, txCode);
}
@Override

View file

@ -1,478 +0,0 @@
/*
* 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.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.jose.jws.JWSInput;
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.ErrorType;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
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.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.Oid4vcCredentialResponse;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.core.type.TypeReference;
import org.apache.directory.api.util.Strings;
import org.junit.Test;
import static org.keycloak.OAuth2Constants.SCOPE_OPENID;
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId;
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 | Username | Valid | Notes |
* +----------+----------+---------+------------------------------------------------------+
* | no | no | yes | Anonymous offer; any logged-in user may redeem. |
* | no | yes | yes | Offer restricted to a specific user. |
* +----------+----------+---------+------------------------------------------------------+
* | yes | no | no | Pre-auth requires a target user. |
* | yes | yes | yes | Pre-auth for a specific target user. |
* +----------+----------+---------+------------------------------------------------------+
*/
public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
String namedClientId = "named-test-app";
String issUsername = "john";
String appUsername = "alice";
String credScopeName = jwtTypeNaturalPersonScopeName;
String credConfigId = jwtTypeNaturalPersonScopeName;
class TestContext {
boolean preAuthorized;
String issUser;
String appUser;
CredentialIssuer issuerMetadata;
OIDCConfigurationRepresentation authorizationMetadata;
SupportedCredentialConfiguration credentialConfiguration;
TestContext(boolean preAuth, String appUser) {
this.preAuthorized = preAuth;
this.issUser = issUsername;
this.appUser = appUser;
this.issuerMetadata = getCredentialIssuerMetadata();
this.authorizationMetadata = getAuthorizationMetadata(this.issuerMetadata.getAuthorizationServers().get(0));
this.credentialConfiguration = this.issuerMetadata.getCredentialsSupported().get(credConfigId);
}
}
@Override
protected void afterAbstractKeycloakTestRealmImport() {
ClientRepresentation namedClient = requireExistingClient(namedClientId);
assignOptionalClientScope(namedClient, credScopeName);
setOid4vciEnabled(namedClient, true);
}
@Test
public void testCredentialWithoutOffer() throws Exception {
var ctx = new TestContext(false, appUsername);
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(ctx.issuerMetadata.getCredentialIssuer()));
// [TODO #44320] Requires Credential scope in AuthorizationRequest although already given in AuthorizationDetails
// https://github.com/keycloak/keycloak/issues/44320
String accessToken = getBearerToken(clientId, ctx.appUser, credScopeName, authDetail);
// Extract credential_identifier from the access token's authorization_details
JsonWebToken tokenDecoded = new JWSInput(accessToken).readJsonContent(JsonWebToken.class);
Object tokenAuthDetails = tokenDecoded.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
assertNotNull("authorization_details not found in access token", tokenAuthDetails);
// When authorization_details are sent in token request, they are returned in token response with credential_identifiers
// The credential request MUST use credential_identifier (not credential_configuration_id)
List<OID4VCAuthorizationDetail> authDetailsResponse = JsonSerialization.readValue(
JsonSerialization.writeValueAsString(tokenAuthDetails),
new TypeReference<>() {}
);
assertNotNull("authorization_details should be present in the response", authDetailsResponse);
assertFalse("authorization_details should not be empty", authDetailsResponse.isEmpty());
OID4VCAuthorizationDetail authDetailResponse = authDetailsResponse.get(0);
List<String> credentialIdentifiers = authDetailResponse.getCredentialIdentifiers();
assertNotNull("credential_identifiers should be present", credentialIdentifiers);
assertFalse("credential_identifiers should not be empty", credentialIdentifiers.isEmpty());
var credRequest = new CredentialRequest()
.setCredentialIdentifier(credentialIdentifiers.get(0));
CredentialResponse credResponse = sendCredentialRequest(accessToken, credRequest);
verifyCredentialResponse(ctx, credResponse);
}
// Pre Authorized --------------------------------------------------------------------------------------------------
@Test
public void testCredentialOffer_PreAuth_SelfIssued() throws Exception {
runCredentialOfferTest(new TestContext(true, issUsername));
}
@Test
public void testCredentialOffer_PreAuth_Targeted() throws Exception {
runCredentialOfferTest(new TestContext(true, appUsername));
}
@Test
public void testCredentialOffer_PreAuth_DisabledUser() throws Exception {
// Disable user
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), appUsername);
UserRepresentation userRep = user.toRepresentation();
userRep.setEnabled(false);
user.update(userRep);
try {
runCredentialOfferTest(new TestContext(true, appUsername));
fail("Expected " + ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue());
} catch (RuntimeException ex) {
List.of(ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), "User '" + appUsername + "' disabled")
.forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it)));
} finally {
// Re-enable user
userRep.setEnabled(true);
user.update(userRep);
}
}
// Authorization Code ----------------------------------------------------------------------------------------------
@Test
public void testCredentialOffer_noPreAuth_Anonymous() throws Exception {
runCredentialOfferTest(new TestContext(false, null));
}
@Test
public void testCredentialOffer_noPreAuth_Targeted() throws Exception {
runCredentialOfferTest(new TestContext(false, appUsername));
}
void runCredentialOfferTest(TestContext ctx) throws Exception {
// Issuer login
//
String issToken = getBearerToken(clientId, ctx.issUser, SCOPE_OPENID);
// Exclude scope: <credScope>
// Require role: credential-offer-create
// [TODO] Require role: credential-offer-create
verifyTokenJwt(issToken,
List.of(), List.of(ctx.credentialConfiguration.getScope()),
List.of(), List.of());
// Retrieving the credential-offer-uri
//
CredentialOfferURI credOfferUri = getCredentialOfferUri(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, credOfferUri);
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(credOffer);
assertTrue(accessToken.getErrorDescription(), accessToken.isSuccess());
List<OID4VCAuthorizationDetail> authDetailsResponse = accessToken.getOID4VCAuthorizationDetails();
if (authDetailsResponse == null || authDetailsResponse.isEmpty()) {
throw new IllegalStateException("No authorization_details in token response");
}
if (authDetailsResponse.size() > 1) {
throw new IllegalStateException("Multiple authorization_details in token response");
}
OID4VCAuthorizationDetail authDetailResponse = authDetailsResponse.get(0);
// Get the credential and verify
//
CredentialResponse credResponse = getCredentialByAuthDetail(accessToken.getAccessToken(), authDetailResponse);
verifyCredentialResponse(ctx, credResponse);
} else {
String username = ctx.appUser != null ? ctx.appUser : appUsername;
String credConfigId = credOffer.getCredentialConfigurationIds().get(0);
SupportedCredentialConfiguration credConfig = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
String scope = credConfig.getScope();
AccessTokenResponse tokenResponse = getBearerTokenResponse(clientId, username, scope);
String accessToken = tokenResponse.getAccessToken();
// Get the credential and verify
//
CredentialResponse credResponse = getCredentialByOffer(accessToken, tokenResponse, credOffer);
verifyCredentialResponse(ctx, credResponse);
}
} finally {
if (ctx.appUser != null) {
logout(ctx.appUser);
}
}
}
// Private ---------------------------------------------------------------------------------------------------------
private AccessTokenResponse getBearerTokenResponse(String clientId, String username, String scope) {
ClientRepresentation client = testRealm().clients().findByClientId(clientId).get(0);
// For credential scopes, we need to request authorization_details to get credential_identifier
if (scope != null && scope.equals(credScopeName)) {
OID4VCAuthorizationDetail authDetail = new OID4VCAuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
authDetail.setCredentialConfigurationId(credConfigId);
authDetail.setLocations(List.of(getCredentialIssuerMetadata().getCredentialIssuer()));
// Set the redirect URI from the client's configuration
if (client.getRedirectUris() != null && !client.getRedirectUris().isEmpty()) {
oauth.redirectUri(client.getRedirectUris().get(0));
}
String authCode = getAuthorizationCode(oauth, client, username, scope);
return getBearerToken(oauth, authCode, authDetail);
}
// For non-credential scopes, use the appropriate flow based on client configuration
if (client.isDirectAccessGrantsEnabled()) {
return getBearerTokenDirectAccess(oauth, client, username, scope);
} else {
return getBearerTokenCodeFlow(oauth, client, username, scope);
}
}
private List<OID4VCAuthorizationDetail> extractAuthorizationDetails(AccessTokenResponse tokenResponse) {
// First check if already populated in token response
List<OID4VCAuthorizationDetail> authDetailsResponse = tokenResponse.getOID4VCAuthorizationDetails();
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
return authDetailsResponse;
}
// Otherwise, extract from JWT access token
try {
JsonWebToken jwt = new JWSInput(tokenResponse.getAccessToken()).readJsonContent(JsonWebToken.class);
Object authDetails = jwt.getOtherClaims().get(OAuth2Constants.AUTHORIZATION_DETAILS);
if (authDetails != null) {
return JsonSerialization.readValue(
JsonSerialization.writeValueAsString(authDetails),
new TypeReference<>() {}
);
}
} catch (Exception e) {
// Ignore - authorization_details not present or couldn't be parsed
}
return null;
}
private String getBearerToken(String clientId, String username, String scope) {
return getBearerTokenResponse(clientId, username, scope).getAccessToken();
}
private String getBearerToken(String clientId, String username, String scope, OID4VCAuthorizationDetail... 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 CredentialOfferURI getCredentialOfferUri(TestContext ctx, String token) throws Exception {
String credConfigId = ctx.credentialConfiguration.getId();
CredentialOfferUriResponse credentialOfferURIResponse = oauth.oid4vc()
.credentialOfferUriRequest(credConfigId)
.preAuthorized(ctx.preAuthorized)
.txCode(ctx.preAuthorized)
.targetUser(ctx.appUser)
.bearerToken(token)
.send();
CredentialOfferURI credentialOfferURI = credentialOfferURIResponse.getCredentialOfferURI();
assertTrue(credentialOfferURI.getIssuer().startsWith(ctx.issuerMetadata.getCredentialIssuer()));
assertTrue(Strings.isNotEmpty(credentialOfferURI.getNonce()));
return credentialOfferURI;
}
private CredentialsOffer getCredentialsOffer(TestContext ctx, CredentialOfferURI credOfferUri) throws Exception {
CredentialOfferResponse credentialOfferResponse = oauth.oid4vc().doCredentialOfferRequest(credOfferUri);
CredentialsOffer credOffer = credentialOfferResponse.getCredentialsOffer();
assertEquals(List.of(ctx.credentialConfiguration.getId()), credOffer.getCredentialConfigurationIds());
return credOffer;
}
private AccessTokenResponse getPreAuthorizedAccessTokenResponse(CredentialsOffer credOffer) throws Exception {
PreAuthorizedCode preAuthCodeGrant = credOffer.getGrants().getPreAuthorizedCode();
String preAuthCode = preAuthCodeGrant.getPreAuthorizedCode();
String txCode = getTestingClient().testing().getTxCode(preAuthCode);
return oauth.oid4vc().preAuthorizedCodeGrantRequest(preAuthCode)
.txCode(txCode)
.send();
}
private CredentialResponse getCredentialByAuthDetail(String accessToken, OID4VCAuthorizationDetail authDetail) throws Exception {
var credentialRequest = new CredentialRequest();
if (authDetail.getCredentialIdentifiers() != null) {
credentialRequest.setCredentialIdentifier(authDetail.getCredentialIdentifiers().get(0));
} else if (authDetail.getCredentialConfigurationId() == null) {
credentialRequest.setCredentialConfigurationId(authDetail.getCredentialConfigurationId());
}
return sendCredentialRequest(accessToken, credentialRequest);
}
private CredentialResponse getCredentialByOffer(String accessToken, AccessTokenResponse tokenResponse, 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();
// Extract authorization_details (from token response or JWT)
List<OID4VCAuthorizationDetail> authDetailsResponse = extractAuthorizationDetails(tokenResponse);
if (authDetailsResponse != null && !authDetailsResponse.isEmpty()) {
// If authorization_details are present, credential_identifier is required
if (authDetailsResponse.get(0).getCredentialIdentifiers() != null &&
!authDetailsResponse.get(0).getCredentialIdentifiers().isEmpty()) {
String credentialIdentifier = authDetailsResponse.get(0).getCredentialIdentifiers().get(0);
credentialRequest.setCredentialIdentifier(credentialIdentifier);
} else {
throw new IllegalStateException("authorization_details present but no credential_identifier found");
}
} else {
// No authorization_details, use credential_configuration_id
credentialRequest.setCredentialConfigurationId(credConfigIds.get(0));
}
return sendCredentialRequest(accessToken, credentialRequest);
}
private CredentialResponse sendCredentialRequest(String accessToken, CredentialRequest credRequest) {
Oid4vcCredentialResponse credRequestResponse = oauth.oid4vc()
.credentialRequest(credRequest)
.bearerToken(accessToken)
.send();
CredentialResponse credResponse = credRequestResponse.getCredentialResponse();
assertNotNull("The credentials array should be present in the response", credResponse.getCredentials());
assertFalse("The credentials array should not be empty", credResponse.getCredentials().isEmpty());
return credResponse;
}
private void verifyCredentialResponse(TestContext ctx, CredentialResponse credResponse) throws Exception {
String issuer = ctx.issuerMetadata.getCredentialIssuer();
List<String> expectedTypes = ctx.credentialConfiguration.getCredentialDefinition().getType();
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 : appUsername;
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
assertEquals(issuer, jsonWebToken.getIssuer());
Object vc = jsonWebToken.getOtherClaims().get("vc");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
assertEquals(expectedTypes, credential.getType());
assertEquals(URI.create(issuer), credential.getIssuer());
assertEquals(expUsername + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}
private void verifyTokenJwt(
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) {
var realmRoles = ((Map<String, List<String>>) realmAccess).get("roles");
allRoles.addAll(realmRoles);
}
Object resourceAccess = jwt.getOtherClaims().get("resource_access");
if (resourceAccess != null) {
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)));
}
}