8000 [OID4VCI] Issuance with Authorization Code Flow assumes same client_id for offer creation and redemption by tdiesler · Pull Request #50576 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8000
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ public Response createCredentialOffer(String credConfigId, boolean preAuthorized
* | yes | yes | yes | Offer restricted to a specific user. |
* +----------+----------+---------+--------------------------------------------+
* </p>
* <b>Pre-Authorized Offer</b>
* <b>Pre-Authorized Code Grant Offer</b>
* <ul>
* <li>A pre-authorized offer is authorized for the clientId from the current login session</li>
* <li>If targetUser is null or empty, it defaults to the user from the current login session</li>
Expand All @@ -439,7 +439,7 @@ public Response createCredentialOffer(String credConfigId, boolean preAuthorized
* <li>An offer can optionally have a predefined expiry date</li>
* </ul>
*
* <b>Non Pre-Authorized Offer</b>
* <b>Authorization Code Grant Offer</b>
* <ul>
* <li>If targetUser is null or empty, the generated offer is "anonymous"</li>
* <li>If targetUser is equal to the current login, the offer is "self issued"</li>
Expand Down Expand Up @@ -540,10 +540,14 @@ public Response createCredentialOffer(

// Create the CredentialsOffer
//
String targetClientId = clientModel.getClientId();
String grantType = preAuthorized ? PRE_AUTH_GRANT_TYPE : AUTH_CODE_GRANT_TYPE;
List<String> credentialConfigurationIds = List.of(credentialConfigurationId);

// Bind pre-authorized code offers to the authenticated client to prevent redemption from a different client_id.
// Authorization Code Grant offers are intentionally not bound to a client_id.
// https://github.com/keycloak/keycloak/issues/48188
String targetClientId = preAuthorized ? clientModel.getClientId() : null;

CredentialOfferState offerState;
try {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.keycloak.tests.oid4vc;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferState;
import org.keycloak.protocol.oid4vc.issuance.credentialoffer.CredentialOfferStorage;
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.util.JsonSerialization;

public final class CredentialOfferStateUtils {

private CredentialOfferStateUtils() {
}

public record CredentialOfferStateRecord(
String credentialsOfferId,
String targetUsername,
String targetClientId,
String txCode,
List<OID4VCAuthorizationDetail> authDetails
) {}

public static CredentialOfferStateRecord getCredentialOfferStateRecord(RunOnServerClient runOnServer, String offerNonce) {
var runtimeOfferState = runOnServer.fetchString(session -> {

RealmModel realm = session.getContext().getRealm();
CredentialOfferStorage offerStorage = session.getProvider(CredentialOfferStorage.class);
CredentialOfferState offerState = Optional.ofNullable(offerStorage.getOfferStateByNonce(offerNonce))
.orElseThrow(() -> new IllegalStateException("No CredentialOfferState for nonce: " + offerNonce));

UserModel userModel = null;
if (offerState.getTargetUserId() != null) {
String targetUserId = offerState.getTargetUserId();
userModel = session.users().getUserById(realm, targetUserId);
if (userModel == null) {
throw new IllegalStateException("No User for id: " + targetUserId);
}
}

Map<String, Object> valueMap = new LinkedHashMap<>();
valueMap.put("credentialsOfferId", offerState.getCredentialsOfferId());

Optional.ofNullable(userModel)
.ifPresent(it -> valueMap.put("targetUsername", it.getUsername()));

Optional.ofNullable(offerState.getTargetClientId())
.ifPresent(it -> valueMap.put("targetClientId", it));

Optional.ofNullable(offerState.getAuthorizationDetails())
.ifPresent(it -> valueMap.put("authDetails", it));

Optional.ofNullable(offerState.getTxCode())
.ifPresent(it -> valueMap.put("txCode", it));

return JsonSerialization.valueAsString(valueMap);
});
return JsonSerialization.valueFromString(runtimeOfferState, CredentialOfferStateRecord.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.vp.SdJwtVP;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.tests.oid4vc.CredentialOfferStateUtils.CredentialOfferStateRecord;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse;
Expand All @@ -26,10 +27,12 @@
import static org.keycloak.OID4VCConstants.CLAIM_NAME_VCT;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S;
import static org.keycloak.protocol.oidc.OIDCLoginProtocol.PROMPT_VALUE_LOGIN;
import static org.keycloak.tests.oid4vc.CredentialOfferStateUtils.getCredentialOfferStateRecord;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand Down Expand Up @@ -66,6 +69,12 @@ public void testAuthCodeOffer_Anonymous() throws Exception {
CredentialOfferURI offerURI = ctx.getCredentialsOfferUri();
assertNotNull(offerURI, "No CredentialOfferURI");

// Verify internal offer state target user and client
//
CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
assertNull(runtimeOfferState.targetUsername(), "Expected null targetUsername");
assertNull(runtimeOfferState.targetClientId(), "Expected null targetClientId");

// Fetch credential offer again
// https://github.com/keycloak/keycloak/issues/48014
credOffer = wallet.credentialsOfferRequest(ctx, offerURI).send().getCredentialsOffer();
Expand All @@ -80,7 +89,7 @@ public void testAuthCodeOffer_Anonymous() throws Exception {
verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);

// Attempt to fetch the credential offer again after it has been consumed

//
CredentialOfferResponse res = wallet.credentialsOfferRequest(ctx, offerURI).send();
assertEquals("invalid_credential_offer_request", res.getError());
assertEquals("Credential offer not found or already consumed", res.getErrorDescription());
Expand Down Expand Up @@ -239,6 +248,15 @@ public void testAuthCodeOffer_Targeted() throws Exception {
String issuerState = credOffer.getIssuerState();
assertNotNull(issuerState, "No IssuerState");

CredentialOfferURI offerURI = ctx.getCredentialsOfferUri();
assertNotNull(offerURI, "No CredentialOfferURI");

// Verify internal target user and client
//
CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
assertEquals("alice", runtimeOfferState.targetUsername());
assertNull(runtimeOfferState.targetClientId(), "targetClientId should be null");

// Send AuthorizationRequest
//
AuthorizationEndpointResponse authResponse = wallet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.tests.oid4vc.CredentialOfferStateUtils.CredentialOfferStateRecord;
import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase;
import org.keycloak.tests.oid4vc.OID4VCTestContext;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
Expand All @@ -22,6 +23,8 @@

import org.junit.jupiter.api.Test;

import static org.keycloak.tests.oid4vc.CredentialOfferStateUtils.getCredentialOfferStateRecord;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
Expand Down Expand Up @@ -117,6 +120,15 @@ public void testPreAuthOffer_SelfIssued() throws Exception {

String preAuthCode = credOffer.getPreAuthorizedCode();

CredentialOfferURI offerURI = ctx.getCredentialsOfferUri();
assertNotNull(offerURI, "No CredentialOfferURI");

// Verify internal offer state target user and client
//
CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
assertEquals("john", runtimeOfferState.targetUsername());
assertEquals(client.getClientId(), runtimeOfferState.targetClientId());

// Redeem Pre-Authorized Code for AccessToken
//
AccessTokenResponse tokenResponse = wallet.accessTokenRequestPreAuth(ctx, preAuthCode).send();
Expand Down Expand Up @@ -149,12 +161,18 @@ public void testPreAuthOffer_Targeted() throws Exception {
req.preAuthorized(true);
});

String preAuthCode = credOffer.getPreAuthorizedCode();
assertNotNull(preAuthCode, "preAuthCode");

CredentialOfferURI offerURI = ctx.getCredentialsOfferUri();
assertNotNull(offerURI, "No CredentialOfferURI");

// Verify internal offer state target user and client
//
CredentialOfferStateRecord runtimeOfferState = getCredentialOfferStateRecord(runOnServer, offerURI.getNonce());
assertEquals("alice", runtimeOfferState.targetUsername());
assertEquals(client.getClientId(), runtimeOfferState.targetClientId());

String preAuthCode = credOffer.getPreAuthorizedCode();
assertNotNull(preAuthCode, "preAuthCode");

// Fetch credential offer again
// https://github.com/keycloak/keycloak/issues/48014
credOffer = wallet.credentialsOfferRequest(ctx, offerURI).send().getCredentialsOffer();
Expand Down
Loading
0