Skip to content
Merged
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
5 changes: 5 additions & 0 deletions core/src/main/java/org/keycloak/OID4VCConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ public class OID4VCConstants {
public static final String SD_JWT_ALG_VALUES = "sd-jwt_alg_values";
public static final String KB_JWT_ALG_VALUES = "kb-jwt_alg_values";
public static final String RESPONSE_MODE_DIRECT_POST = "direct_post";
public static final String RESPONSE_MODE_DIRECT_POST_JWT = "direct_post.jwt";
// client_metadata parameters advertising the verifier's ephemeral response encryption key material.
public static final String JWKS = "jwks";
public static final String JWKS_KEYS = "keys";
public static final String ENCRYPTED_RESPONSE_ENC_VALUES_SUPPORTED = "encrypted_response_enc_values_supported";
public static final String FORMAT_SD_JWT_VC = "dc+sd-jwt";
public static final String SELF_ISSUED_V2 = "https://self-issued.me/v2";
public static final String REQUEST_OBJECT_TYPE = "oauth-authz-req+jwt";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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;

/**
* The response parameters unsealed from a {@code direct_post.jwt} JWE, together with the request
* context they were resolved against.
*/
public record DecryptedResponse(String vpToken, String state, RequestContext context) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* 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;

import java.security.KeyPair;
import java.security.PrivateKey;
import java.util.Base64;

import org.keycloak.common.util.DerUtils;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.crypto.KeyUse;
import org.keycloak.jose.jwe.JWEConstants;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;

/**
* The per request response encryption key for the {@code direct_post.jwt} response mode. The public
* JWK is advertised to the wallet in the request object client metadata, the private key stays with
* the verifier to decrypt the response. Generation takes no algorithm because HAIP pins the key
* management to {@link JWEConstants#ECDH_ES} over secp256r1. The advertised content encryption
* algorithms only select the length of the content key derived during key agreement, so the one key
* serves them all.
*/
public record EphemeralKey(String kid, JWK publicJwk, String privateKeyPkcs8Base64) {

private static final String CURVE_SEC = "secp256r1";

public static EphemeralKey generate(String kid) {
KeyPair keyPair = KeyUtils.generateEcKeyPair(CURVE_SEC);
JWK publicJwk = JWKBuilder.create()
.algorithm(ResponseEncryption.KEY_MANAGEMENT_ALG)
.ec(keyPair.getPublic(), KeyUse.ENC);
publicJwk.setKeyId(kid);
String encoded = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
return new EphemeralKey(kid, publicJwk, encoded);
}

// The private key is PKCS8 encoded so it can live in the single use object store.
public PrivateKey privateKey() {
try {
return DerUtils.decodePrivateKey(Base64.getDecoder().decode(privateKeyPkcs8Base64));
} catch (Exception e) {
throw new IllegalStateException("Failed to restore the ephemeral response encryption key", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;

Expand Down Expand Up @@ -74,18 +74,20 @@ public class OID4VPIdentityProvider extends AbstractIdentityProvider<OID4VPIdent

private static final Logger logger = Logger.getLogger(OID4VPIdentityProvider.class);

// The flow object is written when the login page is rendered and read while serving the request
// object and the presentation. It holds the root authentication session id and tab id needed to
// recover that session, and the nonce the wallet must echo in the key binding JWT.
public static final String FLOW_PREFIX = "oid4vp.flow.";
// TODO the accepted presentation signature algorithms are hardcoded to match the advertised client
// metadata. Derive both from configuration or the available verification key material.
public static final List<String> ACCEPTED_ALGORITHMS = List.of(Algorithm.ES256);

// The request context is written when the login page is rendered and read while serving the request
// 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.
public static final String DEFERRED_PREFIX = "oid4vp.deferred.";
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_NONCE = "nonce";

public OID4VPIdentityProvider(KeycloakSession session, OID4VPIdentityProviderConfig config) {
super(session, config);
Expand All @@ -103,13 +105,19 @@ public Response performLogin(AuthenticationRequest request) {
? authSession.getParentSession().getId()
: null;

session.singleUseObjects().put(
FLOW_PREFIX + state,
loginTimeoutSeconds(),
Map.of(
KEY_ROOT_SESSION_ID, rootSessionId != null ? rootSessionId : "",
KEY_TAB_ID, authSession.getTabId() != null ? authSession.getTabId() : "",
KEY_NONCE, nonce));
EphemeralKey encryptionKey = null;
if (getConfig().isEncryptedResponse()) {
// A fresh encryption key per authorization request, as HAIP requires. Its kid is the
// state, so the cleartext JWE kid resolves the context although the response seals
// the state inside the ciphertext. Assumes the one key per request HAIP mandates.
encryptionKey = EphemeralKey.generate(state);
}

RequestContext context = new RequestContext(
rootSessionId != null ? rootSessionId : "",
authSession.getTabId() != null ? authSession.getTabId() : "",
nonce, encryptionKey);
session.singleUseObjects().put(CONTEXT_PREFIX + state, loginTimeoutSeconds(), context.toMap());

URI requestUri = OID4VPIdentityProviderEndpoint
.endpointBaseUri(request.getUriInfo().getBaseUriBuilder(), request.getRealm().getName(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package org.keycloak.broker.oid4vp;

import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
import org.keycloak.utils.StringUtil;

public class OID4VPIdentityProviderConfig extends IdentityProviderModel {
Expand All @@ -27,6 +28,7 @@ public class OID4VPIdentityProviderConfig extends IdentityProviderModel {
public static final String PRINCIPAL_ATTRIBUTE = "principalAttribute";
public static final String WALLET_SCHEME = "walletScheme";
public static final String SIGNING_KEY_ID = "signingKeyId";
public static final String RESPONSE_MODE = "responseMode";

public static final String DEFAULT_WALLET_SCHEME = "openid4vp://";

Expand Down Expand Up @@ -70,6 +72,28 @@ public void setSigningKeyId(String signingKeyId) {
getConfig().put(SIGNING_KEY_ID, signingKeyId);
}

public ResponseMode getResponseMode() {
String value = getConfig().get(RESPONSE_MODE);
return StringUtil.isBlank(value) ? ResponseMode.DIRECT_POST : ResponseMode.from(value);
}

public void setResponseMode(ResponseMode responseMode) {
getConfig().put(RESPONSE_MODE, responseMode.value());
}

public boolean isEncryptedResponse() {
return getResponseMode() == ResponseMode.DIRECT_POST_JWT;
}

@Override
public void validate(RealmModel realm) {
super.validate(realm);
String responseMode = getConfig().get(RESPONSE_MODE);
if (StringUtil.isNotBlank(responseMode)) {
ResponseMode.from(responseMode);
}
}

public String getWalletScheme() {
String value = getConfig().get(WALLET_SCHEME);
return StringUtil.isNotBlank(value) ? value : DEFAULT_WALLET_SCHEME;
Expand Down
Loading
Loading