| 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 { CopyButton, 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 | /** The guides for connecting AI apps over MCP, in the order Settings shows them. */ |
| 200 | export const CONNECT_GUIDES: Array<{ label: string; href: string }> = [ |
| 201 | { label: "Claude", href: "/docs/connect/claude" }, |
| 202 | { label: "Claude Code", href: "/docs/connect/claude-code" }, |
| 203 | { label: "Codex", href: "/docs/connect/codex" }, |
| 204 | { label: "Other apps", href: "/docs/connect/other-apps" }, |
| 205 | ]; |
| 206 | |
| 207 | /** Settings → Access tokens: how to connect an AI app, the apps this person connected, and a way to disconnect each. */ |
| 208 | export const ConnectedApps: FC<{ t: string; grants: Awaited<ReturnType<OAuth["grantsFor"]>>; mcpUrl: string }> = ({ t, grants, mcpUrl }) => ( |
| 209 | <div class="box" id="connected-apps"> |
| 210 | <div class="head"> |
| 211 | <h2>Connected apps</h2> |
| 212 | </div> |
| 213 | <div class="body connect-ai"> |
| 214 | <p class="muted"> |
| 215 | Connect Claude, Claude Code, Codex or any other MCP app with this server URL. The app sends you here to choose whom it acts as and what it may do, then shows in this list. |
| 216 | </p> |
| 217 | <div class="row"> |
| 218 | <code class="grow connect-url">{mcpUrl}</code> |
| 219 | <CopyButton text={mcpUrl} size="base" /> |
| 220 | </div> |
| 221 | <p class="small mt"> |
| 222 | Step-by-step guides:{" "} |
| 223 | {CONNECT_GUIDES.map((g, i) => ( |
| 224 | <> |
| 225 | {i ? " · " : ""} |
| 226 | <a href={g.href}>{g.label}</a> |
| 227 | </> |
| 228 | ))} |
| 229 | </p> |
| 230 | </div> |
| 231 | {grants.length ? ( |
| 232 | <table class="tbl"> |
| 233 | <thead> |
| 234 | <tr> |
| 235 | <th>App</th> |
| 236 | <th>Acts as</th> |
| 237 | <th>Access</th> |
| 238 | <th>Connected</th> |
| 239 | <th>Last used</th> |
| 240 | <th /> |
| 241 | </tr> |
| 242 | </thead> |
| 243 | <tbody> |
| 244 | {grants.map((g) => ( |
| 245 | <tr> |
| 246 | <td> |
| 247 | <b>{g.client_name}</b> |
| 248 | <div class="faint small">{g.redirect_host}</div> |
| 249 | </td> |
| 250 | <td>{g.account_login}</td> |
| 251 | <td>{g.scope}</td> |
| 252 | <td class="small">{ago(g.created_at)}</td> |
| 253 | <td class="small">{g.last_used_at ? ago(g.last_used_at) : <span class="muted">never</span>}</td> |
| 254 | <td class="right"> |
| 255 | <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.`}> |
| 256 | <button class="btn sm danger">Disconnect</button> |
| 257 | </Form> |
| 258 | </td> |
| 259 | </tr> |
| 260 | ))} |
| 261 | </tbody> |
| 262 | </table> |
| 263 | ) : ( |
| 264 | <div class="empty"> |
| 265 | No apps connected yet. Add the URL above to an app and approve it here when it asks; see <a href="/docs/connect">Connect AI apps</a>. |
| 266 | </div> |
| 267 | )} |
| 268 | </div> |
| 269 | ); |