diff --git a/cloudflare/.dev.vars.example b/cloudflare/.dev.vars.example index 1fd165b2..ad5cabe7 100644 --- a/cloudflare/.dev.vars.example +++ b/cloudflare/.dev.vars.example @@ -1,7 +1,7 @@ # Generate a random secret with `openssl rand -hex 32`; replace these placeholders. AUTH_SECRET=replace-with-a-random-secret-at-least-32-characters -GITHUB_CLIENT_ID=replace-with-github-oauth-client-id -GITHUB_CLIENT_SECRET=replace-with-github-oauth-client-secret +GITHUB_CLIENT_ID=replace-with-github-app-client-id +GITHUB_CLIENT_SECRET=replace-with-github-app-client-secret # Optional Google sign-in, offered only when both are set. Wrangler dev loads # the secret only while it is listed in wrangler.jsonc's `secrets.required`. # GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index 602b1b65..c4eb3676 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -2,8 +2,8 @@ The operator registers one public Quest App from the reviewed [manifest](github-app-manifest.json). Project users install that existing App; -they do not create their own bots. OAuth login remains separate. The App's -installation identity authors service comments and pull requests. App secrets +they do not create their own bots. The same App's OAuth client signs users in, +while its installation identity authors service comments and pull requests. App secrets and installation tokens stay inside the Worker; agents receive neither. ## Permissions and events @@ -21,8 +21,11 @@ The manifest subscribes to push, issues, issue comments, pull requests, reviews, review threads, inline review comments, checks and statuses. Existing App registrations must enable `pull_request_review_thread` and `pull_request_review_comment` in their webhook subscriptions so resolving discussions and replying inline wake merge jobs. GitHub also sends installation lifecycle and repository -selection events automatically. No organization, administration, workflow, -checks-write, or user permissions are requested. These installation grants +selection events automatically. The App also requests the **Email addresses: read** +user permission, which Better Auth needs to sign in users whose email is private. +It grants no repository authority: the user token is used only for the sign-in +profile and email reads, and never for repository writes. No organization, +administration, workflow, or checks-write permissions are requested. These installation grants are necessarily broader than individual operations: minted tokens always name one repository ID and the particular permissions required. Tokens are never cached and are revoked after use; failed cleanup is logged without secrets and @@ -252,7 +255,9 @@ reconcile current provider state rather than replay issue/PR payloads as facts. ## Operator setup -1. Review the manifest's name, homepage, webhook URL, permissions and events. +1. Review the manifest's name, homepage, webhook URL, callback URL, permissions + and events. The callback is `https://kix.quest/api/auth/callback/github`; + **Request user authorization (OAuth) during installation** is enabled. Register the public App once under the service owner's account using [GitHub's manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) or enter the same settings in GitHub. For the manifest flow, submit the JSON @@ -265,8 +270,10 @@ reconcile current provider state rather than replay issue/PR payloads as facts. Put the PEM private key and high-entropy webhook secret into Workers secrets named `GITHUB_APP_PRIVATE_KEY` and `GITHUB_WEBHOOK_SECRET` using `bun run wrangler secret put NAME` from `cloudflare/`. Keep multiline PEM - intact. Keep the existing `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` for OAuth - login; they are separate credentials. Never commit conversion responses, + intact. Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` to this same App's + OAuth client ID and client secret (the manifest conversion returns + `client_id` and `client_secret`). No separate OAuth app is used. The App ID + and OAuth client ID are different values. Never commit conversion responses, PEM keys, installation tokens or `.dev.vars`. 3. Set `GITHUB_FORK_OWNER` to the service-controlled namespace. Fork each pilot upstream into it under the same repository name, disable Actions on @@ -284,10 +291,19 @@ reconcile current provider state rather than replay issue/PR payloads as facts. Use GitHub's delivery/redelivery UI to retry failed deliveries. Rotate the private key and webhook secret through Workers secrets and the App settings. +Sign-in starts at kix.quest and keeps Better Auth's persisted state and cookie +checks. An authorization callback started by installing the App directly on +GitHub has no sign-in state from kix.quest: its code is discarded and the +visitor returns to the sign-in form, then completes a fresh state-bound flow. +Installation and sign-in do not enroll a repository; the maintainer still opts +in on `/projects` after a live authority check. + For local tests, fixtures sign bodies and mock all GitHub API responses. No production credentials, GitHub account or installation are required. References: [installation tokens](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation), +[App user authorization](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app), +[Better Auth GitHub Apps](https://better-auth.com/docs/authentication/github#setting-up-your-github-app), [JWTs](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app), [webhook validation](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries), [repository permissions](https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user), diff --git a/cloudflare/README.md b/cloudflare/README.md index e47c1b5c..4435d749 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -319,7 +319,7 @@ cp cloudflare/.dev.vars.example cloudflare/.dev.vars ``` Fill in the local file with a random `AUTH_SECRET` (at least 32 characters) and -GitHub OAuth app credentials. Google sign-in is optional and off by default: +the development GitHub App's client ID and client secret. Google sign-in is optional and off by default: to try it, register a Google OAuth web client with redirect URI `http://localhost:8787/api/auth/callback/google`, set both Google values in the local file, and temporarily list `GOOGLE_CLIENT_SECRET` under @@ -328,7 +328,8 @@ secrets. Configure the GitHub app's homepage as `http://localhost:8787` and callback as `http://localhost:8787/api/auth/callback/github`. The file is ignored by Git. Its `AUTH_URL` overrides the production origin in `wrangler.jsonc`. Use a -separate OAuth app for production. +separate GitHub App for production. The App needs the manifest's Email addresses +read permission for accounts with private email addresses; see [GITHUB.md](GITHUB.md). ```sh just worker-dev @@ -344,7 +345,7 @@ development and tests need no Cloudflare login. Wrangler reruns the build when ### Seeded local server -Check UI changes in a browser with representative data, and no OAuth app or +Check UI changes in a browser with representative data, and no GitHub App or Cloudflare login: ```sh @@ -451,8 +452,11 @@ for a fresh deployment only. For another environment, change `routes`, `AUTH_URL`, Worker name, account, database, bucket, and OAuth credentials together. -Register the production GitHub OAuth app with callback -`https://kix.quest/api/auth/callback/github`. For Google sign-in, also register +Register the production GitHub App from [the manifest](github-app-manifest.json), +which includes callback `https://kix.quest/api/auth/callback/github` and requests +user authorization during installation. Use that App's client ID and client +secret as `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`; no separate GitHub OAuth +app is needed. For Google sign-in, also register a production Google OAuth web client with redirect URI `https://kix.quest/api/auth/callback/google`, set its public client ID as `GOOGLE_CLIENT_ID` in `wrangler.jsonc`, and set the secret with diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index 1f199e5a..467717ad 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -529,7 +529,7 @@ export function fakeGitHub(world: World) { const me = world.users[0]!; if (url.origin === "https://spdx.org") return json({ licenseId: "MIT", isOsiApproved: true }); if (url.origin === "https://github.com" && path === "/login/oauth/access_token") - return json({ access_token: "fake-user-token", token_type: "bearer", scope: "read:user,user:email" }); + return json({ access_token: "fake-user-token", token_type: "bearer", scope: "" }); if (url.origin !== "https://api.github.com") return missing(method, request.url); const text = await request.text(); const body = text ? (JSON.parse(text) as Record) : {}; diff --git a/cloudflare/github-app-manifest.json b/cloudflare/github-app-manifest.json index f3b20536..1207e8cd 100644 --- a/cloudflare/github-app-manifest.json +++ b/cloudflare/github-app-manifest.json @@ -13,7 +13,8 @@ "issues": "write", "pull_requests": "write", "checks": "read", - "statuses": "read" + "statuses": "read", + "email_addresses": "read" }, "default_events": [ "push", @@ -26,5 +27,7 @@ "check_run", "check_suite", "status" - ] + ], + "callback_urls": ["https://kix.quest/api/auth/callback/github"], + "request_oauth_on_install": true } diff --git a/cloudflare/src/auth.ts b/cloudflare/src/auth.ts index 55b81e49..088be1f0 100644 --- a/cloudflare/src/auth.ts +++ b/cloudflare/src/auth.ts @@ -68,7 +68,9 @@ export function createAuth(env: Env) { }), ], socialProviders: { + // GitHub App permissions are registered in its manifest, not OAuth scopes. github: { + disableDefaultScope: true, clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, }, diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index d4fcca20..c8573aa7 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -32,6 +32,12 @@ app.route("/", githubWebhooks); app.get("/health", (c) => c.json({ status: "ok" })); // Short link for the one-line setup paste. app.get("/setup", (c) => c.redirect("https://github.com/kixelated/quest/blob/main/SETUP.md")); +// Installation-initiated authorization has no sign-in state from this site. +// Discard its code and let the visitor start a state-bound sign-in instead. +app.get("/api/auth/callback/github", (c, next) => { + if (c.req.query("code") && !c.req.query("state")) return c.redirect("/sign-in?next=/projects", 303); + return next(); +}); app.all("/api/auth/*", (c) => createAuth(c.env).handler(c.req.raw)); app.route("/", push.subscriptions); diff --git a/cloudflare/test/worker.test.ts b/cloudflare/test/worker.test.ts index 005cc32b..a84a25d5 100644 --- a/cloudflare/test/worker.test.ts +++ b/cloudflare/test/worker.test.ts @@ -151,7 +151,7 @@ describe("Worker", () => { expect(response.headers.get("referrer-policy")).toBe("same-origin"); expect(await response.text()).toContain('href="/sign-in?next=%2F"'); }); - it("starts GitHub OAuth with persisted state and cookies", async () => { + it("starts GitHub App OAuth without OAuth-app scopes, with persisted state and cookies", async () => { const response = await SELF.fetch(`${origin}/sign-in`, { method: "POST", headers: { Origin: origin }, @@ -161,11 +161,12 @@ describe("Worker", () => { const location = new URL(response.headers.get("location")!); expect(location.origin).toBe("https://github.com"); expect(location.searchParams.get("client_id")).toBe("test-client-id"); + expect(location.searchParams.get("scope") ?? "").toBe(""); expect(location.searchParams.get("redirect_uri")).toBe(`${origin}/api/auth/callback/github`); expect(location.searchParams.get("state")).toBeTruthy(); expect(response.headers.get("set-cookie")).toContain("better-auth.state="); }); - it("completes GitHub OAuth, persists the D1 session, and signs out", async () => { + it("signs a private-email user in with an expiring GitHub App token and signs out", async () => { const start = await SELF.fetch(`${origin}/sign-in`, { method: "POST", headers: { Origin: origin }, @@ -173,13 +174,19 @@ describe("Worker", () => { }); const state = new URL(start.headers.get("location")!).searchParams.get("state")!; const stateCookie = start.headers.get("set-cookie")!.split(";")[0]; - const outbound = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const outbound = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { const url = input instanceof Request ? input.url : String(input); if (url === "https://github.com/login/oauth/access_token") { + const credentials = new URLSearchParams(String(init?.body)); + expect(credentials.get("client_id")).toBe("test-client-id"); + expect(credentials.get("client_secret")).toBe("test-client-secret"); return Response.json({ - access_token: "mock-token", + access_token: "ghu_mock-token", + refresh_token: "ghr_mock-refresh", + expires_in: 28800, + refresh_token_expires_in: 15897600, token_type: "bearer", - scope: "read:user,user:email", + scope: "", }); } if (url === "https://api.github.com/user") { @@ -187,7 +194,7 @@ describe("Worker", () => { id: 1234, login: "contributor", name: "Contributor", - email: "contributor@example.com", + email: null, avatar_url: null, }); } @@ -229,6 +236,18 @@ describe("Worker", () => { expect(response.status).toBe(403); expect(response.headers.get("location")).toBeNull(); }); + it("returns installation authorization to state-bound sign-in without exchanging its code", async () => { + const outbound = vi.spyOn(globalThis, "fetch"); + const before = await env.DB.prepare("SELECT count(*) AS count FROM user").first<{ count: number }>(); + const response = await SELF.fetch(`${origin}/api/auth/callback/github?code=install-code`, { + redirect: "manual", + }); + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe("/sign-in?next=/projects"); + expect(outbound).not.toHaveBeenCalled(); + const users = await env.DB.prepare("SELECT count(*) AS count FROM user").first<{ count: number }>(); + expect(users?.count).toBe(before?.count); + }); it("rejects OAuth callbacks without a valid state", async () => { const before = await env.DB.prepare("SELECT count(*) AS count FROM user").first<{ count: number }>(); const response = await SELF.fetch(`${origin}/api/auth/callback/github?code=fake&state=fake`, { diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 5679ed7b..4b15461f 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -124,7 +124,6 @@ fixes what it finds: - [Deploy kix.quest](/quest/m0/kix/deploy.md) - the operator registers the App and deploys the Worker with GitHub sign-in at kix.quest - [Upstream branches](/quest/m0/kix/upstream-branches.md) - jobs publish on the enrolled repository itself, with no service forks - [Operator setup](/quest/m0/kix/operator-setup.md) - pilot installations and a verified test PR on kixelated/quest and moq-dev/moq -- [App sign-in](/quest/m0/kix/app-sign-in.md) - one GitHub screen signs in and installs the App, with no separate OAuth app - [Pick repositories](/quest/m0/kix/pick-repos.md) - after installing, the maintainer enables repositories from a list - [Setup PR](/quest/m0/kix/setup-pr.md) - enrolling a repository without Quest makes the App open its `quest init` pull request - [Job sandbox](/quest/m0/kix/sandbox/README.md) - untrusted PR devShells built in a VM on Linux runners, maintainer PR heads trusted, opencode sandboxed, and macOS verified diff --git a/quest/m0/kix/app-sign-in.md b/quest/m0/kix/app-sign-in.md deleted file mode 100644 index 1f8b7318..00000000 --- a/quest/m0/kix/app-sign-in.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] Sign in through the GitHub App - -## Goal - -GitHub sign-in on kix.quest uses the registered GitHub App's own OAuth -credentials, so one GitHub screen can both sign a maintainer in and install -the App. The operator registers one GitHub integration instead of an App and -a separate OAuth app. Google and passkey sign-in are unchanged. - -## Plan - -Decided 2026-10-27 (user) while planning a smoother onboarding, where a -maintainer opts in a repository and the bot does the rest: - -- Which onboarding changes should this planning round add as quests? - - ✅ A: bot opens setup PR - - ✅ C: install then pick repos - - ✅ C+: App sign-in replaces OAuth app - - B: setup as a runner job -- With sign-in through the GitHub App, what happens to the separate GitHub - OAuth app? - - ✅ Remove it - - Keep both - - There is no production data to migrate. `GITHUB_CLIENT_ID` and - `GITHUB_CLIENT_SECRET` become the App's client credentials. Add the - callback URL `https://kix.quest/api/auth/callback/github` and - `request_oauth_on_install` to `cloudflare/github-app-manifest.json`, and - drop the OAuth app from `cloudflare/README.md`'s Deployment section and - `cloudflare/GITHUB.md`'s operator setup. -- Sign-in still only proves identity: authority stays the live maintain/admin - check, and the user token is never used to write. -- Keep the seeded server's local GitHub sign-in working. diff --git a/quest/m0/kix/deploy.md b/quest/m0/kix/deploy.md index f6be5ab1..c2a87d76 100644 --- a/quest/m0/kix/deploy.md +++ b/quest/m0/kix/deploy.md @@ -34,7 +34,7 @@ delivery. Follow the Deployment section of `cloudflare/README.md` and steps 1, - Add the `kix.quest` zone to the Worker's Cloudflare account. Keep the Worker name, D1 database ID, and `AUTH_SECRET`; do not create a new production database. -- Set the production GitHub OAuth app's homepage to `https://kix.quest` and +- Set the production GitHub App's homepage to `https://kix.quest` and its callback to `https://kix.quest/api/auth/callback/github`. - The deploy deletes the legacy `RepositoryCoordinator` Durable Object and any storage it held, approved 2026-10-14 since nothing is stored. Afterwards diff --git a/quest/m0/kix/pick-repos.md b/quest/m0/kix/pick-repos.md index 95f810ec..199980ab 100644 --- a/quest/m0/kix/pick-repos.md +++ b/quest/m0/kix/pick-repos.md @@ -9,12 +9,12 @@ maintains, each with its eligibility and an Enable button. Nobody types ## Plan -Decided 2026-10-27 (user), option C of the onboarding round recorded in -[App sign-in](/quest/m0/kix/app-sign-in.md): +Decided 2026-10-27 (user), option C of the onboarding round that also +adopted GitHub App sign-in: - With sign-in on install, GitHub sends the maintainer to the OAuth callback rather than the App's Setup URL, so the picker follows that callback. That - is why this quest requires App sign-in. + is why the picker follows the state-bound sign-in flow. - Each row reuses enrollment's existing checks (live authority, public, OSI license, Quest tree) and states why an ineligible repository cannot be enabled. A repository without Quest files offers the @@ -22,7 +22,3 @@ Decided 2026-10-27 (user), option C of the onboarding round recorded in `/setup`. - Keep `/projects/select` for an already-enrolled repository's pause and disconnect controls. - -## Required - -- [App sign-in](/quest/m0/kix/app-sign-in.md) - installing the App also signs the maintainer in, which this picker follows diff --git a/quest/m0/kix/setup-pr.md b/quest/m0/kix/setup-pr.md index 8a2b8d9a..d8563ff3 100644 --- a/quest/m0/kix/setup-pr.md +++ b/quest/m0/kix/setup-pr.md @@ -9,9 +9,9 @@ enabled when it merges. ## Plan -Decided 2026-10-27 (user), option A of the onboarding round recorded in -[App sign-in](/quest/m0/kix/app-sign-in.md), chosen over running setup as a -runner job, which needs a runner before a project can enroll: +Decided 2026-10-27 (user), option A of the onboarding round that also +adopted GitHub App sign-in, chosen over running setup as a runner job, which +needs a runner before a project can enroll: - A repository without Quest gets the bot's setup PR. What state is it in until that PR merges?