diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5bdb2..35e9d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Automatic session-layer bearer-token refresh for child-scoped clients. A + `SynergiaApiClient` obtained from `LibrusSession.forChild()` now transparently + refreshes its bearer token and retries the request once on a `401`, instead of + surfacing the expired-token error. Concurrent `401`s for the same child + collapse to a single refresh, and a `401` that persists after refresh throws + `LibrusAuthenticationError` with code `AUTH_REFRESH_FAILED` (the original + `401` preserved as `cause`). `LibrusSession.refreshBearerToken(childId)` is + also available directly. Standalone `new SynergiaApiClient(token)` is + unchanged and still throws on `401`. - Automatic retry with exponential backoff and jitter for transient Synergia failures (`408, 425, 429, 500, 502, 503, 504`). Idempotent methods (`GET`/`HEAD`) only by default, honors `Retry-After` on `429`, and respects an diff --git a/README.md b/README.md index 50e0404..bc440dc 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ Current high-level methods on `LibrusSession`: - `forChild(selectorOrChild)` - `forChildWiadomosci(selectorOrChild)` - `forChildBff(selectorOrChild)` +- `refreshBearerToken(childId)` — manually refresh the Synergia bearer for a child (normally called automatically on 401) Current methods on `PortalClient`: diff --git a/src/sdk/LibrusSession.ts b/src/sdk/LibrusSession.ts index 3e2410d..8f390a9 100644 --- a/src/sdk/LibrusSession.ts +++ b/src/sdk/LibrusSession.ts @@ -16,6 +16,7 @@ import { type WiadomosciMessagesClientOptions, } from "./wiadomosci/WiadomosciMessagesClient.js"; import { + LibrusApiError, LibrusConfigurationError, LibrusSdkError, type ChildAccount, @@ -122,6 +123,22 @@ export class LibrusSession { > | undefined; private accountsCache?: SynergiaAccountsResponse; + /** + * Latest known bearer token per child id (keyed by `String(id)`). Updated + * after every successful portal refresh so that: + * - `forChild(childObject)` always starts with the freshest token even when + * the caller holds a stale `ChildAccount` reference. + * - A delayed `401` (one that fires after an in-flight refresh has already + * cleared `refreshInFlight`) can detect the superseded token and return + * the already-refreshed value without a second portal round-trip. + */ + private readonly latestTokenPerChild = new Map(); + /** + * In-flight bearer-token refreshes keyed by child id. Collapses concurrent + * refreshes for the same child to a single portal round-trip (stampede + * protection) while the refresh is pending. + */ + private readonly refreshInFlight = new Map>(); constructor(options: LibrusSessionOptions) { if (options.authMode !== undefined) { @@ -384,7 +401,94 @@ export class LibrusSession { ? await this.resolveChild(selectorOrChild) : selectorOrChild; - return new SynergiaApiClient(child.accessToken, this.synergiaClientOptions); + const token = + this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken; + + return new SynergiaApiClient(token, { + ...this.synergiaClientOptions, + onAuthInvalidated: (staleToken: string) => + this.acquireFreshToken(String(child.id), staleToken), + } as SynergiaApiClientOptions); + } + + /** + * Re-fetch a fresh Synergia bearer token for a known child, reusing the + * cached portal login (and re-logging in if the portal session itself is + * dead). Concurrent calls for the same child share one in-flight promise. + */ + async refreshBearerToken(childId: string | number): Promise { + this.assertApiV3Backend("refreshBearerToken"); + return this.acquireFreshToken(String(childId), undefined); + } + + /** + * Internal refresh path used by the `onAuthInvalidated` callback. Accepts + * the token the client actually used for the failed request so it can detect + * when a sibling request already refreshed to a newer token and skip the + * portal round-trip. + */ + private acquireFreshToken( + key: string, + staleToken: string | undefined, + ): Promise { + if (staleToken !== undefined) { + const latest = this.latestTokenPerChild.get(key); + + if (latest !== undefined && latest !== staleToken) { + return Promise.resolve(latest); + } + } + + const existing = this.refreshInFlight.get(key); + + if (existing) { + return existing; + } + + const promise = this.performBearerTokenRefresh(key).finally(() => { + this.refreshInFlight.delete(key); + }); + + this.refreshInFlight.set(key, promise); + + return promise; + } + + private async performBearerTokenRefresh(childId: string): Promise { + const child = await this.resolveChild(childId); + const portalClient = this.getPortalClient(); + + await this.login(); + + let fresh: ChildAccount; + + try { + fresh = await portalClient.getFreshSynergiaAccount(child.login); + } catch (error) { + if (!isUnauthorized(error)) { + throw error; + } + + // The cached portal session is dead. Force a fresh login and retry once. + await portalClient.login(this.getPortalCredentials()); + fresh = await portalClient.getFreshSynergiaAccount(child.login); + } + + // Record the latest token so delayed 401s and forChild(childObject) calls + // can pick up the fresh value without another portal round-trip. + this.latestTokenPerChild.set(String(fresh.id), fresh.accessToken); + + // Keep accountsCache in sync so resolveChild() returns the fresh account. + if (this.accountsCache) { + this.accountsCache = { + ...this.accountsCache, + accounts: this.accountsCache.accounts.map((a) => + a.id === fresh.id ? fresh : a, + ), + }; + } + + return fresh.accessToken; } async forChildWiadomosci( @@ -401,10 +505,15 @@ export class LibrusSession { portalClient: this.getPortalClient(), }); - return new SynergiaApiClient(child.accessToken, { + const token = + this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken; + + return new SynergiaApiClient(token, { ...this.synergiaClientOptions, messageBackend, - }); + onAuthInvalidated: (staleToken: string) => + this.acquireFreshToken(String(child.id), staleToken), + } as SynergiaApiClientOptions); } async forChildBff( @@ -415,7 +524,10 @@ export class LibrusSession { typeof selectorOrChild === "string" ? await this.resolveChild(selectorOrChild) : selectorOrChild; - return new BffApiClient(child.accessToken, this.bffClientOptions); + const token = + this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken; + + return new BffApiClient(token, this.bffClientOptions); } private getPortalCredentials(): PortalCredentials { @@ -485,6 +597,10 @@ export class LibrusSession { } } +function isUnauthorized(error: unknown): boolean { + return error instanceof LibrusApiError && error.details?.status === 401; +} + function resolveApiBackend(env: NodeJS.ProcessEnv): ApiBackendEnvValue { const rawApiBackend = env.LIBRUS_API_BACKEND; const rawAuthMode = env.LIBRUS_AUTH_MODE; diff --git a/src/sdk/models/errors.ts b/src/sdk/models/errors.ts index efccf60..bf8318e 100644 --- a/src/sdk/models/errors.ts +++ b/src/sdk/models/errors.ts @@ -1,3 +1,10 @@ +/** + * Error code used when a session-layer bearer-token refresh still yields a 401. + * Surfaced on {@link LibrusAuthenticationError} so callers can distinguish a + * failed refresh from an initial authentication failure. + */ +export const CODE_AUTH_REFRESH_FAILED = "AUTH_REFRESH_FAILED"; + export interface LibrusErrorDetails { endpoint?: string; issues?: string[]; @@ -7,12 +14,21 @@ export interface LibrusErrorDetails { [key: string]: unknown; } +export interface LibrusSdkErrorOptions { + cause?: unknown; +} + export class LibrusSdkError extends Error { readonly code: string; readonly details: LibrusErrorDetails | undefined; - constructor(code: string, message: string, details?: LibrusErrorDetails) { - super(message); + constructor( + code: string, + message: string, + details?: LibrusErrorDetails, + options?: LibrusSdkErrorOptions, + ) { + super(message, options); this.name = "LibrusSdkError"; this.code = code; this.details = details; @@ -30,9 +46,17 @@ export class LibrusApiError extends LibrusSdkError { } } +export interface LibrusAuthenticationErrorOptions extends LibrusSdkErrorOptions { + code?: string; +} + export class LibrusAuthenticationError extends LibrusSdkError { - constructor(message = "Portal authentication failed") { - super("AUTHENTICATION_FAILED", message); + constructor( + message = "Portal authentication failed", + options: LibrusAuthenticationErrorOptions = {}, + ) { + const { code = "AUTHENTICATION_FAILED", ...sdkOptions } = options; + super(code, message, undefined, sdkOptions); this.name = "LibrusAuthenticationError"; } } diff --git a/src/sdk/synergia/SynergiaApiClient.ts b/src/sdk/synergia/SynergiaApiClient.ts index 835ce91..86afc6e 100644 --- a/src/sdk/synergia/SynergiaApiClient.ts +++ b/src/sdk/synergia/SynergiaApiClient.ts @@ -1,5 +1,9 @@ import type { FetchLike } from "../models/common.js"; -import { LibrusApiError } from "../models/errors.js"; +import { + CODE_AUTH_REFRESH_FAILED, + LibrusApiError, + LibrusAuthenticationError, +} from "../models/errors.js"; import type { SchoolNoticeResponse, SchoolNoticesResponse, @@ -221,6 +225,17 @@ export interface SynergiaApiClientOptions { logger?: Logger; } +/** + * @internal Not exported. {@link LibrusSession.forChild} and + * {@link LibrusSession.forChildWiadomosci} extend the public options with this + * callback so the session can refresh an expired bearer token transparently. + * Keeping it off the exported interface prevents it from appearing in the + * generated `.d.ts`. + */ +interface SynergiaApiClientInternalOptions extends SynergiaApiClientOptions { + onAuthInvalidated?: (staleToken: string) => Promise; +} + type SynergiaId = string | number; const MESSAGES_SCOPE = "messages"; @@ -230,15 +245,21 @@ const MESSAGES_SCOPE_HINT = export class SynergiaApiClient { private readonly fetchImpl: FetchLike; private readonly apiBaseUrl: string; - private readonly accessToken: string; + private accessToken: string; private readonly authMode: SynergiaAuthMode; private readonly messageBackend: MessageReadBackend | undefined; private readonly requestTimeoutMs: number; private readonly logger: Logger; + private readonly onAuthInvalidated: + | ((staleToken: string) => Promise) + | undefined; constructor(accessToken: string, options: SynergiaApiClientOptions = {}) { this.accessToken = accessToken; this.authMode = options.authMode ?? "bearer"; + this.onAuthInvalidated = ( + options as SynergiaApiClientInternalOptions + ).onAuthInvalidated; this.logger = options.logger ?? noopLogger; this.requestTimeoutMs = resolveRequestTimeoutMs(options.requestTimeoutMs); const timeoutFetch = wrapFetchWithTimeout( @@ -264,15 +285,17 @@ export class SynergiaApiClient { schema: TSchema, options: SynergiaRequestOptions = {}, ): Promise> { - return requestGetJson( - this.fetchImpl, - this.accessToken, - this.apiBaseUrl, - path, - schema, - options, - this.authMode, - this.logger, + return this.withAuthRefresh(() => + requestGetJson( + this.fetchImpl, + this.accessToken, + this.apiBaseUrl, + path, + schema, + options, + this.authMode, + this.logger, + ), ); } @@ -280,17 +303,64 @@ export class SynergiaApiClient { path: string, options: SynergiaRequestOptions = {}, ): Promise { - return requestGetBinary( - this.fetchImpl, - this.accessToken, - this.apiBaseUrl, - path, - options, - this.authMode, - this.logger, + return this.withAuthRefresh(() => + requestGetBinary( + this.fetchImpl, + this.accessToken, + this.apiBaseUrl, + path, + options, + this.authMode, + this.logger, + ), ); } + /** + * Run a GET request, refreshing the bearer token and retrying **once** if the + * session supplied an `onAuthInvalidated` callback and the request fails with + * a `401`. `run` reads `this.accessToken` at call-time, so the retry uses the + * freshly refreshed token. Without a callback the original error propagates + * unchanged, preserving the standalone-client contract. + * + * The token is captured before `run()` so that parallel requests on the same + * client each report the token they actually used, even if a sibling handler + * has already updated `this.accessToken` by the time the catch block fires. + * + * Safe only for idempotent (GET) requests — see the invariant note in + * `request.ts`. + */ + private async withAuthRefresh(run: () => Promise): Promise { + // Capture before run() so a concurrent sibling that already refreshed + // this.accessToken does not pollute our stale-token value. + const tokenUsed = this.accessToken; + + try { + return await run(); + } catch (error) { + if (!this.onAuthInvalidated || !isUnauthorized(error)) { + throw error; + } + + this.accessToken = await this.onAuthInvalidated(tokenUsed); + + try { + return await run(); + } catch (retryError) { + if (isUnauthorized(retryError)) { + // Preserve the original (pre-refresh) 401 as cause so callers can + // inspect the initial expired-token response. + throw new LibrusAuthenticationError( + "Synergia request failed with 401 after refreshing the bearer token.", + { code: CODE_AUTH_REFRESH_FAILED, cause: error }, + ); + } + + throw retryError; + } + } + } + private async withMessageAccessDiagnostics( request: () => Promise, ): Promise { @@ -887,3 +957,7 @@ export class SynergiaApiClient { ); } } + +function isUnauthorized(error: unknown): boolean { + return error instanceof LibrusApiError && error.details?.status === 401; +} diff --git a/src/sdk/synergia/request.ts b/src/sdk/synergia/request.ts index 09af59c..40db457 100644 --- a/src/sdk/synergia/request.ts +++ b/src/sdk/synergia/request.ts @@ -20,6 +20,12 @@ export interface SynergiaRequestOptions { export type SynergiaAuthMode = "bearer" | "cookie"; +// Invariant: every request helper here issues a GET. This keeps them safe to +// replay — both the transient-failure retry (`wrapFetchWithRetry`) and the +// session-layer 401 auth-refresh retry (`SynergiaApiClient.withAuthRefresh`) +// assume idempotency. A future mutating helper (POST/PUT/DELETE) must NOT +// inherit either retry path; add it with its own non-retrying request flow. + const LIBRUS_MOBILE_ORIGIN = "app://librus"; const LIBRUS_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 LibrusMobileApp"; diff --git a/test/librus-session.test.ts b/test/librus-session.test.ts index cf84004..c133708 100644 --- a/test/librus-session.test.ts +++ b/test/librus-session.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { + CODE_AUTH_REFRESH_FAILED, + LibrusApiError, + LibrusAuthenticationError, LibrusConfigurationError, LibrusSession, + SynergiaApiClient, type ChildAccount, } from "../src/sdk/index.js"; import { PortalClient } from "../src/sdk/portal/PortalClient.js"; @@ -1017,3 +1021,421 @@ describe("LibrusSession.resolveChild", () => { expect(readSynergiaClientRequestTimeoutMs(childClient)).toBe(123); }); }); + +const REFRESH_SECRETS = { + email: "parent@example.com", + password: "super-secret-password", + staleToken: "stale-bearer-AAA", + freshToken: "fresh-bearer-BBB", +} as const; + +function authHeaderOf(init: RequestInit | undefined): string | undefined { + return (init?.headers as Record | undefined)?.authorization; +} + +function unauthorizedResponse(): Response { + return new Response(JSON.stringify({ error: "expired" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); +} + +function luckyNumberResponse(): Response { + return new Response( + JSON.stringify({ + LuckyNumber: { LuckyNumber: 13 }, + Resources: {}, + Url: "https://api.librus.pl/3.0/LuckyNumbers", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +describe("LibrusSession token refresh", () => { + it("refreshes the bearer token once on a 401 and retries with the fresh token", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + + const seenAuth: Array = []; + const fetchMock = vi.fn(async (_input, init) => { + seenAuth.push(authHeaderOf(init)); + + return seenAuth.length === 1 + ? unauthorizedResponse() + : luckyNumberResponse(); + }); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + const client = await session.forChild(child); + const lucky = await client.getLuckyNumber(); + + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + expect(getFreshSynergiaAccount).toHaveBeenCalledWith("child-login"); + expect(seenAuth).toEqual([ + `Bearer ${REFRESH_SECRETS.staleToken}`, + `Bearer ${REFRESH_SECRETS.freshToken}`, + ]); + expect(lucky.LuckyNumber.LuckyNumber).toBe(13); + }); + + it("collapses concurrent refreshes for the same child into a single portal call", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + let resolveFresh!: (account: ChildAccount) => void; + const pendingRefresh = new Promise((resolve) => { + resolveFresh = resolve; + }); + getFreshSynergiaAccount.mockReturnValue(pendingRefresh); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + }); + + // Invoked synchronously in sequence: the first registers the in-flight + // refresh, the rest must reuse it rather than starting their own. + const refreshes = Promise.all([ + session.refreshBearerToken(101), + session.refreshBearerToken(101), + session.refreshBearerToken("101"), + ]); + + await vi.waitFor(() => { + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + }); + resolveFresh( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + + await expect(refreshes).resolves.toEqual([ + REFRESH_SECRETS.freshToken, + REFRESH_SECRETS.freshToken, + REFRESH_SECRETS.freshToken, + ]); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + }); + + it("refreshBearerToken always triggers a portal call regardless of the latest-token cache", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + }); + + const first = await session.refreshBearerToken(101); + expect(first).toBe(REFRESH_SECRETS.freshToken); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + + // A second explicit refresh (no stale-token context) always goes to the + // portal — the stale-token short-circuit is internal to onAuthInvalidated. + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: "fresh-bearer-CCC", + }), + ); + const second = await session.refreshBearerToken(101); + expect(second).toBe("fresh-bearer-CCC"); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(2); + }); + + it("single-client parallel 401s collapse to one portal refresh even when the second 401 arrives after the first refresh clears", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + + // Gate: hold the second stale-token 401 response so it arrives only after + // the first refresh has fully completed. Released via vi.waitFor below so + // that a macrotask boundary guarantees latestTokenPerChild is set. + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + let callCount = 0; + const fetchMock = vi.fn(async (_input, init) => { + callCount++; + const auth = authHeaderOf(init); + const isStale = auth === `Bearer ${REFRESH_SECRETS.staleToken}`; + + if (callCount === 2 && isStale) { + // Deterministically delay this second stale 401 until after + // performBearerTokenRefresh has set latestTokenPerChild. + await gate; + } + + return isStale ? unauthorizedResponse() : luckyNumberResponse(); + }); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + const client = await session.forChild(child); + + // Fire both requests. The second stale 401 is held by the gate. + const allPromise = Promise.all([ + client.getLuckyNumber(), + client.getLuckyNumber(), + ]); + + // A macrotask boundary guarantees all pending microtasks (including + // latestTokenPerChild.set inside performBearerTokenRefresh) have run. + await vi.waitFor(() => { + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + }); + + // Release the gate. The second stale 401 now arrives after the refresh, + // exercising the latestTokenPerChild short-circuit path in acquireFreshToken. + releaseGate(); + + const [a, b] = await allPromise; + + expect(a.LuckyNumber.LuckyNumber).toBe(13); + expect(b.LuckyNumber.LuckyNumber).toBe(13); + // Exactly one portal refresh, not two. + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + // Four fetch calls: stale×2 (initial requests), fresh×2 (retries). + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("forChild with a stale ChildAccount object starts with the refreshed token after a refresh", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + + const seenAuth: Array = []; + const fetchMock = vi.fn(async (_input, init) => { + seenAuth.push(authHeaderOf(init)); + return luckyNumberResponse(); + }); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + // Refresh outside of a client call; the session now holds the fresh token. + await session.refreshBearerToken(child.id); + + // Pass the *original* stale child object — forChild must still use the + // fresh token that was produced by the earlier refresh. + const client = await session.forChild(child); + await client.getLuckyNumber(); + + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + expect(seenAuth).toEqual([`Bearer ${REFRESH_SECRETS.freshToken}`]); + }); + + it("throws AUTH_REFRESH_FAILED preserving the original 401 when a 401 persists after refresh", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + // Track which Authorization header was in use when each 401 occurred. + const authAtCall: Array = []; + const fetchMock = vi.fn(async (_input, init) => { + authAtCall.push(authHeaderOf(init)); + return unauthorizedResponse(); + }); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + const client = await session.forChild(child); + const error = await client + .getLuckyNumber() + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(LibrusAuthenticationError); + expect((error as LibrusAuthenticationError).code).toBe( + CODE_AUTH_REFRESH_FAILED, + ); + const cause = (error as LibrusAuthenticationError).cause; + expect(cause).toBeInstanceOf(LibrusApiError); + expect((cause as LibrusApiError).details?.status).toBe(401); + + // cause must be the *first* (pre-refresh) 401 — the one that fired while + // the stale token was in use — not the retry 401. + expect(authAtCall).toHaveLength(2); + expect(authAtCall[0]).toBe(`Bearer ${REFRESH_SECRETS.staleToken}`); + expect(authAtCall[1]).toBe(`Bearer ${REFRESH_SECRETS.freshToken}`); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("leaves a standalone SynergiaApiClient unchanged on 401 with no refresh", async () => { + const fetchMock = vi.fn(async () => unauthorizedResponse()); + const client = new SynergiaApiClient(REFRESH_SECRETS.staleToken, { + fetch: fetchMock, + retry: false, + }); + + const error = await client + .getLuckyNumber() + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(LibrusApiError); + expect((error as LibrusApiError).code).toBe("API_REQUEST_FAILED"); + expect((error as LibrusApiError).details?.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("never leaks the token or password in logs or errors during a failed refresh", async () => { + const logs: string[] = []; + const logger = { + log: (level: string, event: string, fields?: Record) => { + logs.push(JSON.stringify({ level, event, fields })); + }, + }; + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + const fetchMock = vi.fn(async () => unauthorizedResponse()); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + logger, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + const client = await session.forChild(child); + const error = await client + .getLuckyNumber() + .catch((reason: unknown) => reason); + + const haystack = [ + ...logs, + String(error), + (error as Error)?.message ?? "", + (error as Error)?.stack ?? "", + JSON.stringify((error as LibrusApiError)?.details ?? {}), + ].join("\n"); + + for (const secret of [ + REFRESH_SECRETS.staleToken, + REFRESH_SECRETS.freshToken, + REFRESH_SECRETS.password, + ]) { + expect(haystack).not.toContain(secret); + } + }); +});