Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions charts/chat-app/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <origin>/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
Expand Down
10 changes: 10 additions & 0 deletions src/auth/AuthGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 <SilentRenewCallback />;
}

return (
<AuthProvider userManager={userManager} onSigninCallback={handleSigninCallback}>
<AuthErrorBoundary>
Expand Down
23 changes: 23 additions & 0 deletions src/auth/SilentRenewCallback.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions src/auth/user-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading