Skip to content

fix: OIDC desktop auth via backend polling instead of localStorage#1

Merged
mechermu merged 2 commits intomainfrom
copilot/fix-oidc-authentication-flow
Mar 2, 2026
Merged

fix: OIDC desktop auth via backend polling instead of localStorage#1
mechermu merged 2 commits intomainfrom
copilot/fix-oidc-authentication-flow

Conversation

Copy link
Copy Markdown

Copilot AI commented Mar 2, 2026

Summary

In Electron desktop mode, OIDC auth opens the system browser for the full flow (including MFA). The auth cookie and localStorage write happen in the browser's session, never reaching Electron — so auth never completes. This PR replaces the localStorage listener with a backend polling mechanism that sets the cookie directly in Electron's HTTP session.

Related Issue

Changes

Backend (backend/cmd/headlamp.go)

  • Added oidcAuthResult struct + oidcResultMap (mutex-protected) as a temporary in-memory token store (5-min TTL, one-time-use)
  • /oidc-callback: in desktop mode (!UseInCluster), stores token in oidcResultMap and returns an HTML success page instead of redirecting
  • New GET /oidc-token-poll?cluster={cluster} endpoint (registered before the static catch-all):
    • 202 {"status":"pending"} while waiting
    • 200 {"status":"success","cluster":"..."} + Set-Cookie once token is ready, then deletes entry

