8000 [OID4VCI-FAPI2] Pass fapi2-security-profile-final-ensure-request-object-without-redirect-uri-fails (blocked) by tdiesler · Pull Request #48606 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Closed
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
2 changes: 2 additions & 0 deletions common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ public enum Feature {
OID4VC_VCI_PREAUTH_CODE("Support for credential offers with `pre-authorized_code` grant.", Type.EXPERIMENTAL, OID4VC_VCI),
OID4VC_VCI_REST_CREDENTIAL_OFFER("Support for the REST endpoint to create credential offers.", Type.EXPERIMENTAL, OID4VC_VCI),

OID4VC_HAIP("OpenID4VC High Assurance Interoperability Profile 1.0", Type.EXPERIMENTAL, OID4VC_VCI),

OPENTELEMETRY("OpenTelemetry support", Type.DEFAULT),
OPENTELEMETRY_LOGS("OpenTelemetry Logs support", Type.PREVIEW, OPENTELEMETRY),
OPENTELEMETRY_METRICS("Micrometer to OpenTelemetry bridge support for metrics", Type.EXPERIMENTAL, OPENTELEMETRY),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public interface Details {
String ACTION = "action";
String CODE_ID = "code_id";
String REDIRECT_URI = "redirect_uri";
String REQUEST_URI = "request_uri";
String RESPONSE_TYPE = "response_type";
String RESPONSE_MODE = "response_mode";
String GRANT_TYPE = "grant_type";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import jakarta.ws.rs.core.MultivaluedHashMap;
Expand Down Expand Up @@ -59,6 +60,9 @@
import org.keycloak.protocol.LoginProtocol.Error;
import org.keycloak.protocol.RestartLoginCookie;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
import org.keycloak.protocol.oidc.endpoints.request.RequestUriType;
import org.keycloak.protocol.oidc.par.endpoints.request.AuthzEndpointParParser;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorPageException;
import org.keycloak.services.ServicesLogger;
Expand All @@ -80,6 +84,7 @@

import org.jboss.logging.Logger;

import static org.keycloak.events.Errors.INVALID_REQUEST;
import static org.keycloak.models.light.LightweightUserAdapter.isLightweightUser;

/**
Expand Down Expand Up @@ -917,16 +922,16 @@ public Response handleClientAuthException(Exception failure) {
ServicesLogger.LOGGER.failedClientAuthentication(e);
if (e.getError() == AuthenticationFlowError.CLIENT_NOT_FOUND) {
event.error(Errors.CLIENT_NOT_FOUND);
return ClientAuthUtil.errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_client", "Invalid client or Invalid client credentials");
return ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), INVALID_REQUEST, "Invalid client or invalid client credentials");
} else if (e.getError() == AuthenticationFlowError.CLIENT_DISABLED) {
event.error(Errors.CLIENT_DISABLED);
return ClientAuthUtil.errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_client", "Invalid client or Invalid client credentials");
return ClientAuthUtil.errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_client", "Invalid client or invalid client credentials");
} else if (e.getError() == AuthenticationFlowError.CLIENT_CREDENTIALS_SETUP_REQUIRED) {
event.error(Errors.INVALID_CLIENT_CREDENTIALS);
return ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "unauthorized_client", "Client credentials setup required");
} else {
event.error(Errors.INVALID_CLIENT_CREDENTIALS);
return ClientAuthUtil.errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_client", "Invalid client or Invalid client credentials");
return ClientAuthUtil.errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_client", "Invalid client or invalid client credentials");
}
} else {
ServicesLogger.LOGGER.errorAuthenticatingClient(failure);
Expand Down Expand Up @@ -1239,10 +1244,23 @@ protected Response authenticationComplete() {

String nextRequiredAction = nextRequiredAction();
if (nextRequiredAction != null) {
return AuthenticationManager.redirectToRequiredActions(session, realm, authenticationSession, uriInfo, nextRequiredAction);
Response response = AuthenticationManager.redirectToRequiredActions(session, realm, authenticationSession, uriInfo, nextRequiredAction);
return response;
} else {
event.detail(Details.CODE_ID, authenticationSession.getParentSession().getId()); // todo This should be set elsewhere. find out why tests fail. Don't know where this is supposed to be set
return AuthenticationManager.finishedRequiredActions(session, authenticationSession, userSession, connection, request, uriInfo, event);
Response response = AuthenticationManager.finishedRequiredActions(session, authenticationSession, userSession, connection, request, uriInfo, event);

// Authorization servers that enforce one-time use of request_uri values do so at the point of authorization,
// not at the point of visiting the authorization endpoint
String requestUri = authenticationSession.getAuthNote(Details.REQUEST_URI);
RequestUriType requestUriType = Optional.ofNullable(requestUri)
.map(AuthorizationEndpointRequestParserProcessor::getRequestUriType)
.orElse(null);
if (requestUriType == RequestUriType.PAR) {
AuthzEndpointParParser.removeRequestObject(session, requestUri);
}

return response;
}
}

Expand Down
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,13 +51,16 @@
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.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
Expand Down Expand Up @@ -110,6 +114,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 +131,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 @@ -331,7 +347,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 +357,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 +379,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 +419,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.getAttestationJwt();

HttpHeaders headers = context.getHttpRequest().getHttpHeaders();
String headerValue = Optional.ofNullable(headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER))
Expand Down Expand Up @@ -440,20 +459,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 +496,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 +522,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 hasAttestedClient() {
return attestedClient != null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import javax.security.auth.x500.X500Principal;

import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.models.KeycloakSession;
Expand Down Expand Up @@ -80,18 +80,18 @@ public String signCredential(CredentialBody credentialBody, CredentialBuildConfi
*/
private void addX5cHeader(SdJwtCredentialBody sdJwtCredentialBody, SignatureSignerContext signer) {
List<X509Certificate> certificateChain = signer.getCertificateChain();

if (certificateChain != null && !certificateChain.isEmpty()) {
List<String> x5cList = certificateChain.stream()
.filter(Objects::nonNull)
.filter(cert -> !isTrustAnchor(cert))
.map(cert -> {
try {
return Base64.getEncoder().encodeToString(cert.getEncoded());
} catch (CertificateEncodingException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
.toList();

if (!x5cList.isEmpty()) {
sdJwtCredentialBody.getIssuerSignedJWT().getJwsHeader().setX5c(x5cList);
Expand All @@ -102,4 +102,17 @@ private void addX5cHeader(SdJwtCredentialBody sdJwtCredentialBody, SignatureSign
LOGGER.debugf("No certificate or certificate chain available for x5c header in SD-JWT credential.");
}
}

private boolean isTrustAnchor(X509Certificate cert) {
boolean isTrustAnchor = false;
try {
int basicConstraints = cert.getBasicConstraints();
X500Principal issuerPrincipal = cert.getIssuerX500Principal();
X500Principal subjectPrincipal = cert.getSubjectX500Principal();
isTrustAnchor = subjectPrincipal.equals(issuerPrincipal) && basicConstraints >= 0;
} catch (Exception e) {
// ignore
}
return isTrustAnchor;
}
}
Loading
Loading
0