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
1 change: 1 addition & 0 deletions changelog.d/SEP-1760.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
React UI: `@sep/api` gained `setTokenMinter()`, which replaces how the client obtains a fresh access token. It defaults to the existing cookie-backed `POST /oauth/refresh`, so a standalone SEP deployment is unaffected; a host that embeds SEP and owns the session registers a minter that calls `POST /api/oauth/session/exchange` (SEP-1692) instead, which is the only workable path where no refresh cookie exists. The `openapi-fetch` transport also gained the one-shot 401 mint-and-replay the axios transport already had, so typed hooks recover from an expired token instead of surfacing the failure — this applies to both deployments.
1 change: 1 addition & 0 deletions changelog.d/SEP-1760.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
React UI: keep the unsaved-changes prompt after a blocked submit, stop an enable/disable toggle from clearing a scheduled task's arguments, reject silently-truncated numeric input and stop a whitespace-only numeric field from submitting as 0, keep step-less task log lines, let a failed executor-host lookup be retried by reopening the field, bound execution-event stream reconnects, and no longer render a white table in dark mode.
5 changes: 3 additions & 2 deletions frontend/oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "import", "typescript", "unicorn", "oxc"],
"rules": {
"no-console": "error",
"no-debugger": "error",
Expand Down Expand Up @@ -35,5 +36,5 @@
"unicorn/prefer-array-flat-map": "warn",
"unicorn/prefer-includes": "warn"
},
"ignorePatterns": ["dist", "node_modules", "storybook-static", "*.config.ts", "*.config.js"]
"ignorePatterns": ["dist", "node_modules", "storybook-static"]
}
33 changes: 31 additions & 2 deletions frontend/packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`; a host that
embeds SEP and 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** — `useAppSchema`, `useAppTasks`, `useAppTask`,
`useCreateAppTask` (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 an embedding host's
token store for the session exchange.

## Usage

Expand All @@ -60,6 +67,28 @@ setTokenProvider(() => currentAccessToken);
setOnUnauthorized(() => redirectToLogin());
```

### Wire up auth in an embedded host that owns the session

PMM embeds SEP with no SEP login flow and no refresh cookie: it trades its own
session cookie for a short-lived bearer, holds it in memory, and re-exchanges
before expiry. Only the minter differs — the retry, coalescing, and expiry
plumbing are shared.

```ts
import {
postSessionExchange,
setOnRefreshed,
setOnUnauthorized,
setTokenMinter,
setTokenProvider,
} from '@sep/api';

setTokenProvider(getHostToken); // synchronous read of the in-memory bearer
setTokenMinter(() => postSessionExchange()); // POST /oauth/session/exchange
setOnRefreshed(recordHostToken); // store it, schedule the next exchange
setOnUnauthorized(markHostSignedOut); // no SEP login to redirect to
```

### Call the API

```ts
Expand Down
103 changes: 85 additions & 18 deletions frontend/packages/api/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,49 @@ 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<MintedToken | null>;

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` this SPA uses.
*
* A host that embeds SEP and owns the session itself (PMM) registers a minter
* that exchanges its own session cookie via `POST /oauth/session/exchange`:
* there is no refresh cookie in that deployment, 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 minter-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;
Expand Down Expand Up @@ -115,23 +149,42 @@ const isLoginRequest = (url: string | undefined) => !!url && url.includes('/oaut
// React-side fallback.
const isSessionRequest = (url: string | undefined) => !!url && url.includes('/oauth/session');

/**
* 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.
*/
export const isTokenMintRequest = (url: string | undefined) =>
isRefreshRequest(url) || isSessionRequest(url);

// 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<MintedToken> {
const { data } = await apiClient.post<MintedToken>('/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<string | null> | null = null;

/**
* Trigger (or join) the shared silent refresh. Resolves with the new
* access token, or null if the refresh failed (missing/invalid cookie,
* 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, Casdoor rejection).
*/
export function refreshAccessToken(): Promise<string | null> {
Expand All @@ -141,12 +194,13 @@ export function refreshAccessToken(): Promise<string | null> {
// 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 {
Expand All @@ -156,7 +210,21 @@ export function refreshAccessToken(): Promise<string | null> {
refreshInFlight = null;
});
}
_onRefreshed(data.access_token, data.expires_in);
try {
_onRefreshed(data.access_token, data.expires_in);
} catch (handlerError) {
// A throwing auth-layer handler must not invalidate a cookie rotation
// that already succeeded on the backend: it would reject the shared
// promise and force-logout every awaiting caller. It does leave the
// auth layer without the new token or its expiry while this function
// still returns the token, so record that inconsistency — never the
// token or expiry themselves.
// eslint-disable-next-line no-console
console.error(
'[api] onRefreshed handler threw; the rotated token was not recorded',
handlerError,
);
}
return data.access_token;
})();
}
Expand Down Expand Up @@ -192,14 +260,13 @@ 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) &&
!isLoginRequest(url) &&
!isSessionRequest(url)
!isTokenMintRequest(url) &&
!isLoginRequest(url)
) {
const newToken = await refreshAccessToken();
if (newToken) {
Expand Down
2 changes: 2 additions & 0 deletions frontend/packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
60 changes: 59 additions & 1 deletion frontend/packages/api/src/typed-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,22 @@
* 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';

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
Expand All @@ -55,12 +63,49 @@ 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<Request, Request>();

/**
* 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<Response | null> {
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(`[api] → ${request.method} ${new URL(request.url).pathname}`);
Expand All @@ -83,7 +128,20 @@ 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();
}

Expand Down
Loading
Loading