Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,13 @@ public class OID4VPIdentityProvider extends AbstractIdentityProvider<OID4VPIdent
// object and the presentation.
public static final String CONTEXT_PREFIX = "oid4vp.context.";
// The deferred object is written once the presentation is verified and read when the browser
// returns to complete-auth. It marks that a verified identity, serialized under IDENTITY_NOTE in
// the authentication session, is waiting to finish the broker login.
// returns to complete-auth. It marks that verified credential claims, stored under
// VERIFIED_CLAIMS_NOTE in the authentication session, are waiting to finish the broker login.
public static final String DEFERRED_PREFIX = "oid4vp.deferred.";
public static final String IDENTITY_NOTE = "OID4VP_IDENTITY";
public static final String VERIFIED_CLAIMS_NOTE = "OID4VP_VERIFIED_CLAIMS";
// Key in BrokeredIdentityContext#getContextData() holding the disclosed claims of the verified
// credential presentation, consumed by the OID4VP identity provider mappers.
public static final String CREDENTIAL_CLAIMS = "OID4VP_CREDENTIAL_CLAIMS";
public static final String KEY_ROOT_SESSION_ID = "rootSessionId";
public static final String KEY_TAB_ID = "tabId";
public static final String KEY_STATE = "state";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.OID4VCConstants;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.UserAuthenticationIdentityProvider.AuthenticationCallback;
import org.keycloak.common.VerificationException;
Expand Down Expand Up @@ -199,10 +197,10 @@ public Response directPost(@FormParam(OID4VCConstants.VP_TOKEN) String vpToken,
"Authentication session not found", Errors.INVALID_REQUEST);
}

