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 0d907ee151e..1515ac6b026 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 @@ -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. *

- * 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. *

* +----------+-----------+---------+---------+-----------------------------------------------------+ - * | 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)); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java index e04673ca382..dfa12c38f56 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/PreAuthorizedCodeGrantType.java @@ -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); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java index d34063cddbf..966259646d1 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java @@ -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)); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java index 54df3d40070..d32e6def0d9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/OID4VCICredentialOfferMatrixTest.java @@ -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 *

* +----------+-----------+---------+---------+------------------------------------------------------+ - * | 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()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index 6e69e659a78..b6b03e9b935 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -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; }