| 1 | import type { Env } from "../env.js"; |
| 2 | import { Directory, type Account, type TokenScope } from "../lib/directory.js"; |
| 3 | import { HttpError, b64url, newId, now, randomToken, sha256 } from "../lib/util.js"; |
| 4 | import { TOKEN_SCOPES } from "./principal.js"; |
| 5 | |
| 6 | /** |
| 7 | * Signing the CLI in through the browser, so nobody copies tokens by hand. Two flows, both ending |
| 8 | * in a token minted for the CLI and listed in Settings → Access tokens: |
| 9 | * |
| 10 | * - Browser (`diffinery login`): the CLI listens on 127.0.0.1, opens /login/cli with a PKCE |
| 11 | * challenge, and the person approves. Diffinery sends the browser to the CLI's local callback |
| 12 | * with a one-time code that only works together with the verifier the CLI kept (RFC 8252). |
| 13 | * A link someone else crafted leads nowhere: the code goes to the clicking person's own machine. |
| 14 | * - Device (`diffinery login --device`, for machines without a browser): the CLI gets a device |
| 15 | * code it keeps and a short user code it shows; the person types the user code on |
| 16 | * /login/device in any browser and approves, while the CLI polls (RFC 8628). The code is never |
| 17 | * put in a link, so a phishing link can't carry one. |
| 18 | * |
| 19 | * Approving needs a passkey sign-in from the last 2 hours, like creating any token. Only hashes |
| 20 | * of the codes are stored, codes expire within minutes and work once, and the token itself is |
| 21 | * minted when the CLI trades its secret, so it is never stored or shown anywhere but the CLI. |
| 22 | */ |
| 23 | |
| 24 | /** How long a device code waits for the person, and how often the CLI may ask. */ |
| 25 | export const DEVICE_TTL_S = 600; |
| 26 | export const DEVICE_INTERVAL_S = 5; |
| 27 | /** How long the browser flow's one-time code waits for the CLI to trade it. */ |
| 28 | export const CODE_TTL_S = 120; |
| 29 | /** CLI tokens last this long; the expiry reminder email says to run `diffinery login` again. */ |
| 30 | export const CLI_TOKEN_DAYS = 90; |
| 31 | |
| 32 | /** RFC 8628's suggestion: consonants only (no vowels, so no words), without easily confused letters. */ |
| 33 | const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"; |
| 34 | const USER_CODE_LENGTH = 8; |
| 35 | |
| 36 | export interface CliClient { |
| 37 | host: string; |
| 38 | os: string; |
| 39 | version: string; |
| 40 | } |
| 41 | |
| 42 | export interface CliLoginRow { |
| 43 | id: string; |
| 44 | flow: "browser" | "device"; |
| 45 | user_code: string | null; |
| 46 | client: string; |
| 47 | requested_scope: string; |
| 48 | ip: string | null; |
| 49 | location: string | null; |
| 50 | status: "pending" | "approved" | "denied" | "used"; |
| 51 | account_id: string | null; |
| 52 | scope: string | null; |
| 53 | created_at: string; |
| 54 | expires_at: string; |
| 55 | code_challenge: string | null; |
| 56 | } |
| 57 | |
| 58 | /** What the CLI reported about itself, trimmed to something safe to show and to name a token after. */ |
| 59 | export function cleanClient(input: { host?: unknown; os?: unknown; version?: unknown }): CliClient { |
| 60 | const clean = (v: unknown, max: number) => |
| 61 | (typeof v === "string" ? v : "") |
| 62 | .replace(/[^\w .,:@()+/-]/g, "") |
| 63 | .trim() |
| 64 | .slice(0, max); |
| 65 | return { host: clean(input.host, 64) || "unknown host", os: clean(input.os, 40), version: clean(input.version, 20) }; |
| 66 | } |
| 67 | |
| 68 | export function parseClient(json: string): CliClient { |
| 69 | try { |
| 70 | return cleanClient(JSON.parse(json) as Record<string, unknown>); |
| 71 | } catch { |
| 72 | return cleanClient({}); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | export function scopeOf(v: unknown, fallback: TokenScope = "write"): TokenScope { |
| 77 | return TOKEN_SCOPES.includes(v as TokenScope) ? (v as TokenScope) : fallback; |
| 78 | } |
| 79 | |
| 80 | /** The name the token gets, e.g. "Diffinery CLI on alices-mbp". */ |
| 81 | export const tokenName = (c: CliClient) => `Diffinery CLI on ${c.host}`.slice(0, 80); |
| 82 | |
| 83 | /** A user code as typed: any case, with or without the dash or spaces. Null when it can't be one. */ |
| 84 | export function normalizeUserCode(input: string): string | null { |
| 85 | const s = input.toUpperCase().replace(/[\s-]/g, ""); |
| 86 | if (s.length !== USER_CODE_LENGTH) return null; |
| 87 | for (const ch of s) if (!USER_CODE_ALPHABET.includes(ch)) return null; |
| 88 | return s; |
| 89 | } |
| 90 | |
| 91 | export const formatUserCode = (s: string) => `${s.slice(0, 4)}-${s.slice(4)}`; |
| 92 | |
| 93 | function newUserCode(): string { |
| 94 | // 256 is not a multiple of 20: reject the top values so every letter is equally likely. |
| 95 | let out = ""; |
| 96 | while (out.length < USER_CODE_LENGTH) { |
| 97 | for (const b of crypto.getRandomValues(new Uint8Array(16))) { |
| 98 | if (b < 240 && out.length < USER_CODE_LENGTH) out += USER_CODE_ALPHABET[b % 20]; |
| 99 | } |
| 100 | } |
| 101 | return out; |
| 102 | } |
| 103 | |
| 104 | /** The PKCE S256 challenge of a verifier (RFC 7636). */ |
| 105 | export async function pkceChallenge(verifier: string): Promise<string> { |
| 106 | return b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))); |
| 107 | } |
| 108 | |
| 109 | /** A loopback port the browser may be sent to: unprivileged, so not a system service's. */ |
| 110 | export function loopbackPort(v: unknown): number | null { |
| 111 | const n = typeof v === "string" && /^\d{4,5}$/.test(v) ? Number(v) : NaN; |
| 112 | return n >= 1024 && n <= 65535 ? n : null; |
| 113 | } |
| 114 | |
| 115 | /** A PKCE S256 challenge or an OAuth state: base64url, 43 characters for 32 bytes, at most 128. */ |
| 116 | export const isB64urlParam = (v: unknown, min = 16): v is string => typeof v === "string" && v.length >= min && v.length <= 128 && /^[A-Za-z0-9_-]+$/.test(v); |
| 117 | |
| 118 | /** Errors the token endpoint answers with, as in RFC 8628 and RFC 6749. */ |
| 119 | export type TokenError = "authorization_pending" | "slow_down" | "access_denied" | "expired_token" | "invalid_grant" | "invalid_request"; |
| 120 | |
| 121 | export class CliLoginError extends Error { |
| 122 | constructor( |
| 123 | readonly error: TokenError, |
| 124 | readonly description: string, |
| 125 | ) { |
| 126 | super(description); |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | export class CliLogins { |
| 131 | constructor( |
| 132 | private env: Env, |
| 133 | private dir = new Directory(env), |
| 134 | ) {} |
| 135 | |
| 136 | private get db(): D1Database { |
| 137 | return this.env.DB; |
| 138 | } |
| 139 | |
| 140 | /** The CLI starts a device sign-in. Returns the device code (only the CLI ever sees it) and the user code. */ |
| 141 | async startDevice(client: CliClient, scope: TokenScope, from: { ip?: string; location?: string }): Promise<{ deviceCode: string; userCode: string; expiresIn: number; interval: number }> { |
| 142 | const deviceCode = randomToken(32); |
| 143 | for (let attempt = 0; ; attempt++) { |
| 144 | const userCode = newUserCode(); |
| 145 | try { |
| 146 | await this.db |
| 147 | .prepare("INSERT INTO cli_logins (id, flow, secret_hash, user_code, client, requested_scope, ip, location, status, created_at, expires_at) VALUES (?, 'device', ?, ?, ?, ?, ?, ?, 'pending', ?, ?)") |
| 148 | .bind(newId(), await sha256(deviceCode), userCode, JSON.stringify(client), scope, from.ip ?? null, from.location ?? null, now(), new Date(Date.now() + DEVICE_TTL_S * 1000).toISOString()) |
| 149 | .run(); |
| 150 | return { deviceCode, userCode, expiresIn: DEVICE_TTL_S, interval: DEVICE_INTERVAL_S }; |
| 151 | } catch (e) { |
| 152 | // A user code still in the table (they're kept a day): pick another. |
| 153 | if (attempt < 3 && /UNIQUE/i.test(String(e))) continue; |
| 154 | throw e; |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | /** The pending device sign-in a typed code names, or null (unknown, expired or already decided). */ |
| 160 | async pendingDevice(userCode: string): Promise<CliLoginRow | null> { |
| 161 | const code = normalizeUserCode(userCode); |
| 162 | if (!code) return null; |
| 163 | return this.db.prepare("SELECT * FROM cli_logins WHERE user_code = ? AND flow = 'device' AND status = 'pending' AND expires_at > ?").bind(code, now()).first<CliLoginRow>(); |
| 164 | } |
| 165 | |
| 166 | /** The person approves or denies a device sign-in. False when it's no longer pending. */ |
| 167 | async decideDevice(id: string, person: Account, approve: boolean, scope: TokenScope): Promise<boolean> { |
| 168 | const r = await this.db |
| 169 | .prepare("UPDATE cli_logins SET status = ?, account_id = ?, scope = ?, decided_at = ? WHERE id = ? AND flow = 'device' AND status = 'pending' AND expires_at > ?") |
| 170 | .bind(approve ? "approved" : "denied", person.id, approve ? scope : null, now(), id, now()) |
| 171 | .run(); |
| 172 | return (r.meta.changes ?? 0) > 0; |
| 173 | } |
| 174 | |
| 175 | /** The person approved a browser sign-in: a one-time code for the CLI's callback, bound to its PKCE challenge. */ |
| 176 | async approveBrowser(person: Account, client: CliClient, scope: TokenScope, challenge: string): Promise<string> { |
| 177 | const code = randomToken(32); |
| 178 | await this.db |
| 179 | .prepare("INSERT INTO cli_logins (id, flow, secret_hash, code_challenge, client, requested_scope, status, account_id, scope, created_at, expires_at, decided_at) VALUES (?, 'browser', ?, ?, ?, ?, 'approved', ?, ?, ?, ?, ?)") |
| 180 | .bind(newId(), await sha256(code), challenge, JSON.stringify(client), scope, person.id, scope, now(), new Date(Date.now() + CODE_TTL_S * 1000).toISOString(), now()) |
| 181 | .run(); |
| 182 | return code; |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * The CLI trades its secret for a token: the browser flow's code with the PKCE verifier, or the |
| 187 | * device code. Each works once; the claim is a single conditional write, so two requests racing |
| 188 | * with the same secret can't both get a token. |
| 189 | */ |
| 190 | async exchange(grant: { type: "code"; code: string; verifier: string } | { type: "device"; deviceCode: string }): Promise<{ token: string; scope: TokenScope; expiresAt: string; login: string; tokenId: string }> { |
| 191 | const hash = await sha256(grant.type === "code" ? grant.code : grant.deviceCode); |
| 192 | const flow = grant.type === "code" ? "browser" : "device"; |
| 193 | const at = now(); |
| 194 | const claimed = await this.db |
| 195 | .prepare("UPDATE cli_logins SET status = 'used', used_at = ? WHERE secret_hash = ? AND flow = ? AND status = 'approved' AND expires_at > ? RETURNING *") |
| 196 | .bind(at, hash, flow, at) |
| 197 | .first<CliLoginRow>(); |
| 198 | if (!claimed) { |
| 199 | const row = await this.db.prepare("SELECT status, expires_at FROM cli_logins WHERE secret_hash = ? AND flow = ?").bind(hash, flow).first<{ status: string; expires_at: string }>(); |
| 200 | if (!row) throw new CliLoginError("invalid_grant", "unknown or already used code"); |
| 201 | if (row.status === "denied") throw new CliLoginError("access_denied", "the sign-in was denied in the browser"); |
| 202 | if (row.status === "used") throw new CliLoginError("invalid_grant", "this code was already used"); |
| 203 | if (row.expires_at <= at) throw new CliLoginError("expired_token", "the code expired; run `diffinery login` again"); |
| 204 | throw new CliLoginError("authorization_pending", "waiting for you to approve the sign-in in the browser"); |
| 205 | } |
| 206 | // The verifier is checked after the claim so a wrong guess burns the code rather than allowing retries. |
| 207 | if (grant.type === "code" && (!claimed.code_challenge || (await pkceChallenge(grant.verifier)) !== claimed.code_challenge)) { |
| 208 | throw new CliLoginError("invalid_grant", "the code verifier doesn't match"); |
| 209 | } |
| 210 | const person = claimed.account_id ? await this.dir.account(claimed.account_id) : null; |
| 211 | if (!person || person.status !== "active" || person.kind !== "user") throw new CliLoginError("access_denied", "the account that approved this sign-in can't sign in"); |
| 212 | const scope = scopeOf(claimed.scope); |
| 213 | const client = parseClient(claimed.client); |
| 214 | const t = await this.dir.createToken(person, { name: tokenName(client), scopes: [scope], expiresInDays: CLI_TOKEN_DAYS, client: "cli" }); |
| 215 | await this.db.prepare("UPDATE cli_logins SET token_id = ? WHERE id = ?").bind(t.id, claimed.id).run(); |
| 216 | await this.dir.audit({ actorId: person.id, actorLogin: person.login, action: "token.create", target: person.login, detail: { name: tokenName(client), scope, via: `cli ${flow} sign-in` } }); |
| 217 | return { token: t.token, scope, expiresAt: t.expiresAt, login: person.login, tokenId: t.id }; |
| 218 | } |
| 219 | |
| 220 | /** `diffinery logout`: a CLI token revokes itself. Other tokens are left alone (they may be used elsewhere). */ |
| 221 | async revokeCliToken(accountId: string, tokenId: string): Promise<boolean> { |
| 222 | const r = await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ? AND client = 'cli' AND revoked_at IS NULL").bind(now(), tokenId, accountId).run(); |
| 223 | return (r.meta.changes ?? 0) > 0; |
| 224 | } |
| 225 | |
| 226 | /** Housekeeping: rows a day past their expiry. */ |
| 227 | async prune(): Promise<void> { |
| 228 | await this.db.prepare("DELETE FROM cli_logins WHERE expires_at < ?").bind(new Date(Date.now() - 86400_000).toISOString()).run(); |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | /** Throws a 422 for a scope that isn't one of read, write and admin. */ |
| 233 | export function requireScopeName(v: unknown): TokenScope { |
| 234 | if (v === undefined || v === null || v === "") return "write"; |
| 235 | if (!TOKEN_SCOPES.includes(v as TokenScope)) throw new HttpError(422, `scope must be one of: ${TOKEN_SCOPES.join(", ")}`); |
| 236 | return v as TokenScope; |
| 237 | } |