8000 [OID4VCI-HAIP] Treat attestation-based clients as confidential by tdiesler · Pull Request #49155 · keycloak/keycloak · GitHub
[go: up one dir, main page]

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8000
21 changes: 13 additions & 8 deletions core/src/main/java/org/keycloak/util/DPoPGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,25 @@ public static String generateRsaSignedDPoPProof(KeyPair rsaKeyPair, String httpM
}


private static JWK createRsaJwk(Key publicKey) {
return JWKBuilder.create()
.rsa(publicKey, KeyUse.SIG);
public static JWK createRsaJwk(Key publicKey) {
return JWKBuilder.create().rsa(publicKey, KeyUse.SIG);
}

public static JWK createEcJwk(Key publicKey) {
return JWKBuilder.create().ec(publicKey);
}

public String generateSignedDPoPProof(String jti, String htm, String htu, Long iat, JWSHeader jwsHeader, PrivateKey privateKey, String accessToken) {
public static String generateSignedDPoPProof(String jti, String htm, String htu, Long iat, JWSHeader jwsHeader, KeyWrapper keyWrapper, String accessToken) {
DPoP dpop = generateDPoP(jti, htm, htu, iat, accessToken);
KeyWrapper keyWrapper = getKeyWrapper(jwsHeader, privateKey);
return sign(jwsHeader, dpop, keyWrapper);
}

private DPoP generateDPoP(String jti, String htm, String htu, Long iat, String accessToken) {
public String generateSignedDPoPProof(String jti, String htm, String htu, Long iat, JWSHeader jwsHeader, PrivateKey privateKey, String accessToken) {
KeyWrapper keyWrapper = getKeyWrapper(jwsHeader, privateKey);
return generateSignedDPoPProof(jti, htm, htu, iat, jwsHeader, keyWrapper, accessToken);
}

private static DPoP generateDPoP(String jti, String htm, String htu, Long iat, String accessToken) {
DPoP dpop = new DPoP();
dpop.id(jti);
dpop.setHttpMethod(htm);
Expand All @@ -89,9 +95,8 @@ protected KeyWrapper getKeyWrapper(JWSHeader jwsHeader, PrivateKey privateKey) {
return keyWrapper;
}

private String sign(JWSHeader jwsHeader, DPoP dpop, KeyWrapper keyWrapper) {
private static String sign(JWSHeader jwsHeader, DPoP dpop, KeyWrapper keyWrapper) {
SignatureSignerContext sigCtx = KeyWrapperUtil.createSignatureSignerContext(keyWrapper);

return new JWSBuilder()
.header(jwsHeader)
.jsonContent(dpop)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@

import jakarta.ws.rs.core.Response;

import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.models.AuthenticationExecutionModel;
Expand Down Expand Up @@ -89,14 +87,6 @@ public Response processFlow() {
expectedClientAuthType = KeycloakModelUtils.getDefaultClientAuthenticatorType();
}

// Use expectedClientAuthType=attestation-based for public client
// when AttestationBasedClientAuthenticator is processed
//
String abcaAuthType = AttestationBasedClientAuthenticator.PROVIDER_ID;
if (client.isPublicClient() && factory.getId().equals(abcaAuthType) && Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_ABCA)) {
expectedClientAuthType = abcaAuthType;
}

// Check if client authentication matches
if (factory.getId().equals(expectedClientAuthType)) {
Response response = processResult(context);
Expand Down
8000
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
Expand Down Expand Up @@ -50,14 +51,18 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.saml.RandomSecret;
import org.keycloak.services.ServicesLogger;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.Strings;
import org.keycloak.wellknown.WellKnownProvider;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
Expand Down Expand Up @@ -110,6 +115,7 @@ public String getId() {
@Override
public void authenticateClient(ClientAuthenticationFlowContext context) {

KeycloakSession session = context.getSession();
HttpHeaders headers = context.getHttpRequest().getHttpHeaders();
String attestationValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER);
String attestationPoPValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER);
Expand All @@ -126,16 +132,27 @@ public void authenticateClient(ClientAuthenticationFlowContext context) {
context.attempted();

try {
ClientAttestationJwt attesterJwt = validateClientAttestationJwt(context);
validateClientAttestationPoPJwt(context, attesterJwt);
ABCAResult abcaResult = new ABCAResult();
session.setAttribute(ABCAResult.ABCA_RESULT, abcaResult);

validateClientAttestationJwt(context, abcaResult);
validateClientAttestationPoPJwt(context, abcaResult);

context.success();

ClientModel clientModel = context.getClient();
abcaResult.setAttestedClient(clientModel);

} catch (Exception ex) {
ServicesLogger.LOGGER.errorValidatingAssertion(ex);
Response response = ClientAuthUtil.errorResponse(BAD_REQUEST.getStatusCode(), INVALID_CLIENT_ATTESTATION, ex.getMessage());
context.failure(AuthenticationFlowError.INVALID_CLIENT_ATTESTATION, response);
}

// Error Message specifically related to the use of client attestations
// [TODO] use_attestation_challenge MUST be used when the Client Attestation PoP JWT is not using an expected server-provided challenge.
// [TODO] use_fresh_attestation MUST be used when the Client Attestation JWT is deemed to be not fresh enough to be acceptable by the server.
// [TODO] invalid_client_attestation MAY be used in addition to the more general invalid_client error code as defined in [RFC6749] if the attestation or its proof of possession could not be successfully verified
}

@Override
Expand Down Expand Up @@ -199,6 +216,7 @@ public static class ClientAttestationJwt extends JsonWebToken {
@JsonProperty("cnf")
private Confirmation cnf;

@JsonIgnore
public Confirmation getConfirmation() {
return cnf;
}
Expand Down Expand Up @@ -331,7 +349,7 @@ private KeyWrapper toPublicKeyWrapper(JWK jwk) {

// Validate the Client Attestation JWT
// https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.1
private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception {
private void validateClientAttestationJwt(ClientAuthenticationFlowContext context, ABCAResult abcaResult) throws Exception {

KeycloakSession session = context.getSession();
RealmModel realmModel = session.getContext().getRealm();
Expand All @@ -341,17 +359,20 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl
.orElseThrow(() -> new IllegalArgumentException("Required header " + OAUTH_CLIENT_ATTESTATION_HEADER + " is missing"));

MultivaluedMap<String, String> formParams = context.getHttpRequest().getDecodedFormParameters();
String clientIdParam = formParams.getFirst(CLIENT_ID);

JWSInput jws = new JWSInput(headerValue);
String jwsType = jws.getHeader().getType();
if (!OAUTH_CLIENT_ATTESTATION_JWT_TYPE.equals(jwsType))
throw new IllegalArgumentException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwsType);

// Get the client model from the JWT subject
ClientAttestationJwt attestationJwt = jws.readJsonContent(ClientAttestationJwt.class);
ClientModel clientModel = Optional.ofNullable(attestationJwt.getSubject())
String clientId = Optional.ofNullable(clientIdParam).orElse(attestationJwt.getSubject());

// Get the client model from the given client_id
ClientModel clientModel = Optional.ofNullable(clientId)
.map(realmModel::getClientByClientId)
.orElseThrow(() -> new TokenVerificationException(attestationJwt, "The sub (subject) claim MUST identify a known client_id"));
.orElseThrow(() -> new TokenVerificationException(attestationJwt, "No such client: " + clientId));

// Set the target client in the context before we attempt signature verification
context.setClient(clientModel);
Expand All @@ -360,7 +381,6 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl
//

TokenVerifier.Predicate<JsonWebToken> subCheck = (t) -> {
String clientIdParam = formParams.getFirst(CLIENT_ID);
if (clientIdParam != null && !clientIdParam.equals(t.getSubject()))
throw new TokenVerificationException(t, "The sub claim (subject) MUST match the client_id parameter");
return true;
Expand Down Expand Up @@ -401,17 +421,18 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl
throw new TokenSignatureInvalidException(attestationJwt, "Invalid token signature");
}

abcaResult.setAttestationJwt(attestationJwt);

// [TODO] The alg JOSE Header Parameter for both JWTs indicates a registered asymmetric digital signature algorithm
// [TODO] The key contained in the cnf claim of the Client Attestation JWT is not a private key

return attestationJwt;
}

// Validate the Client Attestation PoP JWT
// https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.2
private ClientAttestationPoPJwt validateClientAttestationPoPJwt(ClientAuthenticationFlowContext context, ClientAttestationJwt attesterJwt) throws Exception {
private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext context, ABCAResult abcaResult) throws Exception {

KeycloakSession session = context.getSession();
ClientAttestationJwt attestationJwt = abcaResult.getA 10000 ttestationJwt();

HttpHeaders headers = context.getHttpRequest().getHttpHeaders();
String headerValue = Optional.ofNullable(headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER))
Expand Down Expand Up @@ -440,20 +461,23 @@ private ClientAttestationPoPJwt validateClientAttestationPoPJwt(ClientAuthentica
};

TokenVerifier.Predicate<JsonWebToken> issCheck = (t) -> {
if (Strings.isEmpty(t.getIssuer()) || !t.getIssuer().equals(attesterJwt.getSubject()))
if (Strings.isEmpty(t.getIssuer()) || !t.getIssuer().equals(attestationJwt.getSubject()))
throw new TokenVerificationException(t, "The value of the iss (issuer) claim, representing the client_id MUST match the value of the sub (subject) claim in the Client Attestation");
return true;
};

TokenVerifier.Predicate<JsonWebToken> audCheck = (t) -> {
if (t.getAudience() == null || t.getAudience().length == 0)
WellKnownProvider oidcProvider = session.getProvider(WellKnownProvider.class, OIDCWellKnownProviderFactory.PROVIDER_ID);
OIDCConfigurationRepresentation oidcConfig = (OIDCConfigurationRepresentation) oidcProvider.getConfig();
List<String> audiences = Optional.ofNullable(t.getAudience()).map(Arrays::asList).orElse(List.of());
if (!audiences.contains(oidcConfig.getIssuer()))
throw new TokenVerificationException(t, "The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience.");
return true;
};

// The public key used to verify the ClientAttestationPoP JWT MUST be the key located in the "cnf" claim of the corresponding ClientAttestation JWT
//
JWK jwk = attesterJwt.getConfirmation().getJwk();
JWK jwk = attestationJwt.getConfirmation().getJwk();
KeyWrapper clientKey = toPublicKeyWrapper(jwk);

// Client Attestation PoP JWT verification without signature check
Expand All @@ -474,21 +498,15 @@ private ClientAttestationPoPJwt validateClientAttestationPoPJwt(ClientAuthentica
throw new TokenSignatureInvalidException(attestationPoPJwt, "Invalid token signature");
}

// [TODO] The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience
abcaResult.setAttestationPoPJwt(attestationPoPJwt);

// [TODO] The authorization server can utilize the jti value for replay attack detection
// [TODO] The authorization server may reject JWTs with an "iat" claim value that is unreasonably far in the past

// [TODO] If the server provided a challenge value to the client, the challenge claim is present in the Client Attestation PoP JWT and matches the server-provided challenge value.
// [TODO] Additional checks to guarantee replay protection for the Client Attestation PoP JWT might need to be applied

return attestationPoPJwt;
}

// Error Message specifically related to the use of client attestations
// [TODO] use_attestation_challenge MUST be used when the Client Attestation PoP JWT is not using an expected server-provided challenge.
// [TODO] use_fresh_attestation MUST be used when the Client Attestation JWT is deemed to be not fresh enough to be acceptable by the server.
// [TODO] invalid_client_attestation MAY be used in addition to the more general invalid_client error code as defined in [RFC6749] if the attestation or its proof of possession could not be successfully verified

/**
* The AttestationBasedClientAuthenticator config
*/
Expand All @@ -506,4 +524,44 @@ public ABCAConfig setKeys(List<JWK> keys) {
return this;
}
}

public static class ABCAResult {

/**
* A key to a KeycloakSession attribute that contains the ABCA validation result
*/
public static final String ABCA_RESULT = "ABCAResult";

private ClientAttestationJwt attestationJwt;
private ClientAttestationPoPJwt attestationPoPJwt;
private ClientModel attestedClient;

public ClientAttestationJwt getAttestationJwt() {
return attestationJwt;
}

public void setAttestationJwt(ClientAttestationJwt attestationJwt) {
this.attestationJwt = attestationJwt;
}

public ClientAttestationPoPJwt getAttestationPoPJwt() {
return attestationPoPJwt;
}

public void setAttestationPoPJwt(ClientAttestationPoPJwt attestationPoPJwt) {
this.attestationPoPJwt = attestationPoPJwt;
}

public ClientModel getAttestedClient() {
return attestedClient;
}

public void setAttestedClient(ClientModel client) {
this.attestedClient = client;
}

public boolean isVerified() { 5DFB
return attestedClient != null;
}
}
}
Loading
Loading
0