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

The connect-an-account flow leaves origins.ts for src/web/pages/origin-flow.ts #6322

Merged opened this

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

Merged

mason/forge:mason/6309-the-connect-an-account-flow-leaves-origi → main 1 commit fork

  • Merged. mason/6309-the-connect-an-account-flow-leaves-origi was merged into main as 79a4005.

Review

Sign in to review this change.
3 files changed +281 −260
src/http/server.ts +1 −2
@@ -448,8 +448,6 @@ import {
448448 originAppDelete,
449449 originConnectToken,
450450 originDisconnect,
451 originFlowPage,
452 originFlowPoll,
453451 originMirrorSubmit,
454452 originOAuthStart,
455453 originReposPage,
@@ -457,6 +455,7 @@ import {
457455 originsPage,
458456 originTrustedHosts,
459457 } from "../web/pages/origins.ts";
458 import { originFlowPage, originFlowPoll } from "../web/pages/origin-flow.ts";
460459 import { pushThrough } from "../origins/push-through.ts";
461460 import { notFoundPage } from "../web/chrome.ts";
462461 import { targetForShortCode } from "../origins/oauth.ts";
src/web/pages/origin-flow.ts added +273
@@ -0,0 +1,273 @@
1 /**
2 * One connect in flight: the page a device grant or a code flow waits on,
3 * and the poll it submits. An OAuth flow is a row, not a page state — the
4 * browser can be closed, the phone can do the typing, and coming back to
5 * `/settings/origins/flows/:id` resumes exactly where it was.
6 *
7 * `origins.ts` starts the flow and owns the helpers both pages share; this
8 * file only carries it to its end.
9 */
10
11 import { pollOAuthConnect } from "../../actions/origins.ts";
12 import { getFlowFor } from "../../origins/oauth.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { forgeBaseUrl } from "../../http/api.ts";
15 import { qrEncode, qrSvg } from "../../design/qr.ts";
16 import { readForm } from "../form.ts";
17 import { html, raw, type Html } from "../html.ts";
18 import { page, redirect } from "../layout.ts";
19 import { pollJs } from "../client.ts";
20 import { btn, card, icon, row } from "../ui.ts";
21 import {
22 accountOnly,
23 hostLabel,
24 notFoundNote,
25 shell,
26 thenOf,
27 type Then,
28 } from "./origins.ts";
29
30 const thenQuery = (then: Then): string =>
31 then === undefined ? "" : "then=migrate";
32
33 /**
34 * GET /settings/origins/flows/:id — a connect in progress.
35 *
36 * The device grant shows the code and where to type it; the code flow shows
37 * one link to the instance. Both work with no JavaScript: the page carries a
38 * "check again" form, and reloading it re-polls. With script, the same form
39 * is submitted for you every couple of seconds.
40 */
41 export function originFlowPage(ctx: Ctx): Response {
42 const denied = accountOnly(ctx);
43 if (denied) return denied;
44 const flow = getFlowFor(ctx.app.db, ctx.auth.user!.id, ctx.params.id ?? "");
45 if (!flow) return shell(ctx, notFoundNote(), 404);
46 const then = thenOf(ctx.url.searchParams.get("then"));
47
48 if (flow.status === "done")
49 return redirect(`${then ?? "/settings/origins"}?connected=1`);
50 if (flow.status === "failed")
51 return redirect(
52 `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 120))}`,
53 );
54
55 const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`;
56 const authorize =
57 flow.flow === "code"
58 ? authorizeLink(ctx.url.searchParams.get("to") ?? "", flow.webUrl)
59 : undefined;
60 const base = forgeBaseUrl(ctx.url, ctx.app.config.host);
61 const shortLink =
62 base === "" || flow.shortCode === "" ? "" : `${base}/go/${flow.shortCode}`;
63 const body =
64 flow.flow === "device"
65 ? html`${card(
66 html`<p class="muted small">
67 Type this code at
68 <a href="${flow.verificationUri}" rel="noreferrer noopener"
69 >${flow.verificationUri}</a
70 >. Any device with a browser will do — this page waits.
71 </p>
72 <div class="bar">
73 <p class="device-code" id="device-code">${flow.userCode}</p>
74 ${copyBtn("device-code", "Copy the code")}
75 </div>
76 ${handoff(flow.verificationUri, shortLink)}`,
77 {
78 head: html`${icon("github")}
79 <h2>Enter this code</h2>`,
80 },
81 )}`
82 : html`${card(
83 authorize === undefined
84 ? html`<p>
85 This connect cannot be continued from here — the authorization
86 link was lost. Start it again from Origins.
87 </p>
88 <div class="bar mt">
89 ${btn("Back to origins", {
90 href: "/settings/origins",
91 variant: "primary",
92 })}
93 </div>`
94 : html`<p>
95 Continue to
96 <span class="mono">${hostLabel(flow.webUrl)}</span> and
97 approve the application. You will come straight back here.
98 </p>
99 ${handoff(authorize, shortLink, "Continue to the instance")}`,
100 {
101 head: html`${icon("forgejo")}
102 <h2>Authorize on the instance</h2>`,
103 },
104 )}`;
105
106 const waiting = html`<form
107 class="bar mt"
108 method="post"
109 action="${pollAction}"
110 data-poll="${flow.flow === "device"
111 ? String(Math.max(1, flow.intervalSecs))
112 : "0"}"
113 >
114 <span class="muted small"
115 >Waiting for you to authorize
116 ${flow.flow === "device"
117 ? "on the other device"
118 : "on the instance"}…</span
119 >
120 <span class="spacer"></span>
121 ${btn("Check again", { size: "sm", variant: "outline", icon: "sync" })}
122 ${btn("Cancel", {
123 href: "/settings/origins",
124 size: "sm",
125 variant: "ghost",
126 })}
127 </form>`;
128
129 return page({
130 ctx,
131 title: "Connecting an origin",
132 body: html`<div class="page-head"><h1>Connecting an origin</h1></div>
133 <div class="narrow-wide">
134 ${body}${waiting}
135 <p class="muted small mt">
136 No JavaScript? Refresh this page to check again — the reload re-polls.
137 </p>
138 </div>`,
139 scriptJs: pollJs,
140 });
141 }
142
143 /** The copy affordance: an icon button that is inert without a script, and
144 * sits beside text that stays selectable either way. */
145 function copyBtn(target: string, label: string): Html {
146 return html`<button
147 class="btn btn-icon js-only"
148 data-size="sm"
149 type="button"
150 data-copy="${target}"
151 aria-label="${label}"
152 title="Copy"
153 >
154 ${icon("copy")}
155 </button>`;
156 }
157
158 /**
159 * Getting the person to the other device: a QR code they point a camera at,
160 * the short link they could type instead, the full URL for a browser that is
161 * already here, and one button that opens it.
162 *
163 * The QR carries the short link when the forge knows its own address and the
164 * long URL otherwise — the shorter the payload, the coarser the modules and
165 * the further away a phone can read it.
166 */
167 function handoff(url: string, shortLink: string, openLabel?: string): Html {
168 const payload = shortLink === "" ? url : shortLink;
169 const code = qrEncode(payload);
170 return html`<div class="bar mt">
171 ${code.ok
172 ? raw(
173 qrSvg(code.value, {
174 title: "Scan to open the link",
175 scale: qrScale(code.value.modules.length),
176 }),
177 )
178 : null}
179 </div>
180 <ul class="list setting-list mt">
181 ${shortLink === ""
182 ? null
183 : row({
184 title: "Or open this on a phone:",
185 meta: html`<span class="mono" id="flow-short">${shortLink}</span>`,
186 trail: copyBtn("flow-short", "Copy the short link"),
187 })}
188 ${row({
189 title: "The full link:",
190 meta: html`<span class="mono small" id="flow-url">${url}</span>`,
191 trail: copyBtn("flow-url", "Copy the link"),
192 })}
193 </ul>
194 <div class="bar mt">
195 ${btn(openLabel ?? "Open the verification page", {
196 href: url,
197 variant: "primary",
198 icon: "external",
199 external: true,
200 })}
201 </div>`;
202 }
203
204 /**
205 * The module size that keeps the code at most 200 px square: the encoder sizes
206 * the SVG from it, so a long URL (more modules) must not make a code wider
207 * than a 360 px phone can hold beside the page's padding.
208 */
209 function qrScale(modules: number): number {
210 return Math.max(1, Math.min(4, Math.floor(200 / modules)));
211 }
212
213 /**
214 * The authorize URL for a code flow, checked before it becomes a link.
215 *
216 * It arrives on the query string because the flow row stores a *device*
217 * flow's verification URI and nothing for a code flow. A crafted link could
218 * therefore reach this page, so the URL is accepted only when it points at
219 * the same instance the flow already names and carries a PKCE challenge —
220 * the worst a forged one can do is send you to another page on the forge you
221 * asked to connect.
222 */
223 function authorizeLink(raw: string, webUrl: string): string | undefined {
224 const url = URL.parse(raw);
225 const instance = URL.parse(webUrl);
226 if (!url || !instance) return undefined;
227 if (url.origin !== instance.origin) return undefined;
228 if (url.searchParams.get("code_challenge") === null) return undefined;
229 return url.toString();
230 }
231
232 /**
233 * POST /settings/origins/flows/:id/poll — ask the origin once, on the user's
234 * behalf. The forge does the polling, so a browser (or a phone) only ever
235 * asks "has it happened yet?" and a token never crosses back to a surface.
236 *
237 * Answers JSON to a client that asked for it and a redirect to everyone
238 * else, so the same route serves the enhancement and the plain form.
239 */
240 export async function originFlowPoll(ctx: Ctx): Promise<Response> {
241 const denied = accountOnly(ctx);
242 if (denied) return denied;
243 const form = await readForm(ctx, "admin");
244 if (form instanceof Response) return form;
245 const polled = await pollOAuthConnect(
246 ctx.app,
247 ctx.auth.user,
248 ctx.params.id ?? "",
249 );
250 const wantsJson = (ctx.request.headers.get("accept") ?? "").includes(
251 "application/json",
252 );
253 if (!polled.ok) {
254 return wantsJson
255 ? Response.json(
256 { status: "failed", detail: "not found" },
257 { status: 404 },
258 )
259 : redirect("/settings/origins?error=state");
260 }
261 const { status, detail } = polled.value;
262 const then = thenOf(ctx.url.searchParams.get("then"));
263 if (wantsJson) return Response.json({ status, detail });
264 if (status === "done")
265 return redirect(`${then ?? "/settings/origins"}?connected=1`);
266 if (status === "failed")
267 return redirect(
268 `/settings/origins?error=${encodeURIComponent(detail.slice(0, 120))}`,
269 );
270 return redirect(
271 `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`,
272 );
273 }
src/web/pages/origins.ts +7 −258
@@ -13,7 +13,7 @@
1313 * a value attribute, or carried in a query string. And an OAuth flow is a
1414 * row, not a page state — the browser can be closed, the phone can do the
1515 * typing, and coming back to `/settings/origins/flows/:id` resumes exactly
16 * where it was.
16 * where it was — that page and its poll live in `origin-flow.ts`.
1717 */
1818
1919 import {
@@ -23,7 +23,6 @@ import {
2323 listOriginApps,
2424 listRemoteRepos,
2525 listUserOrigins,
26 pollOAuthConnect,
2726 registerOriginApp,
2827 startMirrorJob,
2928 startOAuthConnect,
@@ -39,7 +38,6 @@ import {
3938 updateTrustedHosts,
4039 } from "../../origins/trusted-hosts.ts";
4140 import { reattachMessage } from "./frozen.ts";
42 import { getFlowFor } from "../../origins/oauth.ts";
4341 import {
4442 getOriginFor,
4543 listDetachedMirrors,
@@ -50,17 +48,15 @@ import { pinnedRepoIds } from "../../core/pins.ts";
5048 import { getRepo, getReposByIds } from "../../core/repos.ts";
5149 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
5250 import type { Ctx } from "../../http/context.ts";
53 import { forgeBaseUrl, oauthCallbackUri } from "../../http/api.ts";
54 import { qrEncode, qrSvg } from "../../design/qr.ts";
51 import { oauthCallbackUri } from "../../http/api.ts";
5552 import {
5653 settingsLayout,
5754 signInFirst,
5855 type SettingsSection,
5956 } from "../chrome.ts";
6057 import { field, readForm } from "../form.ts";
61 import { html, raw, when, type Html } from "../html.ts";
58 import { html, when, type Html } from "../html.ts";
6259 import { flashTo, page, redirect } from "../layout.ts";
63 import { pollJs } from "../client.ts";
6460 import { browseBase, isOwnRepo } from "../scope.ts";
6561 import {
6662 badge,
@@ -802,7 +798,7 @@ function frozenCard({ frozen, repos }: ReturnType<typeof readFrozen>): Html | nu
802798 );
803799 }
804800
805 function shell(ctx: Ctx, body: Html, status = 200): Response {
801 export function shell(ctx: Ctx, body: Html, status = 200): Response {
806802 return page(
807803 {
808804 ctx,
@@ -925,11 +921,9 @@ function connectMessage(error: string): string {
925921 * Where a connect or a disconnect started, when it was not here: only
926922 * `/migrate`, named by a fixed word — never a path a form can choose.
927923 */
928 type Then = "/migrate" | undefined;
929 const thenOf = (value: string | null): Then =>
924 export type Then = "/migrate" | undefined;
925 export const thenOf = (value: string | null): Then =>
930926 value === "migrate" ? "/migrate" : undefined;
931 const thenQuery = (then: Then): string =>
932 then === undefined ? "" : "then=migrate";
933927
934928 export async function originOAuthStart(ctx: Ctx): Promise<Response> {
935929 const denied = accountOnly(ctx);
@@ -979,25 +973,6 @@ export async function originOAuthStart(ctx: Ctx): Promise<Response> {
979973 return redirect(`/settings/origins/flows/${started.value.flowId}${qs}`);
980974 }
981975
982 /**
983 * The authorize URL for a code flow, checked before it becomes a link.
984 *
985 * It arrives on the query string because the flow row stores a *device*
986 * flow's verification URI and nothing for a code flow. A crafted link could
987 * therefore reach this page, so the URL is accepted only when it points at
988 * the same instance the flow already names and carries a PKCE challenge —
989 * the worst a forged one can do is send you to another page on the forge you
990 * asked to connect.
991 */
992 function authorizeLink(raw: string, webUrl: string): string | undefined {
993 const url = URL.parse(raw);
994 const instance = URL.parse(webUrl);
995 if (!url || !instance) return undefined;
996 if (url.origin !== instance.origin) return undefined;
997 if (url.searchParams.get("code_challenge") === null) return undefined;
998 return url.toString();
999 }
1000
1001976 // POST /settings/origins/apps
1002977 export async function originAppCreate(ctx: Ctx): Promise<Response> {
1003978 const denied = accountOnly(ctx);
@@ -1070,189 +1045,7 @@ export async function originAppDelete(ctx: Ctx): Promise<Response> {
10701045 });
10711046 }
10721047
1073 // ---- one connect in flight ----
1074
1075 /**
1076 * GET /settings/origins/flows/:id — a connect in progress.
1077 *
1078 * The device grant shows the code and where to type it; the code flow shows
1079 * one link to the instance. Both work with no JavaScript: the page carries a
1080 * "check again" form, and reloading it re-polls. With script, the same form
1081 * is submitted for you every couple of seconds.
1082 */
1083 export function originFlowPage(ctx: Ctx): Response {
1084 const denied = accountOnly(ctx);
1085 if (denied) return denied;
1086 const flow = getFlowFor(ctx.app.db, ctx.auth.user!.id, ctx.params.id ?? "");
1087 if (!flow) return shell(ctx, notFoundNote(), 404);
1088 const then = thenOf(ctx.url.searchParams.get("then"));
1089
1090 if (flow.status === "done")
1091 return redirect(`${then ?? "/settings/origins"}?connected=1`);
1092 if (flow.status === "failed")
1093 return redirect(
1094 `/settings/origins?error=${encodeURIComponent(flow.detail.slice(0, 120))}`,
1095 );
1096
1097 const pollAction = `/settings/origins/flows/${flow.id}/poll${then === undefined ? "" : `?${thenQuery(then)}`}`;
1098 const authorize =
1099 flow.flow === "code"
1100 ? authorizeLink(ctx.url.searchParams.get("to") ?? "", flow.webUrl)
1101 : undefined;
1102 const base = forgeBaseUrl(ctx.url, ctx.app.config.host);
1103 const shortLink =
1104 base === "" || flow.shortCode === "" ? "" : `${base}/go/${flow.shortCode}`;
1105 const body =
1106 flow.flow === "device"
1107 ? html`${card(
1108 html`<p class="muted small">
1109 Type this code at
1110 <a href="${flow.verificationUri}" rel="noreferrer noopener"
1111 >${flow.verificationUri}</a
1112 >. Any device with a browser will do — this page waits.
1113 </p>
1114 <div class="bar">
1115 <p class="device-code" id="device-code">${flow.userCode}</p>
1116 ${copyBtn("device-code", "Copy the code")}
1117 </div>
1118 ${handoff(flow.verificationUri, shortLink)}`,
1119 {
1120 head: html`${icon("github")}
1121 <h2>Enter this code</h2>`,
1122 },
1123 )}`
1124 : html`${card(
1125 authorize === undefined
1126 ? html`<p>
1127 This connect cannot be continued from here — the authorization
1128 link was lost. Start it again from Origins.
1129 </p>
1130 <div class="bar mt">
1131 ${btn("Back to origins", {
1132 href: "/settings/origins",
1133 variant: "primary",
1134 })}
1135 </div>`
1136 : html`<p>
1137 Continue to
1138 <span class="mono">${hostLabel(flow.webUrl)}</span> and
1139 approve the application. You will come straight back here.
1140 </p>
1141 ${handoff(authorize, shortLink, "Continue to the instance")}`,
1142 {
1143 head: html`${icon("forgejo")}
1144 <h2>Authorize on the instance</h2>`,
1145 },
1146 )}`;
1147
1148 const waiting = html`<form
1149 class="bar mt"
1150 method="post"
1151 action="${pollAction}"
1152 data-poll="${flow.flow === "device"
1153 ? String(Math.max(1, flow.intervalSecs))
1154 : "0"}"
1155 >
1156 <span class="muted small"
1157 >Waiting for you to authorize
1158 ${flow.flow === "device"
1159 ? "on the other device"
1160 : "on the instance"}…</span
1161 >
1162 <span class="spacer"></span>
1163 ${btn("Check again", { size: "sm", variant: "outline", icon: "sync" })}
1164 ${btn("Cancel", {
1165 href: "/settings/origins",
1166 size: "sm",
1167 variant: "ghost",
1168 })}
1169 </form>`;
1170
1171 return page({
1172 ctx,
1173 title: "Connecting an origin",
1174 body: html`<div class="page-head"><h1>Connecting an origin</h1></div>
1175 <div class="narrow-wide">
1176 ${body}${waiting}
1177 <p class="muted small mt">
1178 No JavaScript? Refresh this page to check again — the reload re-polls.
1179 </p>
1180 </div>`,
1181 scriptJs: pollJs,
1182 });
1183 }
1184
1185 /** The copy affordance: an icon button that is inert without a script, and
1186 * sits beside text that stays selectable either way. */
1187 function copyBtn(target: string, label: string): Html {
1188 return html`<button
1189 class="btn btn-icon js-only"
1190 data-size="sm"
1191 type="button"
1192 data-copy="${target}"
1193 aria-label="${label}"
1194 title="Copy"
1195 >
1196 ${icon("copy")}
1197 </button>`;
1198 }
1199
1200 /**
1201 * Getting the person to the other device: a QR code they point a camera at,
1202 * the short link they could type instead, the full URL for a browser that is
1203 * already here, and one button that opens it.
1204 *
1205 * The QR carries the short link when the forge knows its own address and the
1206 * long URL otherwise — the shorter the payload, the coarser the modules and
1207 * the further away a phone can read it.
1208 */
1209 function handoff(url: string, shortLink: string, openLabel?: string): Html {
1210 const payload = shortLink === "" ? url : shortLink;
1211 const code = qrEncode(payload);
1212 return html`<div class="bar mt">
1213 ${code.ok
1214 ? raw(
1215 qrSvg(code.value, {
1216 title: "Scan to open the link",
1217 scale: qrScale(code.value.modules.length),
1218 }),
1219 )
1220 : null}
1221 </div>
1222 <ul class="list setting-list mt">
1223 ${shortLink === ""
1224 ? null
1225 : row({
1226 title: "Or open this on a phone:",
1227 meta: html`<span class="mono" id="flow-short">${shortLink}</span>`,
1228 trail: copyBtn("flow-short", "Copy the short link"),
1229 })}
1230 ${row({
1231 title: "The full link:",
1232 meta: html`<span class="mono small" id="flow-url">${url}</span>`,
1233 trail: copyBtn("flow-url", "Copy the link"),
1234 })}
1235 </ul>
1236 <div class="bar mt">
1237 ${btn(openLabel ?? "Open the verification page", {
1238 href: url,
1239 variant: "primary",
1240 icon: "external",
1241 external: true,
1242 })}
1243 </div>`;
1244 }
1245
1246 /**
1247 * The module size that keeps the code at most 200 px square: the encoder sizes
1248 * the SVG from it, so a long URL (more modules) must not make a code wider
1249 * than a 360 px phone can hold beside the page's padding.
1250 */
1251 function qrScale(modules: number): number {
1252 return Math.max(1, Math.min(4, Math.floor(200 / modules)));
1253 }
1254
1255 function notFoundNote(): Html {
1048 export function notFoundNote(): Html {
12561049 return empty({
12571050 icon: "search",
12581051 title: "No such connect",
@@ -1263,50 +1056,6 @@ function notFoundNote(): Html {
12631056 }),
12641057 });
12651058 }
1266
1267 /**
1268 * POST /settings/origins/flows/:id/poll — ask the origin once, on the user's
1269 * behalf. The forge does the polling, so a browser (or a phone) only ever
1270 * asks "has it happened yet?" and a token never crosses back to a surface.
1271 *
1272 * Answers JSON to a client that asked for it and a redirect to everyone
1273 * else, so the same route serves the enhancement and the plain form.
1274 */
1275 export async function originFlowPoll(ctx: Ctx): Promise<Response> {
1276 const denied = accountOnly(ctx);
1277 if (denied) return denied;
1278 const form = await readForm(ctx, "admin");
1279 if (form instanceof Response) return form;
1280 const polled = await pollOAuthConnect(
1281 ctx.app,
1282 ctx.auth.user,
1283 ctx.params.id ?? "",
1284 );
1285 const wantsJson = (ctx.request.headers.get("accept") ?? "").includes(
1286 "application/json",
1287 );
1288 if (!polled.ok) {
1289 return wantsJson
1290 ? Response.json(
1291 { status: "failed", detail: "not found" },
1292 { status: 404 },
1293 )
1294 : redirect("/settings/origins?error=state");
1295 }
1296 const { status, detail } = polled.value;
1297 const then = thenOf(ctx.url.searchParams.get("then"));
1298 if (wantsJson) return Response.json({ status, detail });
1299 if (status === "done")
1300 return redirect(`${then ?? "/settings/origins"}?connected=1`);
1301 if (status === "failed")
1302 return redirect(
1303 `/settings/origins?error=${encodeURIComponent(detail.slice(0, 120))}`,
1304 );
1305 return redirect(
1306 `/settings/origins/flows/${ctx.params.id ?? ""}${then === undefined ? "" : `?${thenQuery(then)}`}`,
1307 );
1308 }
1309
13101059 // ---- one origin ----
13111060
13121061 // POST /settings/origins/:id/sync

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs