Public: anyone can read this; the crew can change it.

Sign in to another forge in one click: OAuth between forges, no token to copy #8920

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/8902-sign-in-to-another-forge-in-one-click-oa → main 5 commits fork 3 threads · 3 unresolved

  • Merged. torque/8902-sign-in-to-another-forge-in-one-click-oa was merged into main as 89f7562.

Review

Sign in to review this change.
12 files changed +433 −15
docs/features/forge-origin.md +10 −2
@@ -7,13 +7,21 @@ Connect another forge at `/settings/origins` the way you connect Forgejo: type i
77 - **Mirror:** brings the code and its history, and the issues, pulls and comments, read in the forge's own JSON (`src/origins/providers/forge-work.ts`) with the real author kept. The origin's card and `GET /api/v1/user/origins` (`carries`) say what did not come: labels and how the app runs (not yet), and its data and secrets (never by default).
88 - **Write-through:** a comment, an issue or a state change here goes to the other forge first, with the connecting person's token (`rw` needed), and is refused with nothing changed here when the forge is down or says no. A pull is opened there with `createPull` (the contribute path); its merge comes back with the next sync.
99
10 ## Sign in with one click (#8902)
11
12 Type the friend's address at `/settings/origins` and press Continue. If the address is a forge, this forge registers itself there (`POST /oauth/register`, RFC 7591, a public client, kept per user and host), then sends the browser to its approval page with PKCE and the scope `ro` — the least that brings code across. The friend sees who asks and what for, picks the lifetime, and the browser returns to `/settings/origins/callback`. No client id or token is copied.
13
14 - **Refresh:** pick a lifetime other than "Until I revoke it" and the friend's forge issues a rotating refresh token; this forge refreshes before expiry. A spent one (`invalid_grant`) marks the origin needs-reauth, as on every other kind. A token that never expires carries no refresh token and needs none.
15 - **Registration is per callback address:** if this forge's public URL changes, forget the app and connect again.
16 - Test: `test/forge-origin-oauth.test.ts` runs two forges through the whole flow.
17
1018 Not yet: webhooks and a catch-up for missed events, review and check state coming back live, guest rules for a crew on the receiving side, following upstream with one action.
1119
12 Not in the first piece, in order: sign in through OAuth, migrate an app whole with its run and build, follow upstream between forges with a pull back, and the one-page paste-a-link path. Test: `test/forge-origin.test.ts` runs two forges against each other.
20 Not yet, in order: migrate an app whole with its run and build, follow upstream between forges with a pull back, and the one-page paste-a-link path. Test: `test/forge-origin.test.ts` runs two forges against each other.
1321
1422 ## Both doors, and what was exercised
1523
16 The terminal reaches the same code as the web and the API: `src/tui/screens/issue.ts` and `pull.ts` comment and change state through `addCommentThrough` and `setIssueStateThrough` (`src/actions/issues.ts`), which call `providers.createComment` / `setState` when the repository is a mirror. No terminal screen or entry changed, so the write-through tests cover it; there is no separate terminal path to test.
24 The terminal reaches the same code as the web and the API: `src/tui/screens/issue.ts` and `pull.ts` comment and change state through `addCommentThrough` and `setIssueStateThrough` (`src/actions/issues.ts`), which call `providers.createComment` / `setState` when the repository is a mirror. The one-click sign-in has no terminal path (a forge has no device grant): Connected origins and Origin apps say so and point to the web page, and the terminal connects a forge with a token.
1725
1826 A forge's comments carry no edit time, so `updatedAt` is read when the forge sends one and is the creation time otherwise: an edited comment syncs once the forge reports edits. A thread is paged to its end; one of more than 100,000 comments is a failed sync, never a short one.
1927
src/actions/origins.ts +28 −3
@@ -79,6 +79,7 @@ import {
7979 revokeGrant,
8080 saveApp,
8181 scopesFor,
82 registerClient,
8283 startDevice,
8384 touchFlow,
8485 type AppRow,
@@ -456,11 +457,31 @@ export async function startOAuthConnect(
456457 message: `cannot identify ${input.host}`,
457458 });
458459
459 const registration = appFor(app.db, app.config.dataDir, user.value.id, {
460 const software = softwareOf(found.value.software);
461 const target = {
460462 kind: found.value.kind,
461463 apiUrl: found.value.apiUrl,
462464 webUrl: found.value.webUrl,
463 });
465 software,
466 };
467 let registration = appFor(app.db, app.config.dataDir, user.value.id, target);
468 if (!registration.ok && software === "forge" && (input.redirectUri ?? "") !== "") {
469 // Another forge registers us itself: no client id to copy.
470 const made = await registerClient(target.webUrl, {
471 redirectUri: input.redirectUri!,
472 name: `forge at ${new URL(input.redirectUri!).host}`,
473 });
474 if (!made.ok)
475 return err({ error: "provider_refused", message: made.error.message });
476 saveApp(app.db, app.config.dataDir, {
477 userId: user.value.id,
478 kind: target.kind,
479 apiUrl: target.apiUrl,
480 webUrl: target.webUrl,
481 clientId: made.value,
482 });
483 registration = appFor(app.db, app.config.dataDir, user.value.id, target);
484 }
464485 if (!registration.ok) {
465486 return err({
466487 error: "no_app",
@@ -470,7 +491,7 @@ export async function startOAuthConnect(
470491 : "register an OAuth application on this instance first",
471492 });
472493 }
473 const scope = scopesFor(found.value.kind);
494 const scope = scopesFor(found.value.kind, "oauth", software);
474495
475496 if (flowFor(found.value.kind) === "device") {
476497 const started = await startDevice(registration.value, scope);
@@ -662,6 +683,7 @@ export async function completeOAuthCallback(
662683 webUrl: flow.webUrl,
663684 clientId: flow.clientId,
664685 clientSecret: secrets?.clientSecret ?? "",
686 software: flow.software,
665687 };
666688 const exchanged = await exchangeCode(registration, {
667689 code: input.code,
@@ -696,6 +718,7 @@ async function completeConnect(
696718 webUrl: string;
697719 clientId: string;
698720 clientSecret: string;
721 software?: string;
699722 },
700723 tokens: {
701724 accessToken: string;
@@ -709,6 +732,7 @@ async function completeConnect(
709732 apiUrl: registration.apiUrl,
710733 webUrl: registration.webUrl,
711734 authKind: "oauth",
735 software: registration.software ?? "",
712736 token: tokens.accessToken,
713737 }),
714738 ).whoami();
@@ -724,6 +748,7 @@ async function completeConnect(
724748 authKind: "oauth",
725749 refreshToken: tokens.refreshToken,
726750 expiresAt: tokens.expiresAt,
751 software: registration.software ?? "",
727752 });
728753 if (!stored.ok)
729754 return { status: "failed", detail: ORIGIN_ALREADY_CONNECTED };
src/core/migration-pins/20261103150000-flow-software.pin added +1
@@ -0,0 +1,1 @@
1 4174c3bd54a656e121165660163ef1de68ef77071cab3fd8f044d480e5dd4bad
src/core/migrations/20261103150000-flow-software.ts added +7
@@ -0,0 +1,7 @@
1 // The software an origin runs, kept on the connect in flight (#8902): the
2 // callback reads it back instead of asking the origin again, so the token
3 // endpoint it uses is the one the connect began with. Empty for every flow
4 // that already exists. Additive.
5 export default `
6 ALTER TABLE oauth_flow ADD COLUMN software TEXT NOT NULL DEFAULT '';
7 `;
src/origins/credential.ts +1
@@ -137,6 +137,7 @@ async function refreshOnce(app: App, originId: string): Promise<void> {
137137 kind: origin.kind,
138138 apiUrl: origin.apiUrl,
139139 webUrl: origin.webUrl,
140 software: origin.software,

Medium: this line has no test. Add a credential-path refresh test for a forge origin that also covers needs-reauth.

140141 });
141142 if (!registration.ok) {
142143 markNeedsReauth(
src/origins/oauth.ts +107 −5
@@ -91,6 +91,8 @@ export interface AppRegistration {
9191 readonly clientId: string;
9292 /** Empty for a public client — PKCE carries the proof instead. */
9393 readonly clientSecret: string;
94 /** `forge` when the origin is another forge, else absent or "". */
95 readonly software?: string;
9496 }
9597
9698 // ---- the OAuth endpoints, per forge ----
@@ -106,6 +108,10 @@ export function endpoints(app: AppRegistration): {
106108 token: string;
107109 } {
108110 const web = app.webUrl.replace(/\/+$/, "");
111 // Another forge keeps its OAuth at `/oauth/*` (RFC 8414 paths it also
112 // publishes); it has no device grant.
113 if (app.software === "forge")
114 return { device: "", authorize: `${web}/oauth/authorize`, token: `${web}/oauth/token` };
109115 return {
110116 device: `${web}/login/device/code`,
111117 authorize: `${web}/login/oauth/authorize`,
@@ -348,6 +354,8 @@ export interface FlowRow {
348354 readonly apiUrl: string;
349355 readonly webUrl: string;
350356 readonly clientId: string;
357 /** `forge` when the origin is another forge, learned when the connect began. */
358 readonly software: string;
351359 readonly redirectUri: string;
352360 readonly userCode: string;
353361 readonly verificationUri: string;
@@ -370,6 +378,7 @@ interface RawFlow {
370378 api_url: string;
371379 web_url: string;
372380 client_id: string;
381 software: string;
373382 client_secret_enc: string;
374383 state_hash: string | null;
375384 verifier_enc: string;
@@ -395,6 +404,7 @@ const toFlow = (row: RawFlow): FlowRow => ({
395404 apiUrl: row.api_url,
396405 webUrl: row.web_url,
397406 clientId: row.client_id,
407 software: row.software,
398408 redirectUri: row.redirect_uri,
399409 userCode: row.user_code,
400410 verificationUri: row.verification_uri,
@@ -454,9 +464,9 @@ export function createFlow(
454464 `INSERT INTO oauth_flow
455465 (id, user_id, flow, kind, api_url, web_url, client_id, client_secret_enc,
456466 state_hash, verifier_enc, redirect_uri, device_code_enc, user_code,
457 verification_uri, interval_secs, expires_at, short_code, target_enc)
467 verification_uri, interval_secs, expires_at, short_code, target_enc, software)
458468 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
459 ?17, ?18)`,
469 ?17, ?18, ?19)`,
460470 ).run(
461471 flowId,
462472 input.userId,
@@ -487,6 +497,7 @@ export function createFlow(
487497 input.target === undefined || input.target === ""
488498 ? ""
489499 : seal(dataDir, input.target, flowId),
500 input.app.software ?? "",
490501 );
491502 return getFlow(db, flowId)!;
492503 }
@@ -751,7 +762,7 @@ export function appFor(
751762 db: Database,
752763 dataDir: string,
753764 userId: string,
754 target: { kind: OriginKind; apiUrl: string; webUrl: string },
765 target: { kind: OriginKind; apiUrl: string; webUrl: string; software?: string },
755766 env: Record<string, string | undefined> = process.env,
756767 ): Result<AppRegistration, "no_app"> {
757768 const row = db
@@ -774,6 +785,7 @@ export function appFor(
774785 webUrl: row.web_url,
775786 clientId: row.client_id,
776787 clientSecret: opened,
788 software: target.software ?? "",
777789 });
778790 }
779791
@@ -786,6 +798,7 @@ export function appFor(
786798 webUrl: target.webUrl,
787799 clientId: registered,
788800 clientSecret: "",
801 software: target.software ?? "",
789802 });
790803 }
791804
@@ -840,14 +853,103 @@ export function operatorClientId(
840853 export function scopesFor(
841854 kind: OriginKind,
842855 authKind: AuthKind = "oauth",
856 software = "",
843857 ): string {
844858 void authKind;
859 // Another forge offers `ro` and `rw`; bringing code across needs only `ro`,
860 // and the approval page there shows what is asked.
861 if (software === "forge") return "ro";
845862 if (kind === "github") return "repo read:org";
846863 return "read:repository write:repository read:issue write:issue read:user";
847864 }
848865
866 // ---- dynamic client registration (RFC 7591), forge to forge ----
867
868 /**
869 * Register this forge at another forge as a public client, once, so nobody
870 * copies a client id by hand. The other forge answers `POST /oauth/register`
871 * with a `client_id`; PKCE stands in for a secret.
872 */
873 export async function registerClient(
874 webUrl: string,
875 input: { redirectUri: string; name: string },
876 ): Promise<Result<string, OAuthFailure>> {
877 let response: Response;
878 try {
879 response = await fetch(`${webUrl.replace(/\/+$/, "")}/oauth/register`, {
880 method: "POST",
881 headers: { accept: "application/json", "content-type": "application/json", "user-agent": "forge" },
882 body: JSON.stringify({
883 client_name: input.name,
884 redirect_uris: [input.redirectUri],
885 token_endpoint_auth_method: "none",
886 grant_types: ["authorization_code", "refresh_token"],
887 response_types: ["code"],
888 }),
889 redirect: "manual",
outdated

Medium: the body is fully buffered before the size check. Stream it with a byte cap instead.

890 signal: AbortSignal.timeout(TIMEOUT_MS),
891 });
892 } catch (error) {
893 return err({ error: "unreachable", message: String(error) });
894 }
895 if (response.status !== 201 && response.status !== 200) {
896 await response.body?.cancel().catch(() => undefined);
897 return err({
898 error: "no_app",
899 message: `the origin refused to register this forge (${response.status})`,
900 });
901 }
902 const buffer = await readCapped(response);
903 if (buffer === undefined)
904 return err({ error: "invalid_response", message: "answer too large" });
905 try {
906 const parsed: unknown = JSON.parse(new TextDecoder().decode(buffer));
907 const clientId =
908 typeof parsed === "object" && parsed !== null
909 ? str(parsed as Record<string, unknown>, "client_id")
910 : "";
911 if (clientId === "")
912 return err({
913 error: "invalid_response",
914 message: "the origin issued no client id",
915 });
916 return ok(clientId);
917 } catch {
918 return err({ error: "invalid_response", message: "the origin's registration answer was not JSON" });
919 }
920 }
921
849922 // ---- transport ----
850923
924 /**
925 * The body of an answer, read from the stream and abandoned the moment it
926 * passes MAX_BYTES: the bytes actually read are the cap, not a header the
927 * host chose. Undefined when the host sent more.
928 */
929 async function readCapped(response: Response): Promise<Uint8Array | undefined> {
930 const reader = response.body?.getReader();
931 if (!reader) return new Uint8Array();
932 const chunks: Uint8Array[] = [];
933 let total = 0;
934 for (;;) {
935 const { done, value } = await reader.read();
936 if (done) break;
937 total += value.byteLength;
938 if (total > MAX_BYTES) {
939 await reader.cancel().catch(() => undefined);
940 return undefined;
941 }
942 chunks.push(value);
943 }
944 const out = new Uint8Array(total);
945 let at = 0;
946 for (const chunk of chunks) {
947 out.set(chunk, at);
948 at += chunk.byteLength;
949 }
950 return out;
951 }
952
851953 /**
852954 * A form POST to a token endpoint, answered as JSON. Both forges will
853955 * happily answer `application/x-www-form-urlencoded` bodies with a URL-
@@ -882,8 +984,8 @@ async function postForm(
882984 const declared = Number(response.headers.get("content-length") ?? "0");
883985 if (declared > MAX_BYTES)
884986 return err({ error: "invalid_response", message: "answer too large" });
885 const buffer = await response.arrayBuffer();
886 if (buffer.byteLength > MAX_BYTES)
987 const buffer = await readCapped(response);
988 if (buffer === undefined)
887989 return err({ error: "invalid_response", message: "answer too large" });
888990 const text = new TextDecoder().decode(buffer);
889991
src/origins/words.ts +8
@@ -25,3 +25,11 @@ export const PATH_IS_DIRECTORY = "that path is a directory, not a file";
2525 */
2626 export const FORGE_NOT_CARRIED =
2727 "Code comes across, and the repository's history. Issues, pulls and comments read and write through. Not yet: labels, and how the app runs. Never by default: its data and its secrets.";
28
29 /**
30 * Signing in to another forge by OAuth needs a browser to approve in, and a
31 * forge has no device grant, so the terminal cannot start it: said where the
32 * terminal asks for a token, and where it lists the apps, pointing at the page.
33 */
34 export const FORGE_SIGN_IN_ON_PAGE =
35 "Another forge signs in with one click on the web page; here, with a token.";
src/tui/screens/connections.ts +3 −3
@@ -7,7 +7,7 @@
77 import { connectOrigin, disconnectOrigin, listUserOrigins, syncOriginNow } from "../../actions/origins.ts";
88 import { DISCONNECT_DETAIL, NO_SUCH_ORIGIN, connectWords, disconnectedWords, syncedWords } from "../../actions/origin_words.ts";
99 import { mirrorsByOrigin } from "../../core/origins.ts";
10 import { FORGE_NOT_CARRIED } from "../../origins/words.ts";
10 import { FORGE_NOT_CARRIED, FORGE_SIGN_IN_ON_PAGE } from "../../origins/words.ts";
1111 import type { Ctx, Screen } from "../app/app.ts";
1212 import { binding } from "../app/keys.ts";
1313 import { style } from "../term/style.ts";
@@ -37,8 +37,8 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
3737 picked?.software === "forge"
3838 ? `Another forge. ${FORGE_NOT_CARRIED}`
3939 : all.length === 0
40 ? "No origin connected. Press c to connect one."
41 : "The hosts your mirrors come from.";
40 ? `No origin connected. Press c to connect one. ${FORGE_SIGN_IN_ON_PAGE}`
41 : `The hosts your mirrors come from. ${FORGE_SIGN_IN_ON_PAGE}`;
4242 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
4343 const n = counts();
4444 drawList(
src/tui/screens/originapps.ts +2 −1
@@ -17,6 +17,7 @@ import { style } from "../term/style.ts";
1717 import { wrap } from "../term/width.ts";
1818 import { fill, len, split } from "../ui/layout.ts";
1919 import type { Session } from "../session.ts";
20 import { FORGE_SIGN_IN_ON_PAGE } from "../../origins/words.ts";
2021 import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts";
2122
2223 /** A row's name: the host of its web address, as the page shows it. */
@@ -102,7 +103,7 @@ export function originAppsScreen(f: Session, ctx: Ctx): Screen {
102103 : f.user === undefined
103104 ? "Sign in to see your origin apps."
104105 : rows.length === 0
105 ? "No origin app yet. Press a to register one."
106 ? `No origin app yet. Press a to register one. ${FORGE_SIGN_IN_ON_PAGE}`
106107 : `${rows.length} registered; a secret is never shown.`;
107108 // A refusal is the API's sentence, whole: it wraps rather than cuts.
108109 const said = wrap(text, Math.max(1, area.w)).slice(0, 3);
test/forge-origin-oauth.test.ts added +263
@@ -0,0 +1,263 @@
1 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { completeOAuthCallback, startOAuthConnect } from "../src/actions/origins.ts";
7 import { createSession } from "../src/core/sessions.ts";
8 import { getUserByName } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { appFor, endpoints, getFlowFor, isTerminal, refresh, registerClient, scopesFor } from "../src/origins/oauth.ts";
11 import { getOriginById, sealedSecrets, storeRefreshed } from "../src/core/origins.ts";
12 import { credentialFor } from "../src/origins/credential.ts";
13 import { syncsSettled } from "../src/origins/sync.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15 import { proveAll } from "./helpers/verified.ts";
16
17 /**
18 * Sign in to another forge in one click (#8902): two real forges. This one
19 * registers itself at the friend's, sends the browser to its approval page
20 * asking for `ro` only, and comes back with a token that refreshes.
21 */
22
23 setDefaultTimeout(60_000);
24
25 describe("a forge signs in to another forge through OAuth", () => {
26 const dirs: string[] = [];
27 let mine: App;
28 let friend: App;
29 let mineServers: Servers;
30 let friendServers: Servers;
31 let cookie = "";
32 let originId = "";
33
34 beforeAll(async () => {
35 const make = async () => {
36 const dir = await mkdtemp(join(tmpdir(), "forge-forge-oauth-"));
37 dirs.push(dir);
38 return dir;
39 };
40 friend = createApp({ dataDir: await make(), port: 0 });
41 friendServers = await serve(friend);
42 await seedAdmin(friend, { username: "bob", email: "[email protected]", password: "bob-login-secret" });
43 proveAll(friend);
44 cookie = `forge_session=${createSession(friend.db, getUserByName(friend.db, "bob")!.id).token}`;
45 mine = createApp({ dataDir: await make(), port: 0 });
46 mineServers = await serve(mine);
47 await seedAdmin(mine, { username: "alice", email: "[email protected]", password: "alice-login-secret" });
48 proveAll(mine);
49 }, 120_000);
50
51 afterAll(async () => {
52 await syncsSettled();
53 mineServers.stop();
54 friendServers.stop();
55 mine.close();
56 friend.close();
57 for (const dir of dirs) await rm(dir, { recursive: true, force: true, maxRetries: 5 });
58 }, 120_000);
59
60 test("the scope asked is ro, and the endpoints are the forge's own", () => {
61 expect(scopesFor("gitea", "oauth", "forge")).toBe("ro");
62 expect(scopesFor("gitea")).toContain("write:repository");
63 const app = { kind: "gitea" as const, apiUrl: "", webUrl: "https://f.test/", clientId: "c", clientSecret: "" };
64 expect(endpoints({ ...app, software: "forge" }).authorize).toBe("https://f.test/oauth/authorize");
65 expect(endpoints(app).authorize).toBe("https://f.test/login/oauth/authorize");
66 });
67
68 test("one click: register, approve on the friend's forge, come back connected", async () => {
69 const alice = getUserByName(mine.db, "alice")!;
70 const redirectUri = `${mineServers.url}/settings/origins/callback`;
71 const started = await startOAuthConnect(mine, alice, { host: friendServers.url, redirectUri });
72 expect(started.ok).toBe(true);
73 if (!started.ok) return;
74 expect(started.value.flow).toBe("code");
75
76 // The approval page names the requester and offers ro as asked.
77 const url = new URL(started.value.url);
78 expect(url.origin).toBe(friendServers.url);
79 expect(url.pathname).toBe("/oauth/authorize");
80 expect(url.searchParams.get("scope")).toBe("ro");
81 const page = await fetch(url, { headers: { cookie } });
82 expect(page.status).toBe(200);
83 expect(await page.text()).toMatch(/value="ro"\s+checked/);
84
85 // Bob approves; the browser would follow this redirect back to us.
86 const consent = await fetch(`${friendServers.url}/oauth/authorize`, {
87 method: "POST",
88 redirect: "manual",
89 headers: { cookie, origin: friendServers.url },
90 body: new URLSearchParams({
91 ...Object.fromEntries(url.searchParams),
92 decision: "allow",
93 grant: "ro",
94 name: "alice's forge",
95 lifetime: "month",
96 }),
97 });
98 const back = new URL(consent.headers.get("location")!);
99 expect(`${back.origin}${back.pathname}`).toBe(redirectUri);
100
101 const done = await completeOAuthCallback(mine, alice, {
102 state: back.searchParams.get("state")!,
103 code: back.searchParams.get("code")!,
104 });
105 expect(done.ok && done.value.status).toBe("done");
106 originId = done.ok ? done.value.originId ?? "" : "";
107 const origin = getOriginById(mine.db, originId)!;
108 expect(origin).toMatchObject({ login: "bob", authKind: "oauth", software: "forge", needsReauth: false });
109 // The registration is kept, so the next connect needs no registering.
110 const kept = appFor(mine.db, mine.config.dataDir, alice.id, origin);
111 expect(kept.ok).toBe(true);
112
113 const listed = await fetch(`${mineServers.url}/api/v1/user/origins/${originId}/repos`, {
114 headers: { authorization: `Basic ${Buffer.from("alice:alice-login-secret").toString("base64")}` },
115 });
116 expect(listed.status).toBe(200);
117 });
118
119 test("a second connect reuses the registration instead of making another", async () => {
120 const before = friend.db.query<{ n: number }, []>("SELECT count(*) AS n FROM oauth_client").get()!.n;
121 const alice = getUserByName(mine.db, "alice")!;
122 const again = await startOAuthConnect(mine, alice, {
123 host: friendServers.url,
124 redirectUri: `${mineServers.url}/settings/origins/callback`,
125 });
126 expect(again.ok).toBe(true);
127 const after = friend.db.query<{ n: number }, []>("SELECT count(*) AS n FROM oauth_client").get()!.n;
128 expect(after).toBe(before);
129 });
130
131 test("refresh rotates; a spent refresh token is terminal, so needs-reauth applies", async () => {
132 const alice = getUserByName(mine.db, "alice")!;
133 const origin = getOriginById(mine.db, originId)!;
134 const registration = appFor(mine.db, mine.config.dataDir, alice.id, origin);
135 expect(registration.ok).toBe(true);
136 if (!registration.ok) return;
137 const { open } = await import("../src/kernel/secret.ts");
138 const { sealedSecrets } = await import("../src/core/origins.ts");
139 const sealed = sealedSecrets(mine.db, originId)!;
140 const first = open(mine.config.dataDir, sealed.refresh, originId);
141 expect(first.ok).toBe(true);
142 if (!first.ok) return;
143 const next = await refresh(registration.value, first.value);
144 expect(next.ok).toBe(true);
145 if (next.ok) expect(next.value.refreshToken).not.toBe(first.value);
146 // Keep the live pair, already expired, for the credential path below.
147 if (next.ok)
148 storeRefreshed(mine.db, mine.config.dataDir, originId, {
149 token: next.value.accessToken,
150 refresh: next.value.refreshToken,
151 expiresAt: new Date(Date.now() - 1000).toISOString(),
152 });
153 });
154
155 test("the credential path refreshes a forge origin's expired token, and a spent one needs reauth", async () => {
156 const { open } = await import("../src/kernel/secret.ts");
157 const origin = getOriginById(mine.db, originId)!;
158 const before = open(mine.config.dataDir, sealedSecrets(mine.db, originId)!.refresh, originId);
159 const credential = credentialFor(mine, origin);
160 expect(credential.ok).toBe(true);
161 if (!credential.ok || !before.ok) return;
162 expect(credential.value.software).toBe("forge");
163
164 const token = await credential.value.token();
165 expect(token.ok).toBe(true);
166 const after = open(mine.config.dataDir, sealedSecrets(mine.db, originId)!.refresh, originId);
167 expect(after.ok && after.value).not.toBe(before.value);
168 const renewed = getOriginById(mine.db, originId)!;
169 expect(Date.parse(renewed.expiresAt ?? "")).toBeGreaterThan(Date.now());
170 expect(renewed.needsReauth).toBe(false);
171 // It is the friend's own forge that answered: the token works there.
172 if (token.ok) {
173 const who = await fetch(`${friendServers.url}/api/v1/user`, { headers: { authorization: `Bearer ${token.value}` } });
174 expect(who.status).toBe(200);
175 }
176
177 // The refresh token just spent is terminal, direct or through the credential path.
178 const spent = before.value;
179 const app = appFor(mine.db, mine.config.dataDir, origin.userId, origin);
180 if (!app.ok) throw new Error("no registration");
181 const replay = await refresh(app.value, spent);
182 expect(replay.ok).toBe(false);
183 if (!replay.ok) expect(isTerminal(replay.error.error)).toBe(true);
184 storeRefreshed(mine.db, mine.config.dataDir, originId, {
185 token: "stale",
186 refresh: spent,
187 expiresAt: new Date(Date.now() - 1000).toISOString(),
188 });
189 const again = credentialFor(mine, getOriginById(mine.db, originId)!);
190 if (!again.ok) throw new Error("no credential");
191 const dead = await again.value.token();
192 expect(dead.ok).toBe(false);
193 expect(getOriginById(mine.db, originId)!.needsReauth).toBe(true);
194 });
195
196 test("the callback uses the software the connect began with, not a second look", async () => {
197 const alice = getUserByName(mine.db, "alice")!;
198 const redirectUri = `${mineServers.url}/settings/origins/callback`;
199 const started = await startOAuthConnect(mine, alice, { host: friendServers.url, redirectUri });
200 expect(started.ok).toBe(true);
201 if (!started.ok) return;
202 const flow = getFlowFor(mine.db, alice.id, started.value.flowId)!;
203 expect(flow.software).toBe("forge");
204 });
205
206 describe("a forge that refuses to register is told so, in words", () => {
207 const serveOnce = (status: number, body: string) =>
208 Bun.serve({ port: 0, fetch: () => new Response(body, { status, headers: { "content-type": "application/json" } }) });
209 const ask = (url: string) => registerClient(url, { redirectUri: "https://me.test/cb", name: "x" });
210
211 test("a bad redirect uri", async () => {
212 const refused = await registerClient(friendServers.url, { redirectUri: "javascript:alert(1)", name: "x" });
213 expect(refused.ok).toBe(false);
214 if (!refused.ok) expect(refused.error.message).toContain("refused");
215 });
216
217 test("a non-2xx answer", async () => {
218 const server = serveOnce(500, "{}");
219 try {
220 const refused = await ask(`http://127.0.0.1:${server.port}`);
221 expect(refused.ok).toBe(false);
222 if (!refused.ok) expect(refused.error.message).toContain("refused");
223 } finally {
224 server.stop(true);
225 }
226 });
227
228 test("an answer with no client_id", async () => {
229 const server = serveOnce(201, "{}");
230 try {
231 const refused = await ask(`http://127.0.0.1:${server.port}`);
232 expect(refused.ok).toBe(false);
233 if (!refused.ok) expect(refused.error.message).toContain("no client id");
234 } finally {
235 server.stop(true);
236 }
237 });
238
239 test("a body streamed past the cap is cut off, not buffered", async () => {
240 // A body that never ends: reading it whole would never return.
241 const server = Bun.serve({
242 port: 0,
243 fetch: () =>
244 new Response(
245 new ReadableStream({
246 async pull(controller) {
247 controller.enqueue(new Uint8Array(16 * 1024));
248 await Bun.sleep(1);
249 },
250 }),
251 { status: 201 },
252 ),
253 });
254 try {
255 const refused = await ask(`http://127.0.0.1:${server.port}`);
256 expect(refused.ok).toBe(false);
257 if (!refused.ok) expect(refused.error.message).toContain("too large");
258 } finally {
259 server.stop(true);
260 }
261 });
262 });
263 });
test/origin-words.test.ts +1 −1
@@ -15,7 +15,7 @@ const PROVIDERS = [
1515 const sentences = Object.entries(words) as [string, string][];
1616
1717 test("each sentence is non-empty", () => {
18 expect(sentences.length).toBe(6);
18 expect(sentences.length).toBe(7);
1919 for (const [, s] of sentences) expect(s.trim().length).toBeGreaterThan(0);
2020 });
2121
test/tui-origins-forge.test.ts +2
@@ -73,6 +73,8 @@ test("c connects another forge by address and token, and the list says what did
7373 await type("Connected origins");
7474 await key("enter");
7575 expect(app.screen.toText()).toContain("Press c to connect one.");
76 // A forge has no device sign-in: the terminal says so and points at the page.
77 expect(app.screen.toText().replace(/\s+/g, " ")).toContain("Another forge signs in with one click on the web page; here, with a token.");
7678
7779 await key("c");
7880 await type(friendServers.url);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs