From 6258256c1bb5bd7732c7aff4e9194c7f72b65a4e Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Sun, 4 Aug 2024 11:42:40 +0200 Subject: [PATCH] Fix access token issue OID4VC (#31763) closes #31712 Signed-off-by: Stefan Wiedemann --- .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 132 ++++++++++-------- .../oid4vc/model/CredentialsOffer.java | 15 +- .../oid4vc/model/PreAuthorizedCode.java | 16 ++- .../oid4vc/model/PreAuthorizedGrant.java | 16 ++- .../protocol/oidc/utils/OAuth2CodeParser.java | 2 +- .../signing/OID4VCIssuerEndpointTest.java | 20 ++- .../signing/OID4VCJWTIssuerEndpointTest.java | 42 ++---- 7 files changed, 146 insertions(+), 97 deletions(-) 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 b1973d35a9..fed4522cdb 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 @@ -37,6 +37,7 @@ import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; import org.keycloak.common.util.SecretGenerator; +import org.keycloak.events.EventBuilder; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; @@ -54,7 +55,6 @@ import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.ErrorResponse; import org.keycloak.protocol.oid4vc.model.ErrorType; -import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.OID4VCClient; import org.keycloak.protocol.oid4vc.model.OfferUriType; import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; @@ -63,6 +63,8 @@ import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import org.keycloak.protocol.oidc.utils.OAuth2Code; +import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.representations.AccessToken; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.cors.Cors; @@ -105,6 +107,7 @@ public class OID4VCIssuerEndpoint { public static final String CREDENTIAL_PATH = "credential"; public static final String CREDENTIAL_OFFER_PATH = "credential-offer/"; public static final String RESPONSE_TYPE_IMG_PNG = "image/png"; + public static final String CREDENTIAL_OFFER_URI_CODE_SCOPE = "credential-offer"; private final KeycloakSession session; private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator; private final ObjectMapper objectMapper; @@ -118,12 +121,12 @@ public class OID4VCIssuerEndpoint { * Key shall be strings, as configured credential of the same format can * have different configs. Like decoy, visible claims, * time requirements (iat, exp, nbf, ...). - * + *

* Credentials with same configs can share a default entry with locator= format. - * + *

* Credentials in need of special configuration can provide another signer with specific * locator=format::type::vc_config_id - * + *

* The providerId of the signing service factory is still the format. */ private final Map signingServices; @@ -186,26 +189,38 @@ public class OID4VCIssuerEndpoint { LOGGER.debugf("No OID4VP-Client supporting type %s registered.", supportedCredentialConfiguration.getScope()); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); } + // calculate the expiration of the preAuthorizedCode. The sessionCode will also expire at that time. + int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan; + String preAuthorizedCode = generateAuthorizationCodeForClientSession(expiration, clientSession); - String nonce = generateNonce(); + CredentialsOffer theOffer = new CredentialsOffer() + .setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext())) + .setCredentialConfigurationIds(List.of(supportedCredentialConfiguration.getId())) + .setGrants( + new PreAuthorizedGrant() + .setPreAuthorizedCode( + new PreAuthorizedCode() + .setPreAuthorizedCode(preAuthorizedCode))); + + String sessionCode = generateCodeForSession(expiration, clientSession); try { - clientSession.setNote(nonce, objectMapper.writeValueAsString(supportedCredentialConfiguration)); + clientSession.setNote(sessionCode, objectMapper.writeValueAsString(theOffer)); } catch (JsonProcessingException e) { - LOGGER.errorf("Could not convert Supported Credential POJO to JSON: %s", e.getMessage()); + LOGGER.errorf("Could not convert the offer POJO to JSON: %s", e.getMessage()); throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST)); } return switch (type) { - case URI -> getOfferUriAsUri(nonce); - case QR_CODE -> getOfferUriAsQr(nonce, width, height); + case URI -> getOfferUriAsUri(sessionCode); + case QR_CODE -> getOfferUriAsQr(sessionCode, width, height); }; } - private Response getOfferUriAsUri(String nonce) { + private Response getOfferUriAsUri(String sessionCode) { CredentialOfferURI credentialOfferURI = new CredentialOfferURI() .setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH) - .setNonce(nonce); + .setNonce(sessionCode); return Response.ok() .type(MediaType.APPLICATION_JSON) @@ -213,11 +228,11 @@ public class OID4VCIssuerEndpoint { .build(); } - private Response getOfferUriAsQr(String nonce, int width, int height) { + private Response getOfferUriAsQr(String sessionCode, int width, int height) { QRCodeWriter qrCodeWriter = new QRCodeWriter(); - String endcodedOfferUri = URLEncoder.encode(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH + nonce, StandardCharsets.UTF_8); + String encodedOfferUri = URLEncoder.encode(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH + sessionCode, StandardCharsets.UTF_8); try { - BitMatrix bitMatrix = qrCodeWriter.encode("openid-credential-offer://?credential_offer_uri=" + endcodedOfferUri, BarcodeFormat.QR_CODE, width, height); + BitMatrix bitMatrix = qrCodeWriter.encode("openid-credential-offer://?credential_offer_uri=" + encodedOfferUri, BarcodeFormat.QR_CODE, width, height); ByteArrayOutputStream bos = new ByteArrayOutputStream(); MatrixToImageWriter.writeToStream(bitMatrix, "png", bos); return Response.ok().type(RESPONSE_TYPE_IMG_PNG).entity(bos.toByteArray()).build(); @@ -232,45 +247,17 @@ public class OID4VCIssuerEndpoint { */ @GET @Produces(MediaType.APPLICATION_JSON) - @Path(CREDENTIAL_OFFER_PATH + "{nonce}") - public Response getCredentialOffer(@PathParam("nonce") String nonce) { - if (nonce == null) { + @Path(CREDENTIAL_OFFER_PATH + "{sessionCode}") + public Response getCredentialOffer(@PathParam("sessionCode") String sessionCode) { + if (sessionCode == null) { throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST)); } - AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession(); + CredentialsOffer credentialsOffer = getOfferFromSessionCode(sessionCode); + LOGGER.debugf("Responding with offer: %s", credentialsOffer); - String note = clientSession.getNote(nonce); - if (note == null) { - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST)); - } - - SupportedCredentialConfiguration offeredCredential; - try { - offeredCredential = objectMapper.readValue(note, - SupportedCredentialConfiguration.class); - LOGGER.debugf("Creating an offer for %s - %s", offeredCredential.getScope(), - offeredCredential.getFormat()); - clientSession.removeNote(nonce); - } catch (JsonProcessingException e) { - LOGGER.errorf("Could not convert SupportedCredential JSON to POJO: %s", e); - throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST)); - } - - String preAuthorizedCode = generateAuthorizationCodeForClientSession(clientSession); - - CredentialsOffer theOffer = new CredentialsOffer() - .setCredentialIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext())) - .setCredentialConfigurationIds(List.of(offeredCredential.getId())) - .setGrants( - new PreAuthorizedGrant() - .setPreAuthorizedCode( - new PreAuthorizedCode() - .setPreAuthorizedCode(preAuthorizedCode))); - - LOGGER.debugf("Responding with offer: %s", theOffer); return Response.ok() - .entity(theOffer) + .entity(credentialsOffer) .build(); } @@ -283,7 +270,7 @@ public class OID4VCIssuerEndpoint { String credentialIdentifier = credentialRequestVO.getCredentialIdentifier(); String scope = client.getAttributes().get("vc." + credentialIdentifier + ".scope"); // following credential identifier in client attribute AccessToken accessToken = bearerTokenAuthenticator.authenticate().getToken(); - if (Arrays.stream(accessToken.getScope().split(" ")).sequential().noneMatch(i->i.equals(scope))) { + if (Arrays.stream(accessToken.getScope().split(" ")).sequential().noneMatch(i -> i.equals(scope))) { LOGGER.debugf("Scope check failure: credentialIdentifier = %s, required scope = %s, scope in access token = %s.", credentialIdentifier, scope, accessToken.getScope()); throw new CorsErrorResponseException(cors, ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(), "Scope check failure", Response.Status.BAD_REQUEST); } else { @@ -322,7 +309,7 @@ public class OID4VCIssuerEndpoint { String requestedFormat = credentialRequestVO.getFormat(); // Check if at least one of both is available. - if(requestedCredentialId == null && requestedFormat == null){ + if (requestedCredentialId == null && requestedFormat == null) { LOGGER.debugf("Missing both configuration id and requested format. At least one shall be specified."); throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG_AND_FORMAT)); } @@ -333,22 +320,22 @@ public class OID4VCIssuerEndpoint { SupportedCredentialConfiguration supportedCredentialConfiguration = null; if (requestedCredentialId != null) { supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId); - if(supportedCredentialConfiguration == null){ + if (supportedCredentialConfiguration == null) { LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); } // Then for format. We know spec does not allow both parameter. But we are tolerant if you send both // Was found by id, check that the format matches. - if (requestedFormat != null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())){ + if (requestedFormat != null && !requestedFormat.equals(supportedCredentialConfiguration.getFormat())) { LOGGER.debugf("Credential with configuration id %s does not support requested format %s, but supports %s.", requestedCredentialId, requestedFormat, supportedCredentialConfiguration.getFormat()); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); } } - if(supportedCredentialConfiguration == null && requestedFormat != null) { + if (supportedCredentialConfiguration == null && requestedFormat != null) { // Search by format supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat); - if(supportedCredentialConfiguration == null) { + if (supportedCredentialConfiguration == null) { LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat); throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT)); } @@ -357,7 +344,7 @@ public class OID4VCIssuerEndpoint { CredentialResponse responseVO = new CredentialResponse(); Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO); - if(SUPPORTED_FORMATS.contains(requestedFormat)) { + if (SUPPORTED_FORMATS.contains(requestedFormat)) { responseVO.setCredential(theCredential); } else { throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE)); @@ -427,7 +414,7 @@ public class OID4VCIssuerEndpoint { /** * Get a signed credential * - * @param authResult authResult containing the userSession to create the credential for + * @param authResult authResult containing the userSession to create the credential for * @param credentialConfig the supported credential configuration * @param credentialRequestVO the credential request * @return the signed credential @@ -482,12 +469,35 @@ public class OID4VCIssuerEndpoint { .toList(); } - private String generateNonce() { - return SecretGenerator.getInstance().randomString(); + private String generateCodeForSession(int expiration, AuthenticatedClientSessionModel clientSession) { + String codeId = SecretGenerator.getInstance().randomString(); + String nonce = SecretGenerator.getInstance().randomString(); + OAuth2Code oAuth2Code = new OAuth2Code(codeId, expiration, nonce, CREDENTIAL_OFFER_URI_CODE_SCOPE, null, null, null, + clientSession.getUserSession().getId()); + + return OAuth2CodeParser.persistCode(session, clientSession, oAuth2Code); } - private String generateAuthorizationCodeForClientSession(AuthenticatedClientSessionModel clientSessionModel) { - int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan; + private CredentialsOffer getOfferFromSessionCode(String sessionCode) { + EventBuilder eventBuilder = new EventBuilder(session.getContext().getRealm(), session, + session.getContext().getConnection()); + OAuth2CodeParser.ParseResult result = OAuth2CodeParser.parseCode(session, sessionCode, + session.getContext().getRealm(), + eventBuilder); + if (result.isExpiredCode() || result.isIllegalCode() || !result.getCodeData().getScope().equals(CREDENTIAL_OFFER_URI_CODE_SCOPE)) { + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)); + } + try { + return objectMapper.readValue(result.getClientSession().getNote(sessionCode), CredentialsOffer.class); + } catch (JsonProcessingException e) { + LOGGER.errorf("Could not convert JSON to POJO: %s", e); + throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)); + } finally { + result.getClientSession().removeNote(sessionCode); + } + } + + private String generateAuthorizationCodeForClientSession(int expiration, AuthenticatedClientSessionModel clientSessionModel) { return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, clientSessionModel, expiration); } @@ -533,7 +543,7 @@ public class OID4VCIssuerEndpoint { // builds the unsigned credential by applying all protocol mappers. private VCIssuanceContext getVCToSign(List protocolMappers, SupportedCredentialConfiguration credentialConfig, - AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) { + AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) { // set the required claims VerifiableCredential vc = new VerifiableCredential() .setIssuer(URI.create(issuerDid)) diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java index 84aa97635f..957d0e188c 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialsOffer.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Collections; import java.util.List; +import java.util.Objects; /** * Represents a CredentialsOffer according to the OID4VCI Spec @@ -68,4 +69,16 @@ public class CredentialsOffer { this.grants = grants; return this; } -} \ No newline at end of file + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CredentialsOffer that)) return false; + return Objects.equals(getCredentialIssuer(), that.getCredentialIssuer()) && Objects.equals(getCredentialConfigurationIds(), that.getCredentialConfigurationIds()) && Objects.equals(getGrants(), that.getGrants()); + } + + @Override + public int hashCode() { + return Objects.hash(getCredentialIssuer(), getCredentialConfigurationIds(), getGrants()); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedCode.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedCode.java index fe69c93e73..13a9c88bb8 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedCode.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedCode.java @@ -20,6 +20,8 @@ package org.keycloak.protocol.oid4vc.model; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Objects; + /** * Represents a pre-authorized grant, as used by the Credential Offer in OID4VCI * {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-offer} @@ -76,4 +78,16 @@ public class PreAuthorizedCode { this.authorizationServer = authorizationServer; return this; } -} \ No newline at end of file + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PreAuthorizedCode that)) return false; + return getInterval() == that.getInterval() && Objects.equals(getPreAuthorizedCode(), that.getPreAuthorizedCode()) && Objects.equals(getTxCode(), that.getTxCode()) && Objects.equals(getAuthorizationServer(), that.getAuthorizationServer()); + } + + @Override + public int hashCode() { + return Objects.hash(getPreAuthorizedCode(), getTxCode(), getInterval(), getAuthorizationServer()); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedGrant.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedGrant.java index 2ea7233811..ee64d570ed 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedGrant.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/PreAuthorizedGrant.java @@ -22,6 +22,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; +import java.util.Objects; + /** * Container for the pre-authorized code to be used in a Credential Offer *

