Skip to content

OCPBUGS-85485: Append id_token_hint to OIDC logout redirect URL#16548

Open
Cragsmann wants to merge 4 commits into
openshift:mainfrom
Cragsmann:OCPBUGS-85485
Open

OCPBUGS-85485: Append id_token_hint to OIDC logout redirect URL#16548
Cragsmann wants to merge 4 commits into
openshift:mainfrom
Cragsmann:OCPBUGS-85485

Conversation

@Cragsmann
Copy link
Copy Markdown
Contributor

@Cragsmann Cragsmann commented Jun 4, 2026

Analysis / Root cause:
When Keycloak is used as the IDP and logoutRedirect is configured, logout fails when multiple
tabs are open. The console redirects to Keycloak's end_session_endpoint without an id_token_hint,
so Keycloak cannot identify the session and shows a "Do you want to log out?" confirmation page.
While the user sits on that page, another tab re-authenticates via SSO (the Keycloak session is
still active), corrupting the session state. When the user finally confirms, Keycloak returns
"Logout failed."

Additionally, when multiple tabs re-authenticate after logout, they race on the shared login-state
cookie — each tab's login flow overwrites the other's CSRF state, causing invalid_state errors.

JIRA: https://redhat.atlassian.net/browse/OCPBUGS-85485

Solution description:
Per the OIDC RP-Initiated Logout spec,
passing id_token_hint lets Keycloak identify the session and skip the confirmation page.

Backend (pkg/auth/oauth2/auth_oidc.go):

  • Before deleting the session, retrieve the id_token via sessions.GetSession() / AccessToken()
  • Append id_token_hint, client_id, and post_logout_redirect_uri to the logout redirect URL
  • Return the URL as a JSON response ({"logoutRedirectURL": "..."}) with status 200
  • Falls back to existing 204 behavior when no logout redirect is configured or no session exists

Backend (pkg/auth/oauth2/auth.go):

  • Use per-state login cookies (login-state-<prefix>) instead of a single shared login-state cookie
  • Each concurrent login flow (e.g. from different tabs) gets its own cookie, eliminating the CSRF
    state race condition that caused invalid_state errors
  • Falls back to the legacy login-state cookie for rolling-update compatibility
  • Add ConsoleBaseAddress to Config for post_logout_redirect_uri support

Frontend (frontend/public/module/auth.ts):

  • Read the JSON response from POST /api/console/logout
  • If logoutRedirectURL is present, redirect to it (contains id_token_hint)
  • Otherwise fall back to the static logoutRedirect from SERVER_FLAGS (backward compatible)
  • Wrap handle401 in _.once() so only the first 401 per page load triggers the re-auth flow,
    preventing the redirect counter from being exhausted by concurrent API calls

Screenshots / screen recording:

Test setup:

  • OCP cluster with Keycloak deployed as IDP (external OIDC)
  • Console configured with logoutRedirect pointing to Keycloak's OIDC logout endpoint
  • Tested with multiple browser tabs open simultaneously

Test cases:

  • Unit tests for logout() and logoutRedirectURLWithIDTokenHint() covering:
    • Session + logout redirect → returns JSON with id_token_hint (valid JWT)
    • No logout redirect configured → returns 204
    • No session → returns 204 (no redirect loop)
    • Preserves existing query params (client_id, post_logout_redirect_uri)
    • Adds client_id and post_logout_redirect_uri when not present
    • Does not overwrite user-specified params
  • Manual multi-tab testing on OCP cluster with Keycloak:
    • Two tabs open → logout in Tab 1 → both tabs re-authenticate without errors
    • Tab 1 logout redirects through Keycloak (no confirmation page) and back to console
    • Tab 2 detects session loss, re-authenticates via Keycloak SSO transparently

Browser conformance:

  • Chrome
  • Firefox
  • Safari
  • Edge

Summary by CodeRabbit

  • New Features

    • Support for dynamic post-logout redirect URLs and preservation of existing redirect query parameters.
    • OAuth2 login now uses per-login state cookies for more reliable sign-in flows.
    • Console base address is now handled for logout/redirect construction.
  • Bug Fixes

    • More robust logout fallback and clearer logging when redirect data is unavailable.
    • Prevented repeated redirect-loop detection by ensuring the 401 handler runs only once per page load.
    • Removed sensitive token values from refresh error messages.
  • Tests

    • Added tests covering logout behavior and logout-redirect URL construction.

When Keycloak is the IDP and logoutRedirect is configured, logging out
with multiple tabs open causes Keycloak to show a confirmation page
(because it cannot identify the session without id_token_hint). While
the user sits on that page, another tab can re-authenticate via SSO,
corrupting the session state and causing "Logout failed."

Construct the logout redirect URL dynamically at logout time by
retrieving the id_token from the active session and appending it as
the id_token_hint query parameter, per the OIDC RP-Initiated Logout
spec. The backend now returns the URL as JSON so the frontend can
redirect to it. Falls back to the existing static redirect when no
session or no logout URL is configured.
@openshift-ci-robot openshift-ci-robot added jira/severity-moderate Referenced Jira bug's severity is moderate for the branch this PR is targeting. jira/valid-reference Indicates that this PR references a valid Jira ticket of any type. jira/invalid-bug Indicates that a referenced Jira bug is invalid for the branch this PR is targeting. labels Jun 4, 2026
@openshift-ci-robot
Copy link
Copy Markdown
Contributor

@Cragsmann: This pull request references Jira Issue OCPBUGS-85485, which is invalid:

  • expected the bug to target the "5.0.0" version, but no target version was set

Comment /jira refresh to re-evaluate validity if changes to the Jira bug are made, or edit the title of this pull request to link to a different bug.

The bug has been updated to refer to the pull request using the external bug tracker.

Details

In response to this:

Analysis / Root cause:
When Keycloak is used as the IDP and logoutRedirect is configured, logout fails when multiple
tabs are open. The console redirects to Keycloak's end_session_endpoint without an id_token_hint,
so Keycloak cannot identify the session and shows a "Do you want to log out?" confirmation page.
While the user sits on that page, another tab re-authenticates via SSO (the Keycloak session is
still active), corrupting the session state. When the user finally confirms, Keycloak returns
"Logout failed."

JIRA: https://redhat.atlassian.net/browse/OCPBUGS-85485

Solution description:
Per the OIDC RP-Initiated Logout spec,
passing id_token_hint lets Keycloak identify the session and skip the confirmation page.

Backend (pkg/auth/oauth2/auth_oidc.go):

  • Before deleting the session, retrieve the id_token via getLoginState() / AccessToken()
  • Append it as id_token_hint query parameter to the logout redirect URL
  • Return the URL as a JSON response ({"logoutRedirectURL": "..."}) with status 200
  • Falls back to existing 204 behavior when no logout redirect is configured

Frontend (frontend/public/module/auth.ts):

  • Read the JSON response from POST /api/console/logout
  • If logoutRedirectURL is present, redirect to it (contains id_token_hint)
  • Otherwise fall back to the static logoutRedirect from SERVER_FLAGS (backward compatible)

Screenshots / screen recording:

Test setup:

  • OCP cluster with Keycloak deployed as IDP
  • Console configured with logoutRedirect pointing to Keycloak's OIDC logout endpoint

Test cases:

  • Unit tests added for logout() and logoutRedirectURLWithIDTokenHint() covering:
  • Session + logout redirect → returns JSON with id_token_hint (valid JWT)
  • No logout redirect configured → returns 204 (backward compatible)
  • No session + logout redirect → returns JSON with base URL (graceful fallback)
  • Preserves existing query parameters (client_id, post_logout_redirect_uri)
  • All 4 tests fail when fix is reverted, confirming test coverage

Browser conformance:

  • Chrome
  • Firefox
  • Safari (or Epiphany on Linux)

Additional info:
The fix only affects the oidcAuth code path (--user-auth=oidc). The openShiftAuth path
is unchanged. The openShiftAuth path does not have access to Keycloak's id_token (it only has
the OCP OAuth bearer token), so it cannot provide id_token_hint.

Reviewers and assignees:

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: openshift/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: aee1ef2f-7c66-49f9-84a1-5936dd475a13

