From 76070a3a355c9e16c2d3a27f9f6a2eab32a258c4 Mon Sep 17 00:00:00 2001 From: Vitalii Valkov Date: Fri, 14 Aug 2026 09:05:04 +0200 Subject: [PATCH] Renew from the provider's session, for installs that can A refresh token is the only credential a tab holds long enough to be worth stealing, and asking for offline_access is what gets one issued. The alternative is to renew through a hidden frame against the session the provider already keeps -- nothing persisted, and a cookie script cannot read. That only works where the frame is first-party to the issuer, so the default still asks for offline_access and nothing here changes for an install that keeps it: oidc-client-ts renews from the refresh token and never opens the frame, so silent_redirect_uri goes unread and the gate below never sees the path. It is set unconditionally because the library throws on the frame path without it, not because it is always used. The gate has to answer before AuthProvider mounts. Rendering the callback from a route would put it below the gate, and the frame would run the whole sign-in flow in its own context instead of answering its parent. Dropping offline_access against a provider configured elsewhere also needs /silent-renew registered there as a redirect URI. The app cannot detect that it is missing -- the frame swallows the error and sessions end at token expiry -- so the values file says so. --- charts/chat-app/values.yaml | 16 ++++++++++++++++ src/auth/AuthGate.tsx | 10 ++++++++++ src/auth/SilentRenewCallback.tsx | 23 +++++++++++++++++++++++ src/auth/user-manager.ts | 5 +++++ 4 files changed, 54 insertions(+) create mode 100644 src/auth/SilentRenewCallback.tsx diff --git a/charts/chat-app/values.yaml b/charts/chat-app/values.yaml index de54e0e..6a73313 100644 --- a/charts/chat-app/values.yaml +++ b/charts/chat-app/values.yaml @@ -204,6 +204,22 @@ metrics: oidcAuthority: "" oidcClientId: "" +# offline_access stays in the default: a refresh token is the only renewal that +# works against an arbitrary provider. The alternative -- renewing from the +# provider's session through a hidden frame -- needs that frame to be first-party +# to the issuer, and browsers block it wherever the app and the issuer are not +# the same site. Nothing in discovery says which case an install is in. +# +# An install whose issuer is a sibling hostname should drop offline_access and +# hold no refresh token at all; the umbrella does exactly that for the bundled +# provider. Dex rotates refresh tokens either way, which is what RFC 9700 asks +# of a public client that keeps one. +# +# Dropping it against a provider this chart does not configure takes one more +# step there: /silent-renew has to be registered as a redirect URI, the +# same way the callback is. The app cannot do that or detect that it is missing +# -- the provider answers the renewal with an error the frame swallows, and +# sessions end at token expiry instead. oidcScope: "openid profile email offline_access" oidcResource: "" apiBaseUrl: /api diff --git a/src/auth/AuthGate.tsx b/src/auth/AuthGate.tsx index bf65e04..ec3109d 100644 --- a/src/auth/AuthGate.tsx +++ b/src/auth/AuthGate.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useRef } from 'react'; import { AuthProvider, useAuth, withAuthenticationRequired } from 'react-oidc-context'; import { Button } from '@/components/Button'; import { oidcConfig } from '@/config'; +import { SilentRenewCallback } from './SilentRenewCallback'; import { userManager } from './user-manager'; type AuthGateProps = { @@ -56,10 +57,19 @@ function AuthErrorBoundary({ children }: { children: ReactNode }) { return <>{children}; } +export const silentRenewPath = '/silent-renew'; + export function AuthGate({ children }: AuthGateProps) { if (!oidcConfig.enabled) return <>{children}; if (!userManager) throw new Error('auth: user manager not initialized'); + // Checked on the raw location rather than through the router: this has to win + // before AuthProvider mounts, or the renewal frame runs the sign-in flow in + // its own context instead of answering the parent. + if (window.location.pathname === silentRenewPath) { + return ; + } + return ( diff --git a/src/auth/SilentRenewCallback.tsx b/src/auth/SilentRenewCallback.tsx new file mode 100644 index 0000000..bed5a59 --- /dev/null +++ b/src/auth/SilentRenewCallback.tsx @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; +import { userManager } from './user-manager'; + +/** + * The page the renewal iframe lands on. It hands the code back to the parent + * window and renders nothing -- the frame is hidden and torn down as soon as + * oidc-client-ts resolves the promise. + * + * It has to render before AuthGate, not inside it: the frame would otherwise run + * the whole sign-in flow again in its own context. + */ +export function SilentRenewCallback() { + useEffect(() => { + if (!userManager) return; + userManager.signinSilentCallback().catch((error) => { + // The parent already sees this as a rejected renewal and falls back to a + // redirect, so there is nothing to do here but leave a trace. + console.warn('[auth] silent renew callback failed', error); + }); + }, []); + + return null; +} diff --git a/src/auth/user-manager.ts b/src/auth/user-manager.ts index 71b8ead..9d30f68 100644 --- a/src/auth/user-manager.ts +++ b/src/auth/user-manager.ts @@ -7,6 +7,11 @@ export const userManager = oidcConfig.enabled client_id: oidcConfig.clientId, redirect_uri: `${window.location.origin}/callback`, post_logout_redirect_uri: window.location.origin, + // Renewal runs through a hidden frame against the provider's own session + // rather than a refresh token, so the app holds nothing long-lived. Dex + // compares redirect URIs whole, so this is registered alongside the + // callback rather than covered by it. + silent_redirect_uri: `${window.location.origin}/silent-renew`, scope: oidcConfig.scope, // `resource` skips the code->token exchange; extraTokenParams forces it in. resource: oidcConfig.resource ?? undefined,