Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

243 lines · 11.7 KBRaw History
1/** @jsxImportSource hono/jsx */
2import { raw } from "hono/html";
3import type { FC } from "hono/jsx";
4import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js";
5import { ACCESS_TTL_S, OAuth, OAuthError, REFRESH_IDLE_DAYS, adminAllowed, canActAs, formActionSource, redirectLabel, requestedScope, type OAuthClient } from "../auth/oauth.js";
6import type { Account, TokenScope } from "../lib/directory.js";
7import { HttpError } from "../lib/util.js";
8import { ICONS } from "./assets.js";
9import { csrfToken, type FormData } from "./csrf.js";
10import { KRadio } from "./kumo.js";
11import { 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
20const 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. */
27const FIELDS = ["client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "state", "scope", "resource"] as const;
28type Req = Partial<Record<(typeof FIELDS)[number], string>>;
29
30const 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. */
33function 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
40const 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. */
56async 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
63function 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
72const 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. */
148async 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
165export 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. */
200export 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);