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
4 changes: 2 additions & 2 deletions apps/extension/entrypoints/popup/Popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
76 changes: 22 additions & 54 deletions apps/extension/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string> {
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<void> {
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}` });
Expand All @@ -68,28 +53,18 @@ export async function finishSignIn(url: string): Promise<boolean> {
return true;
}

let refreshing: Promise<string | null> | 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<string | null> {
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<boolean> {
await set({ apiKey: key.trim(), tokens: null });
Expand All @@ -109,13 +84,6 @@ async function rememberEmail(): Promise<boolean> {
}

export async function signOut(): Promise<void> {
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 });
}
3 changes: 2 additions & 1 deletion apps/extension/lib/pluck.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
}
68 changes: 20 additions & 48 deletions apps/ios/lib/session.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -44,27 +44,31 @@ 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 ---------------------------------------------------------------------------------

async function clientIdFor(server: string): Promise<string> {
// v3: registered with library:delete; a client from before cannot ask for it, so it registers again.
const ids = await kv.get<Record<string, string>>("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 };
Expand Down Expand Up @@ -134,32 +138,6 @@ async function finishSignIn(p: Pending, url: string): Promise<boolean> {
return true;
}

let refreshing: Promise<string | null> | 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<string | null> {
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<boolean> {
const res = await fetch(`${server}/api/v1/me`, { headers: { authorization: `Bearer ${key.trim()}` } }).catch(() => null);
if (!res?.ok) return false;
Expand All @@ -175,13 +153,7 @@ async function rememberEmail() {
}

export async function signOut(): Promise<void> {
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);
}

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions e2e/lists.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
63 changes: 62 additions & 1 deletion packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -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" } });

Expand Down Expand Up @@ -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<unknown> = Promise.resolve();
return <T>(fn: () => Promise<T>) => {
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);
});
});
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading