/** * native-auth-decisions.ts * * Pure decision helpers extracted from main.ts for the RFC 8252 native-app * auth flow. These encode three choices that were each the site of a real * runtime bug — invisible to the mocked flow tests because the tests never * exercised the real main.ts internals. Keeping them pure + unit-tested here * prevents silent regressions: * * 1. resolveJsonBody — the token/refresh POST body must be the raw * object (fetchJson owns JSON.stringify). Pre-stringifying double-encodes * it into a JSON string, which the gateway's Pydantic model rejects with * 422 "Input should be a valid dictionary". * * 2. oauthSessionIsLive — an OAuth gateway is "signed in" when EITHER a * native bearer token OR a live cookie session exists. Gating on the * cookie alone rejects a completed native login and loops the UI into * "not signed in". * * 3. resolveOauthRestAuth — an oauth-mode REST call authenticates with the * native bearer when present, else the cookie partition. Cookie-only * routing returns 401 no_cookie for a cookieless native session. * * 4. resolveReadinessProbeAuth — the boot readiness probe must authenticate * the same way the rest of the connection does. A credential-free probe * against a gated gateway 401s forever; worse, it cannot tell a missing * route from a rejected session (see backend-health.ts). * * 5. oauthGuardMayHardFail — `auth_required: true` means "this gateway is * gated", NOT "this gateway speaks OAuth". A password-provider gateway * can satisfy neither the native-bearer nor the OAuth-partition-cookie * check by design, so the pre-flight guard must not hard-fail it. * * All five are trivial once named; the value is the test that pins the * contract so the god-file call sites can't drift back to the buggy shape. */ /** * Decide the request body to hand to fetchJson (which JSON.stringifies it). * Returns the object UNCHANGED — callers must NOT pre-stringify. A string here * would be double-encoded downstream; this function exists to document and * pin that contract at the one seam that got it wrong. */ export function resolveJsonBody(body: T): T { return body } /** * True when an oauth gateway should be treated as signed-in. `hasNativeToken` * is whether a native bearer token is stored; `hasCookieSession` is whether a * live AT-or-RT cookie exists in the OAuth partition. Either suffices. */ export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: boolean): boolean { return hasNativeToken || hasCookieSession } export type OauthRestAuth = { kind: 'bearer'; token: string } | { kind: 'cookie' } /** * Decide how an oauth-mode REST request authenticates: prefer the native * bearer (cookieless RFC 8252 flow) when a non-empty access token is present, * otherwise fall back to the cookie partition. `nativeAccessToken` is the * result of ensureNativeAccessToken (null/empty when there is no native * session or the refresh terminally failed). */ export function resolveOauthRestAuth(nativeAccessToken: string | null | undefined): OauthRestAuth { if (nativeAccessToken) { return { kind: 'bearer', token: nativeAccessToken } } return { kind: 'cookie' } } export type ReadinessProbeAuth = OauthRestAuth | { kind: 'token'; token: string | null } | { kind: 'public' } /** * Decide how the boot readiness probe authenticates. * * The probe must present the SAME credentials the rest of the connection * will use. A credential-free probe against a gated gateway 401s until the * boot deadline even though the session is perfectly valid — and because the * dashboard auth gate runs ahead of the SPA catch-all, an unknown `/api/*` * path answers 401 rather than 404, so the probe also cannot detect a backend * that predates `/api/health`. Sending credentials is what lets a missing * route surface as a real 404 (see `isMissingHealthEndpointError`). * * `oauth` reuses `resolveOauthRestAuth` so the probe and every other oauth * REST call make the identical bearer-vs-cookie choice. `token` presents the * connection's session token. `local` (and anything unrecognized) stays * public: a loopback backend has no gate, and sending credentials it never * issued would be meaningless. */ export function resolveReadinessProbeAuth( authMode: string | null | undefined, nativeAccessToken?: string | null, connectionToken?: string | null ): ReadinessProbeAuth { if (authMode === 'oauth') { return resolveOauthRestAuth(nativeAccessToken) } if (authMode === 'token') { return { kind: 'token', token: connectionToken ?? null } } return { kind: 'public' } } export interface AdvertisedAuthProvider { name?: string supportsPassword?: boolean } /** * Whether the oauth pre-flight guard may hard-fail a connection for "not * signed in". * * `authModeFromStatus` maps the gateway's `auth_required: true` onto * `'oauth'`, but that flag only means the dashboard is GATED — it says * nothing about how you authenticate. A gateway whose providers are all * username/password cannot satisfy the guard's checks by construction: * `start_login` raises NotImplementedError, `/auth/native/authorize` rejects * password providers, and its cookies are set by a plain password-login POST * rather than the `/auth/callback` redirect the OAuth partition is primed * for. Hard-failing there rejects a live session one line before the * ws-ticket mint that would have succeeded against that very partition. * * Returns false only when EVERY advertised provider is password-based. An * unknown or empty list keeps the strict guard, so backends that predate * `/api/auth/providers` are unaffected. */ export function oauthGuardMayHardFail(providers: AdvertisedAuthProvider[] | null | undefined): boolean { if (!Array.isArray(providers) || providers.length === 0) { return true } const named = providers.filter(provider => provider && typeof provider === 'object' && provider.name) if (named.length === 0) { return true } return !named.every(provider => provider.supportsPassword) }