diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index dbf55f2addd5..db0385e9caa6 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -67,6 +67,8 @@ import java.util.Map; import java.util.function.BiConsumer; +import static org.keycloak.protocol.oidc.par.endpoints.ParEndpoint.PAR_DPOP_PROF_JKT; + /** * @author Stian Thorgersen */ @@ -185,6 +187,12 @@ private Response process(final MultivaluedMap params) { return redirectErrorToClient(parsedResponseMode, ex.getError(), ex.getErrorDescription()); } + // If DPoP Proof existed with PAR request, its public key needs to be matched with the one with Token Request afterward + String dpopJkt = session.getAttribute(PAR_DPOP_PROF_JKT, String.class); + if (request.getDpopJkt() == null) { + request.setDpopJkt(dpopJkt); + } + try { session.clientPolicy().triggerOnEvent(new AuthorizationRequestContext(parsedResponseType, request, redirectUri, params)); } catch (ClientPolicyException cpe) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java index c382b6deae94..cf089fef87a6 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java @@ -44,11 +44,13 @@ import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.RedirectUtils; +import org.keycloak.representations.dpop.DPoP; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.ErrorPageException; import org.keycloak.services.ServicesLogger; import org.keycloak.services.cors.Cors; import org.keycloak.services.messages.Messages; +import org.keycloak.services.util.DPoPUtil; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.TokenUtil; import org.keycloak.utils.StringUtil; @@ -293,6 +295,21 @@ public void checkParRequired() throws AuthorizationCheckException { throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, errorMessage); } + public void checkParDPoPParams() throws AuthorizationCheckException { + DPoP dpop = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class); + if (dpop == null) { + return; + } + if (request.getDpopJkt() != null) { + if (!request.getDpopJkt().equals(dpop.getThumbprint())) { + String errorMessage = "DPoP Proof public key thumbprint does not match dpop_jkt."; + event.detail(Details.REASON, errorMessage); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, errorMessage); + } + } + } + // https://tools.ietf.org/html/rfc7636#section-4 private boolean isValidPkceCodeChallenge(String codeChallenge) { if (codeChallenge.length() < OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MIN_LENGTH) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java index a8d213644333..9de8517e2188 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequest.java @@ -137,6 +137,8 @@ public String getCodeChallengeMethod() { public String getDpopJkt() { return dpopJkt; } + public void setDpopJkt(String dpopJkt) { this.dpopJkt = dpopJkt; } + public String getDisplay() { return display; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java index 9c46fe7061f5..a4bcbc6e75bf 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java @@ -26,14 +26,18 @@ import org.keycloak.headers.SecurityHeadersProvider; import org.keycloak.models.KeycloakSession; import org.keycloak.models.SingleUseObjectProvider; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.par.ParResponse; import org.keycloak.protocol.oidc.par.clientpolicy.context.PushedAuthorizationRequestContext; import org.keycloak.protocol.oidc.par.endpoints.request.ParEndpointRequestParserProcessor; +import org.keycloak.representations.dpop.DPoP; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.cors.Cors; +import org.keycloak.services.util.DPoPUtil; import org.keycloak.utils.ProfileHelper; import jakarta.ws.rs.Consumes; @@ -57,6 +61,7 @@ public class ParEndpoint extends AbstractParEndpoint { public static final String PAR_CREATED_TIME = "par.created.time"; + public static final String PAR_DPOP_PROF_JKT = "par.dpop.prof.jkt"; private static final String REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; public static final int REQUEST_URI_PREFIX_LENGTH = REQUEST_URI_PREFIX.length(); @@ -96,6 +101,11 @@ public Response request() { throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, "It is not allowed to include request_uri to PAR.", Response.Status.BAD_REQUEST); } + // https://datatracker.ietf.org/doc/html/rfc9449#section-10.1 + DPoPUtil.retrieveDPoPHeaderIfPresent(session, event, cors).ifPresent(dPoP -> { + session.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP); + }); + try { authorizationRequest = ParEndpointRequestParserProcessor.parseRequest(event, session, client, decodedFormParameters); } catch (Exception e) { @@ -137,6 +147,7 @@ public Response request() { checker.checkOIDCRequest(); checker.checkOIDCParams(); checker.checkPKCEParams(); + checker.checkParDPoPParams(); } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { ex.throwAsCorsErrorResponseException(cors); } @@ -157,6 +168,11 @@ public Response request() { flattenDecodedFormParametersToParamsMap(decodedFormParameters, params); params.put(PAR_CREATED_TIME, String.valueOf(System.currentTimeMillis())); + // If DPoP Proof exists, its public key needs to be matched with the one with Token Request afterward + DPoP dpop = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class); + if (dpop != null) { + params.put(PAR_DPOP_PROF_JKT, dpop.getThumbprint()); + } SingleUseObjectProvider singleUseStore = session.singleUseObjects(); singleUseStore.put(key, expiresIn, params); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java index 57882c408f3a..989821fc88ba 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java @@ -32,6 +32,7 @@ import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint; import static org.keycloak.protocol.oidc.par.endpoints.ParEndpoint.PAR_CREATED_TIME; +import static org.keycloak.protocol.oidc.par.endpoints.ParEndpoint.PAR_DPOP_PROF_JKT; /** * Parse the parameters from PAR @@ -70,6 +71,11 @@ public AuthzEndpointParParser(KeycloakSession session, ClientModel client, Strin } else { throw new RuntimeException("PAR expired."); } + // If DPoP Proof existed with PAR request, its public key needs to be matched with the one with Token Request afterward + String dpopJkt = retrievedRequest.get(PAR_DPOP_PROF_JKT); + if (dpopJkt != null) { + session.setAttribute(PAR_DPOP_PROF_JKT, dpopJkt); + } } @Override diff --git a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java index 18ed9425ae02..176280f90b6d 100644 --- a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java +++ b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java @@ -170,6 +170,31 @@ public static Optional retrieveDPoPHeaderIfPresent(KeycloakSession keycloa } } + public static Optional retrieveDPoPHeaderIfPresent(KeycloakSession keycloakSession, + EventBuilder event, + Cors cors) { + boolean isDPoPSupported = Profile.isFeatureEnabled(Profile.Feature.DPOP); + if (!isDPoPSupported) { + return Optional.empty(); + } + + HttpRequest request = keycloakSession.getContext().getHttpRequest(); + final boolean isDpopHeaderPresent = request.getHttpHeaders().getHeaderString(DPoPUtil.DPOP_HTTP_HEADER) != null; + if (!isDpopHeaderPresent) { + return Optional.empty(); + } + + try { + DPoP dPoP = new DPoPUtil.Validator(keycloakSession).request(request).uriInfo(keycloakSession.getContext().getUri()).validate(); + keycloakSession.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP); + return Optional.of(dPoP); + } catch (VerificationException ex) { + event.detail(Details.REASON, ex.getMessage()); + event.error(Errors.INVALID_DPOP_PROOF); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, ex.getMessage(), Response.Status.BAD_REQUEST); + } + } + 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("")) {