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 @@ -26,6 +26,7 @@
import java.util.stream.Stream;

import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;

import org.keycloak.OAuth2Constants;
import org.keycloak.broker.provider.AbstractIdentityProvider;
Expand All @@ -52,17 +53,20 @@
import org.keycloak.sdjwt.vp.TrustedSdJwtIssuerResolver;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.QRCodeUtils;
import org.keycloak.utils.StringUtil;

import org.jboss.logging.Logger;

/**
* Identity provider that authenticates users with an OpenID4VP (OID4VP) wallet presentation.
*
* <p>Supports the same device flow with a single SD-JWT VC. {@link #performLogin} renders a page with
* an {@code openid4vp://} link, the wallet fetches a signed request object and posts the presentation
* <p>Supports the same device and cross device flows with a single SD-JWT VC. {@link #performLogin}
* renders a page with an {@code openid4vp://} link for a wallet on this device and a QR code for a
* wallet on another device. The wallet fetches a signed request object and posts the presentation
* back to {@link OID4VPIdentityProviderEndpoint}, which verifies it and drives the normal broker
* machinery (existing or new user, first and post broker login).
* machinery (existing or new user, first and post broker login). A remote wallet cannot redirect
* the browser, so the login page polls the endpoint until the presentation arrives.
*
* <p>The provider also acts as its own {@link TrustMaterialIdentityProvider}: the credential issuer
* signature is verified against the inline JWKS configured on this provider.
Expand All @@ -85,17 +89,23 @@ public class OID4VPIdentityProvider extends AbstractIdentityProvider<OID4VPIdent
// 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.
public static final String DEFERRED_PREFIX = "oid4vp.deferred.";
// The completion signal for the cross device flow, keyed by the state. Written by a cross device
// direct_post, read but never consumed by the status poll, removed by complete-auth.
public static final String COMPLETE_PREFIX = "oid4vp.complete.";
public static final String IDENTITY_NOTE = "OID4VP_IDENTITY";
public static final String KEY_ROOT_SESSION_ID = "rootSessionId";
public static final String KEY_TAB_ID = "tabId";
public static final String KEY_STATE = "state";
public static final String KEY_RESPONSE_CODE = "responseCode";

public static final int QR_CODE_SIZE = 246;

public OID4VPIdentityProvider(KeycloakSession session, OID4VPIdentityProviderConfig config) {
super(session, config);
}

@Override
public Response performLogin(AuthenticationRequest request) {
// TODO support the cross device flow (QR code and SSE polling).
try {
AuthenticationSessionModel authSession = request.getAuthenticationSession();
// The OID4VP state correlates the request object, the wallet's direct_post and complete-auth.
Expand All @@ -119,16 +129,17 @@ public Response performLogin(AuthenticationRequest request) {
nonce, encryptionKey);
session.singleUseObjects().put(CONTEXT_PREFIX + state, loginTimeoutSeconds(), context.toMap());

URI requestUri = OID4VPIdentityProviderEndpoint
.endpointBaseUri(request.getUriInfo().getBaseUriBuilder(), request.getRealm().getName(),
getConfig().getAlias())
.path(OID4VPIdentityProviderEndpoint.REQUEST_OBJECT_PATH).path(state)
.build();
String walletUrl = buildWalletUrl(clientId(), requestUri);
String clientId = clientId();
String sameDeviceWalletUrl = buildWalletUrl(clientId, requestUri(request, state, false));
String crossDeviceWalletUrl = buildWalletUrl(clientId, requestUri(request, state, true));

return session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authSession)
.setAttribute("sameDeviceWalletUrl", walletUrl)
.setAttribute("sameDeviceWalletUrl", sameDeviceWalletUrl)
.setAttribute("crossDeviceWalletUrl", crossDeviceWalletUrl)
.setAttribute("crossDeviceQrCode",
QRCodeUtils.encodeAsQRString(crossDeviceWalletUrl, QR_CODE_SIZE, QR_CODE_SIZE))
.setAttribute("crossDeviceStatusUrl", statusUrl(request, state))
.createForm("login-oid4vp.ftl");
} catch (Exception e) {
logger.errorf(e, "Failed to initiate OID4VP login: %s", e.getMessage());
Expand Down Expand Up @@ -206,6 +217,28 @@ public String clientId() {
return clientIdentifier().forCertificate(signingKey().getCertificate());
}

protected URI requestUri(AuthenticationRequest request, String state, boolean crossDevice) {
UriBuilder uri = endpointUri(request)
.path(OID4VPIdentityProviderEndpoint.REQUEST_OBJECT_PATH).path(state);
if (crossDevice) {
uri.queryParam(OID4VPIdentityProviderEndpoint.FLOW_PARAM,
OID4VPIdentityProviderEndpoint.FLOW_CROSS_DEVICE);
}
return uri.build();
}

protected String statusUrl(AuthenticationRequest request, String state) {
return endpointUri(request)
.path(OID4VPIdentityProviderEndpoint.STATUS_PATH)
.queryParam(OAuth2Constants.STATE, state)
.build().toString();
}

protected UriBuilder endpointUri(AuthenticationRequest request) {
return OID4VPIdentityProviderEndpoint.endpointBaseUri(
request.getUriInfo().getBaseUriBuilder(), request.getRealm().getName(), getConfig().getAlias());
}

protected String buildWalletUrl(String clientId, URI requestUri) {
String scheme = getConfig().getWalletScheme();
if (!scheme.endsWith("://")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
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.core.MediaType;
import jakarta.ws.rs.core.Response;
Expand Down Expand Up @@ -79,11 +80,12 @@
* <ul>
* <li>{@code GET /request-object/{handle}} serves the signed authorization request object.</li>
* <li>{@code POST /} receives the wallet's {@code direct_post} presentation, verifies it, and
* returns the browser redirect that finalizes login.</li>
* returns the browser redirect that finalizes login, or an empty JSON object for the cross
* device flow where the remote wallet cannot drive the browser.</li>
* <li>{@code GET /status} tells the polling login page whether the cross device presentation
* arrived.</li>
* <li>{@code GET /complete-auth} resolves the deferred identity and hands control back to Keycloak.</li>
* </ul>
*
* <p>TODO add the cross device flow.
*/
public class OID4VPIdentityProviderEndpoint {

Expand All @@ -95,6 +97,21 @@ public class OID4VPIdentityProviderEndpoint {
// Broker-internal endpoint routing, not part of the OID4VP protocol.
public static final String REQUEST_OBJECT_PATH = "request-object";
public static final String COMPLETE_AUTH_PATH = "complete-auth";
public static final String STATUS_PATH = "status";

// The cross device signal, carried as a query parameter on the request_uri and echoed on the
// response_uri the request object advertises. Flipping it gains a wallet nothing, completion is
// bound to the browser session cookie either way.
public static final String FLOW_PARAM = "flow";
public static final String FLOW_CROSS_DEVICE = "cross_device";

// Status poll protocol with the login page.
public static final String STATUS_KEY = "status";
public static final String STATUS_REDIRECT_URI_KEY = "redirectUri";
public static final String STATUS_PENDING = "pending";
public static final String STATUS_COMPLETE = "complete";
public static final String STATUS_EXPIRED = "expired";
public static final String STATUS_ERROR = "error";

private final KeycloakSession session;
private final RealmModel realm;
Expand All @@ -117,15 +134,15 @@ public OID4VPIdentityProviderEndpoint(

@GET
@Path("/request-object/{state}")
public Response requestObject(@PathParam("state") String state) {
public Response requestObject(@PathParam("state") String state, @QueryParam(FLOW_PARAM) String flow) {
Map<String, String> stored = session.singleUseObjects().get(OID4VPIdentityProvider.CONTEXT_PREFIX + state);
if (stored == null) {
return loginError(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST,
"Unknown or expired state", Errors.INVALID_REQUEST);
}
String requestObject;
try {
requestObject = buildRequestObject(state, RequestContext.fromMap(stored))
requestObject = buildRequestObject(state, RequestContext.fromMap(stored), isCrossDevice(flow))
.sign(provider.signingKey());
} catch (RuntimeException e) {
logger.errorf(e, "Failed to build the OID4VP request object: %s", e.getMessage());
Expand All @@ -141,7 +158,8 @@ public Response requestObject(@PathParam("state") String state) {
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response directPost(@FormParam(OID4VCConstants.VP_TOKEN) String vpToken,
@FormParam(OAuth2Constants.STATE) String state,
@FormParam(OAuth2Constants.RESPONSE) String response) {
@FormParam(OAuth2Constants.RESPONSE) String response,
@QueryParam(FLOW_PARAM) String flow) {
RequestContext requestContext;
if (StringUtil.isNotBlank(response)) {
// direct_post.jwt, a JWE whose plaintext carries vp_token and state.
Expand Down Expand Up @@ -191,6 +209,13 @@ public Response directPost(@FormParam(OID4VCConstants.VP_TOKEN) String vpToken,
Errors.IDENTITY_PROVIDER_LOGIN_FAILURE);
}

// Consuming the request context commits this presentation. The single use store guarantees one
// winner, so a repeated direct_post or one racing on the other flow fails cleanly.
if (session.singleUseObjects().remove(OID4VPIdentityProvider.CONTEXT_PREFIX + state) == null) {
return loginError(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST,
"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 key the deferred marker by a fresh response_code
// rather than reusing the state, which may have leaked through the request_uri. The browser
Expand All @@ -203,13 +228,60 @@ public Response directPost(@FormParam(OID4VCConstants.VP_TOKEN) String vpToken,
realm.getAccessCodeLifespanLogin(),
Map.of(
OID4VPIdentityProvider.KEY_ROOT_SESSION_ID, requestContext.rootSessionId(),
OID4VPIdentityProvider.KEY_TAB_ID, requestContext.tabId()));
session.singleUseObjects().remove(OID4VPIdentityProvider.CONTEXT_PREFIX + state);

OID4VPIdentityProvider.KEY_TAB_ID, requestContext.tabId(),
OID4VPIdentityProvider.KEY_STATE, state));

if (isCrossDevice(flow)) {
session.singleUseObjects().put(
OID4VPIdentityProvider.COMPLETE_PREFIX + state,
realm.getAccessCodeLifespanLogin(),
Map.of(
OID4VPIdentityProvider.KEY_ROOT_SESSION_ID, requestContext.rootSessionId(),
OID4VPIdentityProvider.KEY_RESPONSE_CODE, responseCode));
return Response.ok(Map.of())
.type(MediaType.APPLICATION_JSON_TYPE).cacheControl(CacheControlUtil.noCache()).build();
}
return Response.ok(Map.of(OAuth2Constants.REDIRECT_URI, completeAuthUrl(responseCode)))
.type(MediaType.APPLICATION_JSON_TYPE).cacheControl(CacheControlUtil.noCache()).build();
}

// Tells the polling login page whether the cross device presentation arrived. It only reads, the
// deferred identity is consumed by complete-auth alone. The state may have leaked through the QR
// code, so nothing is revealed before the browser session cookie matches.
@GET
@Path("/" + STATUS_PATH)
@Produces(MediaType.APPLICATION_JSON)
public Response status(@QueryParam(OAuth2Constants.STATE) String state) {
if (StringUtil.isBlank(state)) {
return statusResponse(Response.Status.BAD_REQUEST, STATUS_ERROR, null);
}
RootAuthenticationSessionModel cookieRootSession =
new AuthenticationSessionManager(session).getCurrentRootAuthenticationSession(realm);
if (cookieRootSession == null) {
return statusResponse(Response.Status.OK, STATUS_ERROR, null);
}

Map<String, String> complete = session.singleUseObjects().get(OID4VPIdentityProvider.COMPLETE_PREFIX + state);
if (complete != null) {
if (!cookieRootSession.getId().equals(complete.get(OID4VPIdentityProvider.KEY_ROOT_SESSION_ID))) {
return statusResponse(Response.Status.OK, STATUS_ERROR, null);
}

return statusResponse(Response.Status.OK, STATUS_COMPLETE,
completeAuthUrl(complete.get(OID4VPIdentityProvider.KEY_RESPONSE_CODE)));
}

Map<String, String> stored = session.singleUseObjects().get(OID4VPIdentityProvider.CONTEXT_PREFIX + state);
if (stored != null) {
if (!cookieRootSession.getId().equals(RequestContext.fromMap(stored).rootSessionId())) {
return statusResponse(Response.Status.OK, STATUS_ERROR, null);
}
return statusResponse(Response.Status.OK, STATUS_PENDING, null);
}
// Expired, already consumed by complete-auth or finished on the same device. All terminal.
return statusResponse(Response.Status.OK, STATUS_EXPIRED, null);
}

@GET
@Path("/complete-auth")
public Response completeAuth(@QueryParam(OID4VCConstants.RESPONSE_CODE) String responseCode) {
Expand All @@ -218,6 +290,10 @@ public Response completeAuth(@QueryParam(OID4VCConstants.RESPONSE_CODE) String r
if (deferred == null) {
return loginErrorPage(null, Messages.SESSION_NOT_ACTIVE);
}

session.singleUseObjects().remove(
OID4VPIdentityProvider.COMPLETE_PREFIX + deferred.get(OID4VPIdentityProvider.KEY_STATE));

AuthenticationSessionModel authSession = resolveAuthSession(
deferred.get(OID4VPIdentityProvider.KEY_ROOT_SESSION_ID), deferred.get(OID4VPIdentityProvider.KEY_TAB_ID));
if (authSession == null) {
Expand Down Expand Up @@ -249,7 +325,7 @@ public Response completeAuth(@QueryParam(OID4VCConstants.RESPONSE_CODE) String r
return callback.authenticated(context);
}

protected RequestObject buildRequestObject(String state, RequestContext requestContext) {
protected RequestObject buildRequestObject(String state, RequestContext requestContext, boolean crossDevice) {
String clientId = provider.clientId();
Instant now = Instant.now();

Expand All @@ -259,7 +335,7 @@ protected RequestObject buildRequestObject(String state, RequestContext requestC
.responseMode(requestContext.isEncrypted()
? OID4VCConstants.RESPONSE_MODE_DIRECT_POST_JWT
: OID4VCConstants.RESPONSE_MODE_DIRECT_POST)
.responseUri(endpointBaseUrl())
.responseUri(responseUri(crossDevice))
.nonce(requestContext.nonce())
.state(state)
.clientMetadata(requestContext.clientMetadata())
Expand Down Expand Up @@ -300,7 +376,9 @@ protected DecryptedResponse decryptResponse(String response) throws Verification

Map<String, String> stored = session.singleUseObjects().get(OID4VPIdentityProvider.CONTEXT_PREFIX + state);
if (stored == null) {
throw new VerificationException("Unknown or expired response encryption key");
// The kid is the state, so a consumed or expired login lands here. The message matches the
// plain mode so a late second presentation reads the same in both response modes.
throw new VerificationException("Unknown or expired state");
}
RequestContext requestContext = RequestContext.fromMap(stored);
if (!requestContext.isEncrypted()) {
Expand Down Expand Up @@ -456,9 +534,27 @@ public static UriBuilder endpointBaseUri(UriBuilder baseUriBuilder, String realm
return baseUriBuilder.path("realms").path(realmName).path("broker").path(alias).path("endpoint");
}

protected String endpointBaseUrl() {
return endpointBaseUri(session.getContext().getUri().getBaseUriBuilder(), realm.getName(),
provider.getConfig().getAlias()).build().toString();
// The response_uri advertised in the request object. The wallet posts back to it verbatim, which
// carries the cross device signal into directPost.
protected String responseUri(boolean crossDevice) {
UriBuilder uri = endpointBaseUri(session.getContext().getUri().getBaseUriBuilder(), realm.getName(),
provider.getConfig().getAlias());
if (crossDevice) {
uri.queryParam(FLOW_PARAM, FLOW_CROSS_DEVICE);
}
return uri.build().toString();
}

protected static boolean isCrossDevice(String flow) {
return FLOW_CROSS_DEVICE.equals(flow);
}

protected static Response statusResponse(Response.Status httpStatus, String status, String redirectUri) {
Map<String, String> body = redirectUri == null
? Map.of(STATUS_KEY, status)
: Map.of(STATUS_KEY, status, STATUS_REDIRECT_URI_KEY, redirectUri);
return Response.status(httpStatus).entity(body)
.type(MediaType.APPLICATION_JSON_TYPE).cacheControl(CacheControlUtil.noCache()).build();
}

protected String completeAuthUrl(String responseCode) {
Expand Down
Loading
Loading