@@ -43,4 +45,16 @@ public class PreAuthorizedGrant { this.preAuthorizedCode = preAuthorizedCode; return this; } -} \ No newline at end of file + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PreAuthorizedGrant grant)) return false; + return Objects.equals(getPreAuthorizedCode(), grant.getPreAuthorizedCode()); + } + + @Override + public int hashCode() { + return Objects.hash(getPreAuthorizedCode()); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java index fdf170cc19..009e206b82 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2CodeParser.java @@ -65,7 +65,7 @@ public class OAuth2CodeParser { /** * Will parse the code and retrieve the corresponding OAuth2Code and AuthenticatedClientSessionModel. Will also check if code wasn't already - * used and if it wasn't expired. If it was already used (or other error happened during parsing), then returned parser will have "isIllegalHash" + * used and if it wasn't expired. If it was already used (or other error happened during parsing), then returned parser will have "isIllegalCode" * set to true. If it was expired, the parser will have "isExpired" set to true * * @param session 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 6c1995e02e..e334d78a4b 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 @@ -39,7 +39,9 @@ import org.keycloak.common.VerificationException; import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.util.MultivaluedHashMap; import org.keycloak.common.util.SecretGenerator; +import org.keycloak.common.util.Time; import org.keycloak.crypto.Algorithm; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint; @@ -53,6 +55,8 @@ import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OAuth2Code; +import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation; @@ -80,6 +84,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_URI_CODE_SCOPE; /** * Moved test to subclass. so we can reuse initialization code. @@ -244,12 +249,19 @@ public abstract class OID4VCIssuerEndpointTest extends OID4VCTest { }); } - protected static String prepareNonce(AppAuthManager.BearerTokenAuthenticator authenticator, String note) { - String nonce = SecretGenerator.getInstance().randomString(); + protected static String prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) { AuthenticationManager.AuthResult authResult = authenticator.authenticate(); UserSessionModel userSessionModel = authResult.getSession(); - userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId()).setNote(nonce, note); - return nonce; + AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId()); + String codeId = SecretGenerator.getInstance().randomString(); + String nonce = SecretGenerator.getInstance().randomString(); + OAuth2Code oAuth2Code = new OAuth2Code(codeId, Time.currentTime() + 6000, nonce, CREDENTIAL_OFFER_URI_CODE_SCOPE, null, null, null, + authenticatedClientSessionModel.getUserSession().getId()); + + String oauthCode = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oAuth2Code); + + authenticatedClientSessionModel.setNote(oauthCode, note); + return oauthCode; } protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java index f9ac2a22cf..a4d61091e0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCJWTIssuerEndpointTest.java @@ -42,8 +42,8 @@ import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.CredentialsOffer; import org.keycloak.protocol.oid4vc.model.Format; import org.keycloak.protocol.oid4vc.model.OfferUriType; +import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode; import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant; -import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; @@ -189,9 +189,9 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { .run((session -> { AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); authenticator.setTokenString(token); - String nonce = prepareNonce(authenticator, "invalidNote"); + String sessionCode = prepareSessionCode(session, authenticator, "invalidNote"); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - issuerEndpoint.getCredentialOffer(nonce); + issuerEndpoint.getCredentialOffer(sessionCode); })); }); } @@ -199,41 +199,28 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { @Test public void testGetCredentialOffer() { String token = getBearerToken(oauth); - String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString(); testingClient .server(TEST_REALM_NAME) .run((session) -> { AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session); authenticator.setTokenString(token); + CredentialsOffer credentialsOffer = new CredentialsOffer() + .setCredentialIssuer("the-issuer") + .setGrants(new PreAuthorizedGrant().setPreAuthorizedCode(new PreAuthorizedCode().setPreAuthorizedCode("the-code"))) + .setCredentialConfigurationIds(List.of("credential-configuration-id")); - SupportedCredentialConfiguration supportedCredentialConfiguration = new SupportedCredentialConfiguration() - .setId("test-credential") - .setScope("VerifiableCredential") - .setFormat(Format.JWT_VC); - String nonce = prepareNonce(authenticator, JsonSerialization.writeValueAsString(supportedCredentialConfiguration)); - + String sessionCode = prepareSessionCode(session, authenticator, JsonSerialization.writeValueAsString(credentialsOffer)); + // the cache transactions need to be commited explicitly in the test. Without that, the OAuth2Code will only be commited to + // the cache after .run((session)-> ...) + session.getTransactionManager().commit(); OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator); - Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(nonce); + Response credentialOfferResponse = issuerEndpoint.getCredentialOffer(sessionCode); assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus()); Object credentialOfferEntity = credentialOfferResponse.getEntity(); assertNotNull("An actual offer should be in the response.", credentialOfferEntity); - CredentialsOffer credentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class); - assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds()); - assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty()); - List supportedCredentials = credentialsOffer.getCredentialConfigurationIds(); - assertEquals("Exactly one credential should have been returned.", 1, supportedCredentials.size()); - String offeredCredentialId = supportedCredentials.get(0); - assertEquals("The credential should be as defined in the note.", supportedCredentialConfiguration.getId(), offeredCredentialId); - - PreAuthorizedGrant grant = credentialsOffer.getGrants(); - assertNotNull("The grant should be included.", grant); - assertNotNull("The grant should contain the pre-authorized code.", grant.getPreAuthorizedCode()); - assertNotNull("The actual pre-authorized code should be included.", grant - .getPreAuthorizedCode() - .getPreAuthorizedCode()); - - assertEquals("The correct issuer should be included.", rootURL + "/auth/realms/" + TEST_REALM_NAME, credentialsOffer.getCredentialIssuer()); + CredentialsOffer retrievedCredentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class); + assertEquals("The offer should be the one prepared with for the session.", credentialsOffer, retrievedCredentialsOffer); }); } @@ -355,7 +342,6 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest { // 2. Using the uri to get the actual credential offer HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce()); - getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer); assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode());