diff --git a/ui/apps/pmm/src/sep/SepAuthGate.messages.ts b/ui/apps/pmm/src/sep/SepAuthGate.messages.ts new file mode 100644 index 00000000000..144ca5c8ab1 --- /dev/null +++ b/ui/apps/pmm/src/sep/SepAuthGate.messages.ts @@ -0,0 +1,22 @@ +export const Messages = { + loading: 'Authenticating with Smart Expert Platform…', + retry: 'Try again', + // Shown instead of the page: the exchange failed at load, so there is no work + // in progress to preserve. + blocked: { + signedOutTitle: 'Not signed in', + signedOut: + 'Smart Expert Platform could not verify your PMM session. Sign in to PMM again, then retry.', + unreachableTitle: 'Could not reach Smart Expert Platform', + unreachable: + 'Authenticating with Smart Expert Platform failed. This is usually temporary.', + }, + // Shown beside a page that is already open. Never replaces it — the user may + // be part-way through a form. + notice: { + signedOut: + 'Your PMM session has ended, so Smart Expert Platform can no longer be reached. Anything you submit from this page will fail. Sign in to PMM in another tab, then retry — your work here is kept.', + unreachable: + 'Lost the connection to Smart Expert Platform. Anything you submit from this page will fail until it is back. Your work here is kept.', + }, +}; diff --git a/ui/apps/pmm/src/sep/SepAuthGate.test.tsx b/ui/apps/pmm/src/sep/SepAuthGate.test.tsx new file mode 100644 index 00000000000..3dd2f8a5fa2 --- /dev/null +++ b/ui/apps/pmm/src/sep/SepAuthGate.test.tsx @@ -0,0 +1,150 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { ApiError, postSessionExchange, setTokenMinter } from '@sep/api'; +import { SepAuthGate } from './SepAuthGate'; +import { initSepAuth } from './bootstrap'; +import { markSepSignedOut, resetSepAuthStore } from './sepTokenStore'; + +vi.mock('@sep/api', async (importOriginal) => ({ + ...(await importOriginal()), + postSessionExchange: vi.fn(), +})); + +const exchange = vi.mocked(postSessionExchange); + +const bearer = (accessToken = 'bearer-1') => ({ + access_token: accessToken, + expires_in: 300, +}); + +const unauthorized = () => + new ApiError({ kind: 'http', status: 401, message: 'no session' }); + +const renderGate = () => + render( + +
plugin content
+
+ ); + +/** A page with unsaved input, standing in for a half-filled plugin form. */ +const renderGateWithForm = () => + render( + + + + ); + +beforeEach(() => { + exchange.mockReset(); + resetSepAuthStore(); + initSepAuth(); +}); + +afterEach(() => { + resetSepAuthStore(); + setTokenMinter(null); +}); + +describe('SepAuthGate — bootstrap', () => { + it('withholds children until the exchange resolves', async () => { + let resolveExchange: (value: ReturnType) => void = () => {}; + exchange.mockReturnValue( + new Promise((resolve) => { + resolveExchange = resolve; + }) + ); + + renderGate(); + + expect(screen.queryByText('plugin content')).not.toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + + resolveExchange(bearer()); + + expect(await screen.findByText('plugin content')).toBeInTheDocument(); + }); + + it('renders children once a bearer is held', async () => { + exchange.mockResolvedValue(bearer()); + + renderGate(); + + expect(await screen.findByText('plugin content')).toBeInTheDocument(); + expect(exchange).toHaveBeenCalledOnce(); + }); + + it('shows a signed-out page instead of the plugin, and does not loop', async () => { + exchange.mockRejectedValue(unauthorized()); + + renderGate(); + + expect(await screen.findByTestId('sep-auth-error')).toHaveTextContent( + 'Not signed in' + ); + expect(screen.queryByText('plugin content')).not.toBeInTheDocument(); + expect(exchange).toHaveBeenCalledOnce(); + }); + + it('distinguishes an unreachable SEP from a rejected session', async () => { + exchange.mockRejectedValue(new Error('network down')); + + renderGate(); + + expect(await screen.findByTestId('sep-auth-error')).toHaveTextContent( + 'Could not reach Smart Expert Platform' + ); + }); + + it('exchanges again when the user retries', async () => { + exchange.mockRejectedValue(unauthorized()); + renderGate(); + await screen.findByTestId('sep-auth-error'); + + exchange.mockResolvedValue(bearer()); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + expect(await screen.findByText('plugin content')).toBeInTheDocument(); + expect(exchange).toHaveBeenCalledTimes(2); + }); +}); + +describe('SepAuthGate — failure on a mounted page', () => { + it('reports a rejected session without unmounting the page', async () => { + exchange.mockResolvedValue(bearer()); + renderGate(); + await screen.findByText('plugin content'); + + act(() => markSepSignedOut()); + + expect(screen.getByTestId('sep-auth-notice')).toBeInTheDocument(); + expect(screen.getByText('plugin content')).toBeInTheDocument(); + expect(screen.queryByTestId('sep-auth-error')).not.toBeInTheDocument(); + }); + + it('preserves in-progress form state', async () => { + exchange.mockResolvedValue(bearer()); + renderGateWithForm(); + const field = await screen.findByLabelText('target'); + fireEvent.change(field, { target: { value: 'half-written command' } }); + + act(() => markSepSignedOut()); + + expect(screen.getByTestId('sep-auth-notice')).toBeInTheDocument(); + expect(screen.getByLabelText('target')).toHaveValue('half-written command'); + }); + + it('clears the notice when the retry succeeds, keeping the page throughout', async () => { + exchange.mockResolvedValue(bearer()); + renderGateWithForm(); + const field = await screen.findByLabelText('target'); + fireEvent.change(field, { target: { value: 'half-written command' } }); + act(() => markSepSignedOut()); + + exchange.mockResolvedValue(bearer('bearer-2')); + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + await screen.findByLabelText('target'); + expect(screen.queryByTestId('sep-auth-notice')).not.toBeInTheDocument(); + expect(screen.getByLabelText('target')).toHaveValue('half-written command'); + }); +}); diff --git a/ui/apps/pmm/src/sep/SepAuthGate.tsx b/ui/apps/pmm/src/sep/SepAuthGate.tsx new file mode 100644 index 00000000000..868ac2fe5fa --- /dev/null +++ b/ui/apps/pmm/src/sep/SepAuthGate.tsx @@ -0,0 +1,117 @@ +import { FC, PropsWithChildren, useEffect, useSyncExternalStore } from 'react'; +import { + Alert, + AlertTitle, + Box, + Button, + CircularProgress, +} from '@mui/material'; +import { Messages } from './SepAuthGate.messages'; +import { + type SepAuthNotice, + ensureSepToken, + getSepAuthState, + retrySepAuth, + subscribeSepAuth, +} from './sepTokenStore'; + +const RetryButton: FC = () => ( + +); + +/** + * Inline report of a failure that arrived after the page was already open. + * + * Deliberately not a replacement for the page: a background renewal failing + * must not discard a half-filled form. It tells the user that submitting will + * fail and offers a retry, and leaves everything else alone. + */ +const SepAuthNoticeBar: FC<{ kind: SepAuthNotice }> = ({ kind }) => ( + } + > + {kind === 'signedOut' + ? Messages.notice.signedOut + : Messages.notice.unreachable} + +); + +/** + * Holds a SEP route until a SEP bearer has been minted from the PMM session. + * + * Gating here rather than exchanging at app startup keeps SEP out of the boot + * path for the PMM users who never open a SEP page — the UI has no + * `PMM_ENABLE_SEP` flag to check, so an eager exchange would hit SEP on every + * page load for everybody. + * + * It also removes a race the token provider cannot: `setTokenProvider` is + * synchronous, so a plugin's first queries would otherwise fire before the + * exchange resolves and 401 on arrival. Children do not render until a bearer + * is in hand. + * + * Once they have rendered they stay rendered. A later failure is reported by + * `notice`, beside the page rather than instead of it. + */ +export const SepAuthGate: FC = ({ children }) => { + const { phase, notice } = useSyncExternalStore( + subscribeSepAuth, + getSepAuthState + ); + + useEffect(() => { + // No-ops when a bearer is already held or the session was rejected; a + // previous transient failure is retried on the next visit to a SEP route. + void ensureSepToken(); + }, []); + + if (phase === 'ready') { + return ( + <> + {notice !== null && } + {children} + + ); + } + + if (phase === 'signedOut' || phase === 'unreachable') { + const signedOut = phase === 'signedOut'; + return ( + } + > + + {signedOut + ? Messages.blocked.signedOutTitle + : Messages.blocked.unreachableTitle} + + {signedOut ? Messages.blocked.signedOut : Messages.blocked.unreachable} + + ); + } + + return ( + + + + ); +}; diff --git a/ui/apps/pmm/src/sep/SepPage.tsx b/ui/apps/pmm/src/sep/SepPage.tsx index 9ceda00c553..605a0e74ad6 100644 --- a/ui/apps/pmm/src/sep/SepPage.tsx +++ b/ui/apps/pmm/src/sep/SepPage.tsx @@ -3,6 +3,7 @@ import Stack from '@mui/material/Stack'; import { Page } from 'components/page'; import { useUser } from 'contexts/user'; import { OrgRole } from 'types/user.types'; +import { SepAuthGate } from './SepAuthGate'; /** * Shared container for SEP apps mounted as native PMM routes. @@ -19,6 +20,9 @@ import { OrgRole } from 'types/user.types'; * `isPMMAdmin` is `isGrafanaAdmin || orgRole === Admin`, and `roles` (org-role * only) cannot express the Grafana-admin half on its own, so it gates the * remaining case and Page renders its standard unauthorized card. + * + * `SepAuthGate` sits inside that check, so the SEP session exchange only runs + * for a user who is allowed on the page in the first place. */ export const SepPage: FC = ({ children }) => { const { user } = useUser(); @@ -29,7 +33,9 @@ export const SepPage: FC = ({ children }) => { roles={user?.isPMMAdmin ? undefined : [OrgRole.Admin]} > -
{children}
+ +
{children}
+
); diff --git a/ui/apps/pmm/src/sep/bootstrap.ts b/ui/apps/pmm/src/sep/bootstrap.ts index 673acd4d977..e3b08b19eea 100644 --- a/ui/apps/pmm/src/sep/bootstrap.ts +++ b/ui/apps/pmm/src/sep/bootstrap.ts @@ -1,20 +1,35 @@ -import { setTokenProvider, setOnUnauthorized } from '@sep/api'; +import { + setOnRefreshed, + setOnUnauthorized, + setTokenMinter, + setTokenProvider, +} from '@sep/api'; +import { + getSepToken, + markSepSignedOut, + mintSepToken, + recordSepToken, +} from './sepTokenStore'; /** - * Interim SEP auth wiring (migration Option D). + * SEP auth wiring for the embedded UI. * - * SEP's axios client delegates the bearer token via `setTokenProvider`. During the - * migration the PMM dev proxy injects `PMM_DEV_SEP_INTERNAL_TOKEN` server-side, - * so the browser sends no token — the provider returns `null`. `setOnUnauthorized` is a - * no-op because there is no SEP login flow to redirect to (PMM owns the session). + * PMM owns the session, so SEP is authenticated as the actual PMM user by + * exchanging the `pmm_session` cookie for a short-lived SEP bearer + * (`POST /api/oauth/session/exchange`, SEP-1692) rather than by logging in. + * This replaces the interim wiring in which the dev proxy injected + * `PMM_DEV_SEP_INTERNAL_TOKEN` server-side: that authenticated as SEP's + * internal service principal, which hardcodes `is_admin = False`, so every + * admin-gated SEP surface answered 403. * - * This is replaced by the token-exchange provider (Option B), which calls - * `postSessionExchange()` (`POST /api/oauth/session/exchange`, SEP-1692) to trade - * PMM's session cookie for a short-lived SEP bearer, at which point `isAdmin` also - * comes from the token's role claim rather than the internal token's service - * principal, which hardcodes `is_admin = False`. + * Registration is side-effect free — no network call happens here. The first + * exchange is triggered by `SepAuthGate` when a SEP route mounts, so PMM users + * who never open one never talk to SEP. State and lifetime live in + * `./sepTokenStore`. */ export const initSepAuth = () => { - setTokenProvider(() => null); - setOnUnauthorized(() => {}); + setTokenProvider(getSepToken); + setTokenMinter(mintSepToken); + setOnRefreshed(recordSepToken); + setOnUnauthorized(markSepSignedOut); }; diff --git a/ui/apps/pmm/src/sep/sepTokenStore.test.ts b/ui/apps/pmm/src/sep/sepTokenStore.test.ts new file mode 100644 index 00000000000..33a0d158432 --- /dev/null +++ b/ui/apps/pmm/src/sep/sepTokenStore.test.ts @@ -0,0 +1,364 @@ +import { + ApiError, + getToken, + postSessionExchange, + setTokenMinter, +} from '@sep/api'; +import { initSepAuth } from './bootstrap'; +import { + ensureSepToken, + getSepAuthState, + getSepToken, + resetSepAuthStore, + retrySepAuth, +} from './sepTokenStore'; + +// Mock only the network boundary. `refreshAccessToken`'s single-flight, the +// token-minter seam, and the unauthorized wiring stay real, so these exercise +// the store against the coordinator it actually runs against. +vi.mock('@sep/api', async (importOriginal) => ({ + ...(await importOriginal()), + postSessionExchange: vi.fn(), +})); + +const exchange = vi.mocked(postSessionExchange); + +const TTL_SECONDS = 300; +/** The renewal fires 30s before the 300s TTL. */ +const UNTIL_RENEWAL_MS = 270_000; +/** Backoff is 2s, 4s, 8s, 16s; this clears all four plus slack. */ +const PAST_ALL_RETRIES_MS = 60_000; + +const mintedToken = (accessToken: string) => ({ + access_token: accessToken, + expires_in: TTL_SECONDS, +}); + +const unauthorized = () => + new ApiError({ kind: 'http', status: 401, message: 'no session' }); + +const phase = () => getSepAuthState().phase; +const notice = () => getSepAuthState().notice; + +/** Reach `ready` with a live bearer, as a mounted SEP page would be. */ +const becomeReady = async (accessToken = 'bearer-1') => { + exchange.mockResolvedValue(mintedToken(accessToken)); + await ensureSepToken(); + exchange.mockReset(); +}; + +beforeEach(() => { + // Leave `queueMicrotask` real: `refreshAccessToken` clears its single-flight + // slot in a microtask, and faking that would deadlock the second exchange. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + exchange.mockReset(); + resetSepAuthStore(); + initSepAuth(); +}); + +afterEach(() => { + resetSepAuthStore(); + setTokenMinter(null); + vi.useRealTimers(); +}); + +describe('sepTokenStore — acquiring a bearer', () => { + it('holds no token until an exchange runs', () => { + expect(getSepToken()).toBeNull(); + expect(phase()).toBe('idle'); + expect(exchange).not.toHaveBeenCalled(); + }); + + it('exchanges once and exposes the bearer synchronously', async () => { + exchange.mockResolvedValue(mintedToken('bearer-1')); + + await expect(ensureSepToken()).resolves.toBe(true); + + expect(exchange).toHaveBeenCalledOnce(); + expect(getSepToken()).toBe('bearer-1'); + expect(getSepAuthState()).toEqual({ phase: 'ready', notice: null }); + }); + + it('serves the bearer through the token provider registered on @sep/api', async () => { + await becomeReady(); + + expect(getToken()).toBe('bearer-1'); + }); + + it('reuses the held bearer instead of exchanging again', async () => { + exchange.mockResolvedValue(mintedToken('bearer-1')); + + await ensureSepToken(); + await ensureSepToken(); + + expect(exchange).toHaveBeenCalledOnce(); + }); + + it('coalesces concurrent callers into one exchange', async () => { + let resolveExchange: ( + value: ReturnType + ) => void = () => {}; + exchange.mockReturnValue( + new Promise((resolve) => { + resolveExchange = resolve; + }) + ); + + const pending = Promise.all([ + ensureSepToken(), + ensureSepToken(), + ensureSepToken(), + ]); + resolveExchange(mintedToken('bearer-1')); + + await expect(pending).resolves.toEqual([true, true, true]); + expect(exchange).toHaveBeenCalledOnce(); + }); + + it('never writes the bearer to web storage', async () => { + await becomeReady(); + + expect(Object.keys(localStorage)).toHaveLength(0); + expect(Object.keys(sessionStorage)).toHaveLength(0); + }); + + it('hands out a stable snapshot so subscribers do not re-render on no-ops', async () => { + await becomeReady(); + const first = getSepAuthState(); + + await ensureSepToken(); + + expect(getSepAuthState()).toBe(first); + }); +}); + +describe('sepTokenStore — failing closed', () => { + it('serves no token once the bearer has expired', async () => { + await becomeReady(); + + vi.setSystemTime(Date.now() + TTL_SECONDS * 1000 + 1); + + expect(getSepToken()).toBeNull(); + expect(getToken()).toBeNull(); + }); + + it('drops the bearer when a renewal is rejected', async () => { + await becomeReady(); + exchange.mockRejectedValue(unauthorized()); + + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + expect(getSepToken()).toBeNull(); + }); + + it('drops the bearer when a renewal cannot complete', async () => { + await becomeReady(); + exchange.mockRejectedValue(new Error('offline')); + + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + expect(getSepToken()).toBeNull(); + }); + + it('refuses to exchange again once the session is rejected', async () => { + exchange.mockRejectedValue(unauthorized()); + await ensureSepToken(); + + await expect(ensureSepToken()).resolves.toBe(false); + await expect(ensureSepToken()).resolves.toBe(false); + + expect(exchange).toHaveBeenCalledOnce(); + }); + + it('stops renewing after the session is rejected', async () => { + exchange.mockRejectedValue(unauthorized()); + await ensureSepToken(); + + await vi.advanceTimersByTimeAsync(600_000); + + expect(exchange).toHaveBeenCalledOnce(); + }); +}); + +describe('sepTokenStore — bootstrap failure', () => { + it('shows a signed-out page when the session is rejected at load', async () => { + exchange.mockRejectedValue(unauthorized()); + + await expect(ensureSepToken()).resolves.toBe(false); + + expect(getSepAuthState()).toEqual({ phase: 'signedOut', notice: null }); + }); + + it('shows an unreachable page when the exchange cannot complete at load', async () => { + exchange.mockRejectedValue(new Error('network down')); + + await expect(ensureSepToken()).resolves.toBe(false); + + expect(getSepAuthState()).toEqual({ phase: 'unreachable', notice: null }); + }); + + it('recovers on an explicit retry', async () => { + exchange.mockRejectedValue(unauthorized()); + await ensureSepToken(); + exchange.mockResolvedValue(mintedToken('bearer-1')); + + await expect(retrySepAuth()).resolves.toBe(true); + + expect(exchange).toHaveBeenCalledTimes(2); + expect(getSepAuthState()).toEqual({ phase: 'ready', notice: null }); + }); + + it('retries a transient bootstrap failure on the next visit', async () => { + exchange.mockRejectedValue(new Error('network down')); + await ensureSepToken(); + exchange.mockResolvedValue(mintedToken('bearer-1')); + + await expect(ensureSepToken()).resolves.toBe(true); + + expect(getSepToken()).toBe('bearer-1'); + }); +}); + +describe('sepTokenStore — renewal on a mounted page', () => { + it('renews shortly before expiry', async () => { + await becomeReady(); + exchange.mockResolvedValue(mintedToken('bearer-2')); + + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + expect(exchange).toHaveBeenCalledOnce(); + expect(getSepToken()).toBe('bearer-2'); + expect(getSepAuthState()).toEqual({ phase: 'ready', notice: null }); + }); + + it('keeps renewing across successive lifetimes', async () => { + await becomeReady(); + exchange.mockResolvedValue(mintedToken('bearer-2')); + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + exchange.mockResolvedValue(mintedToken('bearer-3')); + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + expect(exchange).toHaveBeenCalledTimes(2); + expect(getSepToken()).toBe('bearer-3'); + }); + + it('retries a transient renewal failure quietly, without a notice', async () => { + await becomeReady(); + exchange.mockRejectedValue(new Error('offline')); + + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + expect(exchange).toHaveBeenCalledOnce(); + // Still `ready` with nothing on screen: a blip must not interrupt the user. + expect(getSepAuthState()).toEqual({ phase: 'ready', notice: null }); + }); + + it('backs off across several quiet attempts before giving up', async () => { + await becomeReady(); + exchange.mockRejectedValue(new Error('offline')); + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + await vi.advanceTimersByTimeAsync(2_000); + expect(exchange).toHaveBeenCalledTimes(2); + expect(notice()).toBeNull(); + + await vi.advanceTimersByTimeAsync(4_000); + expect(exchange).toHaveBeenCalledTimes(3); + expect(notice()).toBeNull(); + + await vi.advanceTimersByTimeAsync(8_000); + expect(exchange).toHaveBeenCalledTimes(4); + expect(notice()).toBeNull(); + }); + + it('surfaces a transient failure only once it persists', async () => { + await becomeReady(); + exchange.mockRejectedValue(new Error('offline')); + + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS + PAST_ALL_RETRIES_MS); + + expect(exchange).toHaveBeenCalledTimes(1 + 4); + // Reported beside the page, never instead of it. + expect(getSepAuthState()).toEqual({ + phase: 'ready', + notice: 'unreachable', + }); + }); + + it('recovers silently when a backoff attempt succeeds', async () => { + await becomeReady(); + exchange.mockRejectedValueOnce(new Error('offline')); + exchange.mockResolvedValue(mintedToken('bearer-2')); + + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS + 2_000); + + expect(getSepToken()).toBe('bearer-2'); + expect(getSepAuthState()).toEqual({ phase: 'ready', notice: null }); + }); + + it('stops retrying once a backoff attempt succeeds', async () => { + await becomeReady(); + exchange.mockRejectedValueOnce(new Error('offline')); + exchange.mockResolvedValue(mintedToken('bearer-2')); + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS + 2_000); + + // Only the next scheduled renewal should fire, not a leftover backoff. + await vi.advanceTimersByTimeAsync(1_000); + + expect(exchange).toHaveBeenCalledTimes(2); + }); + + it('reports a rejected session at once, without backing off', async () => { + await becomeReady(); + exchange.mockRejectedValue(unauthorized()); + + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + expect(getSepAuthState()).toEqual({ phase: 'ready', notice: 'signedOut' }); + // Terminal: retrying would only repeat the rejection. + await vi.advanceTimersByTimeAsync(PAST_ALL_RETRIES_MS); + expect(exchange).toHaveBeenCalledOnce(); + }); + + it('never leaves the ready phase, whatever the failure', async () => { + await becomeReady(); + exchange.mockRejectedValue(unauthorized()); + + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS + PAST_ALL_RETRIES_MS); + + expect(phase()).toBe('ready'); + }); + + it('clears the notice when the user retries successfully', async () => { + await becomeReady(); + exchange.mockRejectedValue(unauthorized()); + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + expect(notice()).toBe('signedOut'); + + exchange.mockResolvedValue(mintedToken('bearer-2')); + await expect(retrySepAuth()).resolves.toBe(true); + + expect(getSepAuthState()).toEqual({ phase: 'ready', notice: null }); + expect(getSepToken()).toBe('bearer-2'); + }); + + it('keeps the notice when the retry fails again', async () => { + await becomeReady(); + exchange.mockRejectedValue(unauthorized()); + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + await expect(retrySepAuth()).resolves.toBe(false); + + expect(getSepAuthState()).toEqual({ phase: 'ready', notice: 'signedOut' }); + }); + + it('stops renewing once the store is cleared', async () => { + await becomeReady(); + exchange.mockResolvedValue(mintedToken('bearer-2')); + + resetSepAuthStore(); + await vi.advanceTimersByTimeAsync(UNTIL_RENEWAL_MS); + + expect(exchange).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/apps/pmm/src/sep/sepTokenStore.ts b/ui/apps/pmm/src/sep/sepTokenStore.ts new file mode 100644 index 00000000000..ea414a40515 --- /dev/null +++ b/ui/apps/pmm/src/sep/sepTokenStore.ts @@ -0,0 +1,328 @@ +import { + ApiError, + type MintedToken, + postSessionExchange, + refreshAccessToken, +} from '@sep/api'; + +/** + * In-memory holder for the SEP bearer PMM mints from its own session. + * + * `POST /api/oauth/session/exchange` (SEP-1692) trades the ambient `pmm_session` + * cookie — attached automatically, same origin through PMM's proxy — for a + * short-lived bearer. No cookie is set and no refresh token is issued, so the + * holder re-exchanges before expiry instead of refreshing. + * + * The token never leaves this module: no `localStorage`, no `sessionStorage`, no + * query cache. A page reload re-exchanges from the cookie, which is the point — + * every exchange re-reads the identity, so a role change lands within one bearer + * lifetime (5 minutes by default). + * + * Two rules shape everything below. + * + * **Fail closed.** Any exchange failure drops the bearer immediately. Nothing + * ever proceeds on a stale, expired, or unverified credential, and there is no + * cached fallback to reach for. A session SEP has rejected is sticky: minting is + * refused until the user retries, so a rejection cannot drive an exchange loop. + * + * **Never destroy user work.** Once a bearer has been held, the page is mounted + * and may hold a half-filled form. From that point a failure is reported through + * {@link SepAuthState.notice} — an inline notice beside the still-mounted page — + * rather than by moving the phase to a full-screen state. Before that point + * there is nothing to preserve, so a bootstrap failure takes over the page. + * + * Concurrency is not handled here. `refreshAccessToken()` in `@sep/api` + * single-flights every caller — the renewal timer, the initial gate, and each + * transport's 401 retry — so a burst of parallel SEP requests triggers one + * exchange. + */ + +/** + * Renew this far before the bearer actually expires, so in-flight requests + * carry a token that is still valid when SEP validates it. + */ +const EXPIRY_SKEW_MS = 30_000; + +/** Floor for the renewal delay, in case SEP ever issues a very short TTL. */ +const MIN_RENEWAL_DELAY_MS = 5_000; + +/** + * Backoff for a renewal that failed for a reason that may not repeat. Quiet + * while it retries; the user is only told once the attempts run out. + */ +const RENEWAL_RETRY_BASE_MS = 2_000; +const RENEWAL_RETRY_MAX_MS = 30_000; +const MAX_RENEWAL_RETRIES = 4; + +/** What the page as a whole is doing. Drives which UI the gate renders. */ +export type SepAuthPhase = + /** No exchange attempted yet. */ + | 'idle' + /** First exchange in flight; nothing to authenticate with yet. */ + | 'exchanging' + /** A bearer has been held. The page is mounted and stays mounted. */ + | 'ready' + /** SEP rejected the session before a bearer was ever held. */ + | 'signedOut' + /** The exchange could not be completed before a bearer was ever held. */ + | 'unreachable'; + +/** + * A failure that arrived after the page was already mounted. Surfaced beside + * the page instead of replacing it, so in-progress work survives. + */ +export type SepAuthNotice = 'signedOut' | 'unreachable'; + +export interface SepAuthState { + phase: SepAuthPhase; + notice: SepAuthNotice | null; +} + +let token: string | null = null; +let expiresAtMs = 0; +let phase: SepAuthPhase = 'idle'; +let notice: SepAuthNotice | null = null; + +/** + * Sticky once SEP has rejected the session. Blocks minting outright — without + * it, every subsequent request would 401, trigger a mint, be rejected, and + * repeat. Only {@link retrySepAuth} clears it. + */ +let sessionRejected = false; + +let renewalTimer: ReturnType | null = null; +let retryTimer: ReturnType | null = null; +let renewalRetries = 0; + +const listeners = new Set<() => void>(); + +// `useSyncExternalStore` compares snapshots by identity, so hand out a cached +// object and only replace it when something actually changed. +let snapshot: SepAuthState = { phase, notice }; + +const publish = () => { + if (snapshot.phase === phase && snapshot.notice === notice) { + return; + } + snapshot = { phase, notice }; + listeners.forEach((listener) => listener()); +}; + +const setPhase = (next: SepAuthPhase) => { + phase = next; + publish(); +}; + +const clearTimer = (timer: ReturnType | null) => { + if (timer !== null) { + clearTimeout(timer); + } + return null; +}; + +/** Subscribe to state changes. Pairs with {@link getSepAuthState}. */ +export const subscribeSepAuth = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const getSepAuthState = (): SepAuthState => snapshot; + +/** + * Current bearer, or null once it has expired. + * + * Synchronous because `setTokenProvider` is: the transports read it while + * building a request and cannot await. An expired token yields null rather than + * a stale bearer, and the resulting 401 routes into the transports' retry, which + * mints and replays. + */ +export const getSepToken = (): string | null => + token !== null && Date.now() < expiresAtMs ? token : null; + +/** Drop the bearer and stop every pending renewal. */ +const clearSepToken = () => { + token = null; + expiresAtMs = 0; + renewalTimer = clearTimer(renewalTimer); + retryTimer = clearTimer(retryTimer); +}; + +/** + * Drop the bearer and report the failure at the right altitude. + * + * Before a bearer has ever been held there is no work in progress, so the + * failure takes over the page. After that the page stays exactly as it is and + * the failure becomes an inline notice — a background renewal must never + * discard what the user was typing. + */ +const failClosed = (kind: SepAuthNotice) => { + clearSepToken(); + if (phase === 'ready') { + notice = kind; + } else { + phase = kind; + notice = null; + } + publish(); +}; + +/** + * Renew ahead of expiry so the bearer is replaced before any request can carry + * a dead one. + * + * A backgrounded tab has its timers throttled and may miss the window; the 401 + * retry in both transports is the backstop for that. + */ +const scheduleRenewal = (expiresIn: number) => { + renewalTimer = clearTimer(renewalTimer); + const delay = Math.max( + expiresIn * 1000 - EXPIRY_SKEW_MS, + MIN_RENEWAL_DELAY_MS + ); + renewalTimer = setTimeout(() => { + renewalTimer = null; + void renew(); + }, delay); +}; + +const scheduleRenewalRetry = () => { + retryTimer = clearTimer(retryTimer); + const delay = Math.min( + RENEWAL_RETRY_BASE_MS * 2 ** (renewalRetries - 1), + RENEWAL_RETRY_MAX_MS + ); + retryTimer = setTimeout(() => { + retryTimer = null; + void renew(); + }, delay); +}; + +const renew = async () => { + // Joins the shared single-flight, so a renewal racing a 401 retry is one call. + const minted = await refreshAccessToken(); + if (minted !== null) { + return; + } + if (sessionRejected) { + // A rejected session is terminal and `markSepSignedOut` already reported it. + // Retrying would only repeat the rejection. + return; + } + + // Transient: the bearer is gone either way (fail closed), but keep quiet and + // back off — a blip should not put a notice in front of someone mid-form. + clearSepToken(); + if (renewalRetries < MAX_RENEWAL_RETRIES) { + renewalRetries += 1; + scheduleRenewalRetry(); + return; + } + failClosed('unreachable'); +}; + +/** + * Record a freshly minted bearer. Wired to `setOnRefreshed`, so it runs whoever + * triggered the exchange — the gate, the renewal timer, or a 401 retry. + */ +export const recordSepToken = (accessToken: string, expiresIn: number) => { + token = accessToken; + expiresAtMs = Date.now() + expiresIn * 1000; + // A successful exchange proves the session is good and clears whatever the + // last failure said about it. + sessionRejected = false; + renewalRetries = 0; + retryTimer = clearTimer(retryTimer); + scheduleRenewal(expiresIn); + phase = 'ready'; + notice = null; + publish(); +}; + +/** + * Record that SEP rejected the session, and refuse to exchange again until + * {@link retrySepAuth}. + * + * Wired to `setOnUnauthorized`, which fires when a SEP call 401s and no token + * could be minted to replay it. Also called directly when the exchange itself + * 401s, so the sticky guarantee holds even if the transports' unauthorized + * wiring changes. + */ +export const markSepSignedOut = () => { + sessionRejected = true; + failClosed('signedOut'); +}; + +/** + * Mint a bearer by exchanging PMM's session cookie. Wired to `setTokenMinter`, + * replacing `@sep/api`'s default `POST /oauth/refresh` — PMM's embedding issues + * no refresh cookie, so the default would 401 on every recovery attempt. + */ +export const mintSepToken = async (): Promise => { + if (sessionRejected) { + return null; + } + try { + return await postSessionExchange(); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + markSepSignedOut(); + } + return null; + } +}; + +/** + * Ensure a usable bearer exists, exchanging if needed. Resolves true when SEP + * calls can be authenticated. + * + * Concurrent callers coalesce inside `refreshAccessToken()`. + */ +export const ensureSepToken = async (): Promise => { + if (getSepToken() !== null) { + return true; + } + if (sessionRejected) { + return false; + } + + // Only show the spinner before the page exists. Once mounted it stays put. + if (phase !== 'ready') { + setPhase('exchanging'); + } + + const minted = await refreshAccessToken(); + if (minted !== null) { + return true; + } + if (!sessionRejected) { + failClosed('unreachable'); + } + return false; +}; + +/** + * Clear a terminal state and exchange again. The only way out of a rejected + * session, so recovery stays an explicit user action rather than a loop. + */ +export const retrySepAuth = (): Promise => { + sessionRejected = false; + renewalRetries = 0; + clearSepToken(); + if (phase !== 'ready') { + setPhase('idle'); + } + return ensureSepToken(); +}; + +/** Reset every module-level field. Tests only. */ +export const resetSepAuthStore = () => { + clearSepToken(); + phase = 'idle'; + notice = null; + sessionRejected = false; + renewalRetries = 0; + snapshot = { phase, notice }; + listeners.clear(); +}; diff --git a/ui/apps/pmm/vite.config.ts b/ui/apps/pmm/vite.config.ts index 30f19a0389d..9b350667a3e 100644 --- a/ui/apps/pmm/vite.config.ts +++ b/ui/apps/pmm/vite.config.ts @@ -28,12 +28,20 @@ const target = (hasNginxCerts ? 'https://localhost:8443' : 'https://localhost'); // SEP backend. The dev server proxies SEP's API paths to it so the migrated SEP -// plugins get real data. Interim auth (Option D): if PMM_DEV_SEP_INTERNAL_TOKEN -// is set, inject it server-side as a Bearer token so no secret reaches the -// browser. Both variables are dev-server-only, hence the PMM_DEV_ prefix. -// Replaced by the token-exchange provider (Option B) later — see src/sep/bootstrap.ts. +// plugins get real data. Residual interim auth: if PMM_DEV_SEP_INTERNAL_TOKEN is +// set, inject it server-side as a Bearer token so no secret reaches the browser. +// Both variables are dev-server-only, hence the PMM_DEV_ prefix. +// +// The browser now mints its own bearer by exchanging the PMM session (see +// src/sep/bootstrap.ts), so the injection is only a fallback for a SEP instance +// whose Grafana provider is not wired up yet. It must never cover the OAuth +// routes: overwriting Authorization there would authenticate the exchange as +// SEP's internal service principal and mask whether the cookie path works at +// all. Retiring the injection entirely is a follow-up. const sepBackendUrl = env.PMM_DEV_SEP_BACKEND_URL || 'http://localhost:8000'; const sepInternalToken = env.PMM_DEV_SEP_INTERNAL_TOKEN; +const isSepAuthPath = (url: string | undefined) => + !!url && url.startsWith('/api/oauth/'); const sepProxy = () => ({ target: sepBackendUrl, secure: false, @@ -44,7 +52,10 @@ const sepProxy = () => ({ if (!sepInternalToken) { return; } - proxy.on('proxyReq', (proxyReq: unknown) => { + proxy.on('proxyReq', (proxyReq: unknown, req: unknown) => { + if (isSepAuthPath((req as { url?: string }).url)) { + return; + } (proxyReq as { setHeader: (k: string, v: string) => void }).setHeader( 'Authorization', `Bearer ${sepInternalToken}` diff --git a/ui/packages/sep/api/README.md b/ui/packages/sep/api/README.md index bea045d3707..1dd111546b2 100644 --- a/ui/packages/sep/api/README.md +++ b/ui/packages/sep/api/README.md @@ -30,11 +30,18 @@ packages. Anything that talks to the backend should go through here. the `openapi-fetch` `{ data, error }` tuple to throw the same shape. - **Token accessor pattern** — `setTokenProvider()` and `setOnUnauthorized()` let the auth layer plug in without the API package depending on auth state. +- **Token minter seam** — `setTokenMinter()` replaces _how_ a fresh token is + obtained. It defaults to the cookie-backed `POST /oauth/refresh`; an embedded + host that owns the session registers its own. Everything downstream — the + single-flight in `refreshAccessToken()`, the 401 retry in both transports, + the `setOnRefreshed()` notification — is minter-agnostic. - **Hooks** — `usePluginSchema`, `usePluginTasks`, `usePluginTask`, `useCreatePluginTask` (generic, predate codegen) and `useCurrentUser` (sample of the typed-hook pattern). -- **Auth functions** — `postLogin`, `postRefresh`, `fetchCurrentUser`. - Thin request wrappers consumed by the `AuthProvider` in `@sep/shell`. +- **Auth functions** — `postLogin`, `postRefresh`, `postSession`, + `postSessionExchange`, `postLogout`, `fetchCurrentUser`. Thin request wrappers + consumed by the `AuthProvider` in `@sep/shell`, and by PMM's embedded token + store for the session exchange. ## Usage @@ -60,6 +67,27 @@ setTokenProvider(() => currentAccessToken); setOnUnauthorized(() => redirectToLogin()); ``` +### Wire up auth in an embedded host that owns the session + +PMM has no SEP login flow and no refresh cookie: it trades its own session +cookie for a short-lived bearer, held in memory, and re-exchanges before expiry. +See `apps/pmm/src/sep/` for the store this wiring points at. + +```ts +import { + postSessionExchange, + setOnRefreshed, + setOnUnauthorized, + setTokenMinter, + setTokenProvider, +} from '@sep/api'; + +setTokenProvider(getSepToken); // synchronous read of the in-memory bearer +setTokenMinter(() => postSessionExchange()); // POST /oauth/session/exchange +setOnRefreshed(recordSepToken); // store it, schedule the next exchange +setOnUnauthorized(markSepSignedOut); // no login to redirect to +``` + ### Call the API ```ts diff --git a/ui/packages/sep/api/src/client.ts b/ui/packages/sep/api/src/client.ts index 30c9c6df130..442db2384e8 100644 --- a/ui/packages/sep/api/src/client.ts +++ b/ui/packages/sep/api/src/client.ts @@ -28,15 +28,50 @@ type TokenProvider = () => string | null; type OnUnauthorized = () => void; type OnRefreshed = (accessToken: string, expiresIn: number) => void; +/** + * Slim token payload every minting endpoint returns. Matches both + * `SPAOAuthTokenResponse` (`/oauth/refresh`) and `SessionExchangeTokenResponse` + * (`/oauth/session/exchange`), which mirror each other by design. + */ +export interface MintedToken { + access_token: string; + expires_in: number; +} + +/** + * Produces a fresh access token. Resolving `null` (or rejecting) means none + * could be obtained, which the caller treats as unauthorized. + */ +type TokenMinter = () => Promise; + let _getToken: TokenProvider = () => null; let _onUnauthorized: OnUnauthorized = () => {}; let _onRefreshed: OnRefreshed = () => {}; +let _mintToken: TokenMinter = mintViaRefreshCookie; /** Inject a callback that returns the current access token. */ export function setTokenProvider(provider: TokenProvider) { _getToken = provider; } +/** + * Replace how a fresh token is obtained. Defaults to the cookie-backed + * `POST /oauth/refresh` used by the standalone SPA. + * + * An embedded host that owns the session instead of SEP (PMM) registers a + * minter that exchanges its own session cookie via + * `POST /oauth/session/exchange`: no refresh cookie exists there, so the + * default would 401 on every recovery attempt. Everything downstream — + * single-flight coalescing in {@link refreshAccessToken}, the 401 retry in + * both transports, the `setOnRefreshed` notification — is transport-agnostic + * and works unchanged. + * + * Pass null to restore the default. + */ +export function setTokenMinter(minter: TokenMinter | null) { + _mintToken = minter ?? mintViaRefreshCookie; +} + /** Inject a callback invoked when the API receives an unauthorized response. */ export function setOnUnauthorized(handler: OnUnauthorized) { _onUnauthorized = handler; @@ -112,24 +147,47 @@ const isRefreshRequest = (url: string | undefined) => const isLoginRequest = (url: string | undefined) => !!url && url.includes('/oauth/login'); +/** + * Every endpoint that mints a token, whichever minter is registered. These + * must never enter the 401 retry path: `refreshAccessToken()` single-flights, + * so a 401 on the in-flight mint would hand the interceptor the very promise + * it is already running inside — an await on itself that never settles. + * + * Broader than {@link isRefreshRequest}, which still guards the unauthorized + * handler alone: a rejected mint on a session endpoint genuinely means "not + * signed in" and should reach the auth layer. + */ +export const isTokenMintRequest = (url: string | undefined) => + isRefreshRequest(url) || (!!url && url.includes('/oauth/session')); + // Internal marker so retried requests don't loop through the refresh path // again on a second 401. type RetriableConfig = InternalAxiosRequestConfig & { _retried?: boolean }; -// Single-flight refresh: concurrent callers (401 retry path + background -// timer + bootstrap) share one in-flight /oauth/refresh call. The promise -// resolves to the new access token on success and null on failure so +/** + * Default minter: rotate the `HttpOnly` refresh cookie for a new access token. + * A function declaration so it can back `_mintToken` above its own definition. + */ +async function mintViaRefreshCookie(): Promise { + const { data } = await apiClient.post('/oauth/refresh'); + return data; +} + +// Single-flight mint: concurrent callers (401 retry path + background +// timer + bootstrap) share one in-flight call to the registered minter. The +// promise resolves to the new access token on success and null on failure so // callers can decide whether to retry, surface the 401, or force logout. // -// All refresh traffic must funnel through here — the refresh token cookie -// rotates on every successful call, so parallel refreshes from different -// code paths would invalidate each other. +// All minting traffic must funnel through here — the default minter rotates +// the refresh token cookie on every successful call, so parallel refreshes +// from different code paths would invalidate each other, and a session +// exchange fanned out per request would hammer the identity provider. let refreshInFlight: Promise | null = null; /** - * Trigger (or join) the shared silent refresh. Resolves with the new - * access token, or null if the refresh failed (missing/invalid cookie, - * network error, Casdoor rejection). + * Trigger (or join) the shared silent mint. Resolves with the new access + * token, or null if minting failed (missing/invalid cookie or host session, + * network error, provider rejection). */ export function refreshAccessToken(): Promise { if (!refreshInFlight) { @@ -138,13 +196,13 @@ export function refreshAccessToken(): Promise { // from the externally-injected _onRefreshed handler must NOT be reported // as a failed refresh, otherwise the auth layer would force-logout a // user whose cookie rotation succeeded on the backend. - let data: { access_token: string; expires_in: number }; + let data: MintedToken; try { - const response = await apiClient.post<{ - access_token: string; - expires_in: number; - }>('/oauth/refresh'); - data = response.data; + const minted = await _mintToken(); + if (!minted) { + return null; + } + data = minted; } catch { return null; } finally { @@ -206,12 +264,12 @@ apiClient.interceptors.response.use( const url = config?.url; // 401 on a normal request: attempt one silent refresh, then retry. - // Skip the refresh/login endpoints themselves and already-retried requests. + // Skip the minting/login endpoints themselves and already-retried requests. if ( status === 401 && config && !config._retried && - !isRefreshRequest(url) && + !isTokenMintRequest(url) && !isLoginRequest(url) ) { const newToken = await refreshAccessToken(); diff --git a/ui/packages/sep/api/src/index.ts b/ui/packages/sep/api/src/index.ts index 7c6f340db65..cc4566e8b80 100644 --- a/ui/packages/sep/api/src/index.ts +++ b/ui/packages/sep/api/src/index.ts @@ -24,9 +24,11 @@ export { getToken, refreshAccessToken, setTokenProvider, + setTokenMinter, setOnUnauthorized, setOnRefreshed, } from './client'; +export type { MintedToken } from './client'; // Query client export { createQueryClient, defaultQueryClientConfig } from './queryClient'; diff --git a/ui/packages/sep/api/src/typed-client.ts b/ui/packages/sep/api/src/typed-client.ts index 33dbd0e1cd5..80f0abc63fb 100644 --- a/ui/packages/sep/api/src/typed-client.ts +++ b/ui/packages/sep/api/src/typed-client.ts @@ -36,7 +36,12 @@ * shape regardless of which client they use. */ import createClient, { type Client, type Middleware } from 'openapi-fetch'; -import { emitUnauthorized, getToken } from './client'; +import { + emitUnauthorized, + getToken, + isTokenMintRequest, + refreshAccessToken, +} from './client'; import { ApiError } from './errors'; import type { paths as MainPaths } from './generated/main'; import type { paths as SepPaths } from './generated/sep'; @@ -44,6 +49,15 @@ import type { paths as SepPaths } from './generated/sep'; const IS_DEV = import.meta.env.DEV; const isRefreshRequest = (url: string) => url.includes('/oauth/refresh'); +const isLoginRequest = (url: string) => url.includes('/oauth/login'); + +/** + * Whether a 401 on this URL is worth one silent mint-and-replay. Minting + * endpoints are the recovery mechanism itself and login carries its own + * credentials, so a 401 from either is the answer, not a stale token. + */ +const isReplayEligible = (url: string) => + !isTokenMintRequest(url) && !isLoginRequest(url); /** * A 200 HTML response (e.g. a follow of a login redirect) means the session @@ -55,12 +69,51 @@ function isHtmlLoginResponse(response: Response): boolean { return response.ok && ct.includes('text/html'); } +// `fetch` consumes a Request's body stream, so the instance handed to +// `onResponse` can no longer be re-sent. Stash an untouched clone taken before +// dispatch, keyed weakly so requests that never come back are not retained. +// +// Only replay-eligible requests are cloned: cloning buffers the body, and the +// endpoints excluded from the retry would never use theirs. +const pristineRequests = new WeakMap(); + +/** + * One silent recovery attempt for a 401: mint a fresh token — single-flighted + * with every other caller, including the axios transport — and replay the + * request with it. + * + * The replay goes through raw `fetch` rather than the typed client so it cannot + * re-enter this middleware; that bounds recovery to a single extra round-trip + * without needing a retry marker. Returns null when there is nothing to replay + * or no token could be minted. + */ +async function replayWithFreshToken( + request: Request +): Promise { + const pristine = pristineRequests.get(request); + if (!pristine) { + return null; + } + pristineRequests.delete(request); + + const token = await refreshAccessToken(); + if (!token) { + return null; + } + + pristine.headers.set('Authorization', `Bearer ${token}`); + return lazyFetch(pristine); +} + const authMiddleware: Middleware = { onRequest({ request }) { const token = getToken(); if (token) { request.headers.set('Authorization', `Bearer ${token}`); } + if (isReplayEligible(request.url)) { + pristineRequests.set(request, request.clone()); + } if (IS_DEV) { // eslint-disable-next-line no-console console.debug( @@ -85,10 +138,23 @@ const authMiddleware: Middleware = { }); } + if (response.status === 401 && isReplayEligible(request.url)) { + const replayed = await replayWithFreshToken(request); + if (replayed && replayed.status !== 401) { + return replayed; + } + // Minting failed, or the replay was rejected too — the session is gone. + emitUnauthorized(); + return replayed ?? response; + } + if ( (response.status === 401 || response.status === 303) && !isRefreshRequest(request.url) ) { + // A 401 left here is a minting endpoint rejecting the ambient session — + // "not signed in", which the auth layer must hear about. A 303 is the + // login redirect on any endpoint. emitUnauthorized(); } diff --git a/ui/packages/sep/api/tests/client.test.ts b/ui/packages/sep/api/tests/client.test.ts index 8aa43ab5f45..5891de09156 100644 --- a/ui/packages/sep/api/tests/client.test.ts +++ b/ui/packages/sep/api/tests/client.test.ts @@ -17,11 +17,13 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { postSessionExchange } from '../src/auth'; import { apiClient, refreshAccessToken, setOnRefreshed, setOnUnauthorized, + setTokenMinter, setTokenProvider, } from '../src/client'; import { ApiError } from '../src/errors'; @@ -42,6 +44,7 @@ afterEach(() => { setTokenProvider(() => null); setOnUnauthorized(() => {}); setOnRefreshed(() => {}); + setTokenMinter(null); }); describe('apiClient — Bearer token injection', () => { @@ -357,3 +360,145 @@ describe('apiClient — 401 refresh-retry', () => { expect(onUnauth).toHaveBeenCalledOnce(); }); }); + +describe('apiClient — pluggable token minter', () => { + it('recovers through the registered minter instead of /oauth/refresh', async () => { + let currentToken = 'old'; + setTokenProvider(() => currentToken); + setOnRefreshed((token) => { + currentToken = token; + }); + // The embedded PMM wiring: exchange the host session cookie, no refresh + // cookie exists. `/oauth/refresh` is deliberately left unhandled — MSW is + // configured to error on unhandled requests, so reaching it fails the test. + setTokenMinter(() => postSessionExchange()); + let exchanges = 0; + + server.use( + http.get(`${BASE}/api/protected`, ({ request }) => { + if (request.headers.get('Authorization') === 'Bearer minted') { + return HttpResponse.json({ ok: true }); + } + return HttpResponse.json({ detail: 'expired' }, { status: 401 }); + }), + http.post(`${BASE}/api/oauth/session/exchange`, () => { + exchanges += 1; + return HttpResponse.json({ access_token: 'minted', expires_in: 300 }); + }) + ); + + const res = await apiClient.get('/protected'); + + expect(res.status).toBe(200); + expect(exchanges).toBe(1); + expect(currentToken).toBe('minted'); + }); + + it('coalesces a burst of 401s into one exchange', async () => { + let currentToken = 'old'; + setTokenProvider(() => currentToken); + setOnRefreshed((token) => { + currentToken = token; + }); + setTokenMinter(() => postSessionExchange()); + let exchanges = 0; + + const protectedHandler = ({ request }: { request: Request }) => + request.headers.get('Authorization') === 'Bearer minted' + ? HttpResponse.json({ ok: true }) + : HttpResponse.json({ detail: 'expired' }, { status: 401 }); + + server.use( + http.get(`${BASE}/api/a`, protectedHandler), + http.get(`${BASE}/api/b`, protectedHandler), + http.get(`${BASE}/api/c`, protectedHandler), + http.post(`${BASE}/api/oauth/session/exchange`, async () => { + exchanges += 1; + // Hold the exchange open so all three 401s land while it is in flight. + await new Promise((resolve) => setTimeout(resolve, 20)); + return HttpResponse.json({ access_token: 'minted', expires_in: 300 }); + }) + ); + + const responses = await Promise.all([ + apiClient.get('/a'), + apiClient.get('/b'), + apiClient.get('/c'), + ]); + + expect(responses.map((r) => r.status)).toEqual([200, 200, 200]); + expect(exchanges).toBe(1); + }); + + it('does not re-enter the retry path when the exchange itself 401s', async () => { + // Regression guard: the mint is single-flighted, so routing its own 401 + // back through the retry interceptor would hand that interceptor the very + // promise it is running inside — an await on itself that never settles. + // A hang here surfaces as a test timeout, not a failed assertion. + setTokenProvider(() => 'old'); + const onUnauth = vi.fn(); + setOnUnauthorized(onUnauth); + setTokenMinter(() => postSessionExchange()); + let exchanges = 0; + + server.use( + http.get(`${BASE}/api/protected`, () => + HttpResponse.json({ detail: 'expired' }, { status: 401 }) + ), + http.post(`${BASE}/api/oauth/session/exchange`, () => { + exchanges += 1; + return HttpResponse.json({ detail: 'no session' }, { status: 401 }); + }) + ); + + await expect(apiClient.get('/protected')).rejects.toBeInstanceOf(ApiError); + expect(exchanges).toBe(1); + // Once for the rejected exchange, once for the unrecoverable request. + expect(onUnauth).toHaveBeenCalledTimes(2); + }); + + it('notifies the auth layer when the exchange endpoint rejects the session', async () => { + const onUnauth = vi.fn(); + setOnUnauthorized(onUnauth); + + server.use( + http.post(`${BASE}/api/oauth/session/exchange`, () => + HttpResponse.json({ detail: 'no session' }, { status: 401 }) + ) + ); + + await expect(postSessionExchange()).rejects.toBeInstanceOf(ApiError); + expect(onUnauth).toHaveBeenCalledOnce(); + }); + + it('treats a minter resolving null as a failed mint', async () => { + setTokenProvider(() => 'old'); + const onUnauth = vi.fn(); + setOnUnauthorized(onUnauth); + const minter = vi.fn(async () => null); + setTokenMinter(minter); + + server.use( + http.get(`${BASE}/api/protected`, () => + HttpResponse.json({ detail: 'expired' }, { status: 401 }) + ) + ); + + await expect(apiClient.get('/protected')).rejects.toBeInstanceOf(ApiError); + expect(minter).toHaveBeenCalledOnce(); + expect(onUnauth).toHaveBeenCalledOnce(); + }); + + it('restores the default /oauth/refresh minter when passed null', async () => { + setTokenMinter(async () => ({ access_token: 'custom', expires_in: 1 })); + setTokenMinter(null); + + server.use( + http.post(`${BASE}/api/oauth/refresh`, () => + HttpResponse.json({ access_token: 'from-cookie', expires_in: 300 }) + ) + ); + + await expect(refreshAccessToken()).resolves.toBe('from-cookie'); + }); +}); diff --git a/ui/packages/sep/api/tests/typed-client.test.ts b/ui/packages/sep/api/tests/typed-client.test.ts index 646bf610f24..a8988e25d5e 100644 --- a/ui/packages/sep/api/tests/typed-client.test.ts +++ b/ui/packages/sep/api/tests/typed-client.test.ts @@ -17,9 +17,14 @@ import { http, HttpResponse } from 'msw'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setOnUnauthorized, setTokenProvider } from '../src/client'; +import { + setOnRefreshed, + setOnUnauthorized, + setTokenMinter, + setTokenProvider, +} from '../src/client'; import { ApiError } from '../src/errors'; -import { mainApi, throwOnApiError } from '../src/typed-client'; +import { mainApi, sepApi, throwOnApiError } from '../src/typed-client'; import { server } from './msw-server'; // openapi-fetch builds absolute URLs from a `baseUrl`. The generated paths @@ -48,11 +53,17 @@ beforeEach(() => { }); setTokenProvider(() => null); setOnUnauthorized(() => {}); + setOnRefreshed(() => {}); + // Default to a minter that cannot recover, so the 401 tests below observe the + // give-up path without reaching the network. The recovery suite opts in. + setTokenMinter(async () => null); }); afterEach(() => { setTokenProvider(() => null); setOnUnauthorized(() => {}); + setOnRefreshed(() => {}); + setTokenMinter(null); if (ORIGINAL_LOCATION_DESCRIPTOR) { Object.defineProperty(globalThis, 'location', ORIGINAL_LOCATION_DESCRIPTOR); } else { @@ -126,6 +137,145 @@ describe('typed-client — auth middleware', () => { }); }); +describe('typed-client — 401 recovery', () => { + const mintOnce = (token: string) => { + const minter = vi.fn(async () => ({ + access_token: token, + expires_in: 300, + })); + setTokenMinter(minter); + return minter; + }; + + it('mints a fresh token and replays the request', async () => { + let currentToken = 'stale'; + setTokenProvider(() => currentToken); + setOnRefreshed((token) => { + currentToken = token; + }); + const minter = mintOnce('fresh'); + const onUnauth = vi.fn(); + setOnUnauthorized(onUnauth); + const seenAuth: Array = []; + + server.use( + http.get('http://localhost/api/users/me', ({ request }) => { + const auth = request.headers.get('Authorization'); + seenAuth.push(auth); + if (auth === 'Bearer fresh') { + return HttpResponse.json({ id: 'abc', username: 'u' }); + } + return HttpResponse.json({ detail: 'expired' }, { status: 401 }); + }) + ); + + const user = await throwOnApiError(mainApi.GET('/api/users/me')); + + expect(user).toMatchObject({ id: 'abc' }); + expect(seenAuth).toEqual(['Bearer stale', 'Bearer fresh']); + expect(minter).toHaveBeenCalledOnce(); + expect(onUnauth).not.toHaveBeenCalled(); + }); + + it('replays a request body — `fetch` consumed the original stream', async () => { + setTokenProvider(() => 'stale'); + mintOnce('fresh'); + const seenBodies: unknown[] = []; + + server.use( + http.post( + 'http://localhost/api/apps/inventory/sync/', + async ({ request }) => { + const auth = request.headers.get('Authorization'); + seenBodies.push(await request.json()); + if (auth === 'Bearer fresh') { + return HttpResponse.json({ status: 'queued' }); + } + return HttpResponse.json({ detail: 'expired' }, { status: 401 }); + } + ) + ); + + await throwOnApiError( + sepApi.POST('/api/apps/inventory/sync/', { + body: { syncer: 'mod.Cls' }, + }) + ); + + expect(seenBodies).toEqual([{ syncer: 'mod.Cls' }, { syncer: 'mod.Cls' }]); + }); + + it('replays at most once, then reports unauthorized', async () => { + setTokenProvider(() => 'stale'); + const minter = mintOnce('fresh'); + const onUnauth = vi.fn(); + setOnUnauthorized(onUnauth); + let calls = 0; + + server.use( + http.get('http://localhost/api/users/me', () => { + calls += 1; + return HttpResponse.json({ detail: 'expired' }, { status: 401 }); + }) + ); + + await expect( + throwOnApiError(mainApi.GET('/api/users/me')) + ).rejects.toSatisfy((err) => err instanceof ApiError && err.status === 401); + expect(calls).toBe(2); + expect(minter).toHaveBeenCalledOnce(); + expect(onUnauth).toHaveBeenCalledOnce(); + }); + + it('shares one mint across concurrent 401s', async () => { + let currentToken = 'stale'; + setTokenProvider(() => currentToken); + setOnRefreshed((token) => { + currentToken = token; + }); + let mints = 0; + setTokenMinter(async () => { + mints += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + return { access_token: 'fresh', expires_in: 300 }; + }); + + server.use( + http.get('http://localhost/api/users/me', ({ request }) => + request.headers.get('Authorization') === 'Bearer fresh' + ? HttpResponse.json({ id: 'abc', username: 'u' }) + : HttpResponse.json({ detail: 'expired' }, { status: 401 }) + ) + ); + + const results = await Promise.all([ + throwOnApiError(mainApi.GET('/api/users/me')), + throwOnApiError(mainApi.GET('/api/users/me')), + ]); + + expect(results).toHaveLength(2); + expect(mints).toBe(1); + }); + + it('does not attempt recovery when the exchange endpoint itself 401s', async () => { + const minter = mintOnce('fresh'); + const onUnauth = vi.fn(); + setOnUnauthorized(onUnauth); + + server.use( + http.post('http://localhost/api/oauth/session/exchange', () => + HttpResponse.json({ detail: 'no session' }, { status: 401 }) + ) + ); + + await mainApi.POST('/api/oauth/session/exchange'); + + expect(minter).not.toHaveBeenCalled(); + // A rejected exchange is "not signed in" and must reach the auth layer. + expect(onUnauth).toHaveBeenCalledOnce(); + }); +}); + describe('throwOnApiError', () => { it('returns typed data on 2xx', async () => { server.use(