Frontend (frontend/src/components/oidcauth/OauthPopup.tsx)

  • Detects desktop mode via window.desktopApi
  • Desktop path: polls /oidc-token-poll every 2s with credentials: 'include' (critical — this is what stores the cookie in Electron's session); calls onCode('success') on result; 5-min timeout; cleans up interval on unmount
  • Web path: existing localStorage storage listener unchanged
if (window.desktopApi) {
  pollIntervalRef.current = setInterval(async () => {
    const resp = await fetch(`/oidc-token-poll?cluster=${encodeURIComponent(clusterParam)}`, {
      credentials: 'include',
    });
    if (resp.ok && (await resp.json()).status === 'success') {
      clearInterval(pollIntervalRef.current!);
      onCode('success');
    }
  }, POLL_INTERVAL_MS);
} else {
  window.addEventListener('storage', storageListener); // existing web flow
}

Steps to Test

  1. Configure a cluster with OIDC (e.g., Entra ID or Keycloak) in Headlamp Desktop on Windows
  2. Navigate to the auth chooser and click Sign In — the system browser should open
  3. Complete authentication (including MFA if configured)
  4. Verify the system browser shows "Authentication successful. You can close this window."
  5. Within ~2s, Headlamp Desktop should complete auth and load the cluster view without any manual intervention

Screenshots (if applicable)

Notes for the Reviewer

  • The credentials: 'include' on the poll fetch is load-bearing — without it the Set-Cookie response header is ignored by Electron's session
  • Web/in-cluster mode is entirely unaffected; the localStorage path is preserved as-is
  • Token entries are deleted on first successful poll (one-time-use) and expire after 5 minutes to prevent stale accumulation

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • example.com
    • Triggering command: /tmp/go-build3764470664/b1352/serviceproxy.test /tmp/go-build3764470664/b1352/serviceproxy.test -test.testlogfile=/tmp/go-build3764470664/b1352/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true -c=4 -nolocalimports -importcfg /tmp/go-build3764470664/b1305/importcfg -pack /home/REDACTED/go/pkg/mod/k8s.io/client-go@v0.33.3/kubernetes/typed/core/v1/fake/doc.go /home/REDACTED/go/pkg/mod/k8s.io/client-go@v0.33.3/kubernetes/typed/core/v1/fake/fake_componentstatus.go (dns block)
  • go.opentelemetry.io
    • Triggering command: /usr/bin/go go get -v ./... --save-callback-to-file /tmp/callback-3827-x96WxKK555Zz-code-review.json --custom-instructions /tmp/custom-instructions-3827-5w5O5bLDcMjX-.json int.go /structtag -lang=go1.23 ache/go/1.24.13/x64/pkg/tool/linux_amd64/compile slin�� x src/ ../app/electron ../plugins/headlamp-plugin --ignore-pattern ../plugins/headlamp-plugin/te/usr/bin/as -trimpath 1/x64/lib/node_modules/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin/sh adlamp-plugin/te/bin/sh ls/stylecheck/st-c -lang=go1.18 277566/b836/importcfg (dns block)
  • kubernetes-sigs.github.io
    • Triggering command: /tmp/go-build3764470664/b1270/helm.test /tmp/go-build3764470664/b1270/helm.test -test.testlogfile=/tmp/go-build3764470664/b1270/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true om/pmezard/go-di-errorsas .cfg ux_amd64/vet -p theus/client_gol-atomic -lang=go1.22 ux_amd64/vet -p 4470664/b752/_pk-errorsas .cfg ux_amd64/vet -I chr/testify/asse-a -I ux_amd64/vet (dns block)
    • Triggering command: /tmp/go-build3733822276/b1270/helm.test /tmp/go-build3733822276/b1270/helm.test -test.testlogfile=/tmp/go-build3733822276/b1270/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true ake/doc.go ake/fake_node_client.go (dns block)
    • Triggering command: /tmp/go-build965747969/b1270/helm.test /tmp/go-build965747969/b1270/helm.test -test.testlogfile=/tmp/go-build965747969/b1270/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true -unreachable=false /tmp/go-build3764470664/b038/vet.cfg x64/pkg/tool/linux_amd64/vet rce/v1alpha3/docas (dns block)
  • test-cluster.example.com
    • Triggering command: /tmp/go-build3764470664/b001/cmd.test /tmp/go-build3764470664/b001/cmd.test -test.testlogfile=/tmp/go-build3764470664/b001/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true lient-go@v0.33.3-errorsas .cfg ux_amd64/vet -p pkg/postrender -lang=go1.18 ux_amd64/vet 8920�� 8920915/b1213/_p-errorsas .cfg ux_amd64/vet --gdwarf-5 /informers/resou-atomic -o ux_amd64/vet (dns block)
    • Triggering command: /tmp/go-build3733822276/b001/cmd.test /tmp/go-build3733822276/b001/cmd.test -test.testlogfile=/tmp/go-build3733822276/b001/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true se 4470664/b135/vetgithub.com/kubernetes-sigs/headlamp/backend/pkg/helm_test ux_amd64/vet -p k8s.io/client-go-o -lang=go1.24 ux_amd64/vet --64�� -o /tmp/ccjDpZ8v.o ux_amd64/vet -c=4 -nolocalimports -importcfg ux_amd64/vet (dns block)
    • Triggering command: /tmp/go-build965747969/b001/cmd.test /tmp/go-build965747969/b001/cmd.test -test.testlogfile=/tmp/go-build965747969/b001/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true -o /tmp/ccoytRMt.o ux_amd64/vet go ency.go (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Original prompt

Problem

When using OIDC authentication (e.g., Entra ID/Keycloak) in Headlamp Desktop (Electron) on Windows, the authentication flow fails because:

  1. The OauthPopup component calls window.open() which Electron intercepts and opens in the system browser (via shell.openExternal) because the URL (http://localhost:{port}/oidc?...) doesn't match the startUrl (file://...).

  2. After the user authenticates (including MFA), the OIDC callback (/oidc-callback) lands in the system browser. The backend sets the auth cookie (Set-Cookie) in the browser's HTTP response, but this cookie belongs to the browser's session, not Electron's session.

  3. The OIDCAuth React component writes localStorage.setItem('auth_status', 'success') in the browser's localStorage, not Electron's. The storageListener in OauthPopup (running in Electron) never sees the change.

  4. Result: authentication never completes in the Electron app, and even if it did, the token cookie would not be available for subsequent API calls from Electron.

Related issue: kubernetes-sigs#2398

Solution

Implement a polling-based token transfer mechanism for Desktop mode that:

1. Backend changes (backend/cmd/headlamp.go)

  • In the /oidc-callback handler, when running in desktop mode (!config.UseInCluster):

    • After exchanging the OIDC code for a token, store the token in a temporary in-memory map (oidcResultMap) keyed by cluster name, with a one-time-use semantic and expiration (5 minutes).
    • Return an HTML page that tells the user "Authentication successful, you can close this window" instead of redirecting to /auth?cluster=....
    • Still set the cookie for the browser response (for web mode compatibility).
  • Add a new endpoint GET /oidc-token-poll?cluster={cluster}:

    • If a completed auth result exists for the requested cluster in oidcResultMap:
      • Call auth.SetTokenCookie() on the response (this sets the cookie in the Electron session since the request comes from Electron's fetch()).
      • Delete the entry from the map (one-time use).
      • Return {"status": "success", "cluster": "..."} with HTTP 200.
    • If no result yet: return {"status": "pending"} with HTTP 202.
    • Clean up expired entries (older than 5 minutes) on each poll.

The oidcResultMap should be protected by a mutex (sync.Mutex), similar to how oauthRequestMap is already implemented.

The oidcAuthResult struct should contain:

type oidcAuthResult struct {
    Token   string
    Cluster string
    Created time.Time
}

2. Frontend changes (frontend/src/components/oidcauth/OauthPopup.tsx)

  • In the createPopup function, detect desktop mode via window.desktopApi:

    • If in desktop mode:
      • Open the OIDC URL with window.open(url) (which Electron will open in system browser — existing behavior).
      • Start polling GET /oidc-token-poll?cluster={cluster} every 2 seconds with credentials: 'include' (so the Set-Cookie from the response is stored in Electron's session).
      • When the poll returns {"status": "success"}, stop polling and call onCode('success').
      • Set a timeout of 5 minutes to stop polling.
    • If NOT in desktop mode (web): keep the existing window.open() + localStorage listener behavior unchanged.
  • Clean up the poll interval in the useEffect cleanup function.

Why this works

  • The authentication happens entirely in the system browser, so all MFA methods work (TOTP, Authenticator push, FIDO2/WebAuthn, Windows Hello, Conditional Access, etc.)
  • The fetch() call from Electron to /oidc-token-poll receives the Set-Cookie header, which stores the auth cookie in Electron's HTTP session (not the browser's).
  • All subsequent API calls from Electron (e.g., fetch('/clusters/k8s/api/v1/pods', {credentials: 'include'})) will include this cookie.
  • The backend's existing middleware (setTokenFromCookie) reads the cookie from the request and sets the Authorization: Bearer {token} header before proxying to the kube-apiserver.
  • The web/in-cluster mode is completely unaffected — the existing localStorage + popup flow continues to work.

Key files to modify

  1. backend/cmd/headlamp.go — Add oidcResultMap, modify /oidc-callback handler for desktop mode, add /oidc-token-poll endpoint
  2. frontend/src/components/oidcauth/OauthPopup.tsx — Add desktop mode detection and polling logic

Important considerations

  • The /oidc-token-poll endpoint must be registered on the router BEFORE the static file catch-all r.PathPrefix("/") handler.
  • The one-time-use semantic (delete from map after successful poll) prevents token replay.
  • The 5-minute expiration prevents stale tokens from accumulating in memory.
  • The credentials: 'include' option on the fetch is CRITICAL — without it, the browser won't store the cookie from the response.
  • Do NOT break the existing web mode flow — the localStorage listen...

This pull request was created from Copilot chat.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Co-authored-by: mechermu <189133602+mechermu@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix OIDC authentication issue in Headlamp Desktop fix: OIDC desktop auth via backend polling instead of localStorage Mar 2, 2026
@mechermu
Copy link
Copy Markdown
Owner

mechermu commented Mar 2, 2026

@copilot peux-tu compiler pour windows que je le teste ?

Copy link
Copy Markdown
Author

Copilot AI commented Mar 2, 2026

@copilot peux-tu compiler pour windows que je le teste ?

Je ne peux pas générer le package Electron Windows depuis cet environnement Linux (la compilation croisée Electron nécessite Wine ou un vrai Windows). Voici deux options pour tester :

Option 1 — Build local (si tu as Node.js ≥ 20 et Go ≥ 1.24 installés) :

git checkout copilot/fix-oidc-authentication-flow
npm run app:package:win
# L'installeur sera dans app/dist/

Option 2 — Workflow GitHub Actions existant (app-artifacts-win.yml) :
Le dépôt contient déjà un workflow Build and upload Windows app artifact (.github/workflows/app-artifacts-win.yml) déclenché manuellement (workflow_dispatch). Tu peux le lancer depuis l'onglet Actions en choisissant la branche copilot/fix-oidc-authentication-flow — il produit un artefact Win exes téléchargeable directement depuis GitHub.

@mechermu mechermu marked this pull request as ready for review March 2, 2026 20:20
@mechermu mechermu merged commit 56399df into main Mar 2, 2026
1 check passed
@mechermu
Copy link
Copy Markdown
Owner

mechermu commented Mar 2, 2026

@copilot
quand je clique sur sign-in j'ai une fenetre navigateur http://localhost:4466/auth?cluster=my-cluster avec 404 page not found

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants