mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
Credential offer endpoint has parameter user_id, but expects username
closes #44642 Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user