BrokeredIdentityContext context;
String claimsJson;
try {
SdJwtVpVerificationResult result = verifyPresentation(vpToken, requestContext.nonce());
context = toBrokeredContext(result, authSession);
claimsJson = JsonSerialization.writeValueAsString(result.getClaims());
Comment thread
dominikschlosser marked this conversation as resolved.
} catch (Exception e) {
logger.warnf("OID4VP presentation rejected: %s", e.getMessage());
return loginError(Response.Status.BAD_REQUEST, OAuthErrorException.ACCESS_DENIED, e.getMessage(),
Expand All @@ -216,13 +214,15 @@ public Response directPost(@FormParam(OID4VCConstants.VP_TOKEN) String vpToken,
"Unknown or expired state", Errors.INVALID_REQUEST);
}

// The wallet cannot finish the browser login, so hand the verified identity to the browser.
// Stash it in the authentication session and add the deferred marker with a fresh response_code.
// The state may have leaked through the request_uri. The browser gets the response_code
// only from this direct_post response (OID4VP session fixation defense).
// The wallet cannot finish the browser login. Stash the verified claims for complete-auth,
// where the browser turns them into the brokered identity, and mark the login as deferred
// under a fresh response_code. Deliberately only the claims and not a serialized identity
// context: deserializing and later reserializing a context restores stale user property
// entries over whatever the identity provider mappers set in between. The state may have
// leaked through the request_uri, so only this direct_post response reveals the
// response_code (OID4VP session fixation defense).
String responseCode = UUID.randomUUID().toString();
SerializedBrokeredIdentityContext.serialize(context)
.saveToAuthenticationSession(authSession, OID4VPIdentityProvider.IDENTITY_NOTE);
authSession.setAuthNote(OID4VPIdentityProvider.VERIFIED_CLAIMS_NOTE, claimsJson);
session.singleUseObjects().put(
OID4VPIdentityProvider.DEFERRED_PREFIX + state,
realm.getAccessCodeLifespanLogin(),
Expand Down Expand Up @@ -312,19 +312,22 @@ public Response completeAuth(@QueryParam(OID4VPIdentityProvider.KEY_STATE) Strin
return loginErrorPage(authSession, Messages.SESSION_NOT_ACTIVE);
}

SerializedBrokeredIdentityContext serialized = SerializedBrokeredIdentityContext.readFromAuthenticationSession(
authSession, OID4VPIdentityProvider.IDENTITY_NOTE);
if (serialized == null) {
String claimsJson = authSession.getAuthNote(OID4VPIdentityProvider.VERIFIED_CLAIMS_NOTE);
if (claimsJson == null) {
return loginErrorPage(authSession, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR);
}
authSession.removeAuthNote(OID4VPIdentityProvider.IDENTITY_NOTE);
authSession.removeAuthNote(OID4VPIdentityProvider.VERIFIED_CLAIMS_NOTE);

session.getContext().setAuthenticationSession(authSession);
session.getContext().setClient(authSession.getClient());

BrokeredIdentityContext context = serialized.deserialize(session, authSession);
context.setAuthenticationSession(authSession);

BrokeredIdentityContext context;
try {
context = toBrokeredContext(JsonSerialization.readValue(claimsJson, JsonNode.class), authSession);
} catch (IllegalStateException | IOException e) {
logger.warnf("OID4VP login not completed: %s", e.getMessage());
return loginErrorPage(authSession, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR);
}
return callback.authenticated(context);
}

Expand Down Expand Up @@ -452,7 +455,10 @@ protected SdJwtVpVerificationResult verifyPresentation(String vpToken, String no
.build();

// TODO bind the presentation to the requested credential type and claims, so that not any
// credential from a trusted issuer satisfies the login.
// credential from a trusted issuer satisfies the login. Planned for the DCQL generation
// task: derive PresentationRequirements from the configured identity provider mappers
// (required or optional per mapper) and enforce them here. The principal check in
// requirePrincipalClaim then becomes just another required claim.
PresentationRequirements noAdditionalRequirements = disclosedPayload -> {
};
TrustedSdJwtIssuer trustedIssuer = provider.trustedIssuerResolver().resolve(sdJwtVP.getIssuerSignedJWT());
Expand Down Expand Up @@ -496,16 +502,20 @@ static String extractCredential(String vpToken) throws VerificationException {
return presentation.textValue();
}

protected BrokeredIdentityContext toBrokeredContext(SdJwtVpVerificationResult result, AuthenticationSessionModel authSession) {
JsonNode claims = result.getClaims();
// The principal claim becomes the brokered subject. Rejects presentations without a usable value.
protected String requirePrincipalClaim(JsonNode claims) {
JsonNode principal = claims.get(provider.getConfig().getPrincipalAttribute());
if (principal == null || principal.isNull() || !principal.isValueNode()
|| StringUtil.isBlank(principal.asText())) {
throw new IllegalStateException(
"Credential does not contain a usable value for the configured principal attribute '"
+ provider.getConfig().getPrincipalAttribute() + "'");
}
String subject = principal.asText();
return principal.asText();
}

protected BrokeredIdentityContext toBrokeredContext(JsonNode claims, AuthenticationSessionModel authSession) {
String subject = requirePrincipalClaim(claims);

BrokeredIdentityContext context = new BrokeredIdentityContext(subject, provider.getConfig());
context.setIdp(provider);
Expand All @@ -519,8 +529,8 @@ protected BrokeredIdentityContext toBrokeredContext(SdJwtVpVerificationResult re
context.setEmail(email.asText());
}

// Expose disclosed claims so identity provider attribute mappers can consume them.
AbstractJsonUserAttributeMapper.storeUserProfileForMapper(context, claims, provider.getConfig().getAlias());
// Expose disclosed claims so the OID4VP identity provider mappers can consume them.
context.getContextData().put(OID4VPIdentityProvider.CREDENTIAL_CLAIMS, claims);
return context;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Copyright 2026 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.broker.oid4vp.mappers;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import org.keycloak.Config;
import org.keycloak.broker.oid4vp.OID4VPIdentityProvider;
import org.keycloak.broker.oid4vp.OID4VPIdentityProviderFactory;
import org.keycloak.broker.provider.AbstractIdentityProviderMapper;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.common.Profile;
import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;

import com.fasterxml.jackson.databind.JsonNode;
import org.jboss.logging.Logger;

/**
* Base for OID4VP identity provider mappers that read a claim of the verified credential
* presentation, addressed by a {@link ClaimPath} over the claims JSON. Subclasses decide where the
* resolved values go. Not tied to a credential format: any format whose verification yields claims
* JSON under {@link OID4VPIdentityProvider#CREDENTIAL_CLAIMS} can build on it.
*/
public abstract class AbstractOID4VPClaimMapper extends AbstractIdentityProviderMapper
implements EnvironmentDependentProviderFactory {

protected static final Logger logger = Logger.getLogger(AbstractOID4VPClaimMapper.class);

public static final String CLAIM = "claim";

private static final String[] COMPATIBLE_PROVIDERS = {OID4VPIdentityProviderFactory.PROVIDER_ID};

protected static ProviderConfigProperty claimProperty() {
ProviderConfigProperty property = new ProviderConfigProperty();
property.setName(CLAIM);
property.setLabel("Claim");
property.setHelpText("Path of the claim in the presented credential. Use dot notation for nested claims, "
+ "i.e. 'address.locality', [] to select all array elements, i.e. 'nationalities[]', and [0] to select the first element of the presented array. "
+ "To use dot (.) literally, escape it with backslash (\\.)");
property.setType(ProviderConfigProperty.STRING_TYPE);
return property;
}

@Override
public String[] getCompatibleProviders() {
return COMPATIBLE_PROVIDERS;
}

@Override
public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) {
return true;
}

@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VP);
}

protected JsonNode credentialClaims(BrokeredIdentityContext context) {
Object claims = context.getContextData().get(OID4VPIdentityProvider.CREDENTIAL_CLAIMS);
if (claims == null) {
return null;
}
if (claims instanceof JsonNode node) {
return node;
}
// The brokered context may have been serialized into the authentication session in
// between, for example while the first broker login shows the review profile page.
return JsonSerialization.mapper.valueToTree(claims);
}

// Null when the claim is absent or the mapper is misconfigured.
protected List<String> claimValues(IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
String claimPath = mapperModel.getConfig().get(CLAIM);
if (StringUtil.isBlank(claimPath)) {
logger.warnf("No claim configured for mapper %s", mapperModel.getName());
return null;
}
ClaimPath path = ClaimPath.parse(claimPath.trim());
if (path == null) {
logger.warnf("Invalid claim path '%s' in mapper %s", claimPath, mapperModel.getName());
return null;
}
List<JsonNode> matches = path.select(credentialClaims(context));
if (matches.isEmpty()) {
return null;
}
Iterable<JsonNode> selected = matches.size() == 1 && matches.get(0).isArray()
? matches.get(0)
: matches;
// The values end up in the brokered context, whose serialization restores lists by their
// concrete class, so they must stay plain ArrayLists.
List<String> values = new ArrayList<>();
for (JsonNode node : selected) {
if (!node.isNull()) {
values.add(value(node));
}
}
return values;
}

protected String value(JsonNode node) {
if (node.isValueNode()) {
return node.asText();
}
try {
return JsonSerialization.writeValueAsString(node);
} catch (IOException e) {
throw new IllegalStateException("Failed to serialize the claim value", e);
}
}
}
Loading
Loading