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,