OID4VCIssuerEndpoint.java
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.issuance;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
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.component.ComponentModel;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperContainerModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.oid4vc.OID4VCClientRegistrationProvider;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.mappers.OID4VCMapper;
import org.keycloak.protocol.oid4vc.issuance.signing.VCSigningServiceProviderFactory;
import org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService;
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
import org.keycloak.protocol.oid4vc.model.CredentialRequest;
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.OID4VCClient;
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.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.provider.ProviderFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.cors.Cors;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.utils.MediaType;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.keycloak.protocol.oid4vc.model.Format.JWT_VC;
import static org.keycloak.protocol.oid4vc.model.Format.LDP_VC;
import static org.keycloak.protocol.oid4vc.model.Format.SD_JWT_VC;
import static org.keycloak.protocol.oid4vc.model.Format.SUPPORTED_FORMATS;
/**
* Provides the (REST-)endpoints required for the OID4VCI protocol.
* <p>
* {@see https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html}
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public class OID4VCIssuerEndpoint {
private static final Logger LOGGER = Logger.getLogger(OID4VCIssuerEndpoint.class);
private Cors cors;
private static final String ISSUER_DID_REALM_ATTRIBUTE_KEY = "issuerDid";
private static final String CODE_LIFESPAN_REALM_ATTRIBUTE_KEY = "preAuthorizedCodeLifespanS";
private static final int DEFAULT_CODE_LIFESPAN_S = 30;
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;
private final TimeProvider timeProvider;
private final String issuerDid;
// lifespan of the preAuthorizedCodes in seconds
private final int preAuthorizedCodeLifeSpan;
/**
* Key shall be strings, as configured credential of the same format can
* have different configs. Like decoy, visible claims,
* time requirements (iat, exp, nbf, ...).
* <p>
* 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
* locator=format::type::vc_config_id
* <p>
* The providerId of the signing service factory is still the format.
*/
private final Map<String, VerifiableCredentialsSigningService> signingServices;
private final boolean isIgnoreScopeCheck;
public OID4VCIssuerEndpoint(KeycloakSession session,
String issuerDid,
Map<String, VerifiableCredentialsSigningService> signingServices,
AppAuthManager.BearerTokenAuthenticator authenticator,
ObjectMapper objectMapper, TimeProvider timeProvider, int preAuthorizedCodeLifeSpan) {
this.session = session;
this.bearerTokenAuthenticator = authenticator;
this.objectMapper = objectMapper;
this.timeProvider = timeProvider;
this.issuerDid = issuerDid;
this.signingServices = signingServices;
this.preAuthorizedCodeLifeSpan = preAuthorizedCodeLifeSpan;
this.isIgnoreScopeCheck = false;
}
public OID4VCIssuerEndpoint(KeycloakSession session,
String issuerDid,
Map<String, VerifiableCredentialsSigningService> signingServices,
AppAuthManager.BearerTokenAuthenticator authenticator,
ObjectMapper objectMapper, TimeProvider timeProvider, int preAuthorizedCodeLifeSpan,
boolean isIgnoreScopeCheck) {
this.session = session;
this.bearerTokenAuthenticator = authenticator;
this.objectMapper = objectMapper;
this.timeProvider = timeProvider;
this.issuerDid = issuerDid;
this.signingServices = signingServices;
this.preAuthorizedCodeLifeSpan = preAuthorizedCodeLifeSpan;
this.isIgnoreScopeCheck = isIgnoreScopeCheck;
}
public OID4VCIssuerEndpoint(KeycloakSession keycloakSession){
this.session = keycloakSession;
this.bearerTokenAuthenticator = new AppAuthManager.BearerTokenAuthenticator(keycloakSession);
this.objectMapper = new ObjectMapper();
this.timeProvider = new OffsetTimeProvider();
RealmModel realm = keycloakSession.getContext().getRealm();
this.signingServices = new HashMap<>();
realm.getComponentsStream(realm.getId(), VerifiableCredentialsSigningService.class.getName())
.forEach(cm -> addServiceFromComponent(signingServices, keycloakSession, cm));
RealmModel realmModel = keycloakSession.getContext().getRealm();
this.issuerDid = Optional.ofNullable(realmModel.getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY))
.orElseThrow(() -> new VCIssuerException("No issuer-did configured."));
this.preAuthorizedCodeLifeSpan = Optional.ofNullable(realmModel.getAttribute(CODE_LIFESPAN_REALM_ATTRIBUTE_KEY))
.map(Integer::valueOf)
.orElse(DEFAULT_CODE_LIFESPAN_S);
this.isIgnoreScopeCheck = false;
}
private void addServiceFromComponent(Map<String, VerifiableCredentialsSigningService> signingServices, KeycloakSession keycloakSession, ComponentModel componentModel) {
ProviderFactory<VerifiableCredentialsSigningService> factory = keycloakSession
.getKeycloakSessionFactory()
.getProviderFactory(VerifiableCredentialsSigningService.class, componentModel.getProviderId());
if (factory instanceof VCSigningServiceProviderFactory sspf) {
VerifiableCredentialsSigningService verifiableCredentialsSigningService = sspf.create(keycloakSession, componentModel);
signingServices.put(verifiableCredentialsSigningService.locator(), verifiableCredentialsSigningService);
} else {
throw new IllegalArgumentException(String.format("The component %s is not a VerifiableCredentialsSigningServiceProviderFactory", componentModel.getProviderId()));
}
}
/**
* Provides the URI to the OID4VCI compliant credentials offer
*/
@GET
@Produces({MediaType.APPLICATION_JSON, RESPONSE_TYPE_IMG_PNG})
@Path("credential-offer-uri")
public Response getCredentialOfferURI(@QueryParam("credential_configuration_id") String vcId, @QueryParam("type") @DefaultValue("uri") OfferUriType type, @QueryParam("width") @DefaultValue("200") int width, @QueryParam("height") @DefaultValue("200") int height) {
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
Map<String, SupportedCredentialConfiguration> credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
LOGGER.debugf("Get an offer for %s", vcId);
if (!credentialsMap.containsKey(vcId)) {
LOGGER.debugf("No credential with id %s exists.", vcId);
LOGGER.debugf("Supported credentials are %s.", credentialsMap);
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
}
SupportedCredentialConfiguration supportedCredentialConfiguration = credentialsMap.get(vcId);
String format = supportedCredentialConfiguration.getFormat();
// check that the user is allowed to get such credential
if (getClientsOfScope(supportedCredentialConfiguration.getScope(), format).isEmpty()) {
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);
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(sessionCode, objectMapper.writeValueAsString(theOffer));
} catch (JsonProcessingException e) {
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(sessionCode);
case QR_CODE -> getOfferUriAsQr(sessionCode, width, height);
};
}
private Response getOfferUriAsUri(String sessionCode) {
CredentialOfferURI credentialOfferURI = new CredentialOfferURI()
.setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
.setNonce(sessionCode);
return Response.ok()
.type(MediaType.APPLICATION_JSON)
.entity(credentialOfferURI)
.build();
}
private Response getOfferUriAsQr(String sessionCode, int width, int height) {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
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=" + 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();
} catch (WriterException | IOException e) {
LOGGER.warnf("Was not able to create a qr code of dimension %s:%s.", width, height, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("Was not able to generate qr.").build();
}
}
/**
* Provides an OID4VCI compliant credentials offer
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path(CREDENTIAL_OFFER_PATH + "{sessionCode}")
public Response getCredentialOffer(@PathParam("sessionCode") String sessionCode) {
if (sessionCode == null) {
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
}
CredentialsOffer credentialsOffer = getOfferFromSessionCode(sessionCode);
LOGGER.debugf("Responding with offer: %s", credentialsOffer);
return Response.ok()
.entity(credentialsOffer)
.build();
}
private void checkScope(CredentialRequest credentialRequestVO) {
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
String vcIssuanceFlow = clientSession.getNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
if (vcIssuanceFlow == null || !vcIssuanceFlow.equals(PreAuthorizedCodeGrantTypeFactory.GRANT_TYPE)) {
// authz code flow
ClientModel client = clientSession.getClient();
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))) {
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 {
LOGGER.debugf("Scope check success: credentialIdentifier = %s, required scope = %s, scope in access token = %s.", credentialIdentifier, scope, accessToken.getScope());
}
} else {
clientSession.removeNote(PreAuthorizedCodeGrantType.VC_ISSUANCE_FLOW);
}
}
/**
* Returns a verifiable credential
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Path(CREDENTIAL_PATH)
public Response requestCredential(
CredentialRequest credentialRequestVO) {
LOGGER.debugf("Received credentials request %s.", credentialRequestVO);
cors = Cors.builder().auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
// do first to fail fast on auth
AuthenticationManager.AuthResult authResult = getAuthResult();
if (!isIgnoreScopeCheck) {
checkScope(credentialRequestVO);
}
// Both Format and identifier are optional.
// If the credential_identifier is present, Format can't be present. But this implementation will
// tolerate the presence of both, waiting for clarity in specifications.
// This implementation will privilege the presence of the credential config identifier.
String requestedCredentialId = credentialRequestVO.getCredentialIdentifier();
String requestedFormat = credentialRequestVO.getFormat();
// Check if at least one of both is available.
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));
}
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(this.session);
// resolve from identifier first
SupportedCredentialConfiguration supportedCredentialConfiguration = null;
if (requestedCredentialId != null) {
supportedCredentialConfiguration = supportedCredentials.get(requestedCredentialId);
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())) {
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) {
// Search by format
supportedCredentialConfiguration = getSupportedCredentialConfiguration(credentialRequestVO, supportedCredentials, requestedFormat);
if (supportedCredentialConfiguration == null) {
LOGGER.debugf("Credential with requested format %s, not supported.", requestedFormat);
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
}
}
CredentialResponse responseVO = new CredentialResponse();
Object theCredential = getCredential(authResult, supportedCredentialConfiguration, credentialRequestVO);
if (SUPPORTED_FORMATS.contains(requestedFormat)) {
responseVO.setCredential(theCredential);
} else {
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_TYPE));
}
return Response.ok().entity(responseVO).build();
}
private SupportedCredentialConfiguration getSupportedCredentialConfiguration(CredentialRequest credentialRequestVO, Map<String, SupportedCredentialConfiguration> supportedCredentials, String requestedFormat) {
// 1. Format resolver
List<SupportedCredentialConfiguration> configs = supportedCredentials.values().stream()
.filter(supportedCredential -> Objects.equals(supportedCredential.getFormat(), requestedFormat))
.collect(Collectors.toList());
List<SupportedCredentialConfiguration> matchingConfigs;
switch (requestedFormat) {
case SD_JWT_VC:
// Resolve from vct for sd-jwt
matchingConfigs = configs.stream()
.filter(supportedCredential -> Objects.equals(supportedCredential.getVct(), credentialRequestVO.getVct()))
.collect(Collectors.toList());
break;
case JWT_VC:
case LDP_VC:
// Will detach this when each format provides logic on how to resolve from definition.
matchingConfigs = configs.stream()
.filter(supportedCredential -> Objects.equals(supportedCredential.getCredentialDefinition(), credentialRequestVO.getCredentialDefinition()))
.collect(Collectors.toList());
break;
default:
throw new BadRequestException(getErrorResponse(ErrorType.UNSUPPORTED_CREDENTIAL_FORMAT));
}
if (matchingConfigs.isEmpty()) {
throw new BadRequestException(getErrorResponse(ErrorType.MISSING_CREDENTIAL_CONFIG));
}
return matchingConfigs.iterator().next();
}
private AuthenticatedClientSessionModel getAuthenticatedClientSession() {
AuthenticationManager.AuthResult authResult = getAuthResult();
UserSessionModel userSessionModel = authResult.getSession();
AuthenticatedClientSessionModel clientSession = userSessionModel.
getAuthenticatedClientSessionByClient(
authResult.getClient().getId());
if (clientSession == null) {
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
}
return clientSession;
}
private AuthenticationManager.AuthResult getAuthResult() {
return getAuthResult(new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN)));
}
// get the auth result from the authentication manager
private AuthenticationManager.AuthResult getAuthResult(WebApplicationException errorResponse) {
AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate();
if (authResult == null) {
throw errorResponse;
}
return authResult;
}
/**
* Get a signed credential
*
* @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
*/
private Object getCredential(AuthenticationManager.AuthResult authResult, SupportedCredentialConfiguration credentialConfig, CredentialRequest credentialRequestVO) {
List<OID4VCClient> clients = getClientsOfScope(credentialConfig.getScope(), credentialConfig.getFormat());
List<OID4VCMapper> protocolMappers = getProtocolMappers(clients)
.stream()
.map(pm -> {
if (session.getProvider(ProtocolMapper.class, pm.getProtocolMapper()) instanceof OID4VCMapper mapperFactory) {
ProtocolMapper protocolMapper = mapperFactory.create(session);
if (protocolMapper instanceof OID4VCMapper oid4VCMapper) {
oid4VCMapper.setMapperModel(pm);
return oid4VCMapper;
}
}
LOGGER.warnf("The protocol mapper %s is not an instance of OID4VCMapper.", pm.getId());
return null;
})
.filter(Objects::nonNull)
.toList();
VCIssuanceContext vcIssuanceContext = getVCToSign(protocolMappers, credentialConfig, authResult, credentialRequestVO);
String fullyQualifiedConfigKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), credentialConfig.deriveType(), credentialConfig.deriveConfiId());
String formatAndTypeKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), credentialConfig.deriveType(), null);
String formatOnlyKey = VerifiableCredentialsSigningService.locator(credentialConfig.getFormat(), null, null);
// Search from specific to general config.
VerifiableCredentialsSigningService signingService = signingServices.getOrDefault(
fullyQualifiedConfigKey,
signingServices.getOrDefault(
formatAndTypeKey,
signingServices.get(formatOnlyKey))
);
return Optional.ofNullable(signingService)
.map(service -> service.signCredential(vcIssuanceContext))
.orElseThrow(() -> new BadRequestException(
String.format("No signer found for specific config '%s' or '%s' or format '%s'.", fullyQualifiedConfigKey, formatAndTypeKey, formatOnlyKey)
));
}
private List<ProtocolMapperModel> getProtocolMappers(List<OID4VCClient> oid4VCClients) {
return oid4VCClients.stream()
.map(OID4VCClient::getClientDid)
.map(this::getClient)
.flatMap(ProtocolMapperContainerModel::getProtocolMappersStream)
.toList();
}
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 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);
}
private Response getErrorResponse(ErrorType errorType) {
var errorResponse = new ErrorResponse();
errorResponse.setError(errorType);
return Response
.status(Response.Status.BAD_REQUEST)
.entity(errorResponse)
.type(MediaType.APPLICATION_JSON)
.build();
}
// Return all {@link OID4VCClient}s that support the given scope and format
// Scope might be different from vct. In the case of sd-jwt for example
private List<OID4VCClient> getClientsOfScope(String vcScope, String format) {
LOGGER.debugf("Retrieve all clients of scope %s, supporting format %s", vcScope, format);
if (Optional.ofNullable(vcScope).filter(scope -> !scope.isEmpty()).isEmpty()) {
throw new BadRequestException("No VerifiableCredential-Scope was provided in the request.");
}
return getOID4VCClientsFromSession()
.stream()
.filter(oid4VCClient -> oid4VCClient.getSupportedVCTypes()
.stream()
.anyMatch(supportedCredential -> supportedCredential.getScope().equals(vcScope)))
.toList();
}
private ClientModel getClient(String clientId) {
return session.clients().getClientByClientId(session.getContext().getRealm(), clientId);
}
private List<OID4VCClient> getOID4VCClientsFromSession() {
return session.clients().getClientsStream(session.getContext().getRealm())
.filter(clientModel -> clientModel.getProtocol() != null)
.filter(clientModel -> clientModel.getProtocol()
.equals(OID4VCLoginProtocolFactory.PROTOCOL_ID))
.map(clientModel -> OID4VCClientRegistrationProvider.fromClientAttributes(clientModel.getClientId(), clientModel.getAttributes()))
.toList();
}
// builds the unsigned credential by applying all protocol mappers.
private VCIssuanceContext getVCToSign(List<OID4VCMapper> protocolMappers, SupportedCredentialConfiguration credentialConfig,
AuthenticationManager.AuthResult authResult, CredentialRequest credentialRequestVO) {
// set the required claims
VerifiableCredential vc = new VerifiableCredential()
.setIssuer(URI.create(issuerDid))
.setIssuanceDate(Instant.ofEpochMilli(timeProvider.currentTimeMillis()))
.setType(List.of(credentialConfig.getScope()));
Map<String, Object> subjectClaims = new HashMap<>();
protocolMappers
.stream()
.filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope()))
.forEach(mapper -> mapper.setClaimsForSubject(subjectClaims, authResult.getSession()));
subjectClaims.forEach((key, value) -> vc.getCredentialSubject().setClaims(key, value));
protocolMappers
.stream()
.filter(mapper -> mapper.isScopeSupported(credentialConfig.getScope()))
.forEach(mapper -> mapper.setClaimsForCredential(vc, authResult.getSession()));
LOGGER.debugf("The credential to sign is: %s", vc);
return new VCIssuanceContext().setAuthResult(authResult)
.setVerifiableCredential(vc)
.setCredentialConfig(credentialConfig)
.setCredentialRequest(credentialRequestVO);
}
}