| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { raw } from "hono/html"; |
| 3 | import type { Child, FC, PropsWithChildren } from "hono/jsx"; |
| 4 | import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; |
| 5 | import { CLI_TOKEN_DAYS, CliLogins, cleanClient, formatUserCode, isB64urlParam, loopbackPort, normalizeUserCode, parseClient, scopeOf, type CliClient } from "../auth/cli-login.js"; |
| 6 | import type { Account, TokenScope } from "../lib/directory.js"; |
| 7 | import { HttpError } from "../lib/util.js"; |
| 8 | import { ICONS } from "./assets.js"; |
| 9 | import { csrfToken, type FormData } from "./csrf.js"; |
| 10 | import { KRadio } from "./kumo.js"; |
| 11 | import { Form, ago, post, render, type WebApp } from "./ui.js"; |
| 12 | |
| 13 | /** |
| 14 | * The pages a person approves a CLI sign-in on (auth/cli-login.ts has the flows): |
| 15 | * /login/cli `diffinery login` opened this with its loopback port and PKCE challenge |
| 16 | * /login/device type the code a `diffinery login --device` printed |
| 17 | * /login/cli/done where the CLI sends the browser once it has its token |
| 18 | * Both need a passkey sign-in from the last 2 hours, like creating a token in Settings. |
| 19 | */ |
| 20 | |
| 21 | const SCOPE_ITEMS = [ |
| 22 | { value: "read", label: "Read", description: "Clone, and view repositories, issues, pull requests and runs." }, |
| 23 | { value: "write", label: "Write", description: "Also push, open and merge pull requests, comment, and run workflows (up to maintain)." }, |
| 24 | { value: "admin", label: "Admin", description: "Everything you can do, including settings, secrets and variables." }, |
| 25 | ]; |
| 26 | |
| 27 | const Card: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => ( |
| 28 | <div class="authcard box"> |
| 29 | <div class="body"> |
| 30 | <div class="authmark" aria-hidden="true"> |
| 31 | {raw(ICONS.code)} |
| 32 | </div> |
| 33 | <h1>{title}</h1> |
| 34 | {children} |
| 35 | </div> |
| 36 | </div> |
| 37 | ); |
| 38 | |
| 39 | const ClientDetails: FC<{ client: CliClient; from?: { ip: string | null; location: string | null; at: string } }> = ({ client, from }) => ( |
| 40 | <dl class="kv mb"> |
| 41 | <dt>Computer</dt> |
| 42 | <dd> |
| 43 | <b>{client.host}</b> |
| 44 | {client.os ? <span class="muted"> · {client.os}</span> : null} |
| 45 | </dd> |
| 46 | {client.version ? ( |
| 47 | <> |
| 48 | <dt>CLI</dt> |
| 49 | <dd>diffinery {client.version}</dd> |
| 50 | </> |
| 51 | ) : null} |
| 52 | {from ? ( |
| 53 | <> |
| 54 | <dt>Requested</dt> |
| 55 | <dd> |
| 56 | {ago(from.at)} |
| 57 | {from.location || from.ip ? <span class="muted"> from {[from.location, from.ip].filter(Boolean).join(" · ")}</span> : null} |
| 58 | </dd> |
| 59 | </> |
| 60 | ) : null} |
| 61 | </dl> |
| 62 | ); |
| 63 | |
| 64 | /** |
| 65 | * Where a device code was asked from, compared with the browser approving it. Device-code phishing |
| 66 | * means someone else's code: it was almost always asked for from another network, often another |
| 67 | * country, so the page says so plainly. |
| 68 | */ |
| 69 | export function placeMismatch(from: { ip: string | null; location: string | null }, here: { ip?: string; country?: string }): "country" | "network" | null { |
| 70 | // Stored as "City, CC" or "CC": the country is the two-letter code after the last comma. A city with no code is no country. |
| 71 | const last = from.location?.split(",").at(-1)?.trim(); |
| 72 | const theirs = last && /^[A-Z]{2}$/.test(last) ? last : null; |
| 73 | if (theirs && here.country && theirs !== here.country) return "country"; |
| 74 | if (from.ip && here.ip && from.ip !== here.ip) return "network"; |
| 75 | return null; |
| 76 | } |
| 77 | |
| 78 | /** A phisher's CLI can ask for any scope, so the page never preselects more than write: admin is always a deliberate pick. */ |
| 79 | export const preselectedScope = (requested: TokenScope): TokenScope => (requested === "admin" ? "write" : requested); |
| 80 | |
| 81 | /** The approval form both flows share: the details, a scope, and Authorize or Cancel. */ |
| 82 | const Approve: FC<{ c: AppContext; me: Account; action: string; client: CliClient; scope: TokenScope; hidden: Record<string, string>; from?: { ip: string | null; location: string | null; at: string }; code?: string }> = (p) => { |
| 83 | const t = csrfToken(p.c); |
| 84 | // Only the edge's request.cf: a client can send a CF-IPCountry header of its choosing. |
| 85 | const cf = (p.c.req.raw as { cf?: { country?: string } }).cf; |
| 86 | const mismatch = p.from ? placeMismatch(p.from, { ip: p.c.req.header("cf-connecting-ip"), country: cf?.country }) : null; |
| 87 | return ( |
| 88 | <Card title="Authorize the Diffinery CLI"> |
| 89 | <p class="muted"> |
| 90 | A Diffinery command-line client wants to sign in as <b>{p.me.login}</b>. |
| 91 | </p> |
| 92 | {p.code ? ( |
| 93 | <p> |
| 94 | Check that your terminal shows this code: <span class="usercode">{p.code}</span> |
| 95 | </p> |
| 96 | ) : null} |
| 97 | <ClientDetails client={p.client} from={p.from} /> |
| 98 | {mismatch === "country" ? ( |
| 99 | <div class="flash error" role="alert"> |
| 100 | <b>This code was asked for from another country</b> ({p.from!.location}) than the one you're in now. If you didn't run <code>diffinery login --device</code> there yourself, someone may be trying to get into your account: click Cancel. |
| 101 | </div> |
| 102 | ) : mismatch === "network" ? ( |
| 103 | <div class="flash warn">This code was asked for from a different network ({p.from!.ip}) than this browser's. That's expected when the computer and this browser are on different connections; if you didn't start it yourself, click Cancel.</div> |
| 104 | ) : null} |
| 105 | <Form action={p.action} t={t}> |
| 106 | {Object.entries(p.hidden).map(([k, v]) => ( |
| 107 | <input type="hidden" name={k} value={v} /> |
| 108 | ))} |
| 109 | <div class="field"> |
| 110 | <label>Access</label> |
| 111 | <KRadio name="scope" label="Access" value={preselectedScope(p.scope)} items={SCOPE_ITEMS} /> |
| 112 | {p.scope === "admin" ? <div class="hint">The CLI asked for Admin. Choose it above only if you want this computer to change settings, secrets and variables.</div> : null} |
| 113 | </div> |
| 114 | <p class="muted small"> |
| 115 | The CLI gets its own token, valid for {CLI_TOKEN_DAYS} days. It's listed under <a href="/settings/tokens">Settings → Access tokens</a>, where you can revoke it, and <code>diffinery logout</code> revokes it too. |
| 116 | </p> |
| 117 | <div class="flash warn small">Only authorize a sign-in you started yourself by running <code>diffinery login</code>. Never approve one because someone asked you to.</div> |
| 118 | <div class="row"> |
| 119 | <button class="btn primary grow" name="decision" value="approve"> |
| 120 | Authorize |
| 121 | </button> |
| 122 | <button class="btn" name="decision" value="deny"> |
| 123 | Cancel |
| 124 | </button> |
| 125 | </div> |
| 126 | </Form> |
| 127 | </Card> |
| 128 | ); |
| 129 | }; |
| 130 | |
| 131 | const Done: FC<{ title: string; children: Child }> = ({ title, children }) => <Card title={title}>{children}</Card>; |
| 132 | |
| 133 | /** The browser flow's parameters, validated; null when anything is missing or malformed. */ |
| 134 | function browserParams(get: (k: string) => string | undefined): { port: number; state: string; challenge: string; client: CliClient; scope: TokenScope } | null { |
| 135 | const port = loopbackPort(get("port")); |
| 136 | const state = get("state"); |
| 137 | const challenge = get("code_challenge"); |
| 138 | const method = get("code_challenge_method"); |
| 139 | if (!port || !isB64urlParam(state) || !isB64urlParam(challenge, 43) || (method !== undefined && method !== "" && method !== "S256")) return null; |
| 140 | return { port, state, challenge, client: cleanClient({ host: get("host"), os: get("os"), version: get("version") }), scope: scopeOf(get("scope")) }; |
| 141 | } |
| 142 | |
| 143 | /** The CLI's callback on this computer. Only 127.0.0.1, so the code never leaves the machine. */ |
| 144 | const callback = (port: number, q: Record<string, string>) => `http://127.0.0.1:${port}/callback?${new URLSearchParams(q)}`; |
| 145 | |
| 146 | function person(c: AppContext, next: string): Account { |
| 147 | const me = requireAccount(c); |
| 148 | if (me.kind !== "user") throw new HttpError(403, "only people can sign the CLI in; agents use their own tokens"); |
| 149 | requireRecentSignIn(c, next); |
| 150 | return me; |
| 151 | } |
| 152 | |
| 153 | const badLink = (c: AppContext) => |
| 154 | render( |
| 155 | c, |
| 156 | { title: "Sign-in link incomplete", status: 400 }, |
| 157 | <Done title="This sign-in link is incomplete"> |
| 158 | <p class="muted"> |
| 159 | Run <code>diffinery login</code> again and use the page it opens. |
| 160 | </p> |
| 161 | </Done>, |
| 162 | ); |
| 163 | |
| 164 | const DeviceForm: FC<{ c: AppContext; error?: string; value?: string }> = ({ c, error, value }) => ( |
| 165 | <Card title="Sign in the Diffinery CLI"> |
| 166 | <p class="muted"> |
| 167 | Enter the code your terminal shows after <code>diffinery login --device</code>. |
| 168 | </p> |
| 169 | {error ? ( |
| 170 | <div class="flash error" role="alert"> |
| 171 | {error} |
| 172 | </div> |
| 173 | ) : null} |
| 174 | <Form action="/login/device" t={csrfToken(c)}> |
| 175 | <div class="field"> |
| 176 | <label for="code">Code</label> |
| 177 | <input id="code" name="code" type="text" class="usercode-input" required autocomplete="off" autocapitalize="characters" spellcheck={false} maxlength={12} placeholder="XXXX-XXXX" value={value ?? ""} autofocus /> |
| 178 | </div> |
| 179 | <button class="btn primary block">Continue</button> |
| 180 | </Form> |
| 181 | </Card> |
| 182 | ); |
| 183 | |
| 184 | export function registerCliLogin(web: WebApp): void { |
| 185 | // ---------------------------------------------------------------- browser flow |
| 186 | |
| 187 | web.get("/login/cli", async (c) => { |
| 188 | const q = browserParams((k) => c.req.query(k)); |
| 189 | if (!q) return badLink(c); |
| 190 | const url = new URL(c.req.url); |
| 191 | const me = person(c, url.pathname + url.search); |
| 192 | return render( |
| 193 | c, |
| 194 | // Authorize leads the browser to the CLI's callback on this computer. |
| 195 | { title: "Authorize the Diffinery CLI", formAction: `http://127.0.0.1:${q.port}` }, |
| 196 | <Approve c={c} me={me} action="/login/cli" client={q.client} scope={q.scope} hidden={{ port: String(q.port), state: q.state, code_challenge: q.challenge, host: q.client.host, os: q.client.os, version: q.client.version }} />, |
| 197 | ); |
| 198 | }); |
| 199 | |
| 200 | post(web, "/login/cli", async (c, f: FormData) => { |
| 201 | const q = browserParams((k) => f.s(k)); |
| 202 | if (!q) return badLink(c); |
| 203 | const back = `/login/cli?${new URLSearchParams({ port: String(q.port), state: q.state, code_challenge: q.challenge, code_challenge_method: "S256", scope: q.scope, host: q.client.host, os: q.client.os, version: q.client.version })}`; |
| 204 | const me = person(c, back); |
| 205 | if (f.s("decision") !== "approve") return c.redirect(callback(q.port, { error: "access_denied", state: q.state }), 303); |
| 206 | const code = await new CliLogins(c.env, c.get("dir")).approveBrowser(me, q.client, scopeOf(f.s("scope"), q.scope), q.challenge); |
| 207 | return c.redirect(callback(q.port, { code, state: q.state }), 303); |
| 208 | }); |
| 209 | |
| 210 | // Where the CLI sends the browser when it's done; it says which outcome, nothing else. |
| 211 | web.get("/login/cli/done", (c) => { |
| 212 | const result = c.req.query("result"); |
| 213 | if (result === "ok") { |
| 214 | return render( |
| 215 | c, |
| 216 | { title: "CLI signed in" }, |
| 217 | <Done title="You're signed in"> |
| 218 | <p class="muted">The Diffinery CLI is signed in. You can close this tab and go back to your terminal.</p> |
| 219 | </Done>, |
| 220 | ); |
| 221 | } |
| 222 | const denied = result === "denied"; |
| 223 | return render( |
| 224 | c, |
| 225 | { title: denied ? "Sign-in cancelled" : "Sign-in failed" }, |
| 226 | <Done title={denied ? "Sign-in cancelled" : "The CLI couldn't finish signing in"}> |
| 227 | <p class="muted">{denied ? "Nothing was signed in. You can close this tab." : "Go back to your terminal for the details, then run diffinery login again."}</p> |
| 228 | </Done>, |
| 229 | ); |
| 230 | }); |
| 231 | |
| 232 | // ---------------------------------------------------------------- device flow |
| 233 | |
| 234 | web.get("/login/device", async (c) => { |
| 235 | person(c, "/login/device"); |
| 236 | return render(c, { title: "Sign in the Diffinery CLI" }, <DeviceForm c={c} />); |
| 237 | }); |
| 238 | |
| 239 | post(web, "/login/device", async (c, f) => { |
| 240 | const me = person(c, "/login/device"); |
| 241 | const typed = f.s("code").trim(); |
| 242 | const row = normalizeUserCode(typed) ? await new CliLogins(c.env, c.get("dir")).pendingDevice(typed) : null; |
| 243 | if (!row) return render(c, { title: "Sign in the Diffinery CLI", status: 404 }, <DeviceForm c={c} value={typed.slice(0, 12)} error="That code isn't valid or has expired. Check the code in your terminal, or run diffinery login --device again." />); |
| 244 | return render( |
| 245 | c, |
| 246 | { title: "Authorize the Diffinery CLI" }, |
| 247 | <Approve c={c} me={me} action="/login/device/decide" client={parseClient(row.client)} scope={scopeOf(row.requested_scope)} hidden={{ id: row.id }} from={{ ip: row.ip, location: row.location, at: row.created_at }} code={formatUserCode(row.user_code ?? "")} />, |
| 248 | ); |
| 249 | }); |
| 250 | |
| 251 | post(web, "/login/device/decide", async (c, f) => { |
| 252 | const me = person(c, "/login/device"); |
| 253 | const approve = f.s("decision") === "approve"; |
| 254 | const ok = await new CliLogins(c.env, c.get("dir")).decideDevice(f.s("id"), me, approve, scopeOf(f.s("scope"))); |
| 255 | if (!ok) return render(c, { title: "Sign in the Diffinery CLI", status: 410 }, <DeviceForm c={c} error="That sign-in expired or was already answered. Run diffinery login --device again." />); |
| 256 | return render( |
| 257 | c, |
| 258 | { title: approve ? "CLI signed in" : "Sign-in cancelled" }, |
| 259 | approve ? ( |
| 260 | <Done title="You're signed in"> |
| 261 | <p class="muted">The Diffinery CLI will finish signing in within a few seconds. You can close this tab.</p> |
| 262 | </Done> |
| 263 | ) : ( |
| 264 | <Done title="Sign-in cancelled"> |
| 265 | <p class="muted">The CLI was not signed in. You can close this tab.</p> |
| 266 | </Done> |
| 267 | ), |
| 268 | ); |
| 269 | }); |
| 270 | } |