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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8902-sign-in-to-another-forge-in-one-click-oa → main 5 commits fork 3 threads · 3 unresolved
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 | ||
| 7 | 7 | - **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). |
| 8 | 8 | - **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. |
| 9 | 9 | |
| 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 | ||
| 10 | 18 | 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. |
| 11 | 19 | |
| 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. | |
| 13 | 21 | |
| 14 | 22 | ## Both doors, and what was exercised |
| 15 | 23 | |
| 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. | |
| 17 | 25 | |
| 18 | 26 | 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. |
| 19 | 27 |
src/actions/origins.ts +28 −3
| @@ -79,6 +79,7 @@ import { | ||
| 79 | 79 | revokeGrant, |
| 80 | 80 | saveApp, |
| 81 | 81 | scopesFor, |
| 82 | registerClient, | |
| 82 | 83 | startDevice, |
| 83 | 84 | touchFlow, |
| 84 | 85 | type AppRow, |
| @@ -456,11 +457,31 @@ export async function startOAuthConnect( | ||
| 456 | 457 | message: `cannot identify ${input.host}`, |
| 457 | 458 | }); |
| 458 | 459 | |
| 459 | const registration = appFor(app.db, app.config.dataDir, user.value.id, { | |
| 460 | const software = softwareOf(found.value.software); | |
| 461 | const target = { | |
| 460 | 462 | kind: found.value.kind, |
| 461 | 463 | apiUrl: found.value.apiUrl, |
| 462 | 464 | 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 | } | |
| 464 | 485 | if (!registration.ok) { |
| 465 | 486 | return err({ |
| 466 | 487 | error: "no_app", |
| @@ -470,7 +491,7 @@ export async function startOAuthConnect( | ||
| 470 | 491 | : "register an OAuth application on this instance first", |
| 471 | 492 | }); |
| 472 | 493 | } |
| 473 | const scope = scopesFor(found.value.kind); | |
| 494 | const scope = scopesFor(found.value.kind, "oauth", software); | |
| 474 | 495 | |
| 475 | 496 | if (flowFor(found.value.kind) === "device") { |
| 476 | 497 | const started = await startDevice(registration.value, scope); |
| @@ -662,6 +683,7 @@ export async function completeOAuthCallback( | ||
| 662 | 683 | webUrl: flow.webUrl, |
| 663 | 684 | clientId: flow.clientId, |
| 664 | 685 | clientSecret: secrets?.clientSecret ?? "", |
| 686 | software: flow.software, | |
| 665 | 687 | }; |
| 666 | 688 | const exchanged = await exchangeCode(registration, { |
| 667 | 689 | code: input.code, |
| @@ -696,6 +718,7 @@ async function completeConnect( | ||
| 696 | 718 | webUrl: string; |
| 697 | 719 | clientId: string; |
| 698 | 720 | clientSecret: string; |
| 721 | software?: string; | |
| 699 | 722 | }, |
| 700 | 723 | tokens: { |
| 701 | 724 | accessToken: string; |
| @@ -709,6 +732,7 @@ async function completeConnect( | ||
| 709 | 732 | apiUrl: registration.apiUrl, |
| 710 | 733 | webUrl: registration.webUrl, |
| 711 | 734 | authKind: "oauth", |
| 735 | software: registration.software ?? "", | |
| 712 | 736 | token: tokens.accessToken, |
| 713 | 737 | }), |
| 714 | 738 | ).whoami(); |
| @@ -724,6 +748,7 @@ async function completeConnect( | ||
| 724 | 748 | authKind: "oauth", |
| 725 | 749 | refreshToken: tokens.refreshToken, |
| 726 | 750 | expiresAt: tokens.expiresAt, |
| 751 | software: registration.software ?? "", | |
| 727 | 752 | }); |
| 728 | 753 | if (!stored.ok) |
| 729 | 754 | 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> { | ||
| 137 | 137 | kind: origin.kind, |
| 138 | 138 | apiUrl: origin.apiUrl, |
| 139 | 139 | webUrl: origin.webUrl, |
| 140 | software: origin.software, | |
| 140 | 141 | }); |
| 141 | 142 | if (!registration.ok) { |
| 142 | 143 | markNeedsReauth( |
src/origins/oauth.ts +107 −5
| @@ -91,6 +91,8 @@ export interface AppRegistration { | ||
| 91 | 91 | readonly clientId: string; |
| 92 | 92 | /** Empty for a public client — PKCE carries the proof instead. */ |
| 93 | 93 | readonly clientSecret: string; |
| 94 | /** `forge` when the origin is another forge, else absent or "". */ | |
| 95 | readonly software?: string; | |
| 94 | 96 | } |
| 95 | 97 | |
| 96 | 98 | // ---- the OAuth endpoints, per forge ---- |
| @@ -106,6 +108,10 @@ export function endpoints(app: AppRegistration): { | ||
| 106 | 108 | token: string; |
| 107 | 109 | } { |
| 108 | 110 | 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` }; | |
| 109 | 115 | return { |
| 110 | 116 | device: `${web}/login/device/code`, |
| 111 | 117 | authorize: `${web}/login/oauth/authorize`, |
| @@ -348,6 +354,8 @@ export interface FlowRow { | ||
| 348 | 354 | readonly apiUrl: string; |
| 349 | 355 | readonly webUrl: string; |
| 350 | 356 | readonly clientId: string; |
| 357 | /** `forge` when the origin is another forge, learned when the connect began. */ | |
| 358 | readonly software: string; | |
| 351 | 359 | readonly redirectUri: string; |
| 352 | 360 | readonly userCode: string; |
| 353 | 361 | readonly verificationUri: string; |
| @@ -370,6 +378,7 @@ interface RawFlow { | ||
| 370 | 378 | api_url: string; |
| 371 | 379 | web_url: string; |
| 372 | 380 | client_id: string; |
| 381 | software: string; | |
| 373 | 382 | client_secret_enc: string; |
| 374 | 383 | state_hash: string | null; |
| 375 | 384 | verifier_enc: string; |
| @@ -395,6 +404,7 @@ const toFlow = (row: RawFlow): FlowRow => ({ | ||
| 395 | 404 | apiUrl: row.api_url, |
| 396 | 405 | webUrl: row.web_url, |
| 397 | 406 | clientId: row.client_id, |
| 407 | software: row.software, | |
| 398 | 408 | redirectUri: row.redirect_uri, |
| 399 | 409 | userCode: row.user_code, |
| 400 | 410 | verificationUri: row.verification_uri, |
| @@ -454,9 +464,9 @@ export function createFlow( | ||
| 454 | 464 | `INSERT INTO oauth_flow |
| 455 | 465 | (id, user_id, flow, kind, api_url, web_url, client_id, client_secret_enc, |
| 456 | 466 | 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) | |
| 458 | 468 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, |
| 459 | ?17, ?18)`, | |
| 469 | ?17, ?18, ?19)`, | |
| 460 | 470 | ).run( |
| 461 | 471 | flowId, |
| 462 | 472 | input.userId, |
| @@ -487,6 +497,7 @@ export function createFlow( | ||
| 487 | 497 | input.target === undefined || input.target === "" |
| 488 | 498 | ? "" |
| 489 | 499 | : seal(dataDir, input.target, flowId), |
| 500 | input.app.software ?? "", | |
| 490 | 501 | ); |
| 491 | 502 | return getFlow(db, flowId)!; |
| 492 | 503 | } |
| @@ -751,7 +762,7 @@ export function appFor( | ||
| 751 | 762 | db: Database, |
| 752 | 763 | dataDir: string, |
| 753 | 764 | userId: string, |
| 754 | target: { kind: OriginKind; apiUrl: string; webUrl: string }, | |
| 765 | target: { kind: OriginKind; apiUrl: string; webUrl: string; software?: string }, | |
| 755 | 766 | env: Record<string, string | undefined> = process.env, |
| 756 | 767 | ): Result<AppRegistration, "no_app"> { |
| 757 | 768 | const row = db |
| @@ -774,6 +785,7 @@ export function appFor( | ||
| 774 | 785 | webUrl: row.web_url, |
| 775 | 786 | clientId: row.client_id, |
| 776 | 787 | clientSecret: opened, |
| 788 | software: target.software ?? "", | |
| 777 | 789 | }); |
| 778 | 790 | } |
| 779 | 791 | |
| @@ -786,6 +798,7 @@ export function appFor( | ||
| 786 | 798 | webUrl: target.webUrl, |
| 787 | 799 | clientId: registered, |
| 788 | 800 | clientSecret: "", |
| 801 | software: target.software ?? "", | |
| 789 | 802 | }); |
| 790 | 803 | } |
| 791 | 804 | |
| @@ -840,14 +853,103 @@ export function operatorClientId( | ||
| 840 | 853 | export function scopesFor( |
| 841 | 854 | kind: OriginKind, |
| 842 | 855 | authKind: AuthKind = "oauth", |
| 856 | software = "", | |
| 843 | 857 | ): string { |
| 844 | 858 | 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"; | |
| 845 | 862 | if (kind === "github") return "repo read:org"; |
| 846 | 863 | return "read:repository write:repository read:issue write:issue read:user"; |
| 847 | 864 | } |
| 848 | 865 | |
| 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 | ||
| 849 | 922 | // ---- transport ---- |
| 850 | 923 | |
| 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 | ||
| 851 | 953 | /** |
| 852 | 954 | * A form POST to a token endpoint, answered as JSON. Both forges will |
| 853 | 955 | * happily answer `application/x-www-form-urlencoded` bodies with a URL- |
| @@ -882,8 +984,8 @@ async function postForm( | ||
| 882 | 984 | const declared = Number(response.headers.get("content-length") ?? "0"); |
| 883 | 985 | if (declared > MAX_BYTES) |
| 884 | 986 | 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) | |
| 887 | 989 | return err({ error: "invalid_response", message: "answer too large" }); |
| 888 | 990 | const text = new TextDecoder().decode(buffer); |
| 889 | 991 |
src/origins/words.ts +8
| @@ -25,3 +25,11 @@ export const PATH_IS_DIRECTORY = "that path is a directory, not a file"; | ||
| 25 | 25 | */ |
| 26 | 26 | export const FORGE_NOT_CARRIED = |
| 27 | 27 | "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 @@ | ||
| 7 | 7 | import { connectOrigin, disconnectOrigin, listUserOrigins, syncOriginNow } from "../../actions/origins.ts"; |
| 8 | 8 | import { DISCONNECT_DETAIL, NO_SUCH_ORIGIN, connectWords, disconnectedWords, syncedWords } from "../../actions/origin_words.ts"; |
| 9 | 9 | 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"; | |
| 11 | 11 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 12 | import { binding } from "../app/keys.ts"; |
| 13 | 13 | import { style } from "../term/style.ts"; |
| @@ -37,8 +37,8 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 37 | 37 | picked?.software === "forge" |
| 38 | 38 | ? `Another forge. ${FORGE_NOT_CARRIED}` |
| 39 | 39 | : 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}`; | |
| 42 | 42 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 43 | 43 | const n = counts(); |
| 44 | 44 | drawList( |
src/tui/screens/originapps.ts +2 −1
| @@ -17,6 +17,7 @@ import { style } from "../term/style.ts"; | ||
| 17 | 17 | import { wrap } from "../term/width.ts"; |
| 18 | 18 | import { fill, len, split } from "../ui/layout.ts"; |
| 19 | 19 | import type { Session } from "../session.ts"; |
| 20 | import { FORGE_SIGN_IN_ON_PAGE } from "../../origins/words.ts"; | |
| 20 | 21 | import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts"; |
| 21 | 22 | |
| 22 | 23 | /** 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 { | ||
| 102 | 103 | : f.user === undefined |
| 103 | 104 | ? "Sign in to see your origin apps." |
| 104 | 105 | : 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}` | |
| 106 | 107 | : `${rows.length} registered; a secret is never shown.`; |
| 107 | 108 | // A refusal is the API's sentence, whole: it wraps rather than cuts. |
| 108 | 109 | 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 = [ | ||
| 15 | 15 | const sentences = Object.entries(words) as [string, string][]; |
| 16 | 16 | |
| 17 | 17 | test("each sentence is non-empty", () => { |
| 18 | expect(sentences.length).toBe(6); | |
| 18 | expect(sentences.length).toBe(7); | |
| 19 | 19 | for (const [, s] of sentences) expect(s.trim().length).toBeGreaterThan(0); |
| 20 | 20 | }); |
| 21 | 21 |
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 | ||
| 73 | 73 | await type("Connected origins"); |
| 74 | 74 | await key("enter"); |
| 75 | 75 | 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."); | |
| 76 | 78 | |
| 77 | 79 | await key("c"); |
| 78 | 80 | await type(friendServers.url); |
new above
offline
Medium: this line has no test. Add a credential-path refresh test for a forge origin that also covers needs-reauth.