| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { raw } from "hono/html"; |
| 3 | import type { FC } from "hono/jsx"; |
| 4 | import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; |
| 5 | import { ACCESS_TTL_S, OAuth, OAuthError, REFRESH_IDLE_DAYS, adminAllowed, canActAs, formActionSource, redirectLabel, requestedScope, type OAuthClient } from "../auth/oauth.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 | * Where a person approves an app that wants to use Diffinery through MCP (auth/oauth.ts has the |
| 15 | * flow): /oauth/authorize shows who is asking, where they'll be sent back, whom the app acts as and |
| 16 | * with what access. It needs a passkey sign-in from the last 2 hours, like creating a token. |
| 17 | * Connected apps are listed, and revoked, on Settings → Access tokens. |
| 18 | */ |
| 19 | |
| 20 | const SCOPE_ITEMS = [ |
| 21 | { value: "read", label: "Read", description: "Read code, issues, pull requests, runs and logs." }, |
| 22 | { value: "write", label: "Write", description: "Also push and commit, open and merge pull requests, comment, and run workflows (up to maintain)." }, |
| 23 | { value: "admin", label: "Admin", description: "Everything the account can do, including settings, secrets and variables." }, |
| 24 | ]; |
| 25 | |
| 26 | /** The authorization request's parameters, as the app sent them. */ |
| 27 | const FIELDS = ["client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "state", "scope", "resource"] as const; |
| 28 | type Req = Partial<Record<(typeof FIELDS)[number], string>>; |
| 29 | |
| 30 | const reqFrom = (get: (k: string) => string | undefined): Req => Object.fromEntries(FIELDS.map((k) => [k, get(k)]).filter(([, v]) => v !== undefined && v !== "")) as Req; |
| 31 | |
| 32 | /** Back to the app with a code or an error, and the issuer (RFC 9207) so it knows who answered. */ |
| 33 | function backTo(c: AppContext, redirectUri: string, q: Record<string, string | undefined>): Response { |
| 34 | const u = new URL(redirectUri); |
| 35 | for (const [k, v] of Object.entries(q)) if (v !== undefined) u.searchParams.set(k, v); |
| 36 | u.searchParams.set("iss", c.env.PUBLIC_URL.replace(/\/$/, "")); |
| 37 | return c.redirect(u.toString(), 303); |
| 38 | } |
| 39 | |
| 40 | const problem = (c: AppContext, message: string, status = 400) => |
| 41 | render( |
| 42 | c, |
| 43 | { title: "Can't connect this app", status }, |
| 44 | <div class="authcard box"> |
| 45 | <div class="body"> |
| 46 | <div class="authmark" aria-hidden="true"> |
| 47 | {raw(ICONS.code)} |
| 48 | </div> |
| 49 | <h1>Can't connect this app</h1> |
| 50 | <p class="muted">{message}</p> |
| 51 | </div> |
| 52 | </div>, |
| 53 | ); |
| 54 | |
| 55 | /** The accounts a person may connect an app as: themselves first, then the agents they run. */ |
| 56 | async function choices(c: AppContext, me: Account): Promise<Account[]> { |
| 57 | const dir = c.get("dir"); |
| 58 | const orgs = (await dir.orgsFor(me.id)).filter((o) => o.role === "owner" || o.role === "admin"); |
| 59 | const agents = (await dir.agentsOwnedBy([me.id, ...orgs.map((o) => o.id)])).filter((a) => a.status === "active"); |
| 60 | return [me, ...agents]; |
| 61 | } |
| 62 | |
| 63 | function person(c: AppContext, next: string): Account { |
| 64 | const me = requireAccount(c); |
| 65 | if (me.kind !== "user") throw new HttpError(403, "Only people can connect apps. Agents use their own tokens."); |
| 66 | const p = c.get("principal"); |
| 67 | if (p.type === "account" && p.via === "token") throw new HttpError(403, "Open this page in your browser, signed in with your passkey."); |
| 68 | requireRecentSignIn(c, next); |
| 69 | return me; |
| 70 | } |
| 71 | |
| 72 | const Consent: FC<{ c: AppContext; me: Account; client: OAuthClient; redirectUri: string; req: Req; accounts: Account[] }> = ({ c, me, client, redirectUri, req, accounts }) => { |
| 73 | const t = csrfToken(c); |
| 74 | const verified = client.kind === "metadata" ? new URL(client.id).host : null; |
| 75 | const scope: TokenScope = requestedScope(req.scope); |
| 76 | return ( |
| 77 | <div class="authcard box"> |
| 78 | <div class="body"> |
| 79 | <div class="authmark" aria-hidden="true"> |
| 80 | {raw(ICONS.bot)} |
| 81 | </div> |
| 82 | <h1> |
| 83 | Connect <span>{client.name}</span> to Diffinery |
| 84 | </h1> |
| 85 | <p class="muted"> |
| 86 | {verified ? ( |
| 87 | <> |
| 88 | An app published by <b>{verified}</b> wants to use Diffinery as you choose below. |
| 89 | </> |
| 90 | ) : ( |
| 91 | <> |
| 92 | An app calling itself <b>{client.name}</b> (a name it chose, not checked by Diffinery) wants to use Diffinery as you choose below. |
| 93 | </> |
| 94 | )} |
| 95 | </p> |
| 96 | <dl class="kv mb"> |
| 97 | <dt>Signed in as</dt> |
| 98 | <dd> |
| 99 | <b>{me.login}</b> |
| 100 | </dd> |
| 101 | <dt>Sends you back to</dt> |
| 102 | <dd> |
| 103 | <b>{redirectLabel(redirectUri)}</b> |
| 104 | </dd> |
| 105 | </dl> |
| 106 | <Form action="/oauth/authorize" t={t}> |
| 107 | {Object.entries(req).map(([k, v]) => ( |
| 108 | <input type="hidden" name={k} value={v} /> |
| 109 | ))} |
| 110 | <div class="field"> |
| 111 | <label>Act as</label> |
| 112 | <KRadio |
| 113 | name="act_as" |
| 114 | label="Act as" |
| 115 | value={me.login} |
| 116 | items={accounts.map((a) => ({ |
| 117 | value: a.login, |
| 118 | label: a.id === me.id ? `${a.login} (you)` : `${a.login} (agent)`, |
| 119 | description: a.id === me.id ? "Changes appear as yours." : "Changes appear as the agent's, on your behalf; its pull requests need a person's approval.", |
| 120 | }))} |
| 121 | /> |
| 122 | </div> |
| 123 | <div class="field"> |
| 124 | <label>Access</label> |
| 125 | <KRadio name="access" label="Access" value={scope} items={SCOPE_ITEMS.map((i) => (i.value === "admin" && !adminAllowed(me) ? { ...i, disabled: true, description: "Not offered to site administrators: the app would carry site-wide administration." } : i))} /> |
| 126 | </div> |
| 127 | {me.site_admin ? <p class="muted small">You're a site administrator, so an app acting as you can do what site administrators can, within the access above. Consider connecting it as one of your agents instead.</p> : null} |
| 128 | <p class="muted small"> |
| 129 | The app gets tokens that last {ACCESS_TTL_S / 3600} hours and renew themselves while it's in use (the connection lapses after {REFRESH_IDLE_DAYS} days unused). It can never do more than the account it acts as, and branch protection still applies. You can disconnect it any time under{" "} |
| 130 | <a href="/settings/tokens#connected-apps">Settings → Access tokens</a>. |
| 131 | </p> |
| 132 | <div class="flash warn small">Only connect an app you are setting up yourself right now. Never approve one because someone sent you a link.</div> |
| 133 | <div class="row"> |
| 134 | <button class="btn primary grow" name="decision" value="approve"> |
| 135 | Connect |
| 136 | </button> |
| 137 | <button class="btn" name="decision" value="deny"> |
| 138 | Cancel |
| 139 | </button> |
| 140 | </div> |
| 141 | </Form> |
| 142 | </div> |
| 143 | </div> |
| 144 | ); |
| 145 | }; |
| 146 | |
| 147 | /** Checks the request. Problems with the app or its redirect URI are shown here; the rest go back to the app. */ |
| 148 | async function check(c: AppContext, req: Req): Promise<{ client: OAuthClient; redirectUri: string } | Response> { |
| 149 | const oauth = new OAuth(c.env, c.get("dir")); |
| 150 | try { |
| 151 | return await oauth.checkAuthorize(req); |
| 152 | } catch (e) { |
| 153 | if (e instanceof HttpError) return problem(c, e.detail); |
| 154 | if (e instanceof OAuthError) { |
| 155 | // checkAuthorize only throws OAuthError once the redirect URI is known to be the app's. |
| 156 | const client = await oauth.client(req.client_id); |
| 157 | const uri = req.redirect_uri ?? client?.redirect_uris[0]; |
| 158 | if (uri) return backTo(c, uri, { error: e.error, error_description: e.description, state: req.state }); |
| 159 | return problem(c, e.description); |
| 160 | } |
| 161 | throw e; |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | export function registerOAuth(web: WebApp): void { |
| 166 | web.get("/oauth/authorize", async (c) => { |
| 167 | const req = reqFrom((k) => c.req.query(k)); |
| 168 | // Signed in first: an app's metadata document is fetched only for a person about to approve it. |
| 169 | const url = new URL(c.req.url); |
| 170 | const me = person(c, url.pathname + url.search); |
| 171 | const ok = await check(c, req); |
| 172 | if (ok instanceof Response) return ok; |
| 173 | return render(c, { title: `Connect ${ok.client.name}`, formAction: formActionSource(ok.redirectUri) }, <Consent c={c} me={me} client={ok.client} redirectUri={ok.redirectUri} req={req} accounts={await choices(c, me)} />); |
| 174 | }); |
| 175 | |
| 176 | post(web, "/oauth/authorize", async (c, f: FormData) => { |
| 177 | const req = reqFrom((k) => f.s(k) || undefined); |
| 178 | const me = person(c, `/oauth/authorize?${new URLSearchParams(req as Record<string, string>)}`); |
| 179 | const ok = await check(c, req); |
| 180 | if (ok instanceof Response) return ok; |
| 181 | if (f.s("decision") !== "approve") return backTo(c, ok.redirectUri, { error: "access_denied", error_description: "the person cancelled", state: req.state }); |
| 182 | const account = (await choices(c, me)).find((a) => a.login === f.s("act_as")); |
| 183 | if (!account || !(await canActAs(c.get("dir"), me, account))) return problem(c, "You can connect an app only as yourself or as an agent you run.", 403); |
| 184 | const scope = SCOPE_ITEMS.find((s) => s.value === f.s("access"))?.value as TokenScope | undefined; |
| 185 | if (!scope) return problem(c, "Choose the access the app gets."); |
| 186 | if (scope === "admin" && !adminAllowed(account)) return problem(c, "Admin access can't be given to an app acting as a site administrator. Choose Write, or connect it as an agent.", 403); |
| 187 | const code = await new OAuth(c.env, c.get("dir")).issueCode({ client: ok.client, redirectUri: ok.redirectUri, challenge: req.code_challenge!, person: me, account, scope }); |
| 188 | return backTo(c, ok.redirectUri, { code, state: req.state }); |
| 189 | }); |
| 190 | |
| 191 | post(web, "/settings/connected-apps/:id/revoke", async (c) => { |
| 192 | const me = requireAccount(c); |
| 193 | const g = await new OAuth(c.env, c.get("dir")).revokeFor(me.id, c.req.param("id") ?? ""); |
| 194 | if (g) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "oauth.disconnect", target: g.account_id, detail: { app: g.client_name, client_id: g.client_id } })); |
| 195 | return c.redirect("/settings/tokens#connected-apps", 303); |
| 196 | }); |
| 197 | } |
| 198 | |
| 199 | /** Settings → Access tokens: the apps this person connected, and a way to disconnect each. */ |
| 200 | export const ConnectedApps: FC<{ t: string; grants: Awaited<ReturnType<OAuth["grantsFor"]>> }> = ({ t, grants }) => ( |
| 201 | <div class="box" id="connected-apps"> |
| 202 | <div class="head"> |
| 203 | <h2>Connected apps</h2> |
| 204 | </div> |
| 205 | {grants.length ? ( |
| 206 | <table class="tbl"> |
| 207 | <thead> |
| 208 | <tr> |
| 209 | <th>App</th> |
| 210 | <th>Acts as</th> |
| 211 | <th>Access</th> |
| 212 | <th>Connected</th> |
| 213 | <th>Last used</th> |
| 214 | <th /> |
| 215 | </tr> |
| 216 | </thead> |
| 217 | <tbody> |
| 218 | {grants.map((g) => ( |
| 219 | <tr> |
| 220 | <td> |
| 221 | <b>{g.client_name}</b> |
| 222 | <div class="faint small">{g.redirect_host}</div> |
| 223 | </td> |
| 224 | <td>{g.account_login}</td> |
| 225 | <td>{g.scope}</td> |
| 226 | <td class="small">{ago(g.created_at)}</td> |
| 227 | <td class="small">{g.last_used_at ? ago(g.last_used_at) : <span class="muted">never</span>}</td> |
| 228 | <td class="right"> |
| 229 | <Form action={`/settings/connected-apps/${g.id}/revoke`} t={t} class="inline" confirm={`Disconnect ${g.client_name}? It stops working until you connect it again.`}> |
| 230 | <button class="btn sm danger">Disconnect</button> |
| 231 | </Form> |
| 232 | </td> |
| 233 | </tr> |
| 234 | ))} |
| 235 | </tbody> |
| 236 | </table> |
| 237 | ) : ( |
| 238 | <div class="empty"> |
| 239 | No apps connected. Apps such as claude.ai, Claude Code and Codex connect through <a href="/docs/agents/claude-and-codex">MCP sign-in</a>. |
| 240 | </div> |
| 241 | )} |
| 242 | </div> |
| 243 | ); |