Skip to content

Commit

Permalink
DPoP support 1st phase (#21202)
Browse files Browse the repository at this point in the history
closes #21200


Co-authored-by: Dmitry Telegin <dmitryt@backbase.com>
Co-authored-by: mposolda <mposolda@gmail.com>
  • Loading branch information
3 people committed Jul 24, 2023
1 parent e1d1678 commit 0ddef5d
Show file tree
Hide file tree
Showing 52 changed files with 1,293 additions and 117 deletions.
4 changes: 3 additions & 1 deletion common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ public enum Feature {

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;
Expand Down
3 changes: 2 additions & 1 deletion common/src/test/java/org/keycloak/common/ProfileTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public void checkDefaults() {

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,
Expand All @@ -90,7 +91,7 @@ public void checkDefaults() {
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
Expand Down
3 changes: 3 additions & 0 deletions core/src/main/java/org/keycloak/OAuthErrorException.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 10 additions & 1 deletion core/src/main/java/org/keycloak/jose/jws/JWSHeader.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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;

Expand All @@ -44,6 +45,9 @@ public class JWSHeader implements JOSEHeader {
@JsonProperty("kid")
private String keyId;

@JsonProperty("jwk")
private JWK key;

public JWSHeader() {
}

Expand All @@ -53,10 +57,11 @@ public JWSHeader(Algorithm algorithm, String type, String contentType) {
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() {
Expand All @@ -81,6 +86,10 @@ public String getKeyId() {
return keyId;
}

public JWK getKey() {
return key;
}

private static final ObjectMapper mapper = new ObjectMapper();

static {
Expand Down
18 changes: 13 additions & 5 deletions core/src/main/java/org/keycloak/jose/jws/crypto/HashUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,20 @@
*/
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 {
Expand All @@ -50,9 +55,12 @@ public static byte[] hash(String javaAlgorithmName, byte[] inputBytes) {
}
}


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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -490,6 +493,14 @@ public void setTlsClientCertificateBoundAccessTokens(Boolean tlsClientCertificat
this.tlsClientCertificateBoundAccessTokens = tlsClientCertificateBoundAccessTokens;
}

public List<String> getDpopSigningAlgValuesSupported() {
return dpopSigningAlgValuesSupported;
}

public void setDpopSigningAlgValuesSupported(List<String> dpopSigningAlgValuesSupported) {
this.dpopSigningAlgValuesSupported = dpopSigningAlgValuesSupported;
}

public String getRevocationEndpoint() {
return revocationEndpoint;
}
Expand Down
25 changes: 18 additions & 7 deletions core/src/main/java/org/keycloak/representations/AccessToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,28 @@ public void setPermissions(Collection<Permission> permissions) {

// 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;
}

public void setCertThumbprint(String certThumbprint) {
this.certThumbprint = certThumbprint;
}

public String getKeyThumbprint() {
return keyThumbprint;
}

public void setKeyThumbprint(String keyThumbprint) {
this.keyThumbprint = keyThumbprint;
}
}

@JsonProperty("trusted-certs")
Expand All @@ -130,7 +141,7 @@ public void setCertThumbprint(String certThumbprint) {
protected Authorization authorization;

@JsonProperty("cnf")
protected CertConf certConf;
protected Confirmation confirmation;

@JsonProperty("scope")
protected String scope;
Expand Down Expand Up @@ -261,13 +272,13 @@ public Authorization getAuthorization() {
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() {
Expand Down
74 changes: 74 additions & 0 deletions core/src/main/java/org/keycloak/representations/dpop/DPoP.java
Original file line number Diff line number Diff line change
@@ -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;
}

}
78 changes: 67 additions & 11 deletions core/src/main/java/org/keycloak/util/JWKSUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,22 @@
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;

/**
Expand All @@ -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)}
**/
Expand All @@ -55,25 +71,20 @@ public static PublicKeysWrapper getKeyWrappersForUse(JSONWebKeySet keySet, JWK.U
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);
}
}
return new PublicKeysWrapper(result);
}

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;
Expand All @@ -92,4 +103,49 @@ public static JWK getKeyForUse(JSONWebKeySet keySet, JWK.Use requestedUse) {

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;
}
}

}
Loading

0 comments on commit 0ddef5d

Please sign in to comment.