Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cloudflare/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 23 additions & 7 deletions cloudflare/GITHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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),
Expand Down
14 changes: 9 additions & 5 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cloudflare/dev/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>) : {};
Expand Down
7 changes: 5 additions & 2 deletions cloudflare/github-app-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"issues": "write",
"pull_requests": "write",
"checks": "read",
"statuses": "read"
"statuses": "read",
"email_addresses": "read"
},
"default_events": [
"push",
Expand All @@ -26,5 +27,7 @@
"check_run",
"check_suite",
"status"
]
],
"callback_urls": ["https://kix.quest/api/auth/callback/github"],
"request_oauth_on_install": true
}
2 changes: 2 additions & 0 deletions cloudflare/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
6 changes: 6 additions & 0 deletions cloudflare/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
31 changes: 25 additions & 6 deletions cloudflare/test/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -161,33 +161,40 @@ 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 },
redirect: "manual",
});
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") {
return Response.json({
id: 1234,
login: "contributor",
name: "Contributor",
email: "contributor@example.com",
email: null,
avatar_url: null,
});
}
Expand Down Expand Up @@ -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`, {
Expand Down
1 change: 0 additions & 1 deletion quest/m0/kix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 0 additions & 33 deletions quest/m0/kix/app-sign-in.md

This file was deleted.

2 changes: 1 addition & 1 deletion quest/m0/kix/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 3 additions & 7 deletions quest/m0/kix/pick-repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,16 @@ 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
[setup PR](/quest/m0/kix/setup-pr.md) once that lands; until then it links
`/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
6 changes: 3 additions & 3 deletions quest/m0/kix/setup-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading