From b2dbdd38665d8ed0149dcd1d10ecc0094511a33a Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Mon, 9 Mar 2026 08:27:32 +0100 Subject: [PATCH] [OID4VCI] Migrate OID4VCCredentialOfferMatrixTest (#46946) closes #46971 Signed-off-by: Thomas Diesler --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 16 +- .../oid4vc/model/CredentialOfferURI.java | 12 - .../oid4vc/model/OfferResponseType.java | 6 +- .../tests/oid4vc/OID4VCBasicWallet.java | 340 +++++++++++++ .../OID4VCCredentialOfferMatrixTest.java | 236 +++++++++ .../tests/oid4vc/OID4VCTestContext.java | 136 +++++ .../testsuite/util/oauth/LoginUrlBuilder.java | 17 +- .../oauth/oid4vc/CredentialOfferRequest.java | 4 +- .../oid4vc/CredentialOfferUriRequest.java | 9 +- .../oauth/oid4vc/Oid4vcCredentialRequest.java | 2 +- .../oid4vc/PreAuthorizedCodeGrantRequest.java | 9 +- .../OID4VCICredentialOfferMatrixTest.java | 478 ------------------ 12 files changed, 738 insertions(+), 527 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCCredentialOfferMatrixTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCTestContext.java delete mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index 0d2967d2d52..2087000b2e2 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -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); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java index aa38eae684b..fe2c1508106 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialOfferURI.java @@ -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; } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/OfferResponseType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/OfferResponseType.java index e1b3857f28e..fab34d0d1fa 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/OfferResponseType.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/OfferResponseType.java @@ -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))); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java new file mode 100644 index 00000000000..269c7eb6dfb --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java @@ -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 Thomas Diesler + */ +public class OID4VCBasicWallet { + + final Keycloak keycloak; + final OAuthClient oauth; + + final Set 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: + // 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 includeScopes, List excludeScopes, + List includeRoles, List excludeRoles + ) throws Exception { + + String accessToken = tokenResponse.getAccessToken(); + JsonWebToken jwt = JsonSerialization.readValue(new JWSInput(accessToken).getContent(), JsonWebToken.class); + List 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 allRoles = new ArrayList<>(); + Object realmAccess = jwt.getOtherClaims().get("realm_access"); + if (realmAccess != null) { + @SuppressWarnings("unchecked") + var realmRoles = ((Map>) realmAccess).get("roles"); + allRoles.addAll(realmRoles); + } + Object resourceAccess = jwt.getOtherClaims().get("resource_access"); + if (resourceAccess != null) { + @SuppressWarnings("unchecked") + var resourceAccessMapping = (Map>>) 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 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 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(); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCCredentialOfferMatrixTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCCredentialOfferMatrixTest.java new file mode 100644 index 00000000000..a923b05397a --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCCredentialOfferMatrixTest.java @@ -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 + *

+ * +----------+----------+---------+------------------------------------------------------+ + * | 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")); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCTestContext.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCTestContext.java new file mode 100644 index 00000000000..d74a6cda6da --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCTestContext.java @@ -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 Thomas Diesler + */ +public class OID4VCTestContext { + + static final AttachmentKey ISSUER_METADATA_ATTACHMENT_KEY = new AttachmentKey<>(CredentialIssuer.class); + static final AttachmentKey CREDENTIAL_OFFER_URI_ATTACHMENT_KEY = new AttachmentKey<>(CredentialOfferURI.class); + static final AttachmentKey CREDENTIALS_OFFER_ATTACHMENT_KEY = new AttachmentKey<>(CredentialsOffer.class); + static final AttachmentKey ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(AccessTokenResponse.class); + static final AttachmentKey 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, 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 getAuthorizedCredentialIdentifiers() { + OID4VCAuthorizationDetail tokenAuthDetails = getOID4VCAuthorizationDetail(); + return Optional.ofNullable(tokenAuthDetails) + .map(OID4VCAuthorizationDetail::getCredentialIdentifiers) + .orElse(Collections.emptyList()); + } + + public String getAuthorizedCredentialIdentifier() { + List 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 getOID4VCAuthorizationDetails() { + AccessTokenResponse response = assertAttachment(ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY); + return Optional.ofNullable(response) + .map(AccessTokenResponse::getOID4VCAuthorizationDetails) + .orElse(Collections.emptyList()); + } + + public OID4VCAuthorizationDetail getOID4VCAuthorizationDetail() { + List tokenAuthDetails = getOID4VCAuthorizationDetails(); + return tokenAuthDetails.size() == 1 ? tokenAuthDetails.get(0) : null; + } + + // Attachment Support ---------------------------------------------------------------------------------------------- + + void putAttachment(AttachmentKey key, T value) { + if (value != null) { + attachments.put(key, value); + } else { + attachments.remove(key, value); + } + } + + T assertAttachment(AttachmentKey key) { + return Optional.of(getAttachment(key)).get(); + } + + @SuppressWarnings("unchecked") + T getAttachment(AttachmentKey key) { + return (T) attachments.get(key); + } + + @SuppressWarnings("unchecked") + T removeAttachment(AttachmentKey key) { + return (T) attachments.remove(key); + } + + static class AttachmentKey { + private final String name; + private final Class type; + + AttachmentKey(Class type) { + this(null, type); + } + + AttachmentKey(String name, Class 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); + } + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/LoginUrlBuilder.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/LoginUrlBuilder.java index 21c43d55472..b6762158f65 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/LoginUrlBuilder.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/LoginUrlBuilder.java @@ -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 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) { diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferRequest.java index b7af1c5acf3..a839c31b2fd 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferRequest.java @@ -13,12 +13,12 @@ public class CredentialOfferRequest extends AbstractHttpGetRequest 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()); diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java index 9e98b8c5a05..bf21efefe6b 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/CredentialOfferUriRequest.java @@ -15,12 +15,11 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest client, String credConfigId) { + public CredentialOfferUriRequest(AbstractOAuthClient client, String credConfigId) { super(client); this.credConfigId = credConfigId; } @@ -30,11 +29,6 @@ public class CredentialOfferUriRequest extends AbstractHttpGetRequest client, CredentialRequest credRequest) { + public Oid4vcCredentialRequest(AbstractOAuthClient client, CredentialRequest credRequest) { super(client); this.credRequest = credRequest; } diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/PreAuthorizedCodeGrantRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/PreAuthorizedCodeGrantRequest.java index 6d06a65618a..606a4ba0b54 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/PreAuthorizedCodeGrantRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/oid4vc/PreAuthorizedCodeGrantRequest.java @@ -16,9 +16,8 @@ import org.apache.http.client.methods.CloseableHttpResponse; public class PreAuthorizedCodeGrantRequest extends AbstractHttpPostRequest { 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 - * +----------+----------+---------+------------------------------------------------------+ - * | 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 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 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: - // 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 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 extractAuthorizationDetails(AccessTokenResponse tokenResponse) { - // First check if already populated in token response - List 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 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 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 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 includeScopes, - List excludeScopes, - List includeRoles, - List excludeRoles - ) throws Exception { - JsonWebToken jwt = JsonSerialization.readValue(new JWSInput(token).getContent(), JsonWebToken.class); - List 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 allRoles = new ArrayList<>(); - Object realmAccess = jwt.getOtherClaims().get("realm_access"); - if (realmAccess != null) { - var realmRoles = ((Map>) realmAccess).get("roles"); - allRoles.addAll(realmRoles); - } - Object resourceAccess = jwt.getOtherClaims().get("resource_access"); - if (resourceAccess != null) { - var resourceAccessMapping = (Map>>) 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))); - } -}