DPoP support 1st phase (#21202)

closes #21200


Co-authored-by: Dmitry Telegin <dmitryt@backbase.com>
Co-authored-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Takashi Norimatsu 2023-07-24 23:44:24 +09:00 committed by GitHub
parent e1d1678d3a
commit 0ddef5dda8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1293 additions and 117 deletions

View file

@ -88,7 +88,9 @@ public class Profile {
JS_ADAPTER("Host keycloak.js and keycloak-authz.js through the Keycloak sever", Type.DEFAULT),
FIPS("FIPS 140-2 mode", Type.DISABLED_BY_DEFAULT);
FIPS("FIPS 140-2 mode", Type.DISABLED_BY_DEFAULT),
DPOP("OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer", Type.PREVIEW);
private final Type type;
private String label;

View file

@ -71,6 +71,7 @@ public class ProfileTest {
Assert.assertEquals(Profile.ProfileName.DEFAULT, profile.getName());
Set<Profile.Feature> disabledFeatures = new HashSet<>(Arrays.asList(
Profile.Feature.DPOP,
Profile.Feature.FIPS,
Profile.Feature.ACCOUNT3,
Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ,
@ -90,7 +91,7 @@ public class ProfileTest {
disabledFeatures.add(Profile.Feature.KERBEROS);
}
assertEquals(profile.getDisabledFeatures(), disabledFeatures);
assertEquals(profile.getPreviewFeatures(), Profile.Feature.ACCOUNT3, Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.RECOVERY_CODES, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Profile.Feature.CLIENT_SECRET_ROTATION, Profile.Feature.UPDATE_EMAIL);
assertEquals(profile.getPreviewFeatures(), Profile.Feature.ACCOUNT3, Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.RECOVERY_CODES, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.DECLARATIVE_USER_PROFILE, Profile.Feature.CLIENT_SECRET_ROTATION, Profile.Feature.UPDATE_EMAIL, Profile.Feature.DPOP);
}
@Test

View file

@ -56,6 +56,9 @@ public class OAuthErrorException extends Exception {
// CIBA
public static final String INVALID_BINDING_MESSAGE = "invalid_binding_message";
// DPoP
public static final String INVALID_DPOP_PROOF = "invalid_dpop_proof";
// Others
public static final String INVALID_CLIENT = "invalid_client";
public static final String INVALID_GRANT = "invalid_grant";

View file

@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.keycloak.jose.JOSEHeader;
import org.keycloak.jose.jwk.JWK;
import java.io.IOException;
@ -44,6 +45,9 @@ public class JWSHeader implements JOSEHeader {
@JsonProperty("kid")
private String keyId;
@JsonProperty("jwk")
private JWK key;
public JWSHeader() {
}
@ -53,10 +57,11 @@ public class JWSHeader implements JOSEHeader {
this.contentType = contentType;
}
public JWSHeader(Algorithm algorithm, String type, String contentType, String keyId) {
public JWSHeader(Algorithm algorithm, String type, String keyId, JWK key) {
this.algorithm = algorithm;
this.type = type;
this.keyId = keyId;
this.key = key;
}
public Algorithm getAlgorithm() {
@ -81,6 +86,10 @@ public class JWSHeader implements JOSEHeader {
return keyId;
}
public JWK getKey() {
return key;
}
private static final ObjectMapper mapper = new ObjectMapper();
static {

View file

@ -30,15 +30,20 @@ import java.util.Arrays;
*/
public class HashUtils {
// See "at_hash" and "c_hash" in OIDC specification
public static String oidcHash(String jwtAlgorithmName, String input) {
// See:
// - "at_hash" and "c_hash" in OIDC specification (full = false)
// - "ath" in DPoP specification (full = true)
public static String accessTokenHash(String jwtAlgorithmName, String input, boolean full) {
byte[] inputBytes = input.getBytes(StandardCharsets.UTF_8);
String javaAlgName = JavaAlgorithm.getJavaAlgorithmForHash(jwtAlgorithmName);
byte[] hash = hash(javaAlgName, inputBytes);
return encodeHashToOIDC(hash);
return encodeHashToOIDC(hash, full);
}
public static String accessTokenHash(String jwtAlgorithmName, String input) {
return HashUtils.accessTokenHash(jwtAlgorithmName, input, false);
}
public static byte[] hash(String javaAlgorithmName, byte[] inputBytes) {
try {
@ -50,9 +55,12 @@ public class HashUtils {
}
}
public static String encodeHashToOIDC(byte[] hash) {
int hashLength = hash.length / 2;
return encodeHashToOIDC(hash, false);
}
public static String encodeHashToOIDC(byte[] hash, boolean full) {
int hashLength = full ? hash.length : hash.length / 2;
byte[] hashInput = Arrays.copyOf(hash, hashLength);
return Base64Url.encode(hashInput);

View file

@ -157,6 +157,9 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("tls_client_certificate_bound_access_tokens")
private Boolean tlsClientCertificateBoundAccessTokens;
@JsonProperty("dpop_signing_alg_values_supported")
private List<String> dpopSigningAlgValuesSupported;
@JsonProperty("revocation_endpoint")
private String revocationEndpoint;
@ -490,6 +493,14 @@ public class OIDCConfigurationRepresentation {
this.tlsClientCertificateBoundAccessTokens = tlsClientCertificateBoundAccessTokens;
}
public List<String> getDpopSigningAlgValuesSupported() {
return dpopSigningAlgValuesSupported;
}
public void setDpopSigningAlgValuesSupported(List<String> dpopSigningAlgValuesSupported) {
this.dpopSigningAlgValuesSupported = dpopSigningAlgValuesSupported;
}
public String getRevocationEndpoint() {
return revocationEndpoint;
}

View file

@ -101,10 +101,13 @@ public class AccessToken extends IDToken {
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3.1
public static class CertConf {
public static class Confirmation {
@JsonProperty("x5t#S256")
protected String certThumbprint;
@JsonProperty("jkt")
protected String keyThumbprint;
public String getCertThumbprint() {
return certThumbprint;
}
@ -112,6 +115,14 @@ public class AccessToken extends IDToken {
public void setCertThumbprint(String certThumbprint) {
this.certThumbprint = certThumbprint;
}
public String getKeyThumbprint() {
return keyThumbprint;
}
public void setKeyThumbprint(String keyThumbprint) {
this.keyThumbprint = keyThumbprint;
}
}
@JsonProperty("trusted-certs")
@ -130,7 +141,7 @@ public class AccessToken extends IDToken {
protected Authorization authorization;
@JsonProperty("cnf")
protected CertConf certConf;
protected Confirmation confirmation;
@JsonProperty("scope")
protected String scope;
@ -261,13 +272,13 @@ public class AccessToken extends IDToken {
public void setAuthorization(Authorization authorization) {
this.authorization = authorization;
}
public CertConf getCertConf() {
return certConf;
public Confirmation getConfirmation() {
return confirmation;
}
public void setCertConf(CertConf certConf) {
this.certConf = certConf;
public void setConfirmation(Confirmation confirmation) {
this.confirmation = confirmation;
}
public String getScope() {

View file

@ -0,0 +1,74 @@
/*
* Copyright 2023 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.representations.dpop;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.representations.JsonWebToken;
/**
* @author <a href="mailto:dmitryt@backbase.com">Dmitry Telegin</a>
*/
public class DPoP extends JsonWebToken {
private static final String ATH = "ath";
private static final String HTM = "htm";
private static final String HTU = "htu";
@JsonProperty(ATH)
private String accessTokenHash;
@JsonProperty(HTM)
private String httpMethod;
@JsonProperty(HTU)
private String httpUri;
private String thumbprint;
public String getAccessTokenHash() {
return accessTokenHash;
}
public void setAccessTokenHash(String accessTokenHash) {
this.accessTokenHash = accessTokenHash;
}
public String getHttpMethod() {
return httpMethod;
}
public void setHttpMethod(String httpMethod) {
this.httpMethod = httpMethod;
}
public String getHttpUri() {
return httpUri;
}
public void setHttpUri(String httpUri) {
this.httpUri = httpUri;
}
public String getThumbprint() {
return thumbprint;
}
public void setThumbprint(String thumbprint) {
this.thumbprint = thumbprint;
}
}

View file

@ -21,14 +21,22 @@ import org.jboss.logging.Logger;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.PublicKeysWrapper;
import org.keycloak.common.util.Base64Url;
import org.keycloak.crypto.KeyType;
import org.keycloak.jose.jwk.ECPublicJWK;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.jose.jwk.RSAPublicJWK;
import org.keycloak.jose.jws.crypto.HashUtils;
import java.io.IOException;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
@ -38,6 +46,14 @@ public class JWKSUtils {
private static final Logger logger = Logger.getLogger(JWKSUtils.class.getName());
private static final String JWK_THUMBPRINT_DEFAULT_HASH_ALGORITHM = "SHA-256";
private static final Map<String, String[]> JWK_THUMBPRINT_REQUIRED_MEMBERS = new HashMap<>();
static {
JWK_THUMBPRINT_REQUIRED_MEMBERS.put(KeyType.RSA, new String[] { RSAPublicJWK.MODULUS, RSAPublicJWK.PUBLIC_EXPONENT });
JWK_THUMBPRINT_REQUIRED_MEMBERS.put(KeyType.EC, new String[] { ECPublicJWK.CRV, ECPublicJWK.X, ECPublicJWK.Y });
}
/**
* @deprecated Use {@link #getKeyWrappersForUse(JSONWebKeySet, JWK.Use)}
**/
@ -55,14 +71,7 @@ public class JWKSUtils {
if (jwk.getPublicKeyUse() == null) {
logger.debugf("Ignoring JWK key '%s'. Missing required field 'use'.", jwk.getKeyId());
} else if (requestedUse.asString().equals(jwk.getPublicKeyUse()) && parser.isKeyTypeSupported(jwk.getKeyType())) {
KeyWrapper keyWrapper = new KeyWrapper();
keyWrapper.setKid(jwk.getKeyId());
if (jwk.getAlgorithm() != null) {
keyWrapper.setAlgorithm(jwk.getAlgorithm());
}
keyWrapper.setType(jwk.getKeyType());
keyWrapper.setUse(getKeyUse(jwk.getPublicKeyUse()));
keyWrapper.setPublicKey(parser.toPublicKey());
KeyWrapper keyWrapper = wrap(jwk, parser);
result.add(keyWrapper);
}
}
@ -70,10 +79,12 @@ public class JWKSUtils {
}
private static KeyUse getKeyUse(String keyUse) {
switch (keyUse) {
case "sig" :
if (keyUse == null) {
return null;
} else switch (keyUse) {
case "sig" :
return KeyUse.SIG;
case "enc" :
case "enc" :
return KeyUse.ENC;
default :
return null;
@ -92,4 +103,49 @@ public class JWKSUtils {
return null;
}
public static KeyWrapper getKeyWrapper(JWK jwk) {
JWKParser parser = JWKParser.create(jwk);
if (parser.isKeyTypeSupported(jwk.getKeyType())) {
return wrap(jwk, parser);
} else {
return null;
}
}
private static KeyWrapper wrap(JWK jwk, JWKParser parser) {
KeyWrapper keyWrapper = new KeyWrapper();
keyWrapper.setKid(jwk.getKeyId());
if (jwk.getAlgorithm() != null) {
keyWrapper.setAlgorithm(jwk.getAlgorithm());
}
keyWrapper.setType(jwk.getKeyType());
keyWrapper.setUse(getKeyUse(jwk.getPublicKeyUse()));
keyWrapper.setPublicKey(parser.toPublicKey());
return keyWrapper;
}
public static String computeThumbprint(JWK key) {
return computeThumbprint(key, JWK_THUMBPRINT_DEFAULT_HASH_ALGORITHM);
}
// TreeMap uses the natural ordering of the keys.
// Therefore, it follows the way of hash value calculation for a public key defined by RFC 7678
public static String computeThumbprint(JWK key, String hashAlg) {
Map<String, String> members = new TreeMap<>();
members.put(JWK.KEY_TYPE, key.getKeyType());
for (String member : JWK_THUMBPRINT_REQUIRED_MEMBERS.get(key.getKeyType())) {
members.put(member, (String) key.getOtherClaims().get(member));
}
try {
byte[] bytes = JsonSerialization.writeValueAsBytes(members);
byte[] hash = HashUtils.hash(hashAlg, bytes);
return Base64Url.encode(hash);
} catch (IOException ex) {
return null;
}
}
}

View file

@ -42,7 +42,7 @@ public class AtHashTest {
}
private void verifyHash(String jwtAlgorithm, String accessToken, String expectedAtHash) {
String atHash = HashUtils.oidcHash(jwtAlgorithm, accessToken);
String atHash = HashUtils.accessTokenHash(jwtAlgorithm, accessToken);
Assert.assertEquals(expectedAtHash, atHash);
}
}

View file

@ -109,6 +109,7 @@
"clientOfflineSessionIdle": "Time a client offline session is allowed to be idle before it expires. Offline tokens are invalidated when a client offline session is expired. The option does not affect the global user SSO session. If not set, it uses the realm Offline Session Idle value.",
"clientOfflineSessionMax": "Max time before a client offline session is expired. If Offline Session Max Limited is enabled at realm level, offline tokens are invalidated when a client offline session is expired. The option does not affect the global user SSO session. If not set, it uses the realm Offline Session Max value.",
"oAuthMutual": "This enables support for OAuth 2.0 Mutual TLS Certificate Bound Access Tokens, which means that keycloak bind an access token and a refresh token with a X.509 certificate of a token requesting client exchanged in mutual TLS between keycloak's Token Endpoint and this client. These tokens can be treated as Holder-of-Key tokens instead of bearer tokens.",
"oAuthDPoP": "This enables support for Demonstrating Proof-of-Possession (DPoP) bound tokens. The access and refresh tokens are bound to the key stored on the user agent. In order to prove the possession of the key, the user agent must send a signed proof alongside the token.",
"keyForCodeExchange": "Choose which code challenge method for PKCE is used. If not specified, keycloak does not applies PKCE to a client unless the client sends an authorization request with appropriate code challenge and code exchange method.",
"pushedAuthorizationRequestRequired": "Boolean parameter indicating whether the authorization server accepts authorization request data only via the pushed authorization request method.",
"acrToLoAMapping": "Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR can be any value, whereas the LoA must be numeric.",

View file

@ -512,6 +512,7 @@
"clientOfflineSessionIdle": "Client Offline Session Idle",
"clientOfflineSessionMax": "Client Offline Session Max",
"oAuthMutual": "OAuth 2.0 Mutual TLS Certificate Bound Access Tokens Enabled",
"oAuthDPoP": "OAuth 2.0 DPoP Bound Access Tokens Enabled",
"keyForCodeExchange": "Proof Key for Code Exchange Code Challenge Method",
"pushedAuthorizationRequestRequired": "Pushed authorization request required",
"acrToLoAMapping": "ACR to LoA Mapping",

View file

@ -25,6 +25,8 @@ import { useFetch } from "../../utils/useFetch";
import { FormFields } from "../ClientDetails";
import { TokenLifespan } from "./TokenLifespan";
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";
type AdvancedSettingsProps = {
save: () => void;
reset: () => void;
@ -44,6 +46,9 @@ export const AdvancedSettings = ({
const [realm, setRealm] = useState<RealmRepresentation>();
const { realm: realmName } = useRealm();
const isFeatureEnabled = useIsFeatureEnabled();
const isDPoPEnabled = isFeatureEnabled(Feature.DPoP);
useFetch(
() => adminClient.realms.findOne({ realm: realmName }),
setRealm,
@ -160,6 +165,37 @@ export const AdvancedSettings = ({
)}
/>
</FormGroup>
{isDPoPEnabled && (
<FormGroup
label={t("oAuthDPoP")}
fieldId="oAuthDPoP"
hasNoPaddingTop
labelIcon={
<HelpItem
helpText={t("clients-help:oAuthDPoP")}
fieldLabelId="clients:oAuthDPoP"
/>
}
>
<Controller
name={convertAttributeNameToForm<FormFields>(
"attributes.dpop.bound.access.tokens",
)}
defaultValue={false}
control={control}
render={({ field }) => (
<Switch
id="oAuthDPoP-switch"
label={t("common:on")}
labelOff={t("common:off")}
isChecked={field.value === "true"}
onChange={(value) => field.onChange("" + value)}
aria-label={t("oAuthDPoP")}
/>
)}
/>
</FormGroup>
)}
<FormGroup
label={t("keyForCodeExchange")}
fieldId="keyForCodeExchange"

View file

@ -6,6 +6,7 @@ export enum Feature {
DeclarativeUserProfile = "DECLARATIVE_USER_PROFILE",
Kerberos = "KERBEROS",
DynamicScopes = "DYNAMIC_SCOPES",
DPoP = "DPOP",
}
export default function useIsFeatureEnabled() {

View file

@ -28,7 +28,7 @@ import static org.keycloak.quarkus.runtime.cli.command.AbstractStartCommand.OPTI
@LegacyStore
public class FeaturesDistTest {
private static final String PREVIEW_FEATURES_EXPECTED_LOG = "Preview features enabled: account3, admin-fine-grained-authz, client-secret-rotation, declarative-user-profile, recovery-codes, scripts, token-exchange, update-email";
private static final String PREVIEW_FEATURES_EXPECTED_LOG = "Preview features enabled: account3, admin-fine-grained-authz, client-secret-rotation, declarative-user-profile, dpop, recovery-codes, scripts, token-exchange, update-email";
@Test
public void testEnableOnBuild(KeycloakDistribution dist) {

View file

@ -46,14 +46,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -46,14 +46,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -58,14 +58,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -121,14 +121,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -58,14 +58,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -121,14 +121,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -73,14 +73,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -71,14 +71,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -136,14 +136,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -134,14 +134,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -79,14 +79,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -77,14 +77,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -142,14 +142,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -140,14 +140,14 @@ Feature:
--features <feature> Enables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.
--features-disabled <feature>
Disables a set of one or more features. Possible values are: account-api,
account2, account3, admin-api, admin-fine-grained-authz, admin2,
authorization, ciba, client-policies, client-secret-rotation,
declarative-user-profile, docker, dynamic-scopes, fips, impersonation,
declarative-user-profile, docker, dpop, dynamic-scopes, fips, impersonation,
js-adapter, kerberos, map-storage, par, preview, recovery-codes, scripts,
step-up-authentication, token-exchange, update-email, web-authn.

View file

@ -93,6 +93,7 @@ public interface Errors {
String PKCE_VERIFICATION_FAILED = "pkce_verification_failed";
String INVALID_CODE_CHALLENGE_METHOD = "invalid_code_challenge_method";
String INVALID_DPOP_PROOF = "invalid_dpop_proof";
String NOT_LOGGED_IN = "not_logged_in";
String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider";

View file

@ -46,6 +46,8 @@ public final class OIDCConfigAttributes {
public static final String USE_MTLS_HOK_TOKEN = "tls.client.certificate.bound.access.tokens";
public static final String DPOP_BOUND_ACCESS_TOKENS = "dpop.bound.access.tokens";
public static final String ID_TOKEN_SIGNED_RESPONSE_ALG = "id.token.signed.response.alg";
public static final String ID_TOKEN_ENCRYPTED_RESPONSE_ALG = "id.token.encrypted.response.alg";

View file

@ -22,6 +22,7 @@ import static org.keycloak.protocol.oidc.OIDCConfigAttributes.USE_LOWER_CASE_IN_
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.models.ClientModel;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.utils.StringUtil;
import java.util.ArrayList;
@ -44,7 +45,6 @@ public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper {
return new OIDCAdvancedConfigWrapper(null, clientRep);
}
public String getUserInfoSignedResponseAlg() {
return getAttribute(OIDCConfigAttributes.USER_INFO_RESPONSE_SIGNATURE_ALG);
}
@ -173,6 +173,16 @@ public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper {
setAttribute(OIDCConfigAttributes.EXCLUDE_ISSUER_FROM_AUTH_RESPONSE, val);
}
public boolean isUseDPoP() {
String mode = getAttribute(OIDCConfigAttributes.DPOP_BOUND_ACCESS_TOKENS);
return Boolean.parseBoolean(mode);
}
public void setUseDPoP(boolean useDPoP) {
String val = String.valueOf(useDPoP);
setAttribute(OIDCConfigAttributes.DPOP_BOUND_ACCESS_TOKENS, val);
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.5
public boolean isUseMtlsHokToken() {

View file

@ -48,6 +48,7 @@ import org.keycloak.services.Urls;
import org.keycloak.services.clientregistration.ClientRegistrationService;
import org.keycloak.services.clientregistration.oidc.OIDCClientRegistrationProviderFactory;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.urls.UrlType;
import org.keycloak.util.JsonSerialization;
import org.keycloak.wellknown.WellKnownProvider;
@ -190,6 +191,8 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2
config.setTlsClientCertificateBoundAccessTokens(true);
config.setDpopSigningAlgValuesSupported(new ArrayList<>(DPoPUtil.DPOP_SUPPORTED_ALGS));
URI revocationEndpoint = frontendUriBuilder.clone().path(OIDCLoginProtocolService.class, "revoke")
.build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL);

View file

@ -75,6 +75,7 @@ import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.LogoutToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
@ -82,6 +83,7 @@ import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.services.util.AuthorizationContextUtil;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
@ -382,6 +384,7 @@ public class TokenManager {
TokenValidation validation = validateToken(session, uriInfo, connection, realm, refreshToken, headers);
AuthenticatedClientSessionModel clientSession = validation.clientSessionCtx.getClientSession();
OIDCAdvancedConfigWrapper clientConfig = OIDCAdvancedConfigWrapper.fromClientModel(authorizedClient);
// validate authorizedClient is same as validated client
if (!clientSession.getClient().getId().equals(authorizedClient.getId())) {
@ -400,26 +403,15 @@ public class TokenManager {
AccessTokenResponseBuilder responseBuilder = responseBuilder(realm, authorizedClient, event, session,
validation.userSession, validation.clientSessionCtx).accessToken(validation.newToken);
if (OIDCAdvancedConfigWrapper.fromClientModel(authorizedClient).isUseRefreshToken()) {
if (clientConfig.isUseRefreshToken()) {
responseBuilder.generateRefreshToken();
}
if (validation.newToken.getAuthorization() != null
&& OIDCAdvancedConfigWrapper.fromClientModel(authorizedClient).isUseRefreshToken()) {
&& clientConfig.isUseRefreshToken()) {
responseBuilder.getRefreshToken().setAuthorization(validation.newToken.getAuthorization());
}
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3.1
// bind refreshed access and refresh token with Client Certificate
AccessToken.CertConf certConf = refreshToken.getCertConf();
if (certConf != null) {
responseBuilder.getAccessToken().setCertConf(certConf);
if (OIDCAdvancedConfigWrapper.fromClientModel(authorizedClient).isUseRefreshToken()) {
responseBuilder.getRefreshToken().setCertConf(certConf);
}
}
String scopeParam = clientSession.getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();
@ -505,7 +497,19 @@ public class TokenManager {
}
}
if (Profile.isFeatureEnabled(Profile.Feature.DPOP)) {
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseDPoP() && client.isPublicClient()) {
DPoP dPoP = (DPoP) session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE);
try {
DPoPUtil.validateBinding(refreshToken, dPoP);
} catch (VerificationException ex) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, ex.getMessage());
}
}
}
return refreshToken;
} catch (JWSInputException e) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
}
@ -1012,6 +1016,7 @@ public class TokenManager {
AccessToken accessToken;
RefreshToken refreshToken;
IDToken idToken;
String responseTokenType;
boolean generateAccessTokenHash = false;
String codeHash;
@ -1028,6 +1033,7 @@ public class TokenManager {
this.session = session;
this.userSession = userSession;
this.clientSessionCtx = clientSessionCtx;
this.responseTokenType = formatTokenType(client);
}
public AccessToken getAccessToken() {
@ -1051,6 +1057,11 @@ public class TokenManager {
return this;
}
public AccessTokenResponseBuilder responseTokenType(String responseTokenType) {
this.responseTokenType = responseTokenType;
return this;
}
public AccessTokenResponseBuilder generateAccessToken() {
UserModel user = userSession.getUser();
accessToken = createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
@ -1174,7 +1185,7 @@ public class TokenManager {
if (accessToken != null) {
String encodedToken = session.tokens().encode(accessToken);
res.setToken(encodedToken);
res.setTokenType(formatTokenType(client));
res.setTokenType(responseTokenType);
res.setSessionState(accessToken.getSessionState());
if (accessToken.getExpiration() != 0) {
res.setExpiresIn(accessToken.getExpiration() - Time.currentTime());

View file

@ -28,6 +28,7 @@ import org.keycloak.authorization.authorization.AuthorizationTokenService;
import org.keycloak.authorization.util.Tokens;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.VerificationException;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.common.util.ResponseSessionTask;
@ -66,6 +67,7 @@ import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.rar.AuthorizationRequestContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.representations.idm.authorization.AuthorizationRequest.Metadata;
import org.keycloak.saml.common.constants.JBossSAMLConstants;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
@ -94,6 +96,7 @@ import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.AuthorizationContextUtil;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
@ -134,6 +137,8 @@ public class TokenEndpoint {
private MultivaluedMap<String, String> formParams;
private ClientModel client;
private Map<String, String> clientAuthAttributes;
private OIDCAdvancedConfigWrapper clientConfig;
private DPoP dPoP;
private enum Action {
AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION, OAUTH2_DEVICE_CODE, CIBA
@ -269,6 +274,7 @@ public class TokenEndpoint {
AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, cors);
client = clientAuth.getClient();
clientAuthAttributes = clientAuth.getClientAuthAttributes();
clientConfig = OIDCAdvancedConfigWrapper.fromClientModel(client);
cors.allowedOrigins(session, client);
@ -325,7 +331,23 @@ public class TokenEndpoint {
}
}
private void checkAndRetrieveDPoPProof(boolean isDPoPSupported) {
if (!isDPoPSupported) return;
if (clientConfig.isUseDPoP()) {
try {
dPoP = new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).validate();
session.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP);
} catch (VerificationException ex) {
event.error(Errors.INVALID_DPOP_PROOF);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_DPOP_PROOF, ex.getMessage(), Response.Status.BAD_REQUEST);
}
}
}
public Response codeToToken() {
checkAndRetrieveDPoPProof(Profile.isFeatureEnabled(Profile.Feature.DPOP));
String code = formParams.getFirst(OAuth2Constants.CODE);
if (code == null) {
event.error(Errors.INVALID_CODE);
@ -462,12 +484,13 @@ public class TokenEndpoint {
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager
.responseBuilder(realm, client, event, session, userSession, clientSessionCtx).accessToken(token);
boolean useRefreshToken = OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken();
boolean useRefreshToken = clientConfig.isUseRefreshToken();
if (useRefreshToken) {
responseBuilder.generateRefreshToken();
}
checkMtlsHoKToken(responseBuilder, useRefreshToken);
checkAndBindMtlsHoKToken(responseBuilder, useRefreshToken);
checkAndBindDPoPToken(responseBuilder, useRefreshToken && client.isPublicClient(), Profile.isFeatureEnabled(Profile.Feature.DPOP));
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();
@ -503,15 +526,15 @@ public class TokenEndpoint {
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
}
private void checkMtlsHoKToken(TokenManager.AccessTokenResponseBuilder responseBuilder, boolean useRefreshToken) {
private void checkAndBindMtlsHoKToken(TokenManager.AccessTokenResponseBuilder responseBuilder, boolean useRefreshToken) {
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (certConf != null) {
responseBuilder.getAccessToken().setCertConf(certConf);
if (clientConfig.isUseMtlsHokToken()) {
AccessToken.Confirmation confirmation = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (confirmation != null) {
responseBuilder.getAccessToken().setConfirmation(confirmation);
if (useRefreshToken) {
responseBuilder.getRefreshToken().setCertConf(certConf);
responseBuilder.getRefreshToken().setConfirmation(confirmation);
}
} else {
event.error(Errors.INVALID_REQUEST);
@ -521,7 +544,25 @@ public class TokenEndpoint {
}
}
private void checkAndBindDPoPToken(TokenManager.AccessTokenResponseBuilder responseBuilder, boolean useRefreshToken, boolean isDPoPSupported) {
if (!isDPoPSupported) return;
if (clientConfig.isUseDPoP()) {
DPoPUtil.bindToken(responseBuilder.getAccessToken(), dPoP);
// TODO Probably uncomment as the accessToken type "DPoP" will have more sense than "Bearer". It will require some changes in the introspection endpoint too...
// responseBuilder.getAccessToken().type(DPoPUtil.DPOP_TOKEN_TYPE);
responseBuilder.responseTokenType(DPoPUtil.DPOP_TOKEN_TYPE);
// Bind refresh tokens for public clients, See "Section 5. DPoP Access Token Request" from DPoP specification
if (useRefreshToken) {
DPoPUtil.bindToken(responseBuilder.getRefreshToken(), dPoP);
}
}
}
public Response refreshTokenGrant() {
checkAndRetrieveDPoPProof(Profile.isFeatureEnabled(Profile.Feature.DPOP));
String refreshToken = formParams.getFirst(OAuth2Constants.REFRESH_TOKEN);
if (refreshToken == null) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "No refresh token", Response.Status.BAD_REQUEST);
@ -542,6 +583,9 @@ public class TokenEndpoint {
session.clientPolicy().triggerOnEvent(new TokenRefreshResponseContext(formParams, responseBuilder));
checkAndBindMtlsHoKToken(responseBuilder, clientConfig.isUseRefreshToken());
checkAndBindDPoPToken(responseBuilder, clientConfig.isUseRefreshToken() && (client.isPublicClient() || client.isBearerOnly()), Profile.isFeatureEnabled(Profile.Feature.DPOP));
res = responseBuilder.build();
if (!responseBuilder.isOfflineToken()) {
@ -550,7 +594,6 @@ public class TokenEndpoint {
updateClientSession(clientSession);
updateUserSessionFromClientAuth(userSession);
}
} catch (OAuthErrorException e) {
logger.trace(e.getMessage(), e);
// KEYCLOAK-6771 Certificate Bound Token
@ -667,7 +710,7 @@ public class TokenEndpoint {
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager
.responseBuilder(realm, client, event, session, userSession, clientSessionCtx).generateAccessToken();
boolean useRefreshToken = OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken();
boolean useRefreshToken = clientConfig.isUseRefreshToken();
if (useRefreshToken) {
responseBuilder.generateRefreshToken();
}
@ -677,7 +720,7 @@ public class TokenEndpoint {
responseBuilder.generateIDToken().generateAccessTokenHash();
}
checkMtlsHoKToken(responseBuilder, useRefreshToken);
checkAndBindMtlsHoKToken(responseBuilder, useRefreshToken);
try {
session.clientPolicy().triggerOnEvent(new ResourceOwnerPasswordCredentialsResponseContext(formParams, clientSessionCtx, responseBuilder));
@ -686,10 +729,8 @@ public class TokenEndpoint {
throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus());
}
// TODO : do the same as codeToToken()
AccessTokenResponse res = responseBuilder.build();
event.success();
AuthenticationManager.logSuccess(session, authSession);
@ -741,7 +782,7 @@ public class TokenEndpoint {
// persisting of userSession by default
UserSessionModel.SessionPersistenceState sessionPersistenceState = UserSessionModel.SessionPersistenceState.PERSISTENT;
boolean useRefreshToken = OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshTokenForClientCredentialsGrant();
boolean useRefreshToken = clientConfig.isUseRefreshTokenForClientCredentialsGrant();
if (!useRefreshToken) {
// we don't want to store a session hence we mark it as transient, see KEYCLOAK-9551
sessionPersistenceState = UserSessionModel.SessionPersistenceState.TRANSIENT;
@ -779,7 +820,7 @@ public class TokenEndpoint {
responseBuilder.getAccessToken().setSessionState(null);
}
checkMtlsHoKToken(responseBuilder, useRefreshToken);
checkAndBindMtlsHoKToken(responseBuilder, useRefreshToken);
String scopeParam = clientSessionCtx.getClientSession().getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) {

View file

@ -92,7 +92,7 @@ public class HolderOfKeyEnforcerExecutor implements ClientPolicyExecutorProvider
case TOKEN_REQUEST:
case SERVICE_ACCOUNT_TOKEN_REQUEST:
case BACKCHANNEL_TOKEN_REQUEST:
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
AccessToken.Confirmation certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (certConf == null) {
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding");
}

View file

@ -42,7 +42,7 @@ public class Cors {
public static final long DEFAULT_MAX_AGE = TimeUnit.HOURS.toSeconds(1);
public static final String DEFAULT_ALLOW_METHODS = "GET, HEAD, OPTIONS";
public static final String DEFAULT_ALLOW_HEADERS = "Origin, Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers";
public static final String DEFAULT_ALLOW_HEADERS = "Origin, Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers, DPoP";
public static final String ORIGIN_HEADER = "Origin";
public static final String AUTHORIZATION_HEADER = "Authorization";

View file

@ -0,0 +1,390 @@
/*
* Copyright 2023 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.services.util;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.apache.commons.codec.binary.Hex;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.exceptions.TokenVerificationException;
import org.keycloak.http.HttpRequest;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.util.JWKSUtils;
import static org.keycloak.utils.StringUtil.isNotBlank;
/**
* @author <a href="mailto:dmitryt@backbase.com">Dmitry Telegin</a>
*/
public class DPoPUtil {
public static final int DEFAULT_PROOF_LIFETIME = 10;
public static final int DEFAULT_ALLOWED_CLOCK_SKEW = 2;
public static final String DPOP_TOKEN_TYPE = "DPoP";
public static final String DPOP_SCHEME = "DPoP";
public final static String DPOP_SESSION_ATTRIBUTE = "dpop";
public final static String DPOP_PARAM = "dpop";
public final static String DPOP_THUMBPRINT_NOTE = "dpop.thumbprint";
public static enum Mode {
ENABLED,
OPTIONAL,
DISABLED
}
private static final String DPOP_HTTP_HEADER = "DPoP";
private static final String DPOP_JWT_HEADER_TYPE = "dpop+jwt";
private static final String DPOP_ATH_ALG = "RS256";
public static final Set<String> DPOP_SUPPORTED_ALGS = Stream.of(
Algorithm.ES256,
Algorithm.ES384,
Algorithm.ES512,
Algorithm.PS256,
Algorithm.PS384,
Algorithm.PS512,
Algorithm.RS256,
Algorithm.RS384,
Algorithm.RS512
).collect(Collectors.toSet());
private static URI normalize(URI uri) {
return UriBuilder.fromUri(uri).replaceQuery("").build();
}
private static DPoP validateDPoP(KeycloakSession session, URI uri, String method, String token, String accessToken, int lifetime, int clockSkew) throws VerificationException {
if (token == null || token.trim().equals("")) {
throw new VerificationException("DPoP proof is missing");
}
TokenVerifier<DPoP> verifier = TokenVerifier.create(token, DPoP.class);
JWSHeader header;
try {
header = verifier.getHeader();
} catch (VerificationException ex) {
throw new VerificationException("DPoP header verification failure");
}
if (!DPOP_JWT_HEADER_TYPE.equals(header.getType())) {
throw new VerificationException("Invalid or missing type in DPoP header: " + header.getType());
}
String algorithm = header.getAlgorithm().name();
if (!DPOP_SUPPORTED_ALGS.contains(algorithm)) {
throw new VerificationException("Unsupported DPoP algorithm: " + header.getAlgorithm());
}
JWK jwk = header.getKey();
KeyWrapper key;
if (jwk == null) {
throw new VerificationException("No JWK in DPoP header");
} else {
key = JWKSUtils.getKeyWrapper(jwk);
if (key.getPublicKey() == null) {
throw new VerificationException("No public key in DPoP header");
}
if (key.getPrivateKey() != null) {
throw new VerificationException("Private key is present in DPoP header");
}
}
key.setAlgorithm(header.getAlgorithm().name());
SignatureVerifierContext signatureVerifier = session.getProvider(SignatureProvider.class, algorithm).verifier(key);
verifier.verifierContext(signatureVerifier);
verifier.withChecks(
DPoPClaimsCheck.INSTANCE,
new DPoPHTTPCheck(uri, method),
new DPoPIsActiveCheck(session, lifetime, clockSkew),
new DPoPReplayCheck(session, lifetime));
if (accessToken != null) {
verifier.withChecks(new DPoPAccessTokenHashCheck(accessToken));
}
try {
DPoP dPoP = verifier.verify().getToken();
dPoP.setThumbprint(JWKSUtils.computeThumbprint(jwk));
return dPoP;
} catch (DPoPVerificationException ex) {
throw ex;
} catch (VerificationException ex) {
throw new VerificationException("DPoP verification failure", ex);
}
}
public static void validateBinding(AccessToken token, DPoP dPoP) throws VerificationException {
try {
TokenVerifier.createWithoutSignature(token)
.withChecks(new DPoPUtil.DPoPBindingCheck(dPoP))
.verify();
} catch (TokenVerificationException ex) {
throw ex;
} catch (VerificationException ex) {
throw new VerificationException("Token verification failure", ex);
}
}
public static void bindToken(AccessToken token, String thumbprint) {
AccessToken.Confirmation confirmation = token.getConfirmation();
if (confirmation == null) {
confirmation = new AccessToken.Confirmation();
token.setConfirmation(confirmation);
}
confirmation.setKeyThumbprint(thumbprint);
}
public static void bindToken(AccessToken token, DPoP dPoP) {
bindToken(token, dPoP.getThumbprint());
}
private static class DPoPClaimsCheck implements TokenVerifier.Predicate<DPoP> {
static final TokenVerifier.Predicate<DPoP> INSTANCE = new DPoPClaimsCheck();
@Override
public boolean test(DPoP t) throws DPoPVerificationException {
Long iat = t.getIat();
String jti = t.getId();
String htu = t.getHttpUri();
String htm = t.getHttpMethod();
if (iat != null &&
isNotBlank(jti) &&
isNotBlank(htm) &&
isNotBlank(htu)) {
return true;
} else {
throw new DPoPVerificationException(t, "DPoP mandatory claims are missing");
}
}
}
private static class DPoPHTTPCheck implements TokenVerifier.Predicate<DPoP> {
private final URI uri;
private final String method;
DPoPHTTPCheck(URI uri, String method) {
this.uri = uri;
this.method = method;
}
@Override
public boolean test(DPoP t) throws DPoPVerificationException {
try {
if (!normalize(new URI(t.getHttpUri())).equals(normalize(uri)))
throw new DPoPVerificationException(t, "DPoP HTTP URL mismatch");
if (!method.equals(t.getHttpMethod()))
throw new DPoPVerificationException(t, "DPoP HTTP method mismatch");
} catch (URISyntaxException ex) {
throw new DPoPVerificationException(t, "Malformed HTTP URL in DPoP proof");
}
return true;
}
}
private static class DPoPReplayCheck implements TokenVerifier.Predicate<DPoP> {
private final KeycloakSession session;
private final int lifetime;
public DPoPReplayCheck(KeycloakSession session, int lifetime) {
this.session = session;
this.lifetime = lifetime;
}
@Override
public boolean test(DPoP t) throws DPoPVerificationException {
SingleUseObjectProvider singleUseCache = session.singleUseObjects();
byte[] hash = HashUtils.hash("SHA1", (t.getId() + "\n" + t.getHttpUri()).getBytes());
String hashString = Hex.encodeHexString(hash);
if (!singleUseCache.putIfAbsent(hashString, (int)(t.getIat() + lifetime - Time.currentTime()))) {
throw new DPoPVerificationException(t, "DPoP proof has already been used");
}
return true;
}
}
private static class DPoPIsActiveCheck implements TokenVerifier.Predicate<DPoP> {
private final int lifetime;
private final int clockSkew;
public DPoPIsActiveCheck(KeycloakSession session, int lifetime, int clockSkew) {
this.lifetime = lifetime;
this.clockSkew = clockSkew;
}
@Override
public boolean test(DPoP t) throws DPoPVerificationException {
long time = Time.currentTime();
Long iat = t.getIat();
if (!(iat <= time + clockSkew && iat > time - lifetime)) {
throw new DPoPVerificationException(t, "DPoP proof is not active");
}
return true;
}
}
private static class DPoPAccessTokenHashCheck implements TokenVerifier.Predicate<DPoP> {
private String hash;
public DPoPAccessTokenHashCheck(String tokenString) {
hash = HashUtils.accessTokenHash(DPOP_ATH_ALG, tokenString, true);
}
@Override
public boolean test(DPoP t) throws DPoPVerificationException {
if (t.getAccessTokenHash() == null) {
throw new DPoPVerificationException(t, "No access token hash in DPoP proof");
}
if (!t.getAccessTokenHash().equals(hash)) {
throw new DPoPVerificationException(t, "DPoP proof access token hash mismatch");
}
return true;
}
}
private static class DPoPBindingCheck implements TokenVerifier.Predicate<AccessToken> {
private final DPoP proof;
public DPoPBindingCheck(DPoP proof) {
this.proof = proof;
}
@Override
public boolean test(AccessToken t) throws VerificationException {
String thumbprint = proof.getThumbprint();
AccessToken.Confirmation confirmation = t.getConfirmation();
if (confirmation == null) {
throw new TokenVerificationException(t, "No DPoP confirmation in access token");
}
String keyThumbprint = confirmation.getKeyThumbprint();
if (keyThumbprint == null) {
throw new TokenVerificationException(t, "No DPoP key thumbprint in access token");
}
if (!keyThumbprint.equals(thumbprint)) {
throw new TokenVerificationException(t, "DPoP confirmation doesn't match DPoP proof");
}
return true;
}
}
public static class DPoPVerificationException extends TokenVerificationException {
public DPoPVerificationException(DPoP token, String message) {
super(token, message);
}
}
public static class Validator {
private URI uri;
private String method;
private String dPoP;
private String accessToken;
private int clockSkew = DEFAULT_ALLOWED_CLOCK_SKEW;
private int lifetime = DEFAULT_PROOF_LIFETIME;
private final KeycloakSession session;
public Validator(KeycloakSession session) {
this.session = session;
}
public Validator request(HttpRequest request) {
this.uri = request.getUri().getAbsolutePath();
this.method = request.getHttpMethod();
this.dPoP = request.getHttpHeaders().getHeaderString(DPOP_HTTP_HEADER);
return this;
}
public Validator dPoP(String dPoP) {
this.dPoP = dPoP;
return this;
}
public Validator accessToken(String accessToken) {
this.accessToken = accessToken;
return this;
}
public Validator uriInfo(UriInfo uriInfo) {
this.uri = uriInfo.getAbsolutePath();
return this;
}
public Validator uri(String uri) throws URISyntaxException {
this.uri = new URI(uri);
return this;
}
public Validator method(String method) {
this.method = method;
return this;
}
public DPoP validate() throws VerificationException {
return validateDPoP(session, uri, method, dPoP, accessToken, lifetime, clockSkew);
}
}
}

View file

@ -24,7 +24,7 @@ public class MtlsHoKTokenUtil {
public static final String CERT_VERIFY_ERROR_DESC = "Client certificate missing, or its thumbprint and one in the refresh token did NOT match";
public static AccessToken.CertConf bindTokenWithClientCertificate(HttpRequest request, KeycloakSession session) {
public static AccessToken.Confirmation bindTokenWithClientCertificate(HttpRequest request, KeycloakSession session) {
X509Certificate[] certs = getCertificateChain(request, session);
if (certs == null || certs.length < 1) {
@ -43,9 +43,9 @@ public class MtlsHoKTokenUtil {
return null;
}
AccessToken.CertConf certConf = new AccessToken.CertConf();
certConf.setCertThumbprint(DERX509Base64UrlEncoded);
return certConf;
AccessToken.Confirmation confirmation = new AccessToken.Confirmation();
confirmation.setCertThumbprint(DERX509Base64UrlEncoded);
return confirmation;
}
public static boolean verifyTokenBindingWithClientCertificate(AccessToken token, HttpRequest request, KeycloakSession session) {
@ -55,7 +55,7 @@ public class MtlsHoKTokenUtil {
}
// Bearer Token, not MTLS HoK Token
if (token.getCertConf() == null) {
if (token.getConfirmation() == null) {
logger.warnf("bearer token received instead of hok token.");
return false;
}
@ -69,7 +69,7 @@ public class MtlsHoKTokenUtil {
}
String DERX509Base64UrlEncoded = null;
String x5ts256 = token.getCertConf().getCertThumbprint();
String x5ts256 = token.getConfirmation().getCertThumbprint();
logger.tracef("hok token cnf-x5t#s256 = %s", x5ts256);
try {

View file

@ -194,6 +194,7 @@ public class OAuthClient {
private String codeChallenge;
private String codeChallengeMethod;
private String origin;
private String dpopProof;
private Map<String, String> customParameters;
@ -297,6 +298,7 @@ public class OAuthClient {
codeChallenge = null;
codeChallengeMethod = null;
origin = null;
dpopProof = null;
customParameters = null;
openid = true;
}
@ -514,6 +516,10 @@ public class OAuthClient {
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE_VERIFIER, codeVerifier));
}
if (dpopProof != null) {
post.addHeader("DPoP", dpopProof);
}
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, Charsets.UTF_8);
post.setEntity(formEntity);
@ -1011,6 +1017,10 @@ public class OAuthClient {
parameters.add(new BasicNameValuePair(AdapterConstants.CLIENT_SESSION_HOST, clientSessionHost));
}
if (dpopProof != null) {
post.addHeader("DPoP", dpopProof);
}
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
@ -1759,6 +1769,11 @@ public class OAuthClient {
return this;
}
public OAuthClient dpopProof(String dpopProof) {
this.dpopProof = dpopProof;
return this;
}
public OAuthClient addCustomParameter(String key, String value) {
if (customParameters == null) {
customParameters = new HashMap<>();

View file

@ -2198,7 +2198,7 @@ public class CIBATest extends AbstractClientPoliciesTest {
accessTokenResponse = doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, response.getAuthReqId(), client);
AccessToken accessToken = oauth.verifyToken(accessTokenResponse.getAccessToken(), AccessToken.class);
assertThat(accessTokenResponse.getStatusCode(), is(equalTo(200)));
assertThat(accessToken.getCertConf().getCertThumbprint(), notNullValue());
assertThat(accessToken.getConfirmation().getCertThumbprint(), notNullValue());
}
// Check logout.

View file

@ -599,7 +599,7 @@ public class FAPI1Test extends AbstractClientPoliciesTest {
assertSuccessfulTokenResponse(tokenResponse);
AccessToken accessToken = oauth.verifyToken(tokenResponse.getAccessToken());
Assert.assertNotNull(accessToken.getCertConf().getCertThumbprint());
Assert.assertNotNull(accessToken.getConfirmation().getCertThumbprint());
// Logout and remove consent of the user for next logins
logoutUserAndRevokeConsent("foo");
@ -653,7 +653,7 @@ public class FAPI1Test extends AbstractClientPoliciesTest {
assertSuccessfulTokenResponse(tokenResponse);
AccessToken accessToken = oauth.verifyToken(tokenResponse.getAccessToken());
Assert.assertNotNull(accessToken.getCertConf().getCertThumbprint());
Assert.assertNotNull(accessToken.getConfirmation().getCertThumbprint());
// Logout and remove consent of the user for next logins
logoutUserAndRevokeConsent("foo");
@ -795,8 +795,8 @@ public class FAPI1Test extends AbstractClientPoliciesTest {
Assert.assertNull(idToken.getAccessTokenHash());
Assert.assertEquals(idToken.getNonce(), "123456");
String state = getParameterFromUrl(OAuth2Constants.STATE, true);
Assert.assertEquals(idToken.getStateHash(), HashUtils.oidcHash(Algorithm.PS256, state));
Assert.assertEquals(idToken.getCodeHash(), HashUtils.oidcHash(Algorithm.PS256, code));
Assert.assertEquals(idToken.getStateHash(), HashUtils.accessTokenHash(Algorithm.PS256, state));
Assert.assertEquals(idToken.getCodeHash(), HashUtils.accessTokenHash(Algorithm.PS256, code));
}

View file

@ -625,7 +625,7 @@ public class FAPICIBATest extends AbstractClientPoliciesTest {
AccessToken accessToken = oauth.verifyToken(tokenRes.getAccessToken());
assertThat(accessToken.getIssuedFor(), is(equalTo(clientId)));
Assert.assertNotNull(accessToken.getCertConf().getCertThumbprint());
Assert.assertNotNull(accessToken.getConfirmation().getCertThumbprint());
events.expectAuthReqIdToToken(null, null).clearDetails().user(accessToken.getSubject()).client(clientId).assertEvent();

View file

@ -0,0 +1,487 @@
/*
* Copyright 2023 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.testsuite.oauth;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.keycloak.jose.jwk.JWKUtil.toIntegerBytes;
import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.SignatureException;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.JavaAlgorithm;
import org.keycloak.crypto.KeyType;
import org.keycloak.jose.jwk.ECPublicJWK;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.RSAPublicJWK;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.dpop.DPoP;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
import org.keycloak.services.resources.Cors;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.ServerURLs;
import org.keycloak.util.JWKSUtils;
import org.keycloak.util.JsonSerialization;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.HttpMethod;
@EnableFeature(value = Profile.Feature.DPOP, skipRestart = true)
public class DPoPTest extends AbstractTestRealmKeycloakTest {
private static final String REALM_NAME = "test";
private static final String TEST_CONFIDENTIAL_CLIENT_ID = "test-app";
private static final String TEST_CONFIDENTIAL_CLIENT_SECRET = "password";
private static final String TEST_PUBLIC_CLIENT_ID = "test-public-client";
private static final String TEST_USER_NAME = "test-user@localhost";
private static final String TEST_USER_PASSWORD = "password";
private static final String DPOP_JWT_HEADER_TYPE = "dpop+jwt";
private KeyPair ecKeyPair;
private KeyPair rsaKeyPair;
private JWK jwkRsa;
private JWK jwkEc;
private JWSHeader jwsRsaHeader;
private JWSHeader jwsEcHeader;
@Rule
public AssertEvents events = new AssertEvents(this);
@Before
public void beforeDPoPTest() throws Exception {
ecKeyPair = generateEcdsaKey("secp256r1");
rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
jwkEc = createEcJwk(ecKeyPair.getPublic());
jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
jwsEcHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), jwkEc);
changeDPoPBound(TEST_CONFIDENTIAL_CLIENT_ID, true);
createClientByAdmin(TEST_PUBLIC_CLIENT_ID, (ClientRepresentation rep) -> {
rep.setPublicClient(Boolean.TRUE);
});
changeDPoPBound(TEST_PUBLIC_CLIENT_ID, true);
}
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Test
public void testDPoPByPublicClient() throws Exception {
oauth.clientId(TEST_PUBLIC_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
JWSHeader jwsEcHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), jwkEc);
String dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofEcEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv());
jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX());
jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY());
String jkt = JWKSUtils.computeThumbprint(jwkEc);
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
assertEquals(jkt, refreshToken.getConfirmation().getKeyThumbprint());
// token refresh
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
accessToken = oauth.verifyToken(response.getAccessToken());
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
assertEquals(jkt, refreshToken.getConfirmation().getKeyThumbprint());
oauth.idTokenHint(response.getIdToken()).openLogout();
}
@Test
public void testDPoPProofByConfidentialClient() throws Exception {
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofRsaEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_CONFIDENTIAL_CLIENT_SECRET);
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK)jwkRsa).getModulus());
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent());
String jkt = JWKSUtils.computeThumbprint(jwkRsa);
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
// For confidential client, DPoP is not bind to refresh token (See "section 5 DPoP Access Token Request" of DPoP specification)
assertNull(refreshToken.getConfirmation());
String tokenResponse = oauth.introspectTokenWithClientCredential(TEST_CONFIDENTIAL_CLIENT_ID, TEST_CONFIDENTIAL_CLIENT_SECRET, "access_token", response.getAccessToken());
Assert.assertNotNull(tokenResponse);
TokenMetadataRepresentation tokenMetadataRepresentation = JsonSerialization.readValue(tokenResponse, TokenMetadataRepresentation.class);
Assert.assertTrue(tokenMetadataRepresentation.isActive());
assertEquals(jkt, tokenMetadataRepresentation.getConfirmation().getKeyThumbprint());
CloseableHttpResponse closableHttpResponse = oauth.doTokenRevoke(response.getAccessToken(), "access_token", TEST_CONFIDENTIAL_CLIENT_SECRET);
tokenResponse = oauth.introspectTokenWithClientCredential(TEST_CONFIDENTIAL_CLIENT_ID, TEST_CONFIDENTIAL_CLIENT_SECRET, "access_token", response.getAccessToken());
Assert.assertNotNull(tokenResponse);
tokenMetadataRepresentation = JsonSerialization.readValue(tokenResponse, TokenMetadataRepresentation.class);
Assert.assertFalse(tokenMetadataRepresentation.isActive());
closableHttpResponse.close();
// token refresh
rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
oauth.dpopProof(dpopProofRsaEncoded);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK)jwkRsa).getModulus());
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent());
jkt = JWKSUtils.computeThumbprint(jwkRsa);
accessToken = oauth.verifyToken(response.getAccessToken());
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
assertEquals(null, refreshToken.getConfirmation());
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
}
@Test
public void testDPoPDisabledByPublicClient() throws Exception {
changeDPoPBound(TEST_PUBLIC_CLIENT_ID, false);
oauth.clientId(TEST_PUBLIC_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
String dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofEcEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
assertEquals(null, accessToken.getConfirmation());
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
assertEquals(null, refreshToken.getConfirmation());
// token refresh
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
accessToken = oauth.verifyToken(response.getAccessToken());
assertEquals(null, accessToken.getConfirmation());
refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
assertEquals(null, refreshToken.getConfirmation());
oauth.idTokenHint(response.getIdToken()).openLogout();
changeDPoPBound(TEST_PUBLIC_CLIENT_ID, true);
}
@Test
public void testTokenRefreshWithReplayedDPoPProofByPublicClient() throws Exception {
oauth.clientId(TEST_PUBLIC_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
String dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofEcEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
// token refresh
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError());
assertEquals("DPoP proof has already been used", response.getErrorDescription());
oauth.idTokenHint(response.getIdToken()).openLogout();
}
@Test
public void testTokenRefreshWithoutDPoPProofByConfidentialClient() throws Exception {
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofRsaEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_USER_PASSWORD);
// token refresh
oauth.dpopProof(null);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError());
assertEquals("DPoP proof is missing", response.getErrorDescription());
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
}
@Test
public void testDPoPProofCorsPreflight() throws Exception {
CloseableHttpResponse response = oauth.doPreflightRequest();
String[] headers = response.getHeaders(Cors.ACCESS_CONTROL_ALLOW_HEADERS)[0].getValue().split(", ");
Set<String> allowedHeaders = new HashSet<String>(Arrays.asList(headers));
assertTrue(allowedHeaders.contains("DPoP"));
}
@Test
public void testDPoPProofWithoutJwk() throws Exception {
JWSHeader jwsHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), (JWK)null);
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsHeader, ecKeyPair.getPrivate()), "No JWK in DPoP header");
}
@Test
public void testDPoPProofInvalidAlgorithm() throws Exception {
JWSHeader jwsHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.none, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), jwkEc);
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsHeader, ecKeyPair.getPrivate()), "Unsupported DPoP algorithm: none");
}
@Test
public void testDPoPProofInvalidType() throws Exception {
JWSHeader jwsEcHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, "jwt+dpop", jwkEc.getKeyId(), jwkEc);
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()), "Invalid or missing type in DPoP header: jwt+dpop");
}
@Test
public void testDPoPProofInvalidSignature() throws Exception {
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsEcHeader, rsaKeyPair.getPrivate()), "DPoP verification failure");
}
@Test
public void testDPoPProofMandatoryClaimMissing() throws Exception {
testDPoPProofFailure(generateSignedDPoPProof(null, HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()), "DPoP mandatory claims are missing");
}
@Test
public void testDPoPProofReplayed() throws Exception {
String dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_USER_PASSWORD);
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
testDPoPProofFailure(dpopProofEcEncoded, "DPoP proof has already been used");
}
@Test
public void testDPoPProofExpired() throws Exception {
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime() - 100000), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()), "DPoP proof is not active");
}
@Test
public void testDPoPProofHttpMethodMismatch() throws Exception {
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()), "DPoP HTTP method mismatch");
}
@Test
public void testDPoPProofHttpUrlMalformed() throws Exception {
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), ":::*;", Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()), "Malformed HTTP URL in DPoP proof");
}
@Test
public void testDPoPProofHttpUrlMismatch() throws Exception {
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), "https://server.example.com/token", Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate()), "DPoP HTTP URL mismatch");
}
@Test
public void testWithoutDPoPProof() throws Exception {
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_USER_PASSWORD);
assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError());
assertEquals("DPoP proof is missing", response.getErrorDescription());
}
private void testDPoPProofFailure(String dpopProofEncoded, String errorDescription) throws Exception {
oauth.dpopProof(dpopProofEncoded);
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_CONFIDENTIAL_CLIENT_SECRET);
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError());
assertEquals(errorDescription, response.getErrorDescription());
}
private JWK createRsaJwk(Key publicKey) {
RSAPublicKey rsaKey = (RSAPublicKey) publicKey;
RSAPublicJWK k = new RSAPublicJWK();
k.setKeyType(KeyType.RSA);
k.setModulus(Base64Url.encode(toIntegerBytes(rsaKey.getModulus())));
k.setPublicExponent(Base64Url.encode(toIntegerBytes(rsaKey.getPublicExponent())));
return k;
}
private JWK createEcJwk(Key publicKey) {
ECPublicKey ecKey = (ECPublicKey) publicKey;
int fieldSize = ecKey.getParams().getCurve().getField().getFieldSize();
ECPublicJWK k = new ECPublicJWK();
k.setKeyType(KeyType.EC);
k.setCrv("P-" + fieldSize);
k.setX(Base64Url.encode(toIntegerBytes(ecKey.getW().getAffineX(), fieldSize)));
k.setY(Base64Url.encode(toIntegerBytes(ecKey.getW().getAffineY(), fieldSize)));
return k;
}
private static KeyPair generateEcdsaKey(String ecDomainParamName) throws NoSuchAlgorithmException, InvalidAlgorithmParameterException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
SecureRandom randomGen = SecureRandom.getInstance("SHA1PRNG");
ECGenParameterSpec ecSpec = new ECGenParameterSpec(ecDomainParamName);
keyGen.initialize(ecSpec, randomGen);
KeyPair keyPair = keyGen.generateKeyPair();
return keyPair;
}
private static String generateSignedDPoPProof(String jti, String htm, String htu, Long iat, String algorithm, JWSHeader jwsHeader, PrivateKey privateKey) throws IOException {
String dpopProofHeaderEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(jwsHeader));
DPoP dpop = new DPoP();
dpop.id(jti);
dpop.setHttpMethod(htm);
dpop.setHttpUri(htu);
dpop.iat(iat);
String dpopProofPayloadEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(dpop));
try {
Signature signature = Signature.getInstance(JavaAlgorithm.getJavaAlgorithm(algorithm));
signature.initSign(privateKey);
String data = dpopProofHeaderEncoded + "." + dpopProofPayloadEncoded;
byte[] dataByteArray = data.getBytes();
signature.update(dataByteArray);
byte[] signatureByteArray = signature.sign();
return data + "." + Base64Url.encode(signatureByteArray);
} catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
throw new RuntimeException(e);
}
}
private void changeDPoPBound(String clientId, boolean isEnabled) {
ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), clientId);
ClientRepresentation clientRep = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setUseDPoP(isEnabled);
clientResource.update(clientRep);
}
private String createClientByAdmin(String clientName, Consumer<ClientRepresentation> op) {
ClientRepresentation clientRep = new ClientRepresentation();
clientRep.setClientId(clientName);
clientRep.setName(clientName);
clientRep.setProtocol("openid-connect");
clientRep.setBearerOnly(Boolean.FALSE);
clientRep.setPublicClient(Boolean.FALSE);
clientRep.setServiceAccountsEnabled(Boolean.TRUE);
clientRep.setRedirectUris(Collections.singletonList(ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth"));
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setPostLogoutRedirectUris(Collections.singletonList("+"));
op.accept(clientRep);
Response resp = adminClient.realm(REALM_NAME).clients().create(clientRep);
resp.close();
assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus());
// registered components will be removed automatically when a test method finishes regardless of its success or failure.
String cId = ApiUtil.getCreatedId(resp);
testContext.getOrCreateCleanup(REALM_NAME).addClientUuid(cId);
return cId;
}
}

View file

@ -399,9 +399,9 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
private void expectSuccessfulResponseFromTokenEndpoint(OAuthClient oauth, String username, AccessTokenResponse response, String sessionId, AccessToken token, RefreshToken refreshToken, EventRepresentation tokenEvent) {
AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken());
RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(response.getRefreshToken());
if (refreshedToken.getCertConf() != null) {
log.warnf("refreshed access token's cnf-x5t#256 = %s", refreshedToken.getCertConf().getCertThumbprint());
log.warnf("refreshed refresh token's cnf-x5t#256 = %s", refreshedRefreshToken.getCertConf().getCertThumbprint());
if (refreshedToken.getConfirmation() != null) {
log.warnf("refreshed access token's cnf-x5t#256 = %s", refreshedToken.getConfirmation().getCertThumbprint());
log.warnf("refreshed refresh token's cnf-x5t#256 = %s", refreshedRefreshToken.getConfirmation().getCertThumbprint());
}
assertEquals(200, response.getStatusCode());
@ -599,7 +599,7 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
// Validate "c_hash"
Assert.assertNull(idToken.getAccessTokenHash());
Assert.assertNotNull(idToken.getCodeHash());
Assert.assertEquals(idToken.getCodeHash(), HashUtils.oidcHash(Algorithm.RS256, authzResponse.getCode()));
Assert.assertEquals(idToken.getCodeHash(), HashUtils.accessTokenHash(Algorithm.RS256, authzResponse.getCode()));
// IDToken exchanged for the code
IDToken idToken2 = sendTokenRequestAndGetIDToken(loginEvent);
@ -634,9 +634,9 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
AccessToken at = jws.readJsonContent(AccessToken.class);
jws = new JWSInput(accessTokenResponse.getRefreshToken());
RefreshToken rt = jws.readJsonContent(RefreshToken.class);
String certThumprintFromAccessToken = at.getCertConf().getCertThumbprint();
String certThumprintFromRefreshToken = rt.getCertConf().getCertThumbprint();
String certThumprintFromTokenIntrospection = rep.getCertConf().getCertThumbprint();
String certThumprintFromAccessToken = at.getConfirmation().getCertThumbprint();
String certThumprintFromRefreshToken = rt.getConfirmation().getCertThumbprint();
String certThumprintFromTokenIntrospection = rep.getConfirmation().getCertThumbprint();
String certThumprintFromBoundClientCertificate = MutualTLSUtils.getThumbprintFromDefaultClientCert();
assertTrue(rep.isActive());
@ -727,7 +727,7 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
} catch (JWSInputException e) {
Assert.fail(e.toString());
}
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), at.getCertConf().getCertThumbprint().getBytes()));
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), at.getConfirmation().getCertThumbprint().getBytes()));
if (checkRefreshToken) {
RefreshToken rt = null;
@ -737,7 +737,7 @@ public class HoKTest extends AbstractTestRealmKeycloakTest {
} catch (JWSInputException e) {
Assert.fail(e.toString());
}
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), rt.getCertConf().getCertThumbprint().getBytes()));
assertTrue(MessageDigest.isEqual(certThumbPrint.getBytes(), rt.getConfirmation().getCertThumbprint().getBytes()));
}
}
}

View file

@ -218,6 +218,9 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
assertTrue(oidcConfig.getFrontChannelLogoutSessionSupported());
assertTrue(oidcConfig.getFrontChannelLogoutSupported());
// DPoP
Assert.assertNames(oidcConfig.getDpopSigningAlgValuesSupported(), Algorithm.PS256, Algorithm.PS384, Algorithm.PS512,
Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256, Algorithm.ES384, Algorithm.ES512);
} finally {
client.close();
}

View file

@ -304,7 +304,7 @@ public abstract class AbstractOIDCResponseTypeTest extends AbstractTestRealmKeyc
Assert.assertNotNull(accessTokenHash);
Assert.assertNotNull(accessToken);
assertEquals(accessTokenHash, HashUtils.oidcHash(getIdTokenSignatureAlgorithm(), accessToken));
assertEquals(accessTokenHash, HashUtils.accessTokenHash(getIdTokenSignatureAlgorithm(), accessToken));
}
/**
@ -316,6 +316,6 @@ public abstract class AbstractOIDCResponseTypeTest extends AbstractTestRealmKeyc
Assert.assertNotNull(codeHash);
Assert.assertNotNull(code);
Assert.assertEquals(codeHash, HashUtils.oidcHash(getIdTokenSignatureAlgorithm(), code));
Assert.assertEquals(codeHash, HashUtils.accessTokenHash(getIdTokenSignatureAlgorithm(), code));
}
}

View file

@ -79,7 +79,7 @@ public class OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTest extends Abstract
// Validate "s_hash"
Assert.assertNotNull(idToken.getStateHash());
Assert.assertEquals(idToken.getStateHash(), HashUtils.oidcHash(getIdTokenSignatureAlgorithm(), authzResponse.getState()));
Assert.assertEquals(idToken.getStateHash(), HashUtils.accessTokenHash(getIdTokenSignatureAlgorithm(), authzResponse.getState()));
// Validate if token_type is null
Assert.assertNull(authzResponse.getTokenType());

View file

@ -79,7 +79,7 @@ public class OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTokenTest extends Abs
// Validate "s_hash"
Assert.assertNotNull(idToken.getStateHash());
Assert.assertEquals(idToken.getStateHash(), HashUtils.oidcHash(getIdTokenSignatureAlgorithm(), authzResponse.getState()));
Assert.assertEquals(idToken.getStateHash(), HashUtils.accessTokenHash(getIdTokenSignatureAlgorithm(), authzResponse.getState()));
// Validate if token_type is present
Assert.assertNotNull(authzResponse.getTokenType());

View file

@ -71,7 +71,7 @@ public class OIDCHybridResponseTypeCodeIDTokenTest extends AbstractOIDCResponseT
// Validate "s_hash"
Assert.assertNotNull(idToken.getStateHash());
Assert.assertEquals(idToken.getStateHash(), HashUtils.oidcHash(getIdTokenSignatureAlgorithm(), authzResponse.getState()));
Assert.assertEquals(idToken.getStateHash(), HashUtils.accessTokenHash(getIdTokenSignatureAlgorithm(), authzResponse.getState()));
// Validate if token_type is null
Assert.assertNull(authzResponse.getTokenType());

View file

@ -71,7 +71,7 @@ public class OIDCHybridResponseTypeCodeIDTokenTokenTest extends AbstractOIDCResp
// Validate "s_hash"
Assert.assertNotNull(idToken.getStateHash());
Assert.assertEquals(idToken.getStateHash(), HashUtils.oidcHash(getIdTokenSignatureAlgorithm(), authzResponse.getState()));
Assert.assertEquals(idToken.getStateHash(), HashUtils.accessTokenHash(getIdTokenSignatureAlgorithm(), authzResponse.getState()));
// Validate if token_type is present
Assert.assertNotNull(authzResponse.getTokenType());

View file

@ -1,6 +1,5 @@
package org.keycloak.testsuite.util;
import org.apache.http.entity.ContentType;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.Base64Url;
import org.keycloak.crypto.JavaAlgorithm;
@ -25,7 +24,7 @@ public class LogoutTokenUtil {
String issuer, String clientId, String userId, String sessionId, boolean revokeOfflineSessions)
throws IOException {
JWSHeader jwsHeader =
new JWSHeader(Algorithm.RS256, OAuth2Constants.JWT, ContentType.APPLICATION_JSON.toString(), keyId);
new JWSHeader(Algorithm.RS256, OAuth2Constants.JWT, keyId, null);
String logoutTokenHeaderEncoded = Base64Url.encode(JsonSerialization.writeValueAsBytes(jwsHeader));
LogoutToken logoutToken = new LogoutToken();