| 1 | import type { Context } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { devAuth } from "./dev.js"; |
| 4 | import { Directory, type Account, type Repo, type RepoGrants, type TokenScope } from "../lib/directory.js"; |
| 5 | import { Auth, SESSION_COOKIE, cookieFrom } from "./sessions.js"; |
| 6 | import { HttpError, fromB64 } from "../lib/util.js"; |
| 7 | import type { Caller, Role } from "../repo/types.js"; |
| 8 | import { ROLE_LEVEL } from "../repo/types.js"; |
| 9 | import { taskPersonAllowed } from "../agentic/access.js"; |
| 10 | import { blockSubjects, isBlocked } from "../lib/moderation.js"; |
| 11 | import { agentMayMerge } from "../lib/agent-setup.js"; |
| 12 | import type { WatchLevel } from "../mail/prefs.js"; |
| 13 | import { Apps, INSTALLATION_TOKEN_PREFIX, type App, type InstallationAuth } from "../apps/store.js"; |
| 14 | import { looksLikeJwt } from "../apps/keys.js"; |
| 15 | import { toJobPermissions } from "../apps/model.js"; |
| 16 | import { roleForPermissions } from "../lib/job-permissions.js"; |
| 17 | |
| 18 | export type Principal = |
| 19 | | { |
| 20 | type: "account"; |
| 21 | account: Account; |
| 22 | via: "session" | "token" | "dev"; |
| 23 | scopes: TokenScope[]; |
| 24 | repoIds: string[] | null; |
| 25 | sessionId?: string; |
| 26 | sessionAt?: string; |
| 27 | importId?: string; |
| 28 | tokenId?: string; |
| 29 | /** An agent's owner as its token's check read it (lib/directory.ts verifyToken), so the request needn't read it again. */ |
| 30 | owner?: Account | null; |
| 31 | } |
| 32 | | { type: "repo-token"; repoId: string; token: string } |
| 33 | /** A GitHub-compatible app signed in with a JWT made with its private key (apps/keys.ts): app endpoints only. */ |
| 34 | | { type: "app"; app: App } |
| 35 | /** An app's installation access token ("dia_"): the repositories the installation covers, within its permissions. */ |
| 36 | | { type: "installation"; auth: InstallationAuth } |
| 37 | | { type: "anonymous" }; |
| 38 | |
| 39 | export interface AppVars { |
| 40 | principal: Principal; |
| 41 | dir: Directory; |
| 42 | /** Set for MCP tools that only read, so POST-carried reads count as reads. */ |
| 43 | readOnly?: boolean; |
| 44 | /** Suspended people whose content this request hides, once looked up (http/common.ts hiddenAuthors). */ |
| 45 | hidden?: Promise<string[]>; |
| 46 | /** The signed-in person's watch level for the repository the request is about, once looked up (http/common.ts watchLevelOnce). */ |
| 47 | watch?: { repoId: string; level: Promise<WatchLevel> }; |
| 48 | } |
| 49 | |
| 50 | export type AppContext = Context<{ Bindings: Env; Variables: AppVars }>; |
| 51 | |
| 52 | /** Extract a bearer/basic credential, as git, curl, the CLI and MCP clients send it. */ |
| 53 | export function credentialFrom(req: Request): string | null { |
| 54 | // A request can carry the header twice (git-lfs sends http.extraheader and the batch API's |
| 55 | // header), which arrives joined with ", ": use the first. |
| 56 | const h = req.headers.get("authorization")?.split(",")[0]!.trim(); |
| 57 | if (!h) return null; |
| 58 | const [scheme, value] = h.split(/\s+/, 2); |
| 59 | if (!value) return null; |
| 60 | if (/^bearer$/i.test(scheme!) || /^token$/i.test(scheme!)) return value.trim(); |
| 61 | if (/^basic$/i.test(scheme!)) { |
| 62 | let bytes: Uint8Array; |
| 63 | try { |
| 64 | // Git LFS may send the same header twice (http.extraHeader and the batch action's header), joined by a comma. |
| 65 | bytes = fromB64(value.split(",")[0]!.trim()); |
| 66 | } catch { |
| 67 | throw new HttpError(401, "malformed basic credentials"); |
| 68 | } |
| 69 | const decoded = new TextDecoder().decode(bytes); |
| 70 | const idx = decoded.indexOf(":"); |
| 71 | const pass = idx >= 0 ? decoded.slice(idx + 1) : decoded; |
| 72 | const user = idx >= 0 ? decoded.slice(0, idx) : ""; |
| 73 | return pass || user || null; |
| 74 | } |
| 75 | return null; |
| 76 | } |
| 77 | |
| 78 | /** The Cloudflare Access assertion, present only on the Access-protected recovery path. */ |
| 79 | export function accessToken(req: Request): string | null { |
| 80 | const h = req.headers.get("cf-access-jwt-assertion"); |
| 81 | if (h) return h; |
| 82 | const cookie = req.headers.get("cookie") ?? ""; |
| 83 | const m = /(?:^|;\s*)CF_Authorization=([^;]+)/.exec(cookie); |
| 84 | return m ? m[1]! : null; |
| 85 | } |
| 86 | |
| 87 | const IMPORT_GIT_PATH = /^\/[^/]+\/[^/]+\/(info\/refs|git-upload-pack|git-receive-pack|info\/lfs\/objects\/[^/]+)$/; |
| 88 | |
| 89 | export async function resolvePrincipal(req: Request, env: Env, dir: Directory): Promise<Principal> { |
| 90 | const cred = credentialFrom(req); |
| 91 | if (cred) { |
| 92 | if (cred.startsWith("dfy_")) { |
| 93 | const v = await dir.verifyToken(cred); |
| 94 | if (!v) throw new HttpError(401, "invalid, expired or revoked token"); |
| 95 | if (v.importId) { |
| 96 | // An import's push token reaches only Git, Git LFS and its own agent endpoints. |
| 97 | const path = new URL(req.url).pathname; |
| 98 | const agent = path.startsWith(`/api/v3/diffinery/imports/${v.importId}/agent/`); |
| 99 | if (!agent && !IMPORT_GIT_PATH.test(path)) throw new HttpError(403, "this token can only be used by its GitHub import"); |
| 100 | return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, importId: v.importId, tokenId: v.id, owner: v.owner }; |
| 101 | } |
| 102 | return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, tokenId: v.id, owner: v.owner }; |
| 103 | } |
| 104 | // Job and sandbox tokens, and self-hosted runners' own tokens (which only claim jobs: runnerAuth refuses them). |
| 105 | if (cred.startsWith("djt_") || cred.startsWith("dst_") || cred.startsWith("drn_")) { |
| 106 | const repoId = cred.split("_")[1]; |
| 107 | if (!repoId) throw new HttpError(401, "malformed token"); |
| 108 | return { type: "repo-token", repoId, token: cred }; |
| 109 | } |
| 110 | if (cred.startsWith(INSTALLATION_TOKEN_PREFIX)) { |
| 111 | const auth = await new Apps(env, dir).verifyToken(cred); |
| 112 | if (!auth) throw new HttpError(401, "Bad credentials"); |
| 113 | return { type: "installation", auth }; |
| 114 | } |
| 115 | if (looksLikeJwt(cred)) return { type: "app", app: await new Apps(env, dir).appForJwt(cred) }; |
| 116 | throw new HttpError(401, "unrecognised token; create one under Settings → Access tokens"); |
| 117 | } |
| 118 | // Browsers sign in with a passkey (or the Access recovery path) and carry a session cookie. |
| 119 | const session = cookieFrom(req, SESSION_COOKIE); |
| 120 | if (session) { |
| 121 | const s = await new Auth(env).session(session); |
| 122 | if (s) return { type: "account", account: s.account, via: "session", scopes: ["admin"], repoIds: null, sessionId: s.session.id, sessionAt: s.session.created_at }; |
| 123 | } |
| 124 | if (devAuth(env, req.url)) { |
| 125 | const account = await dir.userForEmail(env.DEV_AUTH!); |
| 126 | return { type: "account", account, via: "dev", scopes: ["admin"], repoIds: null }; |
| 127 | } |
| 128 | return { type: "anonymous" }; |
| 129 | } |
| 130 | |
| 131 | export function requireAccount(c: AppContext): Account { |
| 132 | const p = c.get("principal"); |
| 133 | // GitHub's answer to an app's credentials on an endpoint for people. |
| 134 | if (p.type === "app" || p.type === "installation") throw new HttpError(403, "Resource not accessible by integration"); |
| 135 | if (p.type !== "account") throw new HttpError(401, "sign in required"); |
| 136 | if (p.account.status === "pending") throw new HttpError(403, "your account is waiting for an invite"); |
| 137 | if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); |
| 138 | return p.account; |
| 139 | } |
| 140 | |
| 141 | export const TOKEN_SCOPES: readonly TokenScope[] = ["read", "write", "admin"]; |
| 142 | |
| 143 | /** |
| 144 | * Personal access tokens carry a scope; browser sessions act with full scope. |
| 145 | * Throws 403 when the request's token lacks `need` (read < write < admin). |
| 146 | */ |
| 147 | export function requireScope(c: AppContext, need: TokenScope): void { |
| 148 | const p = c.get("principal"); |
| 149 | if (p.type !== "account" || p.via !== "token") return; |
| 150 | const have = Math.max(...p.scopes.map((s) => TOKEN_SCOPES.indexOf(s))); |
| 151 | if (have < TOKEN_SCOPES.indexOf(need)) throw new HttpError(403, `this token needs the ${need} scope`); |
| 152 | } |
| 153 | |
| 154 | /** Account-level changes (orgs, invitations, agents, tokens, settings) also refuse tokens limited to repositories. */ |
| 155 | export function requireAccountScope(c: AppContext, need: TokenScope): void { |
| 156 | requireScope(c, need); |
| 157 | const p = c.get("principal"); |
| 158 | if (p.type === "account" && p.via === "token" && p.repoIds) throw new HttpError(403, "this token is limited to repositories and can't change account settings"); |
| 159 | } |
| 160 | |
| 161 | /** `repos` narrowed to the ones a repository-limited token may use, so listing never reveals the others. */ |
| 162 | export function tokenRepos<T extends { id: string }>(c: AppContext, repos: T[]): T[] { |
| 163 | const p = c.get("principal"); |
| 164 | const only = p.type === "account" ? p.repoIds : null; |
| 165 | return only ? repos.filter((r) => only.includes(r.id)) : repos; |
| 166 | } |
| 167 | |
| 168 | /** Notifications span every repository, so a repository-limited token can't read them (as with GitHub's fine-grained tokens). */ |
| 169 | export function requireUnlimitedToken(c: AppContext, what: string): void { |
| 170 | const p = c.get("principal"); |
| 171 | if (p.type === "account" && p.via === "token" && p.repoIds) throw new HttpError(403, `this token is limited to repositories and can't ${what}`); |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Minting credentials (tokens, agents and their tokens) needs a person signed in with a |
| 176 | * passkey. No token can create another, so a leaked, repository-limited, agent, job or |
| 177 | * sandbox token can never turn itself into a wider or longer-lived one. |
| 178 | */ |
| 179 | export function requireBrowserSession(c: AppContext): Account { |
| 180 | const p = c.get("principal"); |
| 181 | if (p.type === "account" && (p.via === "session" || p.via === "dev")) { |
| 182 | requireRecentSignIn(c, "/settings/tokens"); |
| 183 | return requireAccount(c); |
| 184 | } |
| 185 | if (p.type === "anonymous") throw new HttpError(401, "sign in required"); |
| 186 | throw new HttpError(403, `creating tokens needs a browser session: sign in at ${c.env.PUBLIC_URL}/settings/tokens`); |
| 187 | } |
| 188 | |
| 189 | /** How long after a sign-in a browser may do sensitive things, as in GitHub's sudo mode. */ |
| 190 | export const RECENT_SIGNIN_MS = 2 * 3600_000; |
| 191 | |
| 192 | /** A sensitive action from a browser whose sign-in isn't recent: the person signs in again, then comes back to `next`. */ |
| 193 | export class SignInAgain extends HttpError { |
| 194 | readonly confirm: string; |
| 195 | constructor(next: string) { |
| 196 | const confirm = `/login?confirm=1&next=${encodeURIComponent(next)}`; |
| 197 | super(403, "Confirm it's you: sign in with your passkey again to do this.", { confirm }); |
| 198 | this.confirm = confirm; |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Adding a passkey, minting a token or an agent, issuing a sign-in link, changing your |
| 204 | * username, and deleting or transferring a repository or organization need a sign-in from |
| 205 | * the last 2 hours. So a |
| 206 | * session left open on a shared computer, or a stolen session cookie, can't turn itself |
| 207 | * into a lasting credential or destroy work. Tokens are bounded by their scopes instead. |
| 208 | */ |
| 209 | export function requireRecentSignIn(c: AppContext, next: string): void { |
| 210 | const p = c.get("principal"); |
| 211 | if (p.type !== "account" || p.via !== "session") return; |
| 212 | if (p.sessionAt && Date.now() - Date.parse(p.sessionAt) < RECENT_SIGNIN_MS) return; |
| 213 | throw new SignInAgain(next); |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * grantsFor reads for whoever the caller's role in `owner/name` depends on (the person, or an agent and its owner), |
| 218 | * so loadRepo can read them alongside the repository. Nothing for callers whose role needs no grants. |
| 219 | */ |
| 220 | export function grantsFor(c: AppContext, owner: string, name: string): Promise<RepoGrants[]> { |
| 221 | const p = c.get("principal"); |
| 222 | if (p.type !== "account" || p.account.status !== "active" || p.account.site_admin) return Promise.resolve([]); |
| 223 | const a = p.account; |
| 224 | const ids = a.kind === "agent" ? [a.id, ...(a.owner_id && p.owner?.id === a.owner_id && p.owner.kind === "user" ? [a.owner_id] : [])] : a.kind === "user" ? [a.id] : []; |
| 225 | const dir = c.get("dir"); |
| 226 | return Promise.all(ids.map((id) => dir.grantsFor(owner, name, id))).then((all) => all.flat()); |
| 227 | } |
| 228 | |
| 229 | /** Validates scopes sent by a client: known names only, default write. */ |
| 230 | export function parseScopes(input: unknown): TokenScope[] { |
| 231 | if (input === undefined || input === null) return ["write"]; |
| 232 | if (!Array.isArray(input) || !input.length || input.some((s) => !TOKEN_SCOPES.includes(s as TokenScope))) throw new HttpError(422, `scopes must be a non-empty list of: ${TOKEN_SCOPES.join(", ")}`); |
| 233 | return [...new Set(input as TokenScope[])]; |
| 234 | } |
| 235 | |
| 236 | const SCOPE_CAP: Record<TokenScope, Role> = { read: "read", write: "maintain", admin: "admin" }; |
| 237 | |
| 238 | /** |
| 239 | * Resolve the caller's effective role on a repository, combining account |
| 240 | * permissions with token scopes and repo restrictions. Returns null for no access. |
| 241 | */ |
| 242 | export async function callerFor(c: AppContext, repo: Repo, grants: RepoGrants[] = []): Promise<Caller | null> { |
| 243 | const p = c.get("principal"); |
| 244 | const dir = c.get("dir"); |
| 245 | // A suspended person's repositories are hidden from everyone but site administrators, and their |
| 246 | // job, sandbox and runner tokens stop working (lib/moderation.ts). |
| 247 | if (repo.owner_suspended_at && !(p.type === "account" && p.account.site_admin)) return null; |
| 248 | if (p.type === "repo-token") { |
| 249 | // Like GITHUB_TOKEN, a job or sandbox token reads other repositories only when they are public, |
| 250 | // and only once its own repository has vouched for it: a made-up token must not pass as a |
| 251 | // credential (it would skip the anonymous rate limit). Reads only: loadRepo refuses writes. |
| 252 | if (p.repoId !== repo.id) { |
| 253 | if (repo.visibility !== "public") return null; |
| 254 | const own = await dir.repoById(p.repoId); |
| 255 | if (!own || !(await c.env.REPO.get(c.env.REPO.idFromName(own.id)).runnerAuth(p.token))) throw new HttpError(401, "invalid, expired or revoked token"); |
| 256 | return { id: "anonymous", login: "anonymous", kind: "user", role: "read" }; |
| 257 | } |
| 258 | const stub = c.env.REPO.get(c.env.REPO.idFromName(repo.id)); |
| 259 | const auth = await stub.runnerAuth(p.token); |
| 260 | if (!auth) return null; |
| 261 | // A job token can do what the workflow's `permissions:` grant, checked per request in |
| 262 | // loadRepo. Runs an agent started act as an agent, so its guardrails still apply. |
| 263 | if (auth.kind === "job") { |
| 264 | return { id: `job:${auth.jobId}`, login: "diffinery-actions[bot]", kind: auth.actorKind === "agent" ? "agent" : "system", role: auth.role, permissions: auth.permissions }; |
| 265 | } |
| 266 | // A sandbox acts as the person or agent who started it, never beyond what they can do now: |
| 267 | // disabling the account or losing access ends its sandboxes' tokens too. |
| 268 | const account = await dir.account(auth.caller.id); |
| 269 | const now = account?.status === "active" ? await dir.roleFor(account, repo) : null; |
| 270 | if (!now) return null; |
| 271 | // An agent task's sandbox (agentic/) also stops working once the person who started the task |
| 272 | // is disabled, loses write access or can no longer drive the agent. |
| 273 | if (auth.taskPerson && !(await taskPersonAllowed(dir, auth.taskPerson, account!, repo))) return null; |
| 274 | let cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role; |
| 275 | if (ROLE_LEVEL[now] < ROLE_LEVEL[cap]) cap = now; |
| 276 | return { ...auth.caller, role: cap }; |
| 277 | } |
| 278 | if (p.type === "installation") return installationCaller(c, p.auth, repo); |
| 279 | // An app's JWT works on app endpoints only (http/apps.ts), as on GitHub. |
| 280 | if (p.type === "app") return null; |
| 281 | if (p.type !== "account") { |
| 282 | return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null; |
| 283 | } |
| 284 | if (p.repoIds && !p.repoIds.includes(repo.id)) return null; |
| 285 | const a = p.account; |
| 286 | // An agent's owner: from its token's check when that read it, else read alongside the role. |
| 287 | const known = a.kind === "agent" && a.owner_id && p.owner?.id === a.owner_id ? p.owner : undefined; |
| 288 | const [found, owner] = await Promise.all([dir.roleFor(a, repo, known, grants), a.kind === "agent" && a.owner_id ? (known ?? dir.account(a.owner_id)) : null]); |
| 289 | let role = found; |
| 290 | if (!role) return null; |
| 291 | const cap = p.scopes.reduce<Role>((best, s) => (ROLE_LEVEL[SCOPE_CAP[s]] > ROLE_LEVEL[best] ? SCOPE_CAP[s] : best), "read"); |
| 292 | if (ROLE_LEVEL[role] > ROLE_LEVEL[cap]) role = cap; |
| 293 | const onBehalfOf = owner?.login; |
| 294 | // Someone the repository's owner blocked (or their agent) can still read, but not take part (lib/moderation.ts). |
| 295 | // Checked for changes only: reads stay as fast as they were. |
| 296 | const writing = c.req.method !== "GET" && c.req.method !== "HEAD"; |
| 297 | const blocked = writing && repo.owner_id !== a.id && (await isBlocked(c.env, repo.owner_id, blockSubjects(a))); |
| 298 | return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role, ...(blocked ? { blockedBy: repo.owner_login } : {}), ...(agentMayMerge(a) ? { mayMerge: true } : {}) }; |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * An installation token in a repository: the app's bot with the token's permissions, checked per |
| 303 | * endpoint by the same table as a workflow's GITHUB_TOKEN (loadRepo). Outside what the installation |
| 304 | * covers (or the repositories the token was narrowed to) it reads public repositories like anyone. |
| 305 | */ |
| 306 | async function installationCaller(c: AppContext, auth: InstallationAuth, repo: Repo): Promise<Caller | null> { |
| 307 | const mine = (!auth.repoIds || auth.repoIds.includes(repo.id)) && (await new Apps(c.env, c.get("dir")).covers(auth.installation, repo)); |
| 308 | if (!mine) return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null; |
| 309 | const permissions = toJobPermissions(auth.permissions); |
| 310 | return { id: `app:${auth.installation.id}`, login: `${auth.app.slug}[bot]`, kind: "system", role: roleForPermissions(permissions), permissions }; |
| 311 | } |