From f0b729d50f51f36f3b644a8e249bbf46cd313736 Mon Sep 17 00:00:00 2001 From: Beckness Date: Sat, 17 Oct 2026 17:55:27 -0400 Subject: [PATCH] fix(tv-native): sign in again when the server rejects the session (401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the stored bearer stops being honoured (sessions expire after 30 days unused, or get revoked), tv-native has no 401 handling: the guide shows "No channels yet — once channels are set up on your server…", which reads as an empty server, and the only way out is Account → Sign out by hand. tv-web and tv-tauri already sign out on a guide 401. This is the 401 piece trimmed from #63 for scope, brought back on its own: - api.ts: a 401 clears the token (server URL kept) and routes to /login. Network / 5xx / 403 failures never touch it, and a 401 is ignored if the token or server has changed since the request went out. - auth.ts: token writes go through one queue; invalidateToken re-checks the rejected token inside it, so a late 401 can't delete a login that landed between the check and the delete. - 11 bun regressions (fresh process per phase, only storage/native/network substituted), adapted from #63's suite to the current RN Settings store. Co-authored-by: SHRIVES93 <75397028+SHRIVES93@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/tv-native/package.json | 3 +- apps/tv-native/src/lib/api.ts | 14 ++- apps/tv-native/src/lib/auth.ts | 40 ++++++-- apps/tv-native/src/lib/session-test-worker.ts | 86 +++++++++++++++++ apps/tv-native/src/lib/session.test.ts | 95 +++++++++++++++++++ 5 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 apps/tv-native/src/lib/session-test-worker.ts create mode 100644 apps/tv-native/src/lib/session.test.ts diff --git a/apps/tv-native/package.json b/apps/tv-native/package.json index f5ee424e..391457b8 100644 --- a/apps/tv-native/package.json +++ b/apps/tv-native/package.json @@ -11,7 +11,8 @@ "dev:ios": "expo start --dev-client --ios", "start": "expo start --dev-client", "prebuild": "expo prebuild", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "bun test src/lib/session.test.ts" }, "dependencies": { "@airwave/key-input": "workspace:*", diff --git a/apps/tv-native/src/lib/api.ts b/apps/tv-native/src/lib/api.ts index dd0000b2..a83e67e3 100644 --- a/apps/tv-native/src/lib/api.ts +++ b/apps/tv-native/src/lib/api.ts @@ -1,4 +1,6 @@ -import { getServerUrl, getToken } from "./auth"; +import { router } from "expo-router"; + +import { getServerUrl, getToken, invalidateToken } from "./auth"; import { getNetwork } from "./plex-connection"; /** @@ -21,7 +23,8 @@ export class ApiError extends Error { async function request(path: string, init: RequestInit = {}): Promise { const token = getToken(); - const res = await fetch(`${getServerUrl()}${path}`, { + const server = getServerUrl(); + const res = await fetch(`${server}${path}`, { ...init, headers: { "Content-Type": "application/json", @@ -30,6 +33,13 @@ async function request(path: string, init: RequestInit = {}): Promise { }, }); if (!res.ok) { + // A 401 means the server has no session for this bearer (expired / revoked) — clear it and go sign in + // again, keeping the server (tv-web / tv-tauri do the same from the guide). Network / 5xx / 403 failures + // never touch it. A late 401 from before a newer login, or from a previous server, is ignored. + if (res.status === 401 && token && token === getToken() && server === getServerUrl()) { + const invalidated = await invalidateToken(token, server); + if (invalidated && server === getServerUrl() && !getToken()) router.replace("/login"); + } const body = (await res.json().catch(() => null)) as { error?: string } | null; throw new ApiError(res.status, body?.error ?? `Request failed (${res.status})`); } diff --git a/apps/tv-native/src/lib/auth.ts b/apps/tv-native/src/lib/auth.ts index 1aca5786..d8f744b0 100644 --- a/apps/tv-native/src/lib/auth.ts +++ b/apps/tv-native/src/lib/auth.ts @@ -24,6 +24,7 @@ const BAKED = (process.env.EXPO_PUBLIC_SERVER_URL ?? "").replace(/\/+$/, ""); let serverUrl = BAKED; let token: string | null = null; +let tokenWrites: Promise = Promise.resolve(); /** Hydrate the in-memory session from storage. Call once before rendering the app. */ export async function loadSession(): Promise { @@ -56,15 +57,38 @@ export async function clearServerUrl(): Promise { export function getToken(): string | null { return token; } + +/** + * Every token write goes through one queue. A 401 invalidation re-checks, INSIDE the queue, that the token it + * was rejecting is still the current one: without that, a late 401 from a request sent before a fresh login + * could pass its check, then delete the new token once the login's write landed. A failed write doesn't + * block the next one. + */ +function writeToken(next: string | null, rejected?: { token: string; server: string }): Promise { + const write = tokenWrites.then(async () => { + if (rejected && (token !== rejected.token || serverUrl !== rejected.server)) return false; + if (next) { + await CredStore.setItemAsync(TOKEN_KEY, next); + } else { + // Sign-out: clear better-auth's cookie/session alongside the bearer token so no stale session lingers. + await clearSyncCreds(); + await CredStore.deleteItemAsync(TOKEN_KEY); + } + token = next; + return true; + }); + tokenWrites = write.catch(() => {}); + return write; +} + export async function setToken(next: string | null): Promise { - if (next) { - await CredStore.setItemAsync(TOKEN_KEY, next); - } else { - // Sign-out: clear better-auth's cookie/session alongside the bearer token so no stale session lingers. - await clearSyncCreds(); - await CredStore.deleteItemAsync(TOKEN_KEY); - } - token = next; + await writeToken(next); +} + +/** The server rejected `rejectedToken` (401) — clear it, unless it has already been replaced or the server + * changed since the request went out. Resolves true only if this call cleared it. The server URL is kept. */ +export function invalidateToken(rejectedToken: string, rejectedServer: string): Promise { + return writeToken(null, { token: rejectedToken, server: rejectedServer }); } /** Coerce user input into a base URL (add a scheme if missing, drop a trailing slash) — tv-web parity. */ diff --git a/apps/tv-native/src/lib/session-test-worker.ts b/apps/tv-native/src/lib/session-test-worker.ts new file mode 100644 index 00000000..b09aa3e9 --- /dev/null +++ b/apps/tv-native/src/lib/session-test-worker.ts @@ -0,0 +1,86 @@ +// A fresh Bun process per phase models JS module state being discarded on app termination. Only the OS +// storage, native-module and network boundaries are substituted; auth / cred-store / session-storage / api +// are the production modules. Adapted from the PR #63 suite (SHRIVES93) to today's storage (RN `Settings`). +import { mock } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; + +const [file, operation, rawOptions] = process.argv.slice(2); +const options = JSON.parse(rawOptions ?? "{}"); +const read = () => JSON.parse(readFileSync(file!, "utf8")); +function write(store: string, key: string, value: string | null) { + const data = read(); + if (value === null) delete data[store][key]; + else data[store][key] = value; + writeFileSync(file!, JSON.stringify(data)); +} +const get = (store: string, key: string) => read()[store][key] ?? null; + +mock.module("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: async (key: string) => get("cache", key), + setItem: async (key: string, value: string) => write("cache", key, value), + removeItem: async (key: string) => write("cache", key, null), + }, +})); +mock.module("react-native", () => ({ + Platform: { OS: options.platform === "android" ? "android" : "ios", isTV: options.platform !== "ios" }, + // RN `Settings` = NSUserDefaults on Apple TV. + Settings: { + get: (key: string) => get("defaults", key), + set: (values: Record) => { + for (const [k, v] of Object.entries(values)) write("defaults", k, v); + }, + }, +})); +mock.module("expo-secure-store", () => ({ + getItemAsync: async (key: string) => get("secure", key), + setItemAsync: async (key: string, value: string) => write("secure", key, value), + deleteItemAsync: async (key: string) => write("secure", key, null), + getItem: (key: string) => get("secure", key), + setItem: (key: string, value: string) => write("secure", key, value), +})); +let redirect: string | null = null; +mock.module("expo-router", () => ({ router: { replace: (route: string) => { redirect = route; } } })); +mock.module("./plex-connection", () => ({ getNetwork: () => "local" })); +process.env.EXPO_PUBLIC_SERVER_URL = ""; + +const auth = await import("./auth"); +const { SyncCredStore } = await import("./cred-store"); + +// The entry gate in app/index.tsx. +const route = () => (!auth.hasServerUrl() ? "/setup" : auth.getToken() ? "/guide" : "/login"); + +let error = false; +try { + await auth.loadSession(); + if (operation === "save") { + await auth.setServerUrl("example.test///"); + await auth.setToken("test-token"); + SyncCredStore.setItem("cg_cookie", "test-cookie"); + } else if (operation === "overlap") { + await Promise.all([auth.invalidateToken("test-token", auth.getServerUrl()), auth.setToken("new-test-token")]); + } else if (operation === "stale-invalidation") { + await Promise.all([auth.setToken("new-test-token"), auth.invalidateToken("test-token", auth.getServerUrl())]); + } else if (operation === "request") { + globalThis.fetch = (async () => { + if (options.networkFailure) throw new TypeError("offline"); + if (options.newToken) await auth.setToken("new-test-token"); + if (options.newServer) await auth.setServerUrl("other.test"); + return new Response(JSON.stringify({ packages: [] }), { status: options.status ?? 200 }); + }) as unknown as typeof fetch; + const { api } = await import("./api"); + await api.packages(); + } +} catch { + error = true; +} +console.log( + JSON.stringify({ + error, + redirect, + route: route(), + server: auth.getServerUrl(), + hasToken: !!auth.getToken(), + cookie: SyncCredStore.getItem("cg_cookie"), + }), +); diff --git a/apps/tv-native/src/lib/session.test.ts b/apps/tv-native/src/lib/session.test.ts new file mode 100644 index 00000000..b4b0e4ba --- /dev/null +++ b/apps/tv-native/src/lib/session.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// 401 / session-recovery regressions, each phase in a fresh process (see session-test-worker.ts). +// Adapted from the PR #63 suite (SHRIVES93), scoped to the 401 lifecycle. + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true }); +}); + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), "airwave-session-")); + dirs.push(dir); + const file = join(dir, "storage.json"); + writeFileSync(file, JSON.stringify({ cache: {}, defaults: {}, secure: {} })); + return { + run(operation = "launch", options = {}) { + const proc = Bun.spawnSync([process.execPath, join(import.meta.dir, "session-test-worker.ts"), file, operation, JSON.stringify(options)]); + if (proc.exitCode !== 0) throw new Error(proc.stderr.toString()); + return JSON.parse(proc.stdout.toString()); + }, + data: () => JSON.parse(readFileSync(file, "utf8")), + }; +} + +function signedIn(options = {}) { + const app = fixture(); + expect(app.run("save", options)).toMatchObject({ error: false, route: "/guide" }); + return app; +} + +describe("Apple TV session recovery after the server rejects the token", () => { + test("a valid response preserves the session", () => { + expect(signedIn().run("request")).toMatchObject({ error: false, redirect: null, route: "/guide" }); + }); + + test("401 clears the session, keeps the server, and goes to login — across a restart", () => { + const app = signedIn(); + expect(app.run("request", { status: 401 })).toMatchObject({ + error: true, + redirect: "/login", + hasToken: false, + server: "https://example.test", + cookie: null, + }); + expect(app.run()).toMatchObject({ route: "/login", server: "https://example.test" }); + }); + + test("signing in again after a 401 restores the guide", () => { + const app = signedIn(); + app.run("request", { status: 401 }); + expect(app.run("save")).toMatchObject({ route: "/guide" }); + expect(app.run("request")).toMatchObject({ error: false, route: "/guide" }); + }); + + for (const options of [{ networkFailure: true }, { status: 503 }, { status: 403 }]) { + test(`temporary / permission failure keeps the session: ${JSON.stringify(options)}`, () => { + expect(signedIn().run("request", options)).toMatchObject({ + error: true, + redirect: null, + hasToken: true, + server: "https://example.test", + route: "/guide", + }); + }); + } + + test("a late 401 does not invalidate a newer login", () => { + expect(signedIn().run("request", { status: 401, newToken: true })).toMatchObject({ hasToken: true, redirect: null }); + }); + + test("a late 401 from the previous server does not sign out of the new one", () => { + expect(signedIn().run("request", { status: 401, newServer: true })).toMatchObject({ hasToken: true, redirect: null }); + }); + + for (const operation of ["overlap", "stale-invalidation"]) { + test(`401 invalidation cannot erase a concurrent login: ${operation}`, () => { + const app = signedIn(); + expect(app.run(operation)).toMatchObject({ error: false, hasToken: true, route: "/guide" }); + expect(app.data().defaults["cg-tv-token"]).toBe("new-test-token"); + }); + } + + test("iOS / Android: 401 clears the SecureStore token and keeps the server", () => { + for (const platform of ["ios", "android"]) { + const app = signedIn({ platform }); + expect(app.run("request", { platform, status: 401 })).toMatchObject({ redirect: "/login", hasToken: false }); + expect(app.data().secure["cg-tv-token"]).toBeUndefined(); + expect(app.run("launch", { platform })).toMatchObject({ route: "/login", server: "https://example.test" }); + } + }); +});