📥 Commits

Reviewing files that changed from the base of the PR and between ae00071 and 0a1b7ce.

📒 Files selected for processing (1)
  • frontend/public/module/auth.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/public/module/auth.ts

Walkthrough

Backend may return JSON logoutRedirectURL (including an id_token_hint) or 204; OAuth2 uses per-login state cookies and a new ConsoleBaseAddress config; frontend logout reads backend JSON redirect and handle401 is wrapped with _.once. Tests validate logout URL construction and handler responses.

Changes

Dynamic OIDC Logout + OAuth2 state

Layer / File(s) Summary
Bridge authenticator wiring
cmd/bridge/config/auth/authoptions.go
Passes ConsoleBaseAddress into the oauth2.Config initializer used to create the authenticator.
OAuth2 per-state cookie & config
pkg/auth/oauth2/auth.go
Adds Config.ConsoleBaseAddress. Login writes a per-state cookie (name suffix, Path:/auth, MaxAge:300); callback reads that per-state cookie with a legacy single-cookie fallback, validates state, logs and redirects on errors, and deletes the matched cookie.
OIDC logout handler & tests
pkg/auth/oauth2/auth_oidc.go, pkg/auth/oauth2/auth_oidc_test.go
Logout deletes the session and returns JSON logoutRedirectURL when available (constructed by logoutRedirectURLWithIDTokenHint) or 204 otherwise; imports and error formatting updated. Adds tests Test_oidcAuth_logout and Test_oidcAuth_logoutRedirectURLWithIDTokenHint.
Frontend logout & handle401
frontend/public/module/auth.ts
authSvc.logout POSTs to logout and parses optional JSON logoutRedirectURL to redirect when present (preserving kube-admin flow) and falls back to existing redirect logic on error. authSvc.handle401 is wrapped in _.once so redirect-loop detection runs only once per page load.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

jira/valid-bug

Suggested reviewers

  • fsgreco
  • Leo6Leo
  • TheRealJon
