Credential offer endpoint has parameter user_id, but expects username

closes #44642

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda
2025-12-05 18:09:10 +01:00
committed by Marek Posolda
parent 56b08c02ed
commit 3e001a378f
5 changed files with 78 additions and 37 deletions

View File

@@ -348,18 +348,18 @@ public class OID4VCIssuerEndpoint {
/**
* Creates a Credential Offer Uri that can be pre-authorized and hence bound to a specific client/user id.
* <p>
* Credential Offer Validity Matrix for the supported request parameters "pre_authorized", "client_id", "user_id" combinations.
* Credential Offer Validity Matrix for the supported request parameters "pre_authorized", "client_id", "username" combinations.
* </p>
* +----------+-----------+---------+---------+-----------------------------------------------------+
* | pre-auth | clientId | userId | Valid | Notes |
* | pre-auth | clientId | username | Valid | Notes |
* +----------+-----------+---------+---------+-----------------------------------------------------+
* | no | no | no | yes | Generic offer; any logged-in user may redeem. |
* | no | no | yes | yes | Offer restricted to a specific user. |
* | no | yes | no | yes | Bound to client; user determined at login. |
* | no | yes | yes | yes | Bound to both client and user. |
* +----------+-----------+---------+---------+-----------------------------------------------------+
* | yes | no | no | no | Pre-auth requires a user subject; missing userId. |
* | yes | yes | no | no | Same as above; userId required. |
* | yes | no | no | no | Pre-auth requires a user subject; missing username. |
* | yes | yes | no | no | Same as above; username required. |
* | yes | no | yes | yes | Pre-auth for a specific user; client unconstrained. |
* | yes | yes | yes | yes | Fully constrained: user + client. |
* +----------+-----------+---------+---------+-----------------------------------------------------+
@@ -367,7 +367,7 @@ public class OID4VCIssuerEndpoint {
* @param credConfigId A valid credential configuration id
* @param preAuthorized A flag whether the offer should be pre-authorized (requires targetUser)
* @param appClientId The client id that the offer is authorized for
* @param appUserId The user id that the offer is authorized for
* @param appUsername The username that the offer is authorized for
* @param type The response type, which can be 'uri' or 'qr-code'
* @param width The width of the QR code image
* @param height The height of the QR code image
@@ -380,7 +380,7 @@ public class OID4VCIssuerEndpoint {
@QueryParam("credential_configuration_id") String credConfigId,
@QueryParam("pre_authorized") @DefaultValue("true") boolean preAuthorized,
@QueryParam("client_id") String appClientId,
@QueryParam("user_id") String appUserId,
@QueryParam("username") String appUsername,
@QueryParam("type") @DefaultValue("uri") OfferUriType type,
@QueryParam("width") @DefaultValue("200") int width,
@QueryParam("height") @DefaultValue("200") int height
@@ -414,10 +414,21 @@ public class OID4VCIssuerEndpoint {
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
if (appUserId != null && session.users().getUserByUsername(realmModel, appUserId) == null) {
var errorMessage = "No such user id: " + appUserId;
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
String userId = null;
if (appUsername != null) {
UserModel user = session.users().getUserByUsername(realmModel, appUsername);
if (user == null) {
var errorMessage = "Not found user with username: " + appUsername;
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
if (!user.isEnabled()) {
var errorMessage = "User '" + appUsername + "' disabled";
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
}
userId = user.getId();
}
if (preAuthorized) {
@@ -425,7 +436,7 @@ public class OID4VCIssuerEndpoint {
appClientId = clientModel.getClientId();
LOGGER.warnf("Using fallback client id for credential offer: %s", appClientId);
}
if (appUserId == null) {
if (appUsername == null) {
var errorMessage = "Pre-Authorized credential offer requires a target user";
throw new CorsErrorResponseException(cors,
INVALID_CREDENTIAL_OFFER_REQUEST.toString(), errorMessage, Response.Status.BAD_REQUEST);
@@ -450,7 +461,7 @@ public class OID4VCIssuerEndpoint {
.setCredentialConfigurationIds(List.of(credConfigId));
int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan;
CredentialOfferState offerState = new CredentialOfferState(credOffer, appClientId, appUserId, expiration);
CredentialOfferState offerState = new CredentialOfferState(credOffer, appClientId, userId, expiration);
if (preAuthorized) {
String code = "urn:oid4vci:code:" + SecretGenerator.getInstance().randomString(64);
@@ -723,7 +734,7 @@ public class OID4VCIssuerEndpoint {
//
UserSessionModel userSession = authResult.session();
UserModel userModel = userSession.getUser();
if (!userModel.getUsername().equals(offerState.getUserId())) {
if (!userModel.getId().equals(offerState.getUserId())) {
var errorMessage = "Unexpected login user: " + userModel.getUsername();
LOGGER.errorf(errorMessage + " != %s", offerState.getUserId());
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));

View File

@@ -88,9 +88,15 @@ public class PreAuthorizedCodeGrantType extends OAuth2GrantTypeBase {
var credOffer = offerState.getCredentialsOffer();
var appUserId = offerState.getUserId();
var userModel = session.users().getUserByUsername(realm, appUserId);
var userModel = session.users().getUserById(realm, appUserId);
if (userModel == null) {
var errorMessage = "No user model for: " + appUserId;
var errorMessage = "No user with ID: " + appUserId;
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);
}
if (!userModel.isEnabled()) {
var errorMessage = "User '" + userModel.getUsername() + "' disabled";
event.detail(Details.REASON, errorMessage).error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
errorMessage, Response.Status.BAD_REQUEST);

View File

@@ -1134,7 +1134,7 @@ public class TestingResourceProvider implements RealmResourceProvider {
.setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(
new PreAuthorizedCode().setPreAuthorizedCode(code)));
String userId = userSession.getUser().getUsername();
String userId = userSession.getUser().getId();
var offerStorage = session.getProvider(CredentialOfferStorage.class);
offerStorage.putOfferState(session, new CredentialOfferState(credOffer, clientId, userId, expiration));

View File

@@ -28,6 +28,7 @@ import jakarta.ws.rs.core.HttpHeaders;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
@@ -42,6 +43,8 @@ import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.oid4vc.issuance.signing.OID4VCIssuerEndpointTest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.JsonSerialization;
@@ -80,25 +83,25 @@ import static org.junit.Assert.fail;
* Credential Offer Validity Matrix
* <p>
* +----------+-----------+---------+---------+------------------------------------------------------+
* | pre-auth | clientId | userId | Valid | Notes |
* | pre-auth | clientId | username | Valid | Notes |
* +----------+-----------+---------+---------+------------------------------------------------------+
* | no | no | no | yes | Generic offer; any logged-in user may redeem. |
* | no | no | yes | yes | Offer restricted to a specific user. |
* | no | yes | no | yes | Bound to client; user determined at login. |
* | no | yes | yes | yes | Bound to both client and user. |
* +----------+-----------+---------+---------+------------------------------------------------------+
* | yes | no | no | no | Pre-auth requires a user subject; missing userId. |
* | yes | no | no | no | Pre-auth requires a user subject; missing username. |
* | yes | no | yes | yes | Pre-auth for a specific user; client issuer defined. |
* | yes | yes | no | no | Same as above; userId required. |
* | yes | yes | no | no | Same as above; username required. |
* | yes | yes | yes | yes | Fully constrained: user + client. |
* +----------+-----------+---------+---------+------------------------------------------------------+
*/
public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
String issUserId = "john";
String issUsername = "john";
String issClientId = clientId;
String namedUserId = "alice";
String namedUsername = "alice";
String credScopeName = jwtTypeCredentialScopeName;
String credConfigId = jwtTypeCredentialConfigurationIdName;
@@ -117,7 +120,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
OfferTestContext newTestContext(boolean preAuth, String appClient, String appUser) {
var ctx = new OfferTestContext();
ctx.preAuthorized = preAuth;
ctx.issUser = issUserId;
ctx.issUser = issUsername;
ctx.issClient = issClientId;
ctx.appUser = appUser;
ctx.appClient = appClient;
@@ -129,16 +132,16 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
@Test
public void testVariousLogins() {
assertNotNull(getBearerTokenAndLogout(issClientId, issUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(issClientId, namedUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(namedClientId, issUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(namedClientId, namedUserId, "openid"));
assertNotNull(getBearerTokenAndLogout(issClientId, issUsername, "openid"));
assertNotNull(getBearerTokenAndLogout(issClientId, namedUsername, "openid"));
assertNotNull(getBearerTokenAndLogout(namedClientId, issUsername, "openid"));
assertNotNull(getBearerTokenAndLogout(namedClientId, namedUsername, "openid"));
}
@Test
public void testCredentialWithoutOffer() throws Exception {
var ctx = newTestContext(false, null, namedUserId);
var ctx = newTestContext(false, null, namedUsername);
AuthorizationDetail authDetail = new AuthorizationDetail();
authDetail.setType(OPENID_CREDENTIAL);
@@ -160,7 +163,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
@Test
public void testCredentialOffer_noPreAuth_noClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(false, null, namedUserId));
runCredentialOfferTest(newTestContext(false, null, namedUsername));
}
@Test
@@ -170,7 +173,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
@Test
public void testCredentialOffer_noPreAuth_ClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(false, namedClientId, namedUserId));
runCredentialOfferTest(newTestContext(false, namedClientId, namedUsername));
}
// Pre Authorized --------------------------------------------------------------------------------------------------
@@ -188,7 +191,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
@Test
public void testCredentialOffer_PreAuth_noClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(true, null, namedUserId));
runCredentialOfferTest(newTestContext(true, null, namedUsername));
}
@Test
@@ -203,8 +206,29 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
}
@Test
public void testCredentialOffer_PreAuth_ClientId_UserId() throws Exception {
runCredentialOfferTest(newTestContext(true, namedClientId, namedUserId));
public void testCredentialOffer_PreAuth_ClientId_Username() throws Exception {
runCredentialOfferTest(newTestContext(true, namedClientId, namedUsername));
}
@Test
public void testCredentialOffer_PreAuth_ClientId_Username_disabledUser() throws Exception {
// Disable user
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), namedUsername);
UserRepresentation userRep = user.toRepresentation();
userRep.setEnabled(false);
user.update(userRep);
try {
runCredentialOfferTest(newTestContext(true, namedClientId, namedUsername));
fail("Expected " + INVALID_CREDENTIAL_OFFER_REQUEST.name());
} catch (RuntimeException ex) {
List.of(INVALID_CREDENTIAL_OFFER_REQUEST.name(), "User '" + namedUsername + "' disabled")
.forEach(it -> assertTrue(ex.getMessage() + " does not contain " + it, ex.getMessage().contains(it)));
} finally {
// Re-enable user
userRep.setEnabled(true);
user.update(userRep);
}
}
void runCredentialOfferTest(OfferTestContext ctx) throws Exception {
@@ -262,7 +286,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
} else {
String clientId = ctx.appClient != null ? ctx.appClient : namedClientId;
String userId = ctx.appUser != null ? ctx.appUser : namedUserId;
String userId = ctx.appUser != null ? ctx.appUser : namedUsername;
String credConfigId = credOffer.getCredentialConfigurationIds().get(0);
SupportedCredentialConfiguration credConfig = ctx.issuerMetadata.getCredentialsSupported().get(credConfigId);
@@ -415,7 +439,7 @@ public class OID4VCICredentialOfferMatrixTest extends OID4VCIssuerEndpointTest {
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull("The first credential in the array should not be null", credentialObj);
String expUsername = ctx.appUser != null ? ctx.appUser : namedUserId;
String expUsername = ctx.appUser != null ? ctx.appUser : namedUsername;
JsonWebToken jsonWebToken = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
assertEquals("did:web:test.org", jsonWebToken.getIssuer());

View File

@@ -569,14 +569,14 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest {
return getCredentialOfferUriUrl(configId, preAuthorized, targetUser, null);
}
protected String getCredentialOfferUriUrl(String configId, Boolean preAuthorized, String appUserId, String appClientId) {
protected String getCredentialOfferUriUrl(String configId, Boolean preAuthorized, String appUsername, String appClientId) {
String res = getBasePath("test") + "credential-offer-uri?credential_configuration_id=" + configId;
if (preAuthorized != null)
res += "&pre_authorized=" + preAuthorized;
if (appClientId != null)
res += "&client_id=" + appClientId;
if (appUserId != null)
res += "&user_id=" + appUserId;
if (appUsername != null)
res += "&username=" + appUsername;
return res;
}