Fix access token issue OID4VC (#31763)
closes #31712 Signed-off-by: Stefan Wiedemann <wistefan@googlemail.com>
This commit is contained in:
parent
7c69c857a1
commit
6258256c1b
7 changed files with 146 additions and 97 deletions
|
@ -37,6 +37,7 @@ import jakarta.ws.rs.WebApplicationException;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.keycloak.common.util.SecretGenerator;
|
import org.keycloak.common.util.SecretGenerator;
|
||||||
|
import org.keycloak.events.EventBuilder;
|
||||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
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.CredentialsOffer;
|
||||||
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
|
import org.keycloak.protocol.oid4vc.model.ErrorResponse;
|
||||||
import org.keycloak.protocol.oid4vc.model.ErrorType;
|
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.OID4VCClient;
|
||||||
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
||||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
|
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.oid4vc.model.VerifiableCredential;
|
||||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
|
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
|
||||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
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.representations.AccessToken;
|
||||||
import org.keycloak.services.CorsErrorResponseException;
|
import org.keycloak.services.CorsErrorResponseException;
|
||||||
import org.keycloak.services.cors.Cors;
|
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_PATH = "credential";
|
||||||
public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
|
public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
|
||||||
public static final String RESPONSE_TYPE_IMG_PNG = "image/png";
|
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 KeycloakSession session;
|
||||||
private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
|
private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
@ -118,12 +121,12 @@ public class OID4VCIssuerEndpoint {
|
||||||
* Key shall be strings, as configured credential of the same format can
|
* Key shall be strings, as configured credential of the same format can
|
||||||
* have different configs. Like decoy, visible claims,
|
* have different configs. Like decoy, visible claims,
|
||||||
* time requirements (iat, exp, nbf, ...).
|
* time requirements (iat, exp, nbf, ...).
|
||||||
*
|
* <p>
|
||||||
* Credentials with same configs can share a default entry with locator= format.
|
* Credentials with same configs can share a default entry with locator= format.
|
||||||
*
|
* <p>
|
||||||
* Credentials in need of special configuration can provide another signer with specific
|
* Credentials in need of special configuration can provide another signer with specific
|
||||||
* locator=format::type::vc_config_id
|
* locator=format::type::vc_config_id
|
||||||
*
|
* <p>
|
||||||
* The providerId of the signing service factory is still the format.
|
* The providerId of the signing service factory is still the format.
|
||||||
*/
|
*/
|
||||||
private final Map<String, VerifiableCredentialsSigningService> signingServices;
|
private final Map<String, VerifiableCredentialsSigningService> signingServices;
|
||||||
|
@ -186,26 +189,38 @@ public class OID4VCIssuerEndpoint {
|
||||||
LOGGER.debugf("No OID4VP-Client supporting type %s registered.", supportedCredentialConfiguration.getScope());
|
LOGGER.debugf("No OID4VP-Client supporting type %s registered.", supportedCredentialConfiguration.getScope());
|
||||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
|
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 {
|
try {
|
||||||
clientSession.setNote(nonce, objectMapper.writeValueAsString(supportedCredentialConfiguration));
|
clientSession.setNote(sessionCode, objectMapper.writeValueAsString(theOffer));
|
||||||
} catch (JsonProcessingException e) {
|
} 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));
|
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
|
||||||
}
|
}
|
||||||
|
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
case URI -> getOfferUriAsUri(nonce);
|
case URI -> getOfferUriAsUri(sessionCode);
|
||||||
case QR_CODE -> getOfferUriAsQr(nonce, width, height);
|
case QR_CODE -> getOfferUriAsQr(sessionCode, width, height);
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Response getOfferUriAsUri(String nonce) {
|
private Response getOfferUriAsUri(String sessionCode) {
|
||||||
CredentialOfferURI credentialOfferURI = new CredentialOfferURI()
|
CredentialOfferURI credentialOfferURI = new CredentialOfferURI()
|
||||||
.setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
|
.setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
|
||||||
.setNonce(nonce);
|
.setNonce(sessionCode);
|
||||||
|
|
||||||
return Response.ok()
|
return Response.ok()
|
||||||
.type(MediaType.APPLICATION_JSON)
|
.type(MediaType.APPLICATION_JSON)
|
||||||
|
@ -213,11 +228,11 @@ public class OID4VCIssuerEndpoint {
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Response getOfferUriAsQr(String nonce, int width, int height) {
|
private Response getOfferUriAsQr(String sessionCode, int width, int height) {
|
||||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
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 {
|
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();
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
MatrixToImageWriter.writeToStream(bitMatrix, "png", bos);
|
MatrixToImageWriter.writeToStream(bitMatrix, "png", bos);
|
||||||
return Response.ok().type(RESPONSE_TYPE_IMG_PNG).entity(bos.toByteArray()).build();
|
return Response.ok().type(RESPONSE_TYPE_IMG_PNG).entity(bos.toByteArray()).build();
|
||||||
|
@ -232,45 +247,17 @@ public class OID4VCIssuerEndpoint {
|
||||||
*/
|
*/
|
||||||
@GET
|
@GET
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Path(CREDENTIAL_OFFER_PATH + "{nonce}")
|
@Path(CREDENTIAL_OFFER_PATH + "{sessionCode}")
|
||||||
public Response getCredentialOffer(@PathParam("nonce") String nonce) {
|
public Response getCredentialOffer(@PathParam("sessionCode") String sessionCode) {
|
||||||
if (nonce == null) {
|
if (sessionCode == null) {
|
||||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
|
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()
|
return Response.ok()
|
||||||
.entity(theOffer)
|
.entity(credentialsOffer)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -283,7 +270,7 @@ public class OID4VCIssuerEndpoint {
|
||||||
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
|
String credentialIdentifier = credentialRequestVO.getCredentialIdentifier();
|
||||||
String scope = client.getAttributes().get("vc." + credentialIdentifier + ".scope"); // following credential identifier in client attribute
|
String scope = client.getAttributes().get("vc." + credentialIdentifier + ".scope"); // following credential identifier in client attribute
|
||||||
AccessToken accessToken = bearerTokenAuthenticator.authenticate().getToken();
|
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());
|
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);
|
throw new CorsErrorResponseException(cors, ErrorType.UNSUPPORTED_CREDENTIAL_TYPE.toString(), "Scope check failure", Response.Status.BAD_REQUEST);
|
||||||
} else {
|
} else {
|
||||||
|
@ -322,7 +309,7 @@ public class OID4VCIssuerEndpoint {
|
||||||
String requestedFormat = credentialRequestVO.getFormat();
|
String requestedFormat = credentialRequestVO.getFormat();
|
||||||
|
|
||||||
// Check if at least one of both is available.
|
// 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.");
|
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));
|
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG_AND_FORMAT));
|
||||||
}
|
}
|
||||||
|
@ -333,22 +320,22 @@ public class OID4VCIssuerEndpoint {
|
||||||
SupportedCredentialConfiguration supportedCredentialConfiguration = null;
|
SupportedCredentialConfiguration supportedCredentialConfiguration = null;
|
||||||
if (requestedCredentialId != null) {
|
if (requestedCredentialId != null) {
|
||||||
supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId);
|
supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId);
|
||||||
if(supportedCredentialConfiguration == null){
|
if (supportedCredentialConfiguration == null) {
|
||||||
LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId);
|
LOGGER.debugf("Credential with configuration id %s not found.", requestedCredentialId);
|
||||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
|
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
|
// 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.
|
// 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());
|
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));
|
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(supportedCredentialConfiguration == null && requestedFormat != null) {
|
if (supportedCredentialConfiguration == null && requestedFormat != null) {
|
||||||
// Search by format
|
// Search by format
|
||||||
supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat);
|
supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat);
|
||||||
if(supportedCredentialConfiguration == null) {
|
if (supportedCredentialConfiguration == null) {
|
||||||
LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat);
|
LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat);
|
||||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
|
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
|
||||||
}
|
}
|
||||||
|
@ -357,7 +344,7 @@ public class OID4VCIssuerEndpoint {
|
||||||
CredentialResponse responseVO = new CredentialResponse();
|
CredentialResponse responseVO = new CredentialResponse();
|
||||||
|
|
||||||
Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO);
|
Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO);
|
||||||
if(SUPPORTED_FORMATS.contains(requestedFormat)) {
|
if (SUPPORTED_FORMATS.contains(requestedFormat)) {
|
||||||
responseVO.setCredential(theCredential);
|
responseVO.setCredential(theCredential);
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
|
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
|
||||||
|
@ -482,12 +469,35 @@ public class OID4VCIssuerEndpoint {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateNonce() {
|
private String generateCodeForSession(int expiration, AuthenticatedClientSessionModel clientSession) {
|
||||||
return SecretGenerator.getInstance().randomString();
|
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) {
|
private CredentialsOffer getOfferFromSessionCode(String sessionCode) {
|
||||||
int expiration = timeProvider.currentTimeSeconds() + preAuthorizedCodeLifeSpan;
|
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);
|
return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, clientSessionModel, expiration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a CredentialsOffer according to the OID4VCI Spec
|
* Represents a CredentialsOffer according to the OID4VCI Spec
|
||||||
|
@ -68,4 +69,16 @@ public class CredentialsOffer {
|
||||||
this.grants = grants;
|
this.grants = grants;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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());
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -20,6 +20,8 @@ package org.keycloak.protocol.oid4vc.model;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a pre-authorized grant, as used by the Credential Offer in OID4VCI
|
* 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}
|
* {@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;
|
this.authorizationServer = authorizationServer;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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());
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -22,6 +22,8 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
|
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
|
||||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Container for the pre-authorized code to be used in a Credential Offer
|
* Container for the pre-authorized code to be used in a Credential Offer
|
||||||
* <p>
|
* <p>
|
||||||
|
@ -43,4 +45,16 @@ public class PreAuthorizedGrant {
|
||||||
this.preAuthorizedCode = preAuthorizedCode;
|
this.preAuthorizedCode = preAuthorizedCode;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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());
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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
|
* 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
|
* set to true. If it was expired, the parser will have "isExpired" set to true
|
||||||
*
|
*
|
||||||
* @param session
|
* @param session
|
||||||
|
|
|
@ -39,7 +39,9 @@ import org.keycloak.common.VerificationException;
|
||||||
import org.keycloak.common.crypto.CryptoIntegration;
|
import org.keycloak.common.crypto.CryptoIntegration;
|
||||||
import org.keycloak.common.util.MultivaluedHashMap;
|
import org.keycloak.common.util.MultivaluedHashMap;
|
||||||
import org.keycloak.common.util.SecretGenerator;
|
import org.keycloak.common.util.SecretGenerator;
|
||||||
|
import org.keycloak.common.util.Time;
|
||||||
import org.keycloak.crypto.Algorithm;
|
import org.keycloak.crypto.Algorithm;
|
||||||
|
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
|
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.SupportedCredentialConfiguration;
|
||||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
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.JsonWebToken;
|
||||||
import org.keycloak.representations.idm.ClientRepresentation;
|
import org.keycloak.representations.idm.ClientRepresentation;
|
||||||
import org.keycloak.representations.idm.ClientScopeRepresentation;
|
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.assertFalse;
|
||||||
import static org.junit.Assert.assertNotNull;
|
import static org.junit.Assert.assertNotNull;
|
||||||
import static org.junit.Assert.assertTrue;
|
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.
|
* 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) {
|
protected static String prepareSessionCode(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator, String note) {
|
||||||
String nonce = SecretGenerator.getInstance().randomString();
|
|
||||||
AuthenticationManager.AuthResult authResult = authenticator.authenticate();
|
AuthenticationManager.AuthResult authResult = authenticator.authenticate();
|
||||||
UserSessionModel userSessionModel = authResult.getSession();
|
UserSessionModel userSessionModel = authResult.getSession();
|
||||||
userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId()).setNote(nonce, note);
|
AuthenticatedClientSessionModel authenticatedClientSessionModel = userSessionModel.getAuthenticatedClientSessionByClient(authResult.getClient().getId());
|
||||||
return nonce;
|
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) {
|
protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(KeycloakSession session, AppAuthManager.BearerTokenAuthenticator authenticator) {
|
||||||
|
|
|
@ -42,8 +42,8 @@ import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||||
import org.keycloak.protocol.oid4vc.model.Format;
|
import org.keycloak.protocol.oid4vc.model.Format;
|
||||||
import org.keycloak.protocol.oid4vc.model.OfferUriType;
|
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.PreAuthorizedGrant;
|
||||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
|
||||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||||
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||||
|
@ -189,9 +189,9 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||||
.run((session -> {
|
.run((session -> {
|
||||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||||
authenticator.setTokenString(token);
|
authenticator.setTokenString(token);
|
||||||
String nonce = prepareNonce(authenticator, "invalidNote");
|
String sessionCode = prepareSessionCode(session, authenticator, "invalidNote");
|
||||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||||
issuerEndpoint.getCredentialOffer(nonce);
|
issuerEndpoint.getCredentialOffer(sessionCode);
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -199,41 +199,28 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||||
@Test
|
@Test
|
||||||
public void testGetCredentialOffer() {
|
public void testGetCredentialOffer() {
|
||||||
String token = getBearerToken(oauth);
|
String token = getBearerToken(oauth);
|
||||||
String rootURL = suiteContext.getAuthServerInfo().getContextRoot().toString();
|
|
||||||
testingClient
|
testingClient
|
||||||
.server(TEST_REALM_NAME)
|
.server(TEST_REALM_NAME)
|
||||||
.run((session) -> {
|
.run((session) -> {
|
||||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||||
authenticator.setTokenString(token);
|
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()
|
String sessionCode = prepareSessionCode(session, authenticator, JsonSerialization.writeValueAsString(credentialsOffer));
|
||||||
.setId("test-credential")
|
// the cache transactions need to be commited explicitly in the test. Without that, the OAuth2Code will only be commited to
|
||||||
.setScope("VerifiableCredential")
|
// the cache after .run((session)-> ...)
|
||||||
.setFormat(Format.JWT_VC);
|
session.getTransactionManager().commit();
|
||||||
String nonce = prepareNonce(authenticator, JsonSerialization.writeValueAsString(supportedCredentialConfiguration));
|
|
||||||
|
|
||||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
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());
|
assertEquals("The offer should have been returned.", HttpStatus.SC_OK, credentialOfferResponse.getStatus());
|
||||||
Object credentialOfferEntity = credentialOfferResponse.getEntity();
|
Object credentialOfferEntity = credentialOfferResponse.getEntity();
|
||||||
assertNotNull("An actual offer should be in the response.", credentialOfferEntity);
|
assertNotNull("An actual offer should be in the response.", credentialOfferEntity);
|
||||||
|
|
||||||
CredentialsOffer credentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class);
|
CredentialsOffer retrievedCredentialsOffer = JsonSerialization.mapper.convertValue(credentialOfferEntity, CredentialsOffer.class);
|
||||||
assertNotNull("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds());
|
assertEquals("The offer should be the one prepared with for the session.", credentialsOffer, retrievedCredentialsOffer);
|
||||||
assertFalse("Credentials should have been offered.", credentialsOffer.getCredentialConfigurationIds().isEmpty());
|
|
||||||
List<String> 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());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -355,7 +342,6 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||||
|
|
||||||
// 2. Using the uri to get the actual credential offer
|
// 2. Using the uri to get the actual credential offer
|
||||||
HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce());
|
HttpGet getCredentialOffer = new HttpGet(credentialOfferURI.getIssuer() + "/" + credentialOfferURI.getNonce());
|
||||||
getCredentialOffer.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
|
||||||
CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer);
|
CloseableHttpResponse credentialOfferResponse = httpClient.execute(getCredentialOffer);
|
||||||
|
|
||||||
assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode());
|
assertEquals("A valid offer should be returned", HttpStatus.SC_OK, credentialOfferResponse.getStatusLine().getStatusCode());
|
||||||
|
|
Loading…
Reference in a new issue