🚥 Pre-merge checks | ✅ 14 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: appending id_token_hint to OIDC logout redirect URLs, which directly addresses the root cause described in the PR.
Description check ✅ Passed The PR description covers all required template sections: root cause analysis, solution description with backend/frontend changes, test setup, test cases, browser conformance, and references the Jira ticket OCPBUGS-85485.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Stable And Deterministic Test Names ✅ Passed PR contains no Ginkgo tests (uses standard Go tests only). The check for Ginkgo test name stability is not applicable to this codebase.
Test Structure And Quality ✅ Passed PR adds only standard Go unit tests (Test_* with *testing.T), not Ginkgo tests. Custom check is inapplicable as it targets Ginkgo test patterns.
Microshift Test Compatibility ✅ Passed No Ginkgo e2e tests were added in this PR. The new tests in pkg/auth/oauth2/auth_oidc_test.go are standard Go unit tests using testing.T, not Ginkgo tests with It()/Describe()/etc.
Single Node Openshift (Sno) Test Compatibility ✅ Passed PR adds Go unit tests, not Ginkgo e2e tests. The custom check applies only to Ginkgo e2e tests (It(), Describe(), Context(), When()), which this PR does not add.
Topology-Aware Scheduling Compatibility ✅ Passed PR modifies authentication/OIDC handler code and frontend auth module; does not add/modify deployment manifests, operators, or Kubernetes scheduling constraints. Check is not applicable.
Ote Binary Stdout Contract ✅ Passed Changed files are library packages and frontend code, not OTE test binaries. All logging (3 klog calls) is within method bodies, not process-level code. Check is not applicable.
Ipv6 And Disconnected Network Test Compatibility ✅ Passed No Ginkgo e2e tests were added in this PR. The added tests in auth_oidc_test.go are standard Go unit tests using testing.T, not Ginkgo e2e tests, so the check is not applicable.
No-Weak-Crypto ✅ Passed PR introduces no new weak crypto usage (MD5, SHA1, DES, RC4, 3DES, Blowfish, ECB), custom crypto, or non-constant-time comparisons of secrets.
Container-Privileges ✅ Passed PR contains only source code changes (TypeScript, Go, config) for OIDC/OAuth2 authentication logic; no Kubernetes manifests or container configurations present.
No-Sensitive-Data-In-Logs ✅ Passed No sensitive data exposed in logs. Refresh token error message omits token value; JSON responses contain only logoutRedirectURL; all error logging uses %v for errors, not token data.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@openshift-ci openshift-ci Bot requested review from jhadvig and stefanonardo June 4, 2026 10:20
@openshift-ci openshift-ci Bot added component/backend Related to backend component/core Related to console core functionality labels Jun 4, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/auth/oauth2/auth_oidc_test.go`:
- Around line 791-797: The test currently ignores url.Parse errors when parsing
tt.logoutRedirectOverride into expectedBase; change the code in the test where
expectedBase is set (the url.Parse call for tt.logoutRedirectOverride in
pkg/auth/oauth2/auth_oidc_test.go) to check the returned error and fail fast
(e.g., require.NoError/require.Nil or t.Fatalf) if parsing fails, so malformed
table entries don't produce silent incorrect assertions; then continue using
expectedBase.Host/Path and expectedBase.Query() as before.

In `@pkg/auth/oauth2/auth_oidc.go`:
- Around line 149-157: The logout helper currently calls getLoginState which may
trigger refreshSession and hit the IdP; change it to use a non-refreshing
session lookup (e.g., a new or existing helper like getCachedLoginState /
lookupSessionNoRefresh or directly query the session store) to read the cached
session into ls without invoking refreshSession, then obtain idToken :=
ls.AccessToken() and fall back to returning baseURL if no cached id token
exists; ensure you remove any code path that can call refreshSession from this
logout helper so logout never depends on the token endpoint.
- Around line 132-136: The response writer's JSON encoding call
json.NewEncoder(w).Encode(...) currently ignores errors; update the OIDC logout
handler (the block that checks logoutURL and writes the JSON to w) to capture
the error returned by Encode, log it with contextual info (including logoutURL)
and, if the write failed before any body was sent, return an HTTP error status
(e.g., http.Error with 500) or at least ensure the client sees a failure; do not
swallow write errors so disconnects or write failures are surfaced and logged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: openshift/coderabbit/.coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: ca47103b-6a90-4588-a6b7-bdd7b679c1d3

📥 Commits

Reviewing files that changed from the base of the PR and between 8e06ab5 and 7802572.

📒 Files selected for processing (3)
  • frontend/public/module/auth.ts
  • pkg/auth/oauth2/auth_oidc.go
  • pkg/auth/oauth2/auth_oidc_test.go

Comment thread pkg/auth/oauth2/auth_oidc_test.go Outdated
Comment thread pkg/auth/oauth2/auth_oidc.go Outdated
Comment thread pkg/auth/oauth2/auth_oidc.go Outdated
- Remove refresh token from error message to avoid logging sensitive
  credentials (pre-existing issue on line 108)
- Use non-refreshing session lookup (GetSession) instead of
  getLoginState in logoutRedirectURLWithIDTokenHint to avoid hitting
  the IdP token endpoint during logout
- Handle json.Encode error in logout response
- Check url.Parse error in test assertions
- Add docstrings for logout and logoutRedirectURLWithIDTokenHint
return baseURL
}

idToken := ls.AccessToken()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I might be a little confused here - but isn't the access_token and id_token different? Can id_token_hint param accept access_token if it was used to authenticate?

My understanding was id_token_hint needs to take id_token . Since id_token needs to use OIDC flow would this work in OAuth2 ? I believe the oauth-server or osin when making the request gets responseData which would contain id_token but I believe id_token is never specified as something stored to use in the same way access_token is.

I'm asking because I'm curious as to how console works. I was looking at this work and thought of using id_token_hint but ran across this issue. :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great question! The method name AccessToken() is indeed misleading here. It returns ls.rawToken, and what rawToken contains depends on the auth path:

OIDC path (this fix's scope): When tokenVerifier != nil, rawToken is set to token.Extra("id_token").(string) — the raw id_token JWT from the OIDC provider (loginstate.go L75-88). So AccessToken() actually returns the id_token in this path, which is exactly what id_token_hint needs.

OpenShift OAuth path: When tokenVerifier == nil, rawToken is set to token.AccessToken — the OCP OAuth bearer token (e.g. sha256~...) (loginstate.go L64-72). This is NOT an id_token and can't be used as id_token_hint. But this fix only runs in the oidcAuth.logout() handler, which is never called in the OpenShift auth path — openShiftAuth.logout() has its own separate implementation.

So to directly answer your question: this won't work in pure OAuth2 (OpenShift auth mode) because the id_token isn't stored there. It only works in the OIDC auth path where the console talks directly to an OIDC provider like Keycloak.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for getting back to me so quickly. That makes perfect sense. Thanks for the explanation!

Copy link
Copy Markdown
Contributor Author

@Cragsmann Cragsmann left a comment

Choose a reason for hiding this comment

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

/jira refresh

@Cragsmann
Copy link
Copy Markdown
Contributor Author

/retest

Copy link
Copy Markdown
Member

@TheRealJon TheRealJon left a comment

Choose a reason for hiding this comment

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

Could we use a 302 response with a Location header to automatically redirect the browser to the logout URL?

@TheRealJon
Copy link
Copy Markdown
Member

@Cragsmann I'm trying to verify by deploying your changes on a cluster and setting up external oidc with keycloak, but I wasn't able to get an env up today. IIRC, the last time I did this I had some help from either QE or someone from the auth team. I feel like we should test this at least once with a real external OIDC setup before merging.

…re-auth

Use a unique cookie per login flow (login-state-<prefix>) instead of a
single shared cookie. This prevents concurrent login flows from multiple
browser tabs from overwriting each other's CSRF state, which caused
invalid_state errors when re-authenticating after logout.

Also wraps the frontend handle401 handler in _.once() so only the first
401 per page load triggers the re-auth flow, preventing the redirect
counter from being exhausted by concurrent API calls.
@Cragsmann
Copy link
Copy Markdown
Contributor Author

Cragsmann commented Jun 5, 2026

@TheRealJon regarding redirect to the logout URL. The logout is triggered by a POST /api/console/logout via coFetch (JavaScript fetch). A 302/303 on a fetch response doesn't cause the browser to navigate — the fetch client silently follows the redirect and returns the response from the redirect target. The actual browser navigation needs to happen in JavaScript via window.location.assign(), which is why the backend returns the URL as JSON and lets the frontend handle the redirect.

Re: testing on a real cluster — I've been testing this on an OCP cluster with Keycloak as the IDP (external OIDC). The multi-tab logout flow works cleanly now: multiple tabs re-authenticate without any auth errors after logout. Please review my changes and check for possible side effects to the auth flow. So far what I have tested worked fine.

Screen.Recording.2026-06-05.at.17.42.55.mov

@openshift-ci
Copy link
Copy Markdown
Contributor

openshift-ci Bot commented Jun 5, 2026

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: Cragsmann
Once this PR has been reviewed and has the lgtm label, please assign jhadvig for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@Cragsmann Cragsmann requested a review from TheRealJon June 5, 2026 15:47
@Cragsmann
Copy link
Copy Markdown
Contributor Author

/retest

@openshift-ci
Copy link
Copy Markdown
Contributor

openshift-ci Bot commented Jun 5, 2026

@Cragsmann: The following tests failed, say /retest to rerun all failed tests or /retest-required to rerun all mandatory failed tests:

Test name Commit Details Required Rerun command
ci/prow/e2e-gcp-console 0a1b7ce link true /test e2e-gcp-console
ci/prow/backend 0a1b7ce link true /test backend
ci/prow/e2e-playwright 0a1b7ce link false /test e2e-playwright

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

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

Labels

component/backend Related to backend component/core Related to console core functionality jira/invalid-bug Indicates that a referenced Jira bug is invalid for the branch this PR is targeting. jira/severity-moderate Referenced Jira bug's severity is moderate for the branch this PR is targeting. jira/valid-reference Indicates that this PR references a valid Jira ticket of any type.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants