| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { AppVars } from "../auth/principal.js"; |
| 4 | import { CliLoginError, CliLogins, cleanClient, formatUserCode, isB64urlParam, requireScopeName } from "../auth/cli-login.js"; |
| 5 | import { HttpError } from "../lib/util.js"; |
| 6 | |
| 7 | /** |
| 8 | * What the CLI calls to sign in through the browser (auth/cli-login.ts): start a device sign-in, |
| 9 | * trade a code for a token, and revoke its own token on `diffinery logout`. JSON in and out, no |
| 10 | * cookies; the pages people approve on are in web/cli-login.tsx. |
| 11 | */ |
| 12 | export const cliLogin = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 13 | |
| 14 | const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" }; |
| 15 | |
| 16 | async function jsonBody(req: Request): Promise<Record<string, unknown>> { |
| 17 | if (!/^application\/json\b/i.test(req.headers.get("content-type") ?? "")) throw new HttpError(415, "expected JSON"); |
| 18 | const len = Number(req.headers.get("content-length") ?? "0"); |
| 19 | if (len > 8192) throw new HttpError(413, "request too large"); |
| 20 | const b = await req.json().catch(() => null); |
| 21 | if (!b || typeof b !== "object" || Array.isArray(b)) throw new HttpError(400, "invalid JSON"); |
| 22 | return b as Record<string, unknown>; |
| 23 | } |
| 24 | |
| 25 | cliLogin.post("/-/cli/device", async (c) => { |
| 26 | const b = await jsonBody(c.req.raw); |
| 27 | const client = cleanClient((b.client ?? {}) as Record<string, unknown>); |
| 28 | const scope = requireScopeName(b.scope); |
| 29 | const cf = (c.req.raw as { cf?: { city?: string; country?: string } }).cf; |
| 30 | const location = [cf?.city, cf?.country].filter(Boolean).join(", ") || undefined; |
| 31 | const d = await new CliLogins(c.env, c.get("dir")).startDevice(client, scope, { ip: c.req.header("cf-connecting-ip") ?? undefined, location }); |
| 32 | const base = c.env.PUBLIC_URL.replace(/\/$/, ""); |
| 33 | return c.json( |
| 34 | { |
| 35 | device_code: d.deviceCode, |
| 36 | user_code: formatUserCode(d.userCode), |
| 37 | // No verification_uri_complete: the code is typed, never carried in a link someone could send. |
| 38 | verification_uri: `${base}/login/device`, |
| 39 | expires_in: d.expiresIn, |
| 40 | interval: d.interval, |
| 41 | }, |
| 42 | 200, |
| 43 | NO_STORE, |
| 44 | ); |
| 45 | }); |
| 46 | |
| 47 | cliLogin.post("/-/cli/token", async (c) => { |
| 48 | const b = await jsonBody(c.req.raw); |
| 49 | const logins = new CliLogins(c.env, c.get("dir")); |
| 50 | try { |
| 51 | let grant: Parameters<CliLogins["exchange"]>[0]; |
| 52 | if (b.grant_type === "authorization_code" && typeof b.code === "string" && isB64urlParam(b.code_verifier, 43)) grant = { type: "code", code: b.code, verifier: b.code_verifier }; |
| 53 | else if (b.grant_type === "urn:ietf:params:oauth:grant-type:device_code" && typeof b.device_code === "string") grant = { type: "device", deviceCode: b.device_code }; |
| 54 | else throw new CliLoginError("invalid_request", "send grant_type authorization_code with code and code_verifier, or the device code grant with device_code"); |
| 55 | const t = await logins.exchange(grant); |
| 56 | return c.json({ access_token: t.token, token_type: "bearer", scope: t.scope, expires_at: t.expiresAt, login: t.login }, 200, NO_STORE); |
| 57 | } catch (e) { |
| 58 | if (!(e instanceof CliLoginError)) throw e; |
| 59 | return c.json({ error: e.error, error_description: e.description }, 400, NO_STORE); |
| 60 | } |
| 61 | }); |
| 62 | |
| 63 | cliLogin.delete("/-/cli/token", async (c) => { |
| 64 | const p = c.get("principal"); |
| 65 | if (p.type !== "account" || p.via !== "token" || !p.tokenId) throw new HttpError(401, "send the token to revoke as the Authorization header"); |
| 66 | const revoked = await new CliLogins(c.env, c.get("dir")).revokeCliToken(p.account.id, p.tokenId); |
| 67 | if (revoked) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: p.account.id, actorLogin: p.account.login, action: "token.revoke", target: p.tokenId, detail: { via: "diffinery logout" } })); |
| 68 | return c.json({ revoked }, 200, NO_STORE); |
| 69 | }); |