Skip to content
Draft
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
1 change: 1 addition & 0 deletions core/src/main/java/org/keycloak/OAuthErrorException.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public class OAuthErrorException extends Exception {
// Others
public static final String INVALID_CLIENT = "invalid_client";
public static final String INVALID_CLIENT_ATTESTATION = "invalid_client_attestation";
public static final String USE_ATTESTATION_CHALLENGE = "use_attestation_challenge";
public static final String INVALID_GRANT = "invalid_grant";
public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type";
public static final String UNSUPPORTED_TOKEN_TYPE = "unsupported_token_type";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("client_attestation_pop_signing_alg_values_supported")
private List<String> clientAttestationPopSigningAlgValuesSupported;

@JsonProperty("challenge_endpoint")
private String challengeEndpoint;

@JsonProperty("introspection_endpoint_auth_methods_supported")
private List<String> introspectionEndpointAuthMethodsSupported;

Expand Down Expand Up @@ -432,6 +435,14 @@ public void setClientAttestationPopSigningAlgValuesSupported(List<String> client
this.clientAttestationPopSigningAlgValuesSupported = clientAttestationPopSigningAlgValuesSupported;
}

public String getChallengeEndpoint() {
return challengeEndpoint;
}

public void setChallengeEndpoint(String challengeEndpoint) {
this.challengeEndpoint = challengeEndpoint;
}

public List<String> getIntrospectionEndpointAuthMethodsSupported() {
return introspectionEndpointAuthMethodsSupported;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.security.PublicKey;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
Expand All @@ -38,6 +39,7 @@
import org.keycloak.broker.provider.TrustMaterialResolver;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.SignatureProvider;
Expand All @@ -47,12 +49,17 @@
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler;
import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory;
import org.keycloak.protocol.oidc.endpoints.ClientAttestationChallengeEndpoint;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
Expand All @@ -71,6 +78,7 @@

import static org.keycloak.OAuth2Constants.CLIENT_ID;
import static org.keycloak.OAuthErrorException.INVALID_CLIENT_ATTESTATION;
import static org.keycloak.OAuthErrorException.USE_ATTESTATION_CHALLENGE;


/**
Expand All @@ -87,9 +95,12 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic
public static final String PROVIDER_ID = "attestation-based";
public static final String OAUTH_CLIENT_ATTESTATION_HEADER = "OAuth-Client-Attestation";
public static final String OAUTH_CLIENT_ATTESTATION_POP_HEADER = "OAuth-Client-Attestation-PoP";
public static final String OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER = "OAuth-Client-Attestation-Challenge";

public static final String OAUTH_CLIENT_ATTESTATION_JWT_TYPE = "oauth-client-attestation+jwt";
public static final String OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE = "oauth-client-attestation-pop+jwt";
private static final int CLIENT_ATTESTATION_POP_REPLAY_WINDOW_SECONDS = 300;
private static final int CLIENT_ATTESTATION_POP_ALLOWED_CLOCK_SKEW_SECONDS = 15;

/**
* Comma-separated aliases of trust-material identity providers that expose the trusted attester keys.
Expand Down Expand Up @@ -135,14 +146,19 @@ public void authenticateClient(ClientAuthenticationFlowContext context) {
ClientModel clientModel = context.getClient();
abcaResult.setAttestedClient(clientModel);

} catch (ClientAttestationChallengeException ex) {
ServicesLogger.LOGGER.errorValidatingAssertion(ex);
Response response = Response.fromResponse(ClientAuthUtil.errorResponse(BAD_REQUEST.getStatusCode(), USE_ATTESTATION_CHALLENGE, ex.getMessage()))
.header(OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER, ex.getChallenge())
.build();
context.failure(AuthenticationFlowError.INVALID_CLIENT_ATTESTATION, response);
} 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
}
Expand Down Expand Up @@ -447,7 +463,7 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con
};

TokenVerifier.Predicate<JsonWebToken> iatCheck = (t) -> {
if (t.getIat() == 0)
if (t.getIat() == null || t.getIat() == 0)
throw new TokenVerificationException(t, "The iat (issued at) claim MUST specify the time at which the Client Attestation PoP was issued.");
return true;
};
Expand Down Expand Up @@ -490,13 +506,115 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con
throw new TokenSignatureInvalidException(attestationPoPJwt, "Invalid token signature");
}

ensureClientAttestationPoPNotReplayed(session, attestationJwt, attestationPoPJwt, clientKey);
validateClientAttestationChallenge(session, attestationPoPJwt);
markClientAttestationPoPAsUsed(session, attestationJwt, attestationPoPJwt, clientKey);

abcaResult.setAttestationPoPJwt(attestationPoPJwt);
}

private void ensureClientAttestationPoPNotReplayed(KeycloakSession session, ClientAttestationJwt attestationJwt,
ClientAttestationPoPJwt attestationPoPJwt, KeyWrapper clientKey) throws TokenVerificationException {
getClientAttestationPoPReplayEntryLifespan(attestationPoPJwt);

String cacheKey = getClientAttestationPoPReplayCacheKey(attestationJwt, attestationPoPJwt, clientKey);
if (session.singleUseObjects().contains(cacheKey)) {
throw new TokenVerificationException(attestationPoPJwt, "Client Attestation PoP JWT has already been used");
}
}

private void markClientAttestationPoPAsUsed(KeycloakSession session, ClientAttestationJwt attestationJwt,
ClientAttestationPoPJwt attestationPoPJwt, KeyWrapper clientKey) throws TokenVerificationException {
long lifespan = getClientAttestationPoPReplayEntryLifespan(attestationPoPJwt);
String cacheKey = getClientAttestationPoPReplayCacheKey(attestationJwt, attestationPoPJwt, clientKey);

SingleUseObjectProvider singleUseStore = session.singleUseObjects();
if (!singleUseStore.putIfAbsent(cacheKey, lifespan)) {
throw new TokenVerificationException(attestationPoPJwt, "Client Attestation PoP JWT has already been used");
}
}

private long getClientAttestationPoPReplayEntryLifespan(ClientAttestationPoPJwt attestationPoPJwt)
throws TokenVerificationException {
long now = Time.currentTime();
Long issuedAt = attestationPoPJwt.getIat();
if (issuedAt == null || issuedAt == 0) {
throw new TokenVerificationException(attestationPoPJwt, "The iat (issued at) claim MUST specify the time at which the Client Attestation PoP was issued.");
}
if (issuedAt > now + CLIENT_ATTESTATION_POP_ALLOWED_CLOCK_SKEW_SECONDS) {
throw new TokenVerificationException(attestationPoPJwt, "Client Attestation PoP JWT was issued in the future");
}

// [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
long lifespan = issuedAt + CLIENT_ATTESTATION_POP_REPLAY_WINDOW_SECONDS
+ CLIENT_ATTESTATION_POP_ALLOWED_CLOCK_SKEW_SECONDS - now;
if (lifespan <= 0) {
throw new TokenVerificationException(attestationPoPJwt, "Client Attestation PoP JWT was issued too far in the past");
}
return lifespan;
}

private String getClientAttestationPoPReplayCacheKey(ClientAttestationJwt attestationJwt,
ClientAttestationPoPJwt attestationPoPJwt, KeyWrapper clientKey) {
String clientInstanceKeyHash = HashUtils.sha256UrlEncodedHash(
Base64Url.encode(clientKey.getPublicKey().getEncoded()), StandardCharsets.UTF_8);
String replayKeyMaterial = String.join("\n", attestationJwt.getSubject(), clientInstanceKeyHash,
attestationPoPJwt.getId());
String replayKeyHash = HashUtils.sha256UrlEncodedHash(replayKeyMaterial, StandardCharsets.UTF_8);

// Scope the jti cache key to the attested client instance key, so unrelated instances can choose the same jti.
return AttestationBasedClientAuthenticator.class.getName().toLowerCase(Locale.ROOT) + ".pop-replay." + replayKeyHash;
}

// [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
private void validateClientAttestationChallenge(KeycloakSession session, ClientAttestationPoPJwt attestationPoPJwt)
throws ClientAttestationChallengeException, TokenVerificationException {
String challenge = attestationPoPJwt.getChallenge();
if (Strings.isEmpty(challenge)) {
return;
}

CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class);
if (cNonceHandler == null) {
throw new TokenVerificationException(attestationPoPJwt, "Client attestation challenge validation is not available");
}
if (!cNonceHandler.supportsCNonceConsumption()) {
throw new TokenVerificationException(attestationPoPJwt, "Client attestation challenge consumption is not available");
}

WellKnownProvider oidcProvider = session.getProvider(WellKnownProvider.class, OIDCWellKnownProviderFactory.PROVIDER_ID);
OIDCConfigurationRepresentation oidcConfig = (OIDCConfigurationRepresentation) oidcProvider.getConfig();

try {
Map<String, Object> challengeDetails = Map.of(JwtCNonceHandler.SOURCE_ENDPOINT,
ClientAttestationChallengeEndpoint.getChallengeEndpoint(session.getContext()));
List<String> challengeAudiences = List.of(oidcConfig.getIssuer());
if (cNonceHandler.supportsCNonceTokenRetrieval()) {
JsonWebToken challengeToken = cNonceHandler.verifyCNonceAndGetToken(challenge,
challengeAudiences, challengeDetails);
cNonceHandler.consumeCNonce(challenge, challengeToken);
} else {
cNonceHandler.verifyCNonce(challenge, challengeAudiences, challengeDetails);
cNonceHandler.consumeCNonce(challenge);
}
} catch (Exception ex) {
throw new ClientAttestationChallengeException(
"Client Attestation PoP JWT challenge is invalid: " + ex.getMessage(),
ClientAttestationChallengeEndpoint.buildChallenge(session),
ex);
}
}

private static class ClientAttestationChallengeException extends Exception {

private final String challenge;

private ClientAttestationChallengeException(String message, String challenge, Throwable cause) {
super(message, cause);
this.challenge = challenge;
}

private String getChallenge() {
return challenge;
}
}

public static class ABCAResult {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.keycloak.protocol.oidc;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;

/**
* Challenge response for OAuth 2.0 Attestation-Based Client Authentication.
*
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-attestation-based-client-auth-10#section-6.1">Challenge endpoint</a>
* @author <a href="mailto:ogenbertrand@gmail.com">Bertrand Ogen</a>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ClientAttestationChallengeResponse {

@JsonProperty("attestation_challenge")
private String attestationChallenge;

public String getAttestationChallenge() {
return attestationChallenge;
}

public void setAttestationChallenge(String attestationChallenge) {
this.attestationChallenge = attestationChallenge;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.ClientAttestationChallengeEndpoint;
import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint;
import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
import org.keycloak.protocol.oidc.endpoints.ThirdPartyCookiesIframeEndpoint;
Expand Down Expand Up @@ -116,6 +117,10 @@ public static UriBuilder tokenUrl(UriBuilder baseUriBuilder) {
return uriBuilder.path(OIDCLoginProtocolService.class, "token");
}

public static UriBuilder clientAttestationChallengeUrl(UriBuilder baseUriBuilder) {
return ClientAttestationChallengeEndpoint.challengeUrl(baseUriBuilder);
}

public static UriBuilder certsUrl(UriBuilder baseUriBuilder) {
UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
return uriBuilder.path(OIDCLoginProtocolService.class, "certs");
Expand Down Expand Up @@ -179,6 +184,14 @@ public Object token() {
return new TokenEndpoint(session, tokenManager, event);
}

/**
* Attestation-Based Client Authentication challenge endpoint
*/
@Path(ClientAttestationChallengeEndpoint.PATH)
public Object clientAttestationChallenge() {
return new ClientAttestationChallengeEndpoint(session);
}

@Path("login-status-iframe.html")
public Object getLoginStatusIframe() {
return new LoginStatusIframeEndpoint(session);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.ClientAttestationChallengeEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.protocol.oidc.grants.OAuth2GrantType;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
Expand Down Expand Up @@ -166,6 +167,8 @@ public Object getConfig() {
if (clientAuthMethodsSupported.contains(ATTEST_JWT_CLIENT_AUTH)) {
config.setClientAttestationSigningAlgValuesSupported(getSupportedSigningAlgorithms(false));
config.setClientAttestationPopSigningAlgValuesSupported(getSupportedSigningAlgorithms(false));
config.setChallengeEndpoint(ClientAttestationChallengeEndpoint.challengeUrl(backendUriInfo.getBaseUriBuilder())
.build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
}

config.setAuthorizationSigningAlgValuesSupported(getSupportedSigningAlgorithms(false));
Expand Down
Loading
Loading