Skip to content

Parse DPoP header in LogoutEndpoint to fix DPoP-bound refresh token logout#50528

Open
sergioperezcheco wants to merge 3 commits into
keycloak:mainfrom
sergioperezcheco:fix-dpop-logout-50306
Open

Parse DPoP header in LogoutEndpoint to fix DPoP-bound refresh token logout#50528
sergioperezcheco wants to merge 3 commits into
keycloak:mainfrom
sergioperezcheco:fix-dpop-logout-50306

Conversation

@sergioperezcheco

Copy link
Copy Markdown

hey, so I found that once you bind a refresh token to DPoP, there's no way to log out with it — the logout endpoint always returns 400 with 'DPoP proof is missing'.

Turns out only TokenEndpoint and ParEndpoint bother to parse the DPoP HTTP header (via DPoPUtil.handleDPoPHeader). LogoutEndpoint calls verifyRefreshToken which checks the session for a DPoP proof, but nobody ever put one there because the header was never parsed in the first place.

The fix is just adding the same DPoPUtil.handleDPoPHeader() call before verifyRefreshToken() in logoutToken(), same pattern as what TokenEndpoint does at line 168. That way if a DPoP proof is present in the request, it gets validated and stored in the session where verifyRefreshToken can find it.

This is a regression from #36475 which made the DPoP check token-based (isDPoPToken) rather than client-setting-based — the check is now shared by the logout path which never had DPoP header parsing.

Closes #50306

@sergioperezcheco
sergioperezcheco requested a review from a team as a code owner July 1, 2026 15:48
Copilot AI review requested due to automatic review settings July 1, 2026 15:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes RP-initiated (legacy) logout with DPoP-bound refresh tokens by ensuring LogoutEndpoint.logoutToken() parses and validates the DPoP request header and stores the proof in the session before TokenManager.verifyRefreshToken() enforces DPoP binding.

Changes:

  • Add DPoPUtil.handleDPoPHeader(...) to LogoutEndpoint.logoutToken() prior to refresh-token verification.
  • Import DPoPUtil into LogoutEndpoint.

Comment on lines 85 to 87
import org.keycloak.services.util.LocaleUtil;
import org.keycloak.services.util.DPoPUtil;
import org.keycloak.services.util.MtlsHoKTokenUtil;
Comment on lines +510 to +513
// Parse and validate the DPoP proof from the request header (if present),
// so that DPoP-bound refresh tokens can be verified in verifyRefreshToken.
DPoPUtil.handleDPoPHeader(session, event, cors, OIDCAdvancedConfigWrapper.fromClientModel(client));

@sergioperezcheco
sergioperezcheco force-pushed the fix-dpop-logout-50306 branch from b9dd71d to 50883f5 Compare July 3, 2026 05:34
Copilot AI review requested due to automatic review settings July 15, 2026 02:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.


// Parse and validate the DPoP proof from the request header (if present),
// so that DPoP-bound refresh tokens can be verified in verifyRefreshToken.
DPoPUtil.handleDPoPHeader(session, event, cors, OIDCAdvancedConfigWrapper.fromClientModel(client));
Copilot AI review requested due to automatic review settings July 17, 2026 10:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.


// Parse and validate the DPoP proof from the request header (if present),
// so that DPoP-bound refresh tokens can be verified in verifyRefreshToken.
DPoPUtil.handleDPoPHeader(session, event, cors, OIDCAdvancedConfigWrapper.fromClientModel(client));

// Parse and validate the DPoP proof from the request header (if present),
// so that DPoP-bound refresh tokens can be verified in verifyRefreshToken.
DPoPUtil.handleDPoPHeader(session, event, cors, OIDCAdvancedConfigWrapper.fromClientModel(client));
sergioperezcheco added a commit to sergioperezcheco/keycloak that referenced this pull request Jul 18, 2026
Passing client config forced DPoP proof on all logouts for DPoP-enabled
clients, even when the refresh token is not DPoP-bound. Pass null and let
verifyRefreshToken() enforce binding via the token's cnf claim, mirroring
ParEndpoint. Addresses Copilot review feedback on keycloak#50528.
Copilot AI review requested due to automatic review settings July 18, 2026 08:38
@sergioperezcheco

Copy link
Copy Markdown
Author

Addressed the main review concern in commit a7374b3: handleDPoPHeader now receives null for the client config, mirroring ParEndpoint.java:106. The previous argument would have forced a DPoP proof on every logout for DPoP-enabled clients, even when the refresh token itself is not DPoP-bound (confidential clients can legitimately hold unbound tokens). Binding is correctly enforced by TokenManager.verifyRefreshToken() based on the token's cnf claim (TokenManager.java:357-389), so non-bound tokens proceed without a proof.

Regarding the integration-test request: the DPoP logout path is exercised by the existing DPoPTest refresh-then-revoke flows, but a dedicated legacy-logout-with-DPoP-bound-refresh-token case would be valuable. I can add one if maintainers confirm the desired test layout (it touches both DPoPTest and LogoutTest fixtures).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java:515

  • Please add a regression test that obtains a DPoP-bound refresh token and calls the refresh-token logout endpoint with a fresh proof, asserting logout succeeds; also cover a missing or mismatched proof. The existing DPoP integration suite exercises refresh-token proof validation but not this newly added logout path, so the reported regression could return unnoticed.
        DPoPUtil.handleDPoPHeader(session, event, cors, null);

… logout

LogoutEndpoint.logoutToken() calls verifyRefreshToken() which checks for
a DPoP proof in the session, but LogoutEndpoint never parses the DPoP
HTTP header unlike TokenEndpoint and ParEndpoint. This causes logout
with DPoP-bound refresh tokens to always fail with 'DPoP proof is missing'.

Add DPoPUtil.handleDPoPHeader() call before verifyRefreshToken() in
logoutToken(), matching the pattern used by TokenEndpoint.

Closes keycloak#50306

Signed-off-by: sergioperezcheco <checo520@outlook.com>
Copilot review noted the import broke alphabetical ordering.

Signed-off-by: sergioperezcheco <checo520@outlook.com>
Passing client config forced DPoP proof on all logouts for DPoP-enabled
clients, even when the refresh token is not DPoP-bound. Pass null and let
verifyRefreshToken() enforce binding via the token's cnf claim, mirroring
ParEndpoint. Addresses Copilot review feedback on keycloak#50528.

Signed-off-by: sergioperezcheco <checo520@outlook.com>
@sergioperezcheco

Copy link
Copy Markdown
Author

Gentle ping for review — this parses the DPoP header in LogoutEndpoint so that DPoP-bound refresh tokens can be properly logged out. Without this, the logout silently fails for DPoP clients. CI is green and the change is scoped to the logout flow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DPoP: Logout with a DPoP-bound refresh token always fails with "DPoP proof is missing"

2 participants