diff --git a/apps/extension/entrypoints/popup/Popup.tsx b/apps/extension/entrypoints/popup/Popup.tsx index 24fbf3c..6639c83 100644 --- a/apps/extension/entrypoints/popup/Popup.tsx +++ b/apps/extension/entrypoints/popup/Popup.tsx @@ -3,7 +3,7 @@ import { KindGlyph, Logo } from "@pluck/ui"; import { useEffect, useState, type ReactNode } from "react"; import { useFormatter, useTranslations } from "use-intl"; import { api, openInPluck, Refused, Unreachable } from "@/lib/api"; -import { startSignIn } from "@/lib/auth"; +import { isSignedIn, startSignIn } from "@/lib/auth"; import { markTab, pluckTab, type PluckOutcome } from "@/lib/pluck"; import { pageShortcut } from "@/lib/shortcut"; import { get, set } from "@/lib/store"; @@ -45,7 +45,7 @@ export function Popup() { useEffect(() => { (async () => { - if (!(await get("tokens")) && !(await get("apiKey"))) return setView({ k: "signedOut" }); + if (!(await isSignedIn())) return setView({ k: "signedOut" }); const tab = await targetTab(); if (!tab?.url || !/^https?:\/\//.test(tab.url)) return setView({ k: "nothing" }); try { diff --git a/apps/extension/lib/auth.ts b/apps/extension/lib/auth.ts index 285e9cb..fdf86dc 100644 --- a/apps/extension/lib/auth.ts +++ b/apps/extension/lib/auth.ts @@ -1,4 +1,4 @@ -import { b64url, tokenRequest } from "@pluck/client"; +import { createSession, pkcePair, register, tokenRequest } from "@pluck/client"; import { get, set, settings } from "./store"; // Sign-in is OAuth 2.1 with PKCE against the person's Pluck server. The authorisation runs in an ordinary tab, @@ -10,42 +10,27 @@ const clientKey = (server: string) => `${server} ${SCOPE}`; type Pending = { server: string; clientId: string; verifier: string; state: string }; -const random = (n = 32) => b64url(crypto.getRandomValues(new Uint8Array(n))); - // Each server gets its own client registration (dynamic registration), made once and remembered for these scopes: // a registration made before a scope was added cannot ask for it, so a new SCOPE registers again. async function clientIdFor(server: string): Promise { const ids = await get("clientIds"); const key = clientKey(server); if (ids[key]) return ids[key]; - const res = await fetch(`${server}/api/auth/oauth2/register`, { - method: "POST", - credentials: "omit", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - client_name: "Pluck for your browser", - redirect_uris: [redirectFor(server)], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - scope: SCOPE, - resources: [`${server}/api/v1`], - }), - }); - if (!res.ok) throw new Error(`Could not register with ${server} (${res.status})`); - const { client_id } = (await res.json()) as { client_id: string }; - await set({ clientIds: { ...ids, [key]: client_id } }); - return client_id; + const clientId = await register(server, { name: "Pluck for your browser", redirect: redirectFor(server), scope: SCOPE }); + await set({ clientIds: { ...ids, [key]: clientId } }); + return clientId; } export async function startSignIn(): Promise { const { server } = await settings(); const clientId = await clientIdFor(server); - const pending: Pending = { server, clientId, verifier: random(), state: random(16) }; - await browser.storage.session.set({ pending }); - const challenge = b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pending.verifier)))); + const { verifier, challenge, state } = await pkcePair( + (n) => crypto.getRandomValues(new Uint8Array(n)), + async (s) => new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s))), + ); + await browser.storage.session.set({ pending: { server, clientId, verifier, state } satisfies Pending }); const q = new URLSearchParams({ - response_type: "code", client_id: clientId, redirect_uri: redirectFor(server), scope: SCOPE, state: pending.state, + response_type: "code", client_id: clientId, redirect_uri: redirectFor(server), scope: SCOPE, state, code_challenge: challenge, code_challenge_method: "S256", resource: `${server}/api/v1`, }); await browser.tabs.create({ url: `${server}/api/auth/oauth2/authorize?${q}` }); @@ -68,28 +53,18 @@ export async function finishSignIn(url: string): Promise { return true; } -let refreshing: Promise | null = null; - -// The Authorization header for the API, refreshing an access token that is about to run out. One refresh at a time: -// a refresh token works once, and the popup and the badge asking together would spend it twice and sign you out. -// Offline throws Unreachable and keeps the tokens, so the pluck is queued rather than lost. -export async function authHeader(): Promise { - const apiKey = await get("apiKey"); - if (apiKey) return `Bearer ${apiKey}`; - const tokens = await get("tokens"); - if (!tokens) return null; - if (tokens.expiresAt - Date.now() > 60_000) return `Bearer ${tokens.access}`; - if (!tokens.refresh) return null; - refreshing ??= (async () => { +// The popup, the options page and the service worker each load this, so the refresh is guarded by a Web Lock, +// which all of them share, not by memory, which they do not. +const session = createSession({ + read: async () => { const { server } = await settings(); - const fresh = await tokenRequest(server, { - grant_type: "refresh_token", refresh_token: tokens.refresh!, client_id: (await get("clientIds"))[clientKey(server)] ?? "", resource: `${server}/api/v1`, - }); - await set({ tokens: fresh }); - return fresh ? `Bearer ${fresh.access}` : null; - })().finally(() => (refreshing = null)); - return refreshing; -} + return { server, clientId: (await get("clientIds"))[clientKey(server)], apiKey: await get("apiKey"), tokens: await get("tokens") }; + }, + write: (tokens) => set({ tokens }), + lock: async (fn) => await navigator.locks.request("pluck-refresh", fn), +}); + +export const { authHeader, isSignedIn } = session; export async function useApiKey(key: string): Promise { await set({ apiKey: key.trim(), tokens: null }); @@ -109,13 +84,6 @@ async function rememberEmail(): Promise { } export async function signOut(): Promise { - const tokens = await get("tokens"); - const { server } = await settings(); - const clientId = (await get("clientIds"))[clientKey(server)]; - if (tokens?.refresh && clientId) { - await fetch(`${server}/api/auth/oauth2/revoke`, { - method: "POST", credentials: "omit", body: new URLSearchParams({ token: tokens.refresh, token_type_hint: "refresh_token", client_id: clientId }), - }).catch(() => {}); - } + await session.revoke(); await set({ tokens: null, apiKey: null, email: null }); } diff --git a/apps/extension/lib/pluck.ts b/apps/extension/lib/pluck.ts index 08ebb8e..4726447 100644 --- a/apps/extension/lib/pluck.ts +++ b/apps/extension/lib/pluck.ts @@ -1,5 +1,6 @@ import type { CreateItemBody, Item, PluckResponse } from "@pluck/shared-schemas"; import { api, Refused, Unreachable } from "./api"; +import { isSignedIn } from "./auth"; import { capture, screenshot } from "./capture"; import { createQueue } from "@pluck/client"; import { get, set, settings } from "./store"; @@ -85,7 +86,7 @@ export async function markTab(tabId: number, plucked: boolean) { } export async function refreshBadge(tabId: number, url?: string) { - if (!isWeb(url) || !(await get("tokens")) && !(await get("apiKey"))) return markTab(tabId, false); + if (!isWeb(url) || !(await isSignedIn())) return markTab(tabId, false); const res = await api<{ item: Item | null }>(`/api/v1/items/lookup?url=${encodeURIComponent(url!)}`).catch(() => null); if (res) await markTab(tabId, Boolean(res.item)); } diff --git a/apps/ios/lib/session.ts b/apps/ios/lib/session.ts index d8b5d84..1610d40 100644 --- a/apps/ios/lib/session.ts +++ b/apps/ios/lib/session.ts @@ -1,7 +1,7 @@ import Constants from "expo-constants"; import * as Crypto from "expo-crypto"; import * as SecureStore from "expo-secure-store"; -import { codeFrom, createClient, pkcePair, tokenRequest } from "@pluck/client"; +import { codeFrom, createClient, createSession, pkcePair, register, tokenRequest } from "@pluck/client"; import { locale } from "./i18n"; import { kv } from "./kv"; @@ -44,8 +44,21 @@ async function setSession(s: Session | null) { listeners.forEach((fn) => fn(s)); } -const signedIn = (s: Session | null) => Boolean(s?.access || s?.apiKey); -export const isSignedIn = async () => signedIn(await getSession()); +// The share extension (Swift) refreshes from the same Keychain item and cannot share a lock with the app, so the +// session re-reads the Keychain before refreshing and keeps the extension's tokens when it got there first. +const session = createSession({ + read: async (fresh) => { + if (fresh) current = undefined; + const s = await getSession(); + return s && { server: s.server, clientId: s.clientId, apiKey: s.apiKey, tokens: s.access ? { access: s.access, refresh: s.refresh ?? null, expiresAt: s.expiresAt ?? 0 } : null }; + }, + write: async (t) => { + const s = await getSession(); + if (s) await setSession(t ? { ...s, ...t } : { server: s.server }); // refused: show sign-in rather than screens where every request fails + }, +}); + +export const { authHeader, isSignedIn } = session; // ---- OAuth with PKCE --------------------------------------------------------------------------------- @@ -53,18 +66,9 @@ async function clientIdFor(server: string): Promise { // v3: registered with library:delete; a client from before cannot ask for it, so it registers again. const ids = await kv.get>("clientIds.v3", {}); if (ids[server]) return ids[server]; - const res = await fetch(`${server}/api/auth/oauth2/register`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - client_name: "Pluck for iPhone", redirect_uris: [REDIRECT], token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], scope: SCOPE, resources: [`${server}/api/v1`], - }), - }); - if (!res.ok) throw new Error(`Could not reach ${server}.`); - const { client_id } = (await res.json()) as { client_id: string }; - await kv.set("clientIds.v3", { ...ids, [server]: client_id }); - return client_id; + const clientId = await register(server, { name: "Pluck for iPhone", redirect: REDIRECT, scope: SCOPE }); + await kv.set("clientIds.v3", { ...ids, [server]: clientId }); + return clientId; } type Pending = { server: string; clientId: string; verifier: string; state: string; challenge: string; at: number }; @@ -134,32 +138,6 @@ async function finishSignIn(p: Pending, url: string): Promise { return true; } -let refreshing: Promise | null = null; - -// "Bearer …" for the API: a personal key, or an access token refreshed a minute before it runs out. -export async function authHeader(): Promise { - const s = await getSession(); - if (!s) return null; - if (s.apiKey) return `Bearer ${s.apiKey}`; - if (s.access && (s.expiresAt ?? 0) - Date.now() > 60_000) return `Bearer ${s.access}`; - refreshing ??= (async () => { - // The share extension refreshes too, and a refresh token works once: spending the one this app read at launch - // after the extension replaced it signs both out. So read the Keychain again first. - current = undefined; - const now = await getSession(); - if (!now?.refresh || !now.clientId) return null; - if (now.access && (now.expiresAt ?? 0) - Date.now() > 60_000) return `Bearer ${now.access}`; - const t = await tokenRequest(now.server, { grant_type: "refresh_token", refresh_token: now.refresh, client_id: now.clientId, resource: `${now.server}/api/v1` }); - if (!t) { - await setSession({ server: now.server }); // refused: show sign-in rather than screens where every request fails - return null; - } - await setSession({ ...now, ...t }); - return `Bearer ${t.access}`; - })().finally(() => (refreshing = null)); - return refreshing; -} - export async function signInWithKey(server: string, key: string): Promise { const res = await fetch(`${server}/api/v1/me`, { headers: { authorization: `Bearer ${key.trim()}` } }).catch(() => null); if (!res?.ok) return false; @@ -175,13 +153,7 @@ async function rememberEmail() { } export async function signOut(): Promise { - const s = await getSession(); - if (s?.refresh && s.clientId) { - await fetch(`${s.server}/api/auth/oauth2/revoke`, { - method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ token: s.refresh, token_type_hint: "refresh_token", client_id: s.clientId }).toString(), - }).catch(() => {}); - } + await session.revoke(); await setSession(null); } diff --git a/docs/architecture.md b/docs/architecture.md index fa44643..7e50531 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ One Next.js app (`apps/web`) is the website, the signed-in app, the REST API for | `packages/extract` | Guarded fetch (`fetch.ts`), page and PDF reading (`extract.ts`, Defuddle then Readability), email (`email.ts`) and imports. Its fixtures are served by `pnpm stack` so tests never touch the internet. | | `packages/blob` | S3 through the AWS SDK; MinIO locally and for self-hosting. | | `packages/shared-schemas` | Every type as a Zod schema, used by the server and every client. | -| `packages/client` | What the iPhone app and the extension share: `createClient` (bearer auth, refresh a minute early, errors mapped to `Unreachable`, `SignedOut` or `Refused` by `answer`), `createQueue` (the offline queue) and PKCE. The web's offline queue is `createQueue` too, over `localStorage`; the iPhone app drains the share extension's App Group files with `createDrain`, the same keep and drop rules. | +| `packages/client` | What the iPhone app and the extension share: `createClient` (bearer auth, errors mapped to `Unreachable`, `SignedOut` or `Refused` by `answer`), `createSession` (the token refresh, once across every context that shares a store, and sign-out), `register`, `createQueue` (the offline queue) and PKCE. The web's offline queue is `createQueue` too, over `localStorage`; the iPhone app drains the share extension's App Group files with `createDrain`, the same keep and drop rules. | | `packages/i18n` | The words, in ten languages, one catalog per surface. See [i18n.md](i18n.md). | | `packages/ui` | Tokens, the logo, the icon and the Kind glyphs. | | `apps/extension` | WXT. The popup, options and a background worker that drains the queue every minute. | diff --git a/e2e/lists.spec.ts b/e2e/lists.spec.ts index 40b6576..8700b5f 100644 --- a/e2e/lists.spec.ts +++ b/e2e/lists.spec.ts @@ -162,5 +162,6 @@ test("share a List with one person, See only: the email link, their Lists, no ta // The owner takes it back; it is gone for them at once. await owner.getByRole("button", { name: `Stop sharing with ${friend}` }).click(); + await expect(owner.getByRole("button", { name: `Stop sharing with ${friend}` })).toHaveCount(0); // the row goes once the server agreed expect((await visitor.request.get(`/api/v1/shared/${list.id}`)).status()).toBe(404); }); diff --git a/packages/client/package.json b/packages/client/package.json index e8fa7a0..555aa8a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@pluck/client", - "description": "The API client, offline queue and PKCE shared by the Pluck iPhone app and browser extension; the web uses the queue too.", + "description": "The API client, session, offline queue and PKCE shared by the Pluck iPhone app and browser extension; the web uses the queue too.", "repository": { "type": "git", "url": "https://github.com/codu-code/pluck.git", "directory": "packages/client" }, "version": "0.0.0", "private": true, diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index b1700eb..a36ac88 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; import { describe, expect, test, vi } from "vitest"; -import { codeFrom, createClient, createQueue, pkcePair, Refused, SignedOut, tokenRequest, Unreachable, type Queued } from "./index"; +import { codeFrom, createClient, createQueue, createSession, pkcePair, Refused, SignedOut, tokenRequest, Unreachable, type Queued, type SessionStore, type Stored } from "./index"; const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); @@ -114,3 +114,64 @@ test("token requests: offline or a 5xx is Unreachable, so nobody is signed out f expect(await tokenRequest("https://pluck.test", {}, (async () => json(400, { error: "invalid_grant" })) as typeof fetch)).toBeNull(); expect(await tokenRequest("https://pluck.test", {}, (async () => json(200, { access_token: "a", refresh_token: "r", expires_in: 3600 })) as typeof fetch)).toMatchObject({ access: "a", refresh: "r" }); }); + +describe("the session", () => { + // The token endpoint as Better Auth runs it: a refresh token works once, and of two refreshes at once one wins and + // the other gets invalid_grant (a retry within 30 seconds of the win would be replayed the same pair; one in flight is not). + const tokenServer = () => { + let n = 0; + const live = new Set(["r0"]); + const spend = (r: string) => (live.delete(r) ? (live.add(`r${++n}`), { access: `a${n}`, refresh: `r${n}` }) : null); + const f = vi.fn(async (_url: string, init?: RequestInit) => { + const pair = spend(new URLSearchParams(String(init?.body)).get("refresh_token")!); + await new Promise((r) => setTimeout(r, 5)); // both requests are on the wire before either answers + return pair ? json(200, { access_token: pair.access, refresh_token: pair.refresh, expires_in: 3600 }) : json(400, { error: "invalid_grant" }); + }); + return { f, spend, fetch: f as unknown as typeof fetch }; + }; + const expired = { access: "a0", refresh: "r0", expiresAt: Date.now() - 1 }; + const memory = (lock?: SessionStore["lock"]) => { + const box: { s: Stored } = { s: { server: "https://pluck.test", clientId: "c", tokens: expired } }; + const store: SessionStore = { read: async () => structuredClone(box.s), write: async (tokens) => void (box.s = { ...box.s, tokens }), lock }; + return { box, store }; + }; + // A Web Lock: one holder at a time across every context of the extension. + const webLock = () => { + let held: Promise = Promise.resolve(); + return (fn: () => Promise) => { + const run = held.then(fn); + held = run.catch(() => {}); + return run; + }; + }; + + test("the popup and the background refreshing at once both stay signed in, spending the refresh token once", async () => { + const server = tokenServer(); + const { box, store } = memory(webLock()); + const popup = createSession(store, server.fetch), background = createSession(store, server.fetch); + expect(await Promise.all([popup.authHeader(), background.authHeader()])).toEqual(["Bearer a1", "Bearer a1"]); + expect(box.s.tokens).toMatchObject({ access: "a1", refresh: "r1" }); + expect(server.f).toHaveBeenCalledTimes(1); + }); + + test("a refresh refused because another app (the share extension) spent the token first keeps that app's tokens", async () => { + const server = tokenServer(); + const { box, store } = memory(); + server.f.mockImplementationOnce(async () => { + const pair = server.spend("r0")!; // the share extension refreshed and wrote the Keychain while we were asking + box.s = { ...box.s, tokens: { ...pair, expiresAt: Date.now() + 3600_000 } }; + return json(400, { error: "invalid_grant" }); + }); + expect(await createSession(store, server.fetch).authHeader()).toBe("Bearer a1"); + expect(box.s.tokens).toMatchObject({ refresh: "r1" }); + }); + + test("a refresh token the server refuses signs out; offline keeps the tokens", async () => { + const { box, store } = memory(); + expect(await createSession(store, (async () => json(400, { error: "invalid_grant" })) as typeof fetch).authHeader()).toBeNull(); + expect(box.s.tokens).toBeNull(); + box.s.tokens = expired; + await expect(createSession(store, (async () => { throw new TypeError("offline"); }) as typeof fetch).authHeader()).rejects.toBeInstanceOf(Unreachable); + expect(box.s.tokens).toEqual(expired); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 9709261..6ecfba2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ export { answer, createClient, Refused, SignedOut, tokenRequest, Unreachable, type Client, type ClientDeps, type Init, type Tokens } from "./api"; export { createDrain, createQueue, type Queued, type QueueStore } from "./queue"; export { b64url, codeFrom, pkcePair } from "./pkce"; +export { createSession, register, type SessionStore, type Stored } from "./session"; diff --git a/packages/client/src/session.ts b/packages/client/src/session.ts new file mode 100644 index 0000000..4895451 --- /dev/null +++ b/packages/client/src/session.ts @@ -0,0 +1,77 @@ +import { tokenRequest, type Tokens } from "./api"; + +// The signed-in session the extension and the iPhone app share. Where it is kept differs (browser storage, the Keychain), +// so each app passes a store; the rules for using and refreshing it live here once. +export type Stored = { server: string; clientId?: string; apiKey?: string | null; tokens?: Tokens | null }; +export type SessionStore = { + read: (fresh?: boolean) => Promise; // fresh: skip any cache, another context may have written since + write: (tokens: Tokens | null) => Promise; // null: the server refused the refresh token, sign in again + lock?: (fn: () => Promise) => Promise; // held by every context that refreshes from the same store +}; + +// The header that works without asking the server: a personal key, or an access token with more than a minute left. +const ready = (s: Stored | null) => + s?.apiKey ? `Bearer ${s.apiKey}` : s?.tokens && s.tokens.expiresAt - Date.now() > 60_000 ? `Bearer ${s.tokens.access}` : null; + +export function createSession(store: SessionStore, f: typeof fetch = fetch) { + let refreshing: Promise | null = null; + + // A refresh token works once, and of two refreshes at once the server refuses the second. So read the store again + // under the lock (another context may have refreshed already), and treat a refusal as success if it did meanwhile. + async function refresh(): Promise { + const s = await store.read(true); + if (ready(s)) return ready(s); + if (!s?.tokens?.refresh || !s.clientId) return null; + const spent = s.tokens.refresh; + const fresh = await tokenRequest(s.server, { grant_type: "refresh_token", refresh_token: spent, client_id: s.clientId, resource: `${s.server}/api/v1` }, f); + if (fresh) { + await store.write(fresh); + return `Bearer ${fresh.access}`; + } + const now = await store.read(true); // the iPhone's share extension cannot share a lock with the app + if (now?.tokens?.refresh !== spent && ready(now)) return ready(now); + await store.write(null); + return null; + } + + return { + // "Bearer …" for the API: a personal key, or an access token refreshed a minute before it runs out. + // Offline throws Unreachable and keeps the tokens, so the pluck is queued rather than lost. + async authHeader(): Promise { + const s = await store.read(); + if (ready(s) || !s?.tokens?.refresh) return ready(s); + refreshing ??= (store.lock ? store.lock(refresh) : refresh()).finally(() => (refreshing = null)); + return refreshing; + }, + + isSignedIn: async () => { + const s = await store.read(); + return Boolean(s?.apiKey || s?.tokens); + }, + + // Sign out, server side: the refresh token stops working. Each app then clears what it keeps. + async revoke(): Promise { + const s = await store.read(true); + if (!s?.tokens?.refresh || !s.clientId) return; + await f(`${s.server}/api/auth/oauth2/revoke`, { + method: "POST", credentials: "omit", headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: s.tokens.refresh, token_type_hint: "refresh_token", client_id: s.clientId }).toString(), + }).catch(() => {}); + }, + }; +} + +// Dynamic client registration: each app registers once per server and remembers the client_id itself. +export async function register(server: string, app: { name: string; redirect: string; scope: string }): Promise { + const res = await fetch(`${server}/api/auth/oauth2/register`, { + method: "POST", + credentials: "omit", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: app.name, redirect_uris: [app.redirect], token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], scope: app.scope, resources: [`${server}/api/v1`], + }), + }); + if (!res.ok) throw new Error(`Could not register with ${server} (${res.status})`); + return ((await res.json()) as { client_id: string }).client_id; +}