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)));
- }
-}