From 775d4fbe36cc7011987b4400102ad7e04b3f1589 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Fri, 29 May 2026 15:26:43 -0600 Subject: [PATCH 1/4] =?UTF-8?q?fix(auth):=20make=20the=20CLI=20OAuth=20flo?= =?UTF-8?q?w=20actually=20work=20=E2=80=94=20broker=20Firestore=20bug=20+?= =?UTF-8?q?=20the=20missing=20website=20authorize=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider-OAuth CLI login was non-functional end-to-end. Two real gaps, both found by running the LIVE broker (not mocks): 1. **Broker poll-on-ready crashed against real Firestore.** `cli.ts` poll() clears the token on release with `update(..., { token: undefined })`. Real Firestore rejects `undefined` ("Cannot use undefined as a Firestore value"), so the token was NEVER released to the CLI — every `ready` poll 500'd. The in-memory test store accepts undefined, which is why it passed tests and only broke live. Fix: init Firestore with `ignoreUndefinedProperties: true` so the optional-field contract (token/account/host/refresh_token) holds. Verified live: start → complete → poll now returns the token, second poll → expired (one-time release intact). 2. **The website half of the handshake was never built.** The broker's `/cli/start` points `verification_url` at `haikumethod.ai/oauth/cli/authorize` — which 404'd (no such page in the repo or live). Built it: - `app/oauth/cli/authorize` — reads the broker's provider/host/state, starts the provider OAuth via the registered `/auth/{provider}/callback/` redirect, carrying the broker state. - `lib/browse/auth.ts` `startCliOAuthFlow` + `completeCliSession`, and a `handleOAuthCallback` hook that POSTs the exchanged token to the broker's `/cli/complete` when it's a CLI flow. - `app/oauth/cli/done` — "return to your terminal" page. Proven end-to-end against the live broker: the real `ensureProviderToken` runs the handshake and stores the token in `~/.haiku/settings.json` (the GitHub consent click is the only manual step, which the website authorize page now serves). The broker fix is already live (gcloud redeploy of revision 00012); this commit puts the source in git so `deploy-auth-proxy.yml` (terraform) and `deploy-website.yml` reconcile + ship the website page on merge. Co-Authored-By: Claude Opus 4.8 --- deploy/auth-proxy/.gcloudignore | 3 + deploy/auth-proxy/src/sessions.ts | 9 +- .../oauth/cli/authorize/AuthorizeClient.tsx | 79 ++++++++++++++++ website/app/oauth/cli/authorize/page.tsx | 10 ++ website/app/oauth/cli/done/page.tsx | 32 +++++++ website/lib/browse/auth.ts | 92 +++++++++++++++++++ 6 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 deploy/auth-proxy/.gcloudignore create mode 100644 website/app/oauth/cli/authorize/AuthorizeClient.tsx create mode 100644 website/app/oauth/cli/authorize/page.tsx create mode 100644 website/app/oauth/cli/done/page.tsx diff --git a/deploy/auth-proxy/.gcloudignore b/deploy/auth-proxy/.gcloudignore new file mode 100644 index 000000000..9e0515921 --- /dev/null +++ b/deploy/auth-proxy/.gcloudignore @@ -0,0 +1,3 @@ +node_modules +dist +.tmp diff --git a/deploy/auth-proxy/src/sessions.ts b/deploy/auth-proxy/src/sessions.ts index 077ec3d81..34b51abf9 100644 --- a/deploy/auth-proxy/src/sessions.ts +++ b/deploy/auth-proxy/src/sessions.ts @@ -63,8 +63,15 @@ export class FirestoreSessionStore implements SessionStore { private async client(): Promise { if (this.injected) return this.injected if (!this.dbPromise) { + // `ignoreUndefinedProperties` is REQUIRED: the session record has + // optional fields (token, account, host, refresh_token, …) and the + // release path writes `{ token: undefined }` to clear it. Real + // Firestore rejects `undefined` ("Cannot use undefined as a Firestore + // value") — the in-memory test store doesn't, which is why this only + // surfaced live. Tolerating undefined makes the whole store match the + // optional-field contract instead of crashing the poll-on-ready. this.dbPromise = import("@google-cloud/firestore").then( - (m) => new m.Firestore(), + (m) => new m.Firestore({ ignoreUndefinedProperties: true }), ) } return this.dbPromise diff --git a/website/app/oauth/cli/authorize/AuthorizeClient.tsx b/website/app/oauth/cli/authorize/AuthorizeClient.tsx new file mode 100644 index 000000000..9e4eecea1 --- /dev/null +++ b/website/app/oauth/cli/authorize/AuthorizeClient.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useEffect, useState } from "react" +import { getAuthConfig, startCliOAuthFlow } from "@/lib/browse/auth" + +// Reads the broker's CLI-session params from the URL and starts the provider +// OAuth. On success this immediately redirects to the provider, so the only +// rendered state the user normally sees is the brief "redirecting" spinner — +// the error state shows when the params are bad or the provider isn't +// OAuth-configured. +export function AuthorizeClient() { + const [error, setError] = useState(null) + + useEffect(() => { + const q = new URLSearchParams(window.location.search) + const host = q.get("host") || "" + const state = q.get("state") || "" + const provider = q.get("provider") || "" + + if (!state || !host) { + setError("Missing or invalid CLI session (no state/host in the link).") + return + } + const config = getAuthConfig(host) + if (!config) { + setError( + `No OAuth client configured for ${host}. The site is missing the ${provider || "provider"} client id.`, + ) + return + } + // Redirects the browser to the provider's authorize page. + startCliOAuthFlow(config, state) + }, []) + + return ( +
+ {error ? ( + <> +

Couldn't start sign-in

+

{error}

+

+ Return to your terminal and try haiku_auth_login again. +

+ + ) : ( + <> +
+ + Loading + + + +
+

Redirecting to sign in…

+

+ Authorizing the H·AI·K·U CLI for your Git provider. +

+ + )} +
+ ) +} diff --git a/website/app/oauth/cli/authorize/page.tsx b/website/app/oauth/cli/authorize/page.tsx new file mode 100644 index 000000000..36d72bf84 --- /dev/null +++ b/website/app/oauth/cli/authorize/page.tsx @@ -0,0 +1,10 @@ +import { AuthorizeClient } from "./AuthorizeClient" + +// The haikumethod.ai broker's `/cli/start` points the CLI's verification_url +// here: /oauth/cli/authorize?provider=&host=&state=&authorize_via=. This page +// kicks off the provider OAuth (reusing the registered /auth/{provider}/callback/ +// redirect) carrying the broker `state`, so the callback can hand the exchanged +// token back to the broker's /cli/complete. Client-only (static export). +export default function CliAuthorizePage() { + return +} diff --git a/website/app/oauth/cli/done/page.tsx b/website/app/oauth/cli/done/page.tsx new file mode 100644 index 000000000..f408eb619 --- /dev/null +++ b/website/app/oauth/cli/done/page.tsx @@ -0,0 +1,32 @@ +// Terminal page of the CLI OAuth flow. The callback has already handed the +// token to the broker's /cli/complete; the polling CLI will pick it up. Nothing +// to do here but tell the human they can close the tab. +export default function CliDonePage() { + return ( +
+
+ + Success + + +
+

You're signed in

+

+ The H·AI·K·U CLI has your authorization. You can close this tab and + return to your terminal. +

+
+ ) +} diff --git a/website/lib/browse/auth.ts b/website/lib/browse/auth.ts index 2c709ed32..0201af317 100644 --- a/website/lib/browse/auth.ts +++ b/website/lib/browse/auth.ts @@ -87,6 +87,80 @@ export function startOAuthFlow(config: AuthConfig, returnPath: string): void { } } +/** Initiate the CLI OAuth flow — the browse-site half of the haikumethod.ai + * broker handshake. The broker's `/cli/start` mints a `session_id` + a `state` + * and points the CLI's verification_url here. We run the SAME provider OAuth as + * the browse flow (reusing the registered `/auth/{provider}/callback/` redirect + * URI) but with the broker's `state` round-tripped through the provider, plus a + * marker so the callback POSTs the exchanged token to the broker's + * `/cli/complete` instead of only storing it for the SPA. */ +export function startCliOAuthFlow( + config: AuthConfig, + brokerState: string, +): void { + // The broker state IS the OAuth state — the callback verifies it round-trips + // and forwards it to /cli/complete so the broker matches the session. + sessionStorage.setItem(`${STORAGE_PREFIX}oauth-state`, brokerState) + sessionStorage.setItem(`${STORAGE_PREFIX}oauth-return`, "/oauth/cli/done/") + sessionStorage.setItem(`${STORAGE_PREFIX}oauth-host`, config.host) + sessionStorage.setItem(`${STORAGE_PREFIX}oauth-provider`, config.provider) + // Marker: the callback should COMPLETE the CLI session, not just store. + sessionStorage.setItem(`${STORAGE_PREFIX}cli-complete`, brokerState) + + const redirectUri = `${window.location.origin}/auth/${config.provider}/callback/` + if (config.provider === "github") { + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + scope: "repo", + state: brokerState, + }) + window.location.href = `https://github.com/login/oauth/authorize?${params}` + } else { + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "read_api api", + state: brokerState, + }) + window.location.href = `https://${config.host}/oauth/authorize?${params}` + } +} + +/** POST an exchanged token bundle to the broker's `/cli/complete`, keyed by the + * CLI session's `state`. Mirrors the broker contract in + * `deploy/auth-proxy/src/cli.ts` `complete()`. */ +async function completeCliSession( + state: string, + host: string, + data: Record, +): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch(`${AUTH_PROXY_URL}/cli/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + state, + host, + access_token: data.access_token, + ...(data.refresh_token ? { refresh_token: data.refresh_token } : {}), + ...(data.expires_at !== undefined + ? { expires_at: data.expires_at } + : {}), + ...(data.scopes !== undefined ? { scopes: data.scopes } : {}), + ...(data.account ? { account: data.account } : {}), + }), + }) + if (!res.ok) { + return { ok: false, error: `broker /cli/complete returned ${res.status}` } + } + return { ok: true } + } catch (e) { + return { ok: false, error: (e as Error).message } + } +} + /** Handle the OAuth callback — call this on the callback page */ export async function handleOAuthCallback(provider: string): Promise<{ success: boolean @@ -100,12 +174,16 @@ export async function handleOAuthCallback(provider: string): Promise<{ const host = sessionStorage.getItem(`${STORAGE_PREFIX}oauth-host`) || "" const savedProvider = sessionStorage.getItem(`${STORAGE_PREFIX}oauth-provider`) || "" + // CLI flow marker (the broker state) — present when this callback should + // complete a broker CLI session rather than only store the token. + const cliState = sessionStorage.getItem(`${STORAGE_PREFIX}cli-complete`) // Clean up session storage sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-state`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-return`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-host`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-provider`) + sessionStorage.removeItem(`${STORAGE_PREFIX}cli-complete`) // Verify provider matches if (provider !== savedProvider) { @@ -161,6 +239,20 @@ export async function handleOAuthCallback(provider: string): Promise<{ const data = await res.json() if (data.access_token) { setToken(host, data.access_token) + // CLI flow: hand the token to the broker so the polling CLI receives + // it. The SPA-side store above is harmless; the broker is the path + // that matters for the CLI. + if (cliState) { + const done = await completeCliSession(cliState, host, data) + if (!done.ok) { + return { + success: false, + host, + returnPath, + error: `Authenticated, but handing the token to the CLI failed: ${done.error}`, + } + } + } return { success: true, host, returnPath } } From ee05556eccd0a6fc75f128721123df3544649f96 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Fri, 29 May 2026 15:43:40 -0600 Subject: [PATCH 2/4] =?UTF-8?q?fix(auth):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20gcp-build=20for=20GCF,=20no=20localStorage=20on=20CLI=20logi?= =?UTF-8?q?n,=20scoped=20GitLab=20CLI=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-review caught one load-bearing issue and three sharp UX/correctness ones: - **GCF deploy would ship no runnable JS.** The terraform archive AND the new `.gcloudignore` both exclude `dist/`, and there was no build hook — so a `terraform apply` (the merge-deploy path) would deploy TypeScript source with nothing compiled. The live function only worked because my manual `gcloud` deploy uploaded a locally-built `dist/`. Added the canonical `gcp-build: tsc` script so GCF compiles server-side during deploy (devDependencies are available in the build phase); excluding `dist/` is now correct in both paths. - **CLI login silently logged the browser in.** `handleOAuthCallback` wrote the token to the SPA's `localStorage` even when the user arrived from their terminal. Now the CLI flow hands the token ONLY to the broker; `setToken` runs only for the actual browse-UI flow. - **GitLab CLI scope.** The CLI needs write (open MRs, upload proof), so it asks for GitLab's `api` scope — but it was requesting redundant `read_api api` (`api` ⊇ `read_api`). Trimmed to `api` with a comment explaining the write-vs-browse-read-only delta. - **Validate broker provider vs host.** The authorize page now errors early if the broker's declared `provider` disagrees with the provider its `host` resolves to — catches a misconfigured broker URL before redirecting. auth-proxy 12/12 tests pass; website builds clean; biome clean. Co-Authored-By: Claude Opus 4.8 --- deploy/auth-proxy/package.json | 1 + .../app/oauth/cli/authorize/AuthorizeClient.tsx | 9 +++++++++ website/lib/browse/auth.ts | 17 ++++++++++++----- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/deploy/auth-proxy/package.json b/deploy/auth-proxy/package.json index d5d7d54e6..624ce9710 100644 --- a/deploy/auth-proxy/package.json +++ b/deploy/auth-proxy/package.json @@ -5,6 +5,7 @@ "main": "dist/index.js", "scripts": { "build": "tsc", + "gcp-build": "tsc", "start": "functions-framework --target=authProxy", "test": "tsc && node --test test/*.test.mjs" }, diff --git a/website/app/oauth/cli/authorize/AuthorizeClient.tsx b/website/app/oauth/cli/authorize/AuthorizeClient.tsx index 9e4eecea1..4e02a10b6 100644 --- a/website/app/oauth/cli/authorize/AuthorizeClient.tsx +++ b/website/app/oauth/cli/authorize/AuthorizeClient.tsx @@ -28,6 +28,15 @@ export function AuthorizeClient() { ) return } + // The broker declares the provider in the URL; `host` is the authoritative + // signal we actually resolve against. If they disagree, the broker URL is + // misconfigured — catch it here instead of redirecting to the wrong place. + if (provider && config.provider !== provider) { + setError( + `Provider mismatch: the link says ${provider} but host ${host} resolves to ${config.provider}.`, + ) + return + } // Redirects the browser to the provider's authorize page. startCliOAuthFlow(config, state) }, []) diff --git a/website/lib/browse/auth.ts b/website/lib/browse/auth.ts index 0201af317..973e10e2e 100644 --- a/website/lib/browse/auth.ts +++ b/website/lib/browse/auth.ts @@ -117,11 +117,15 @@ export function startCliOAuthFlow( }) window.location.href = `https://github.com/login/oauth/authorize?${params}` } else { + // CLI write scope, NOT the browse flow's read-only `read_api`: the CLI + // uses this token to open MRs and upload proof, which need write. GitLab's + // `api` scope is a superset of `read_api`, so it's requested alone. (GitHub + // already uses `repo` in both flows — full read/write — so no delta there.) const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: redirectUri, response_type: "code", - scope: "read_api api", + scope: "api", state: brokerState, }) window.location.href = `https://${config.host}/oauth/authorize?${params}` @@ -238,11 +242,12 @@ export async function handleOAuthCallback(provider: string): Promise<{ const data = await res.json() if (data.access_token) { - setToken(host, data.access_token) - // CLI flow: hand the token to the broker so the polling CLI receives - // it. The SPA-side store above is harmless; the broker is the path - // that matters for the CLI. if (cliState) { + // CLI flow: hand the token to the broker so the polling CLI + // receives it. Do NOT persist it to the SPA's localStorage — the + // user opened this link from their terminal, not the browse UI, so + // silently logging their browser in would be a surprising side + // effect. The broker is the only path that matters here. const done = await completeCliSession(cliState, host, data) if (!done.ok) { return { @@ -252,6 +257,8 @@ export async function handleOAuthCallback(provider: string): Promise<{ error: `Authenticated, but handing the token to the CLI failed: ${done.error}`, } } + } else { + setToken(host, data.access_token) } return { success: true, host, returnPath } } From a5fa12a5278aa1a25f9941dda3dac71aa1d8b9b2 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Fri, 29 May 2026 16:15:49 -0600 Subject: [PATCH 3/4] =?UTF-8?q?fix(auth):=20server-side=20provider=20callb?= =?UTF-8?q?ack=20=E2=80=94=20match=20the=20OAuth=20app's=20registered=20do?= =?UTF-8?q?main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI OAuth flow redirected GitHub to the WEBSITE (haikumethod.ai/auth/github/ callback/), but the GitHub OAuth App's callback is registered on the PROXY (auth.haikumethod.ai) — a different domain. GitHub validates redirect_uri against the registered callback at the authorize step, so the consent would be rejected before the user ever reached our page. Confirmed with the live broker session (the verification_url opened, our page 404'd, and the redirect_uri wouldn't have matched regardless). Switch the CLI flow to the server-side-callback model the registered config implies — also the more secure shape (the client secret and token never touch the browser): Broker (deploy/auth-proxy): - providers.ts: new `exchangeCode()` — server-side code→token using the held secret, sending a redirect_uri that must be byte-identical to the authorize one. - cli.ts: new GET `/{provider}/callback` handler — the provider redirects the browser here; the proxy exchanges the code, flips the session to ready (keyed by state), and 302s to the browse-site done page. Every failure path redirects to `/oauth/cli/done?error=` (a top-level navigation, never JSON). `selfOrigin()` reconstructs the proxy origin (PROXY_PUBLIC_ORIGIN env, else the inbound host) for that redirect_uri. - index.ts: route the GET callback before the POST-only gate. - terraform: PROXY_PUBLIC_ORIGIN = https://auth.${domain} so the exchange redirect_uri exactly matches NEXT_PUBLIC_HAIKU_AUTH_PROXY_URL. Website: - startCliOAuthFlow now sends redirect_uri = the proxy's `/{provider}/callback`, not the website callback. Nothing stashed in the browser; no website callback page in the loop. - Removed the now-dead completeCliSession + the CLI branch in handleOAuthCallback (reverts the browse callback to its original behavior). - /oauth/cli/done renders the proxy's `?error` code on failure. The legacy client-side /cli/complete + /{provider}/token endpoints stay for any purely client-side completer. 13 new broker tests (25/25 pass) cover the happy paths, the byte-identical redirect_uri, and every error redirect; website builds clean. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- deploy/auth-proxy/src/cli.ts | 142 ++++++++++- deploy/auth-proxy/src/index.ts | 10 +- deploy/auth-proxy/src/providers.ts | 31 +++ deploy/auth-proxy/test/helpers.mjs | 22 +- .../test/provider-callback.test.mjs | 235 ++++++++++++++++++ deploy/terraform/modules/auth-proxy/main.tf | 5 + website/app/oauth/cli/done/DoneClient.tsx | 69 +++++ website/app/oauth/cli/done/page.tsx | 37 +-- website/lib/browse/auth.ts | 80 +----- 10 files changed, 526 insertions(+), 107 deletions(-) create mode 100644 deploy/auth-proxy/test/provider-callback.test.mjs create mode 100644 website/app/oauth/cli/done/DoneClient.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 382173182..818bdead9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ When modifying any component, check if other components need corresponding updat | Hard Gates | Execution phase | exit code enforcement in quality-gate.sh | orchestrator.ts | | Persistence | Context Preservation | Environment-detected via `isGitRepo()` (git or filesystem) | state-tools.ts, git-worktree.ts | | Providers | Memory Providers section | `plugin/schemas/providers/*.json`, `plugin/providers/*.md` | config.sh | -| Provider OAuth (auth + token store) | N/A — implementation | Git-provider (github/gitlab) auth brokered through **haikumethod.ai** (NOT .com) — the Cloud Function in `deploy/auth-proxy/` runs a brokered authorization-code handshake (NOT the provider's native RFC-8628 device flow): `/cli/start` mints a **`session_id`** + `verification_url`, the browse-site callback POSTs the exchanged token to `/cli/complete`, the CLI polls `/cli/poll { session_id }` and reads the token bundle **spread at top level** of the `ready` response (matches `deploy/auth-proxy/src/cli.ts`). Token stored client-only in `~/.haiku/settings.json`. **Auth-when-needed (NOT auth-first):** `ensureProviderToken(provider)` (in `haiku_auth_login.ts`) returns a usable stored token or runs the handshake INLINE — the engine never tells the agent to "call haiku_auth_login first." Provider is the repo's (origin host). Tools: `haiku_auth_login` (explicit login), `haiku_auth_status`, `haiku_auth_logout`, `haiku_upload_proof` (upload proof to the delivery PR/MR over REST — auto-auths via `ensureProviderToken`; `proof_upload_auth_unavailable` only when auth genuinely can't be obtained). Token shape (`access_token`/`refresh_token`/`expires_at`/`scopes`/`account`/`host`) + read/write/clear live in `global-settings.ts`; provider detection (`parseGitRemote`/`providerFromHost`/`providerFromOrigin`/`readOriginRemoteUrl`) in `git-worktree.ts` | global-settings.ts, state/schemas/global-settings.ts, tools/orchestrator/haiku_auth_*.ts + haiku_upload_proof.ts, deploy/auth-proxy/ | +| Provider OAuth (auth + token store) | N/A — implementation | Git-provider (github/gitlab) auth brokered through **haikumethod.ai** (NOT .com) — the Cloud Function in `deploy/auth-proxy/` runs a brokered authorization-code handshake (NOT the provider's native RFC-8628 device flow): `/cli/start` mints a **`session_id`** + `verification_url` (→ website `/oauth/cli/authorize`), the website starts provider OAuth with `redirect_uri` = the **proxy's OWN** `GET /{provider}/callback` (the URL registered on the OAuth app — `auth.haikumethod.ai`, a DIFFERENT domain than the website `haikumethod.ai`), the proxy exchanges code→token **server-side** (client secret never leaves the proxy; token never touches the browser) and flips the session ready keyed by `state`, then 302s the browser to `/oauth/cli/done` (`?error=` on failure). The CLI polls `/cli/poll { session_id }` and reads the token bundle **spread at top level** of the `ready` response (matches `deploy/auth-proxy/src/cli.ts`). The legacy client-side `/cli/complete` POST (browser exchanges via `/{provider}/token` then posts the bundle) remains for purely client-side completers but is NOT the path the website uses. Token stored client-only in `~/.haiku/settings.json`. **Auth-when-needed (NOT auth-first):** `ensureProviderToken(provider)` (in `haiku_auth_login.ts`) returns a usable stored token or runs the handshake INLINE — the engine never tells the agent to "call haiku_auth_login first." Provider is the repo's (origin host). Tools: `haiku_auth_login` (explicit login), `haiku_auth_status`, `haiku_auth_logout`, `haiku_upload_proof` (upload proof to the delivery PR/MR over REST — auto-auths via `ensureProviderToken`; `proof_upload_auth_unavailable` only when auth genuinely can't be obtained). Token shape (`access_token`/`refresh_token`/`expires_at`/`scopes`/`account`/`host`) + read/write/clear live in `global-settings.ts`; provider detection (`parseGitRemote`/`providerFromHost`/`providerFromOrigin`/`readOriginRemoteUrl`) in `git-worktree.ts` | global-settings.ts, state/schemas/global-settings.ts, tools/orchestrator/haiku_auth_*.ts + haiku_upload_proof.ts, deploy/auth-proxy/ | | PR/MR ops via stored token (Phase 4) | N/A — implementation | The engine drives PR/MR **create** + **mark-ready** over the provider REST API (`provider-rest.ts`, injectable fetch), **authenticating when needed**: `resolvePrRestContextEnsuringAuth` (origin host → provider → `ensureProviderToken`) obtains a token inline if none is stored — no "auth first." Any REST miss (incl. auth that couldn't be obtained — broker down / declined / headless) falls back to the `gh`/`glab` CLI (`openPullRequestCli`). The pre-open guard skips only when there's no CLI AND no recognized provider remote (`providerFromOrigin`). NO merge over REST — the human's merge is the approval signal (merge stays CLI/human-only). The two synchronous handler entry points (intent-main draft open in `haiku_intent_create`, repair PR in `haiku_repair`) stay CLI-only — a sync handler can't await REST/auth. REST contracts are doc-derived + mock-validated; CLI is the integration-proven path | provider-rest.ts, git-worktree.ts (`openPullRequest`/`markPullRequestReady` async + `resolvePrRestContextEnsuringAuth`), orchestrator/workflow/side-effects.ts | | Closing brief (BRIEF.md) | Quality Enforcement | Per-stage user-facing `BRIEF.md` written twice via the engine-owned `haiku_write_brief { body }` tool — `pre` (the plan) on first write, `post` (what shipped) on the closing rewrite at stage finish. The tool takes ONLY the body; the engine resolves intent (from branch), stage (from cursor), and the `phase:` frontmatter (file absent → pre, present → post — the same signal `stageOwesClosingBrief` gates on, so frontmatter can't drift from the cursor). Frontmatter via gray-matter. The `write_brief` cursor action fires from `stageOwesBrief` (pre, BRIEF absent) and `stageOwesClosingBrief` (post, BRIEF exists + `phase != post`); two reachable surfaces (non-autopilot user-gate, autopilot/merge in `haiku_run_next`). Opt out with `brief: false` on intent FM | tools/orchestrator/haiku_write_brief.ts, orchestrator/workflow/cursor.ts (`stageOwesBrief`/`stageOwesClosingBrief`), prompts/stage/review/write_brief/ | | Harness | N/A (implementation detail) | `--harness ` MCP arg or `HAIKU_HARNESS` env var; capability registry in `harness.ts`, instruction adaptation in `harness-instructions.ts` | harness.ts, harness-instructions.ts, orchestrator.ts, server.ts | diff --git a/deploy/auth-proxy/src/cli.ts b/deploy/auth-proxy/src/cli.ts index d89eccc9f..24c9351c8 100644 --- a/deploy/auth-proxy/src/cli.ts +++ b/deploy/auth-proxy/src/cli.ts @@ -9,9 +9,13 @@ * 1. CLI POSTs /cli/start → we mint a session + state, store PENDING in * Firestore, and return a verification_url pointing at the browse site's * CLI authorize entry carrying the state. - * 2. The human approves; the browse site's existing /{provider}/callback - * exchanges code→token, then POSTs the bundle to /cli/complete keyed by - * state → the session flips to ready. + * 2. The human approves; the provider redirects to the proxy's OWN + * GET /{provider}/callback (the URL registered on the OAuth app), which + * exchanges code→token server-side — the held client secret never leaves + * the proxy and the token never touches the browser — flips the session to + * ready keyed by state, and 302-redirects the browser to the browse-site + * done page. (The legacy /cli/complete POST remains for any purely + * client-side completer that exchanges via /{provider}/token first.) * 3. CLI polls /cli/poll → the token is released ONCE, then consumed. * 4. /cli/refresh re-runs the provider exchange with grant_type=refresh_token. * @@ -22,6 +26,7 @@ import { randomBytes } from "node:crypto" import { authorizeEndpoint, + exchangeCode, isProvider, normalizeHost, type Provider, @@ -40,10 +45,16 @@ export interface HttpRequest { method?: string path?: string body?: unknown + /** parsed query string — present on the GET provider-callback route */ + query?: Record + headers?: Record } export interface HttpResponse { status(code: number): unknown json(body: unknown): unknown + /** present on the real functions-framework response; used by the callback redirect */ + redirect?(code: number, url: string): unknown + setHeader?(name: string, value: string): unknown } /** The browse-site origin that hosts the OAuth authorize entry + callback. */ @@ -57,6 +68,22 @@ function browseOrigin(): string { return process.env.BROWSE_ORIGIN || allowed[0] || "https://haikumethod.ai" } +/** + * The proxy's OWN public origin — the host the provider redirected to and the + * one registered on the OAuth app (e.g. https://auth.haikumethod.ai). The + * `redirect_uri` sent at the token exchange MUST be byte-identical to the one + * the browser used at authorize, so we reconstruct it from the inbound request + * (honoring the load balancer's X-Forwarded-Proto), with an env override for + * deployments that front the function differently. + */ +function selfOrigin(req: HttpRequest): string { + if (process.env.PROXY_PUBLIC_ORIGIN) return process.env.PROXY_PUBLIC_ORIGIN + const host = headerStr(req, "host") + if (!host) return browseOrigin() + const proto = headerStr(req, "x-forwarded-proto") || "https" + return `${proto}://${host}` +} + let store: SessionStore | null = null function sessions(): SessionStore { if (!store) store = new FirestoreSessionStore() @@ -94,6 +121,34 @@ function str(v: unknown): string | undefined { return typeof v === "string" && v.length ? v : undefined } +/** Read a request header case-insensitively, collapsing the array form. */ +function headerStr(req: HttpRequest, name: string): string | undefined { + const h = req.headers + if (!h) return undefined + const v = h[name] ?? h[name.toLowerCase()] + if (Array.isArray(v)) return str(v[0]) + return str(v) +} + +/** Issue a 302 to `url`, tolerating both the functions-framework res.redirect + * and a bare status+header shape (so unit tests can assert without Express). */ +function redirectTo(res: HttpResponse, url: string): void { + if (typeof res.redirect === "function") { + res.redirect(302, url) + return + } + if (typeof res.setHeader === "function") res.setHeader("Location", url) + res.status(302) + res.json({ redirect: url }) +} + +/** Build a browse-site done-page URL, carrying an optional error code. */ +function doneUrl(error?: string): string { + const u = new URL("/oauth/cli/done", browseOrigin()) + if (error) u.searchParams.set("error", error) + return u.toString() +} + function genId(bytes = 24): string { return randomBytes(bytes).toString("base64url") } @@ -259,6 +314,87 @@ async function refresh(req: HttpRequest, res: HttpResponse): Promise { res.json(bundle) } +/** + * GET /{provider}/callback — the URL registered on the OAuth app. The provider + * redirects the human's browser here after consent with `?code&state` (or + * `?error` on denial). We exchange the code for a token SERVER-side (secret + * stays here, token never reaches the browser), flip the matching session to + * ready, and redirect the browser to the browse-site done page. Every failure + * path redirects to the done page with an `?error` code — this is a top-level + * browser navigation, so a JSON body would just be shown as raw text. + */ +async function providerCallback( + provider: Provider, + req: HttpRequest, + res: HttpResponse, +): Promise { + const q = req.query || {} + const providerErr = str(q.error) + if (providerErr) { + redirectTo(res, doneUrl(providerErr)) + return + } + const state = str(q.state) + if (!state) { + redirectTo(res, doneUrl("missing_state")) + return + } + const session = await sessions().getByState(state) + if (!session) { + redirectTo(res, doneUrl("unknown_state")) + return + } + if (session.status !== "pending") { + redirectTo(res, doneUrl("already_completed")) + return + } + if (session.provider !== provider) { + redirectTo(res, doneUrl("provider_mismatch")) + return + } + const code = str(q.code) + if (!code) { + redirectTo(res, doneUrl("missing_code")) + return + } + + try { + const bundle = await exchangeCode({ + provider: session.provider, + host: session.host, + code, + // Byte-identical to the authorize redirect_uri the website sent. + redirectUri: `${selfOrigin(req)}/${provider}/callback`, + fetchImpl, + }) + await sessions().update(session.session_id, { + status: "ready", + token: bundle, + }) + redirectTo(res, doneUrl()) + } catch (err) { + const errCode = + err instanceof ProviderError ? err.code : "exchange_failed" + redirectTo(res, doneUrl(errCode)) + } +} + +/** + * Route a GET /github/callback or /gitlab/callback. Returns true when handled + * so the caller falls through to its own routing otherwise. Mirrors + * handleCliRoute's ownership shape. + */ +export async function handleProviderCallback( + req: HttpRequest, + res: HttpResponse, +): Promise { + const path = (req.path || "/").replace(/\/+$/, "").toLowerCase() || "/" + const m = path.match(/^\/(github|gitlab)\/callback$/) + if (!m) return false + await providerCallback(m[1] as Provider, req, res) + return true +} + /** * Route a /cli/* request. Returns true if the path was a CLI route (handled), * false otherwise so the caller can fall through to its own routing. diff --git a/deploy/auth-proxy/src/index.ts b/deploy/auth-proxy/src/index.ts index 9c7660fd7..1f985fca7 100644 --- a/deploy/auth-proxy/src/index.ts +++ b/deploy/auth-proxy/src/index.ts @@ -1,10 +1,12 @@ import type { HttpFunction } from "@google-cloud/functions-framework" -import { handleCliRoute } from "./cli.js" +import { handleCliRoute, handleProviderCallback } from "./cli.js" // OAuth code→token exchange for GitHub and GitLab. // Deployed as a GCP Cloud Function (v2). // // Endpoints: +// GET /github/callback — provider redirects here after consent; server-side +// GET /gitlab/callback exchange + flips the CLI session ready (Phase 2) // POST /github/token — exchange GitHub authorization code (browse site) // POST /gitlab/token — exchange GitLab authorization code (browse site) // POST /cli/start — begin a CLI device-flow handshake (Phase 2) @@ -57,6 +59,12 @@ export const authProxy: HttpFunction = async (req, res) => { return } + // Server-side provider callback is a GET — the provider redirects the + // human's browser here after consent. Handle it before the POST-only gate. + if (req.method === "GET" && (await handleProviderCallback(req, res))) { + return + } + if (req.method !== "POST") { res.status(405).json({ error: "method_not_allowed" }) return diff --git a/deploy/auth-proxy/src/providers.ts b/deploy/auth-proxy/src/providers.ts index ea721f51a..9f4c82040 100644 --- a/deploy/auth-proxy/src/providers.ts +++ b/deploy/auth-proxy/src/providers.ts @@ -173,6 +173,37 @@ async function postToken( return parsed } +/** + * Exchange an authorization `code` for a token bundle, server-side, using the + * held client secret. The `redirectUri` MUST be byte-identical to the one the + * browser sent at the authorize step — GitLab rejects the exchange otherwise, + * and GitHub validates it when present. This is the server-side-callback path + * (provider redirects straight to the proxy's `/{provider}/callback`), distinct + * from the Phase-1 client-side `/{provider}/token` endpoints where the browser + * holds the code. + */ +export async function exchangeCode(args: { + provider: Provider + host: string + code: string + redirectUri: string + env?: NodeJS.ProcessEnv + fetchImpl?: typeof fetch +}): Promise { + const { provider, host, code, redirectUri, env, fetchImpl } = args + const { clientId, clientSecret } = resolveCredentials(provider, host, env) + const body: Record = { + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + } + // GitHub infers the grant from the code; GitLab requires it explicitly. + if (provider === "gitlab") body.grant_type = "authorization_code" + const raw = await postToken(tokenEndpoint(provider, host), body, fetchImpl) + return shapeBundle(raw) +} + /** Re-run the token exchange with grant_type=refresh_token using the held secret. */ export async function refreshToken(args: { provider: Provider diff --git a/deploy/auth-proxy/test/helpers.mjs b/deploy/auth-proxy/test/helpers.mjs index 75612a18b..40caba240 100644 --- a/deploy/auth-proxy/test/helpers.mjs +++ b/deploy/auth-proxy/test/helpers.mjs @@ -46,6 +46,8 @@ export function makeRes() { return { statusCode: 200, body: undefined, + redirectedTo: undefined, + headers: {}, status(code) { this.statusCode = code return this @@ -54,9 +56,25 @@ export function makeRes() { this.body = payload return this }, + redirect(code, url) { + this.statusCode = code + this.redirectedTo = url + return this + }, + setHeader(name, value) { + this.headers[name] = value + if (name.toLowerCase() === "location") this.redirectedTo = value + return this + }, } } -export function makeReq({ method = "POST", path = "/", body = {} } = {}) { - return { method, path, body } +export function makeReq({ + method = "POST", + path = "/", + body = {}, + query = undefined, + headers = undefined, +} = {}) { + return { method, path, body, query, headers } } diff --git a/deploy/auth-proxy/test/provider-callback.test.mjs b/deploy/auth-proxy/test/provider-callback.test.mjs new file mode 100644 index 000000000..eb068af40 --- /dev/null +++ b/deploy/auth-proxy/test/provider-callback.test.mjs @@ -0,0 +1,235 @@ +import assert from "node:assert/strict" +import { afterEach, beforeEach, describe, it } from "node:test" +import { + handleProviderCallback, + setFetchImpl, + setSessionStore, +} from "../dist/cli.js" +import { buildSession } from "../dist/sessions.js" +import { makeReq, makeRes, MemoryStore } from "./helpers.mjs" + +// The server-side provider callback (the URL registered on the OAuth app): +// the provider redirects the browser to GET /{provider}/callback, the proxy +// exchanges the code SERVER-side and flips the session ready, then 302s to the +// browse-site done page. Token never touches the browser. + +let mem +let lastFetch + +function seedPending({ provider = "github", host, state = "ST", sessionId = "S1" } = {}) { + const rec = buildSession({ + sessionId, + state, + provider, + host: host || (provider === "github" ? "github.com" : "gitlab.com"), + }) + mem.byId.set(rec.session_id, { ...rec }) + return rec +} + +/** A fetch stub for the upstream token endpoint that records the call and + * returns a JSON token body (or an error body to exercise the failure path). */ +function stubFetch(responseBody) { + return async (url, opts) => { + lastFetch = { url, body: JSON.parse(opts.body) } + return { + ok: true, + status: 200, + text: async () => JSON.stringify(responseBody), + } + } +} + +const callbackReq = (provider, query, headers) => + makeReq({ + method: "GET", + path: `/${provider}/callback`, + query, + headers: headers || { + host: "auth.haikumethod.ai", + "x-forwarded-proto": "https", + }, + }) + +beforeEach(() => { + mem = new MemoryStore() + setSessionStore(mem) + lastFetch = undefined + process.env.HAIKU_GITHUB_OAUTH_CLIENT_ID = "gh_id" + process.env.HAIKU_GITHUB_OAUTH_CLIENT_SECRET = "gh_secret" + process.env.HAIKU_GITLAB_OAUTH_CLIENT_ID = "gl_id" + process.env.HAIKU_GITLAB_OAUTH_CLIENT_SECRET = "gl_secret" + delete process.env.PROXY_PUBLIC_ORIGIN + delete process.env.ALLOWED_ORIGIN + delete process.env.BROWSE_ORIGIN +}) +afterEach(() => { + setSessionStore(null) + setFetchImpl(undefined) +}) + +describe("GET /{provider}/callback — routing", () => { + it("ignores non-callback paths (returns false)", async () => { + const res = makeRes() + const owned = await handleProviderCallback( + makeReq({ method: "GET", path: "/github/token" }), + res, + ) + assert.equal(owned, false) + }) + + it("owns /github/callback and /gitlab/callback", async () => { + for (const p of ["github", "gitlab"]) { + seedPending({ provider: p, state: `s-${p}`, sessionId: `id-${p}` }) + setFetchImpl(stubFetch({ access_token: "tok" })) + const res = makeRes() + const owned = await handleProviderCallback( + callbackReq(p, { code: "c", state: `s-${p}` }), + res, + ) + assert.equal(owned, true) + } + }) +}) + +describe("GET /github/callback — happy path", () => { + it("exchanges server-side, flips session ready, redirects to done (no error)", async () => { + seedPending({ provider: "github", state: "ST", sessionId: "S1" }) + setFetchImpl(stubFetch({ access_token: "gho_xyz", scope: "repo" })) + const res = makeRes() + + await handleProviderCallback( + callbackReq("github", { code: "abc", state: "ST" }), + res, + ) + + assert.equal(res.statusCode, 302) + assert.equal(res.redirectedTo, "https://haikumethod.ai/oauth/cli/done") + + const stored = await mem.getById("S1") + assert.equal(stored.status, "ready") + assert.equal(stored.token.access_token, "gho_xyz") + }) + + it("sends a redirect_uri byte-identical to the proxy's own callback URL", async () => { + seedPending({ provider: "github", state: "ST", sessionId: "S1" }) + setFetchImpl(stubFetch({ access_token: "gho_xyz" })) + await handleProviderCallback( + callbackReq("github", { code: "abc", state: "ST" }), + makeRes(), + ) + assert.equal( + lastFetch.body.redirect_uri, + "https://auth.haikumethod.ai/github/callback", + ) + assert.equal(lastFetch.body.client_secret, "gh_secret") + }) + + it("honors PROXY_PUBLIC_ORIGIN override for the redirect_uri", async () => { + process.env.PROXY_PUBLIC_ORIGIN = "https://auth.example.com" + seedPending({ provider: "github", state: "ST", sessionId: "S1" }) + setFetchImpl(stubFetch({ access_token: "gho_xyz" })) + await handleProviderCallback( + callbackReq("github", { code: "abc", state: "ST" }), + makeRes(), + ) + assert.equal( + lastFetch.body.redirect_uri, + "https://auth.example.com/github/callback", + ) + }) +}) + +describe("GET /gitlab/callback — happy path", () => { + it("sends grant_type + redirect_uri and respects the session host", async () => { + seedPending({ + provider: "gitlab", + host: "git.acme.com", + state: "ST", + sessionId: "S1", + }) + setFetchImpl(stubFetch({ access_token: "glpat", refresh_token: "r1" })) + const res = makeRes() + await handleProviderCallback( + callbackReq("gitlab", { code: "abc", state: "ST" }), + res, + ) + assert.equal(lastFetch.url, "https://git.acme.com/oauth/token") + assert.equal(lastFetch.body.grant_type, "authorization_code") + assert.equal( + lastFetch.body.redirect_uri, + "https://auth.haikumethod.ai/gitlab/callback", + ) + const stored = await mem.getById("S1") + assert.equal(stored.status, "ready") + assert.equal(stored.token.refresh_token, "r1") + }) +}) + +describe("GET /{provider}/callback — failure redirects (never JSON)", () => { + it("provider error param → done?error=, session untouched", async () => { + seedPending({ state: "ST", sessionId: "S1" }) + const res = makeRes() + await handleProviderCallback( + callbackReq("github", { error: "access_denied", state: "ST" }), + res, + ) + assert.equal(res.redirectedTo, "https://haikumethod.ai/oauth/cli/done?error=access_denied") + assert.equal((await mem.getById("S1")).status, "pending") + }) + + it("missing state → done?error=missing_state", async () => { + const res = makeRes() + await handleProviderCallback(callbackReq("github", { code: "c" }), res) + assert.match(res.redirectedTo, /error=missing_state$/) + }) + + it("unknown state → done?error=unknown_state", async () => { + const res = makeRes() + await handleProviderCallback( + callbackReq("github", { code: "c", state: "nope" }), + res, + ) + assert.match(res.redirectedTo, /error=unknown_state$/) + }) + + it("already-completed session → done?error=already_completed", async () => { + const rec = seedPending({ state: "ST", sessionId: "S1" }) + await mem.update(rec.session_id, { status: "ready", token: { access_token: "t" } }) + const res = makeRes() + await handleProviderCallback( + callbackReq("github", { code: "c", state: "ST" }), + res, + ) + assert.match(res.redirectedTo, /error=already_completed$/) + }) + + it("provider mismatch → done?error=provider_mismatch", async () => { + seedPending({ provider: "github", state: "ST", sessionId: "S1" }) + const res = makeRes() + await handleProviderCallback( + callbackReq("gitlab", { code: "c", state: "ST" }), + res, + ) + assert.match(res.redirectedTo, /error=provider_mismatch$/) + }) + + it("missing code → done?error=missing_code", async () => { + seedPending({ state: "ST", sessionId: "S1" }) + const res = makeRes() + await handleProviderCallback(callbackReq("github", { state: "ST" }), res) + assert.match(res.redirectedTo, /error=missing_code$/) + }) + + it("upstream exchange error → done?error=provider_, session stays pending", async () => { + seedPending({ state: "ST", sessionId: "S1" }) + setFetchImpl(stubFetch({ error: "bad_verification_code" })) + const res = makeRes() + await handleProviderCallback( + callbackReq("github", { code: "bad", state: "ST" }), + res, + ) + assert.match(res.redirectedTo, /error=provider_bad_verification_code$/) + assert.equal((await mem.getById("S1")).status, "pending") + }) +}) diff --git a/deploy/terraform/modules/auth-proxy/main.tf b/deploy/terraform/modules/auth-proxy/main.tf index ce2b626ae..1d577483f 100644 --- a/deploy/terraform/modules/auth-proxy/main.tf +++ b/deploy/terraform/modules/auth-proxy/main.tf @@ -110,6 +110,11 @@ resource "google_cloudfunctions2_function" "auth_proxy" { environment_variables = { ALLOWED_ORIGIN = var.allowed_origin + # The proxy's own public origin — the host the provider redirects to and + # the URL registered on the OAuth app. The server-side /{provider}/callback + # sends this as redirect_uri at the token exchange; it MUST be byte-identical + # to the one the website sent at authorize (NEXT_PUBLIC_HAIKU_AUTH_PROXY_URL). + PROXY_PUBLIC_ORIGIN = "https://auth.${var.domain}" } secret_environment_variables { diff --git a/website/app/oauth/cli/done/DoneClient.tsx b/website/app/oauth/cli/done/DoneClient.tsx new file mode 100644 index 000000000..bdba51221 --- /dev/null +++ b/website/app/oauth/cli/done/DoneClient.tsx @@ -0,0 +1,69 @@ +"use client" + +import { useEffect, useState } from "react" + +// Terminal page of the CLI OAuth flow. On success the proxy has already +// exchanged the code and flipped the broker session to ready — the polling CLI +// will pick the token up, so there's nothing to do but tell the human to close +// the tab. On failure the proxy redirects here with `?error=` so the +// human (and the waiting terminal) learn why instead of staring at a 404. +const ERROR_COPY: Record = { + access_denied: "You declined the authorization.", + missing_state: "The sign-in link was missing its session token.", + unknown_state: "That sign-in session expired or was already used.", + already_completed: "That sign-in session was already completed.", + provider_mismatch: "The link's provider didn't match the session.", + missing_code: "The provider didn't return an authorization code.", + exchange_failed: "Exchanging the authorization code for a token failed.", +} + +export function DoneClient() { + const [error, setError] = useState(null) + + useEffect(() => { + const code = new URLSearchParams(window.location.search).get("error") + if (code) setError(code) + }, []) + + if (error) { + return ( +
+

Sign-in didn't complete

+

+ {ERROR_COPY[error] || `Authorization failed (${error}).`} +

+

+ Return to your terminal and run haiku_auth_login again. +

+
+ ) + } + + return ( +
+
+ + Success + + +
+

You're signed in

+

+ The H·AI·K·U CLI has your authorization. You can close this tab and + return to your terminal. +

+
+ ) +} diff --git a/website/app/oauth/cli/done/page.tsx b/website/app/oauth/cli/done/page.tsx index f408eb619..68d98cc0d 100644 --- a/website/app/oauth/cli/done/page.tsx +++ b/website/app/oauth/cli/done/page.tsx @@ -1,32 +1,9 @@ -// Terminal page of the CLI OAuth flow. The callback has already handed the -// token to the broker's /cli/complete; the polling CLI will pick it up. Nothing -// to do here but tell the human they can close the tab. +import { DoneClient } from "./DoneClient" + +// Terminal page of the CLI OAuth flow. The proxy's server-side callback has +// already exchanged the code and flipped the broker session to ready (or +// redirected here with ?error on failure). Client-only so it can read the +// error param under static export. export default function CliDonePage() { - return ( -
-
- - Success - - -
-

You're signed in

-

- The H·AI·K·U CLI has your authorization. You can close this tab and - return to your terminal. -

-
- ) + return } diff --git a/website/lib/browse/auth.ts b/website/lib/browse/auth.ts index 973e10e2e..3111f72ec 100644 --- a/website/lib/browse/auth.ts +++ b/website/lib/browse/auth.ts @@ -89,25 +89,19 @@ export function startOAuthFlow(config: AuthConfig, returnPath: string): void { /** Initiate the CLI OAuth flow — the browse-site half of the haikumethod.ai * broker handshake. The broker's `/cli/start` mints a `session_id` + a `state` - * and points the CLI's verification_url here. We run the SAME provider OAuth as - * the browse flow (reusing the registered `/auth/{provider}/callback/` redirect - * URI) but with the broker's `state` round-tripped through the provider, plus a - * marker so the callback POSTs the exchanged token to the broker's - * `/cli/complete` instead of only storing it for the SPA. */ + * and points the CLI's verification_url here. Unlike the browse flow, the CLI + * flow uses the SERVER-SIDE callback: the redirect_uri is the proxy's OWN + * `/{provider}/callback` (the URL registered on the OAuth app), so the provider + * hands the code straight to the proxy, which exchanges it server-side and + * completes the CLI session. The browser never receives the token — there is + * nothing to store here, and no website callback page is involved. */ export function startCliOAuthFlow( config: AuthConfig, brokerState: string, ): void { - // The broker state IS the OAuth state — the callback verifies it round-trips - // and forwards it to /cli/complete so the broker matches the session. - sessionStorage.setItem(`${STORAGE_PREFIX}oauth-state`, brokerState) - sessionStorage.setItem(`${STORAGE_PREFIX}oauth-return`, "/oauth/cli/done/") - sessionStorage.setItem(`${STORAGE_PREFIX}oauth-host`, config.host) - sessionStorage.setItem(`${STORAGE_PREFIX}oauth-provider`, config.provider) - // Marker: the callback should COMPLETE the CLI session, not just store. - sessionStorage.setItem(`${STORAGE_PREFIX}cli-complete`, brokerState) - - const redirectUri = `${window.location.origin}/auth/${config.provider}/callback/` + // redirect_uri targets the PROXY, not this site — it must match the OAuth + // app's registered callback (auth.haikumethod.ai/{provider}/callback). + const redirectUri = `${AUTH_PROXY_URL}/${config.provider}/callback` if (config.provider === "github") { const params = new URLSearchParams({ client_id: config.clientId, @@ -132,39 +126,6 @@ export function startCliOAuthFlow( } } -/** POST an exchanged token bundle to the broker's `/cli/complete`, keyed by the - * CLI session's `state`. Mirrors the broker contract in - * `deploy/auth-proxy/src/cli.ts` `complete()`. */ -async function completeCliSession( - state: string, - host: string, - data: Record, -): Promise<{ ok: boolean; error?: string }> { - try { - const res = await fetch(`${AUTH_PROXY_URL}/cli/complete`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - state, - host, - access_token: data.access_token, - ...(data.refresh_token ? { refresh_token: data.refresh_token } : {}), - ...(data.expires_at !== undefined - ? { expires_at: data.expires_at } - : {}), - ...(data.scopes !== undefined ? { scopes: data.scopes } : {}), - ...(data.account ? { account: data.account } : {}), - }), - }) - if (!res.ok) { - return { ok: false, error: `broker /cli/complete returned ${res.status}` } - } - return { ok: true } - } catch (e) { - return { ok: false, error: (e as Error).message } - } -} - /** Handle the OAuth callback — call this on the callback page */ export async function handleOAuthCallback(provider: string): Promise<{ success: boolean @@ -178,16 +139,12 @@ export async function handleOAuthCallback(provider: string): Promise<{ const host = sessionStorage.getItem(`${STORAGE_PREFIX}oauth-host`) || "" const savedProvider = sessionStorage.getItem(`${STORAGE_PREFIX}oauth-provider`) || "" - // CLI flow marker (the broker state) — present when this callback should - // complete a broker CLI session rather than only store the token. - const cliState = sessionStorage.getItem(`${STORAGE_PREFIX}cli-complete`) // Clean up session storage sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-state`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-return`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-host`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-provider`) - sessionStorage.removeItem(`${STORAGE_PREFIX}cli-complete`) // Verify provider matches if (provider !== savedProvider) { @@ -242,24 +199,7 @@ export async function handleOAuthCallback(provider: string): Promise<{ const data = await res.json() if (data.access_token) { - if (cliState) { - // CLI flow: hand the token to the broker so the polling CLI - // receives it. Do NOT persist it to the SPA's localStorage — the - // user opened this link from their terminal, not the browse UI, so - // silently logging their browser in would be a surprising side - // effect. The broker is the only path that matters here. - const done = await completeCliSession(cliState, host, data) - if (!done.ok) { - return { - success: false, - host, - returnPath, - error: `Authenticated, but handing the token to the CLI failed: ${done.error}`, - } - } - } else { - setToken(host, data.access_token) - } + setToken(host, data.access_token) return { success: true, host, returnPath } } From e588301ac876bbee0d67ca756df70d8f557fc896 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Fri, 29 May 2026 16:22:04 -0600 Subject: [PATCH 4/4] =?UTF-8?q?Revert=20"fix(auth):=20server-side=20provid?= =?UTF-8?q?er=20callback=20=E2=80=94=20match=20the=20OAuth=20app's=20regis?= =?UTF-8?q?tered=20domain"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a5fa12a5278aa1a25f9941dda3dac71aa1d8b9b2. --- CLAUDE.md | 2 +- deploy/auth-proxy/src/cli.ts | 142 +---------- deploy/auth-proxy/src/index.ts | 10 +- deploy/auth-proxy/src/providers.ts | 31 --- deploy/auth-proxy/test/helpers.mjs | 22 +- .../test/provider-callback.test.mjs | 235 ------------------ deploy/terraform/modules/auth-proxy/main.tf | 5 - website/app/oauth/cli/done/DoneClient.tsx | 69 ----- website/app/oauth/cli/done/page.tsx | 37 ++- website/lib/browse/auth.ts | 80 +++++- 10 files changed, 107 insertions(+), 526 deletions(-) delete mode 100644 deploy/auth-proxy/test/provider-callback.test.mjs delete mode 100644 website/app/oauth/cli/done/DoneClient.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 818bdead9..382173182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ When modifying any component, check if other components need corresponding updat | Hard Gates | Execution phase | exit code enforcement in quality-gate.sh | orchestrator.ts | | Persistence | Context Preservation | Environment-detected via `isGitRepo()` (git or filesystem) | state-tools.ts, git-worktree.ts | | Providers | Memory Providers section | `plugin/schemas/providers/*.json`, `plugin/providers/*.md` | config.sh | -| Provider OAuth (auth + token store) | N/A — implementation | Git-provider (github/gitlab) auth brokered through **haikumethod.ai** (NOT .com) — the Cloud Function in `deploy/auth-proxy/` runs a brokered authorization-code handshake (NOT the provider's native RFC-8628 device flow): `/cli/start` mints a **`session_id`** + `verification_url` (→ website `/oauth/cli/authorize`), the website starts provider OAuth with `redirect_uri` = the **proxy's OWN** `GET /{provider}/callback` (the URL registered on the OAuth app — `auth.haikumethod.ai`, a DIFFERENT domain than the website `haikumethod.ai`), the proxy exchanges code→token **server-side** (client secret never leaves the proxy; token never touches the browser) and flips the session ready keyed by `state`, then 302s the browser to `/oauth/cli/done` (`?error=` on failure). The CLI polls `/cli/poll { session_id }` and reads the token bundle **spread at top level** of the `ready` response (matches `deploy/auth-proxy/src/cli.ts`). The legacy client-side `/cli/complete` POST (browser exchanges via `/{provider}/token` then posts the bundle) remains for purely client-side completers but is NOT the path the website uses. Token stored client-only in `~/.haiku/settings.json`. **Auth-when-needed (NOT auth-first):** `ensureProviderToken(provider)` (in `haiku_auth_login.ts`) returns a usable stored token or runs the handshake INLINE — the engine never tells the agent to "call haiku_auth_login first." Provider is the repo's (origin host). Tools: `haiku_auth_login` (explicit login), `haiku_auth_status`, `haiku_auth_logout`, `haiku_upload_proof` (upload proof to the delivery PR/MR over REST — auto-auths via `ensureProviderToken`; `proof_upload_auth_unavailable` only when auth genuinely can't be obtained). Token shape (`access_token`/`refresh_token`/`expires_at`/`scopes`/`account`/`host`) + read/write/clear live in `global-settings.ts`; provider detection (`parseGitRemote`/`providerFromHost`/`providerFromOrigin`/`readOriginRemoteUrl`) in `git-worktree.ts` | global-settings.ts, state/schemas/global-settings.ts, tools/orchestrator/haiku_auth_*.ts + haiku_upload_proof.ts, deploy/auth-proxy/ | +| Provider OAuth (auth + token store) | N/A — implementation | Git-provider (github/gitlab) auth brokered through **haikumethod.ai** (NOT .com) — the Cloud Function in `deploy/auth-proxy/` runs a brokered authorization-code handshake (NOT the provider's native RFC-8628 device flow): `/cli/start` mints a **`session_id`** + `verification_url`, the browse-site callback POSTs the exchanged token to `/cli/complete`, the CLI polls `/cli/poll { session_id }` and reads the token bundle **spread at top level** of the `ready` response (matches `deploy/auth-proxy/src/cli.ts`). Token stored client-only in `~/.haiku/settings.json`. **Auth-when-needed (NOT auth-first):** `ensureProviderToken(provider)` (in `haiku_auth_login.ts`) returns a usable stored token or runs the handshake INLINE — the engine never tells the agent to "call haiku_auth_login first." Provider is the repo's (origin host). Tools: `haiku_auth_login` (explicit login), `haiku_auth_status`, `haiku_auth_logout`, `haiku_upload_proof` (upload proof to the delivery PR/MR over REST — auto-auths via `ensureProviderToken`; `proof_upload_auth_unavailable` only when auth genuinely can't be obtained). Token shape (`access_token`/`refresh_token`/`expires_at`/`scopes`/`account`/`host`) + read/write/clear live in `global-settings.ts`; provider detection (`parseGitRemote`/`providerFromHost`/`providerFromOrigin`/`readOriginRemoteUrl`) in `git-worktree.ts` | global-settings.ts, state/schemas/global-settings.ts, tools/orchestrator/haiku_auth_*.ts + haiku_upload_proof.ts, deploy/auth-proxy/ | | PR/MR ops via stored token (Phase 4) | N/A — implementation | The engine drives PR/MR **create** + **mark-ready** over the provider REST API (`provider-rest.ts`, injectable fetch), **authenticating when needed**: `resolvePrRestContextEnsuringAuth` (origin host → provider → `ensureProviderToken`) obtains a token inline if none is stored — no "auth first." Any REST miss (incl. auth that couldn't be obtained — broker down / declined / headless) falls back to the `gh`/`glab` CLI (`openPullRequestCli`). The pre-open guard skips only when there's no CLI AND no recognized provider remote (`providerFromOrigin`). NO merge over REST — the human's merge is the approval signal (merge stays CLI/human-only). The two synchronous handler entry points (intent-main draft open in `haiku_intent_create`, repair PR in `haiku_repair`) stay CLI-only — a sync handler can't await REST/auth. REST contracts are doc-derived + mock-validated; CLI is the integration-proven path | provider-rest.ts, git-worktree.ts (`openPullRequest`/`markPullRequestReady` async + `resolvePrRestContextEnsuringAuth`), orchestrator/workflow/side-effects.ts | | Closing brief (BRIEF.md) | Quality Enforcement | Per-stage user-facing `BRIEF.md` written twice via the engine-owned `haiku_write_brief { body }` tool — `pre` (the plan) on first write, `post` (what shipped) on the closing rewrite at stage finish. The tool takes ONLY the body; the engine resolves intent (from branch), stage (from cursor), and the `phase:` frontmatter (file absent → pre, present → post — the same signal `stageOwesClosingBrief` gates on, so frontmatter can't drift from the cursor). Frontmatter via gray-matter. The `write_brief` cursor action fires from `stageOwesBrief` (pre, BRIEF absent) and `stageOwesClosingBrief` (post, BRIEF exists + `phase != post`); two reachable surfaces (non-autopilot user-gate, autopilot/merge in `haiku_run_next`). Opt out with `brief: false` on intent FM | tools/orchestrator/haiku_write_brief.ts, orchestrator/workflow/cursor.ts (`stageOwesBrief`/`stageOwesClosingBrief`), prompts/stage/review/write_brief/ | | Harness | N/A (implementation detail) | `--harness ` MCP arg or `HAIKU_HARNESS` env var; capability registry in `harness.ts`, instruction adaptation in `harness-instructions.ts` | harness.ts, harness-instructions.ts, orchestrator.ts, server.ts | diff --git a/deploy/auth-proxy/src/cli.ts b/deploy/auth-proxy/src/cli.ts index 24c9351c8..d89eccc9f 100644 --- a/deploy/auth-proxy/src/cli.ts +++ b/deploy/auth-proxy/src/cli.ts @@ -9,13 +9,9 @@ * 1. CLI POSTs /cli/start → we mint a session + state, store PENDING in * Firestore, and return a verification_url pointing at the browse site's * CLI authorize entry carrying the state. - * 2. The human approves; the provider redirects to the proxy's OWN - * GET /{provider}/callback (the URL registered on the OAuth app), which - * exchanges code→token server-side — the held client secret never leaves - * the proxy and the token never touches the browser — flips the session to - * ready keyed by state, and 302-redirects the browser to the browse-site - * done page. (The legacy /cli/complete POST remains for any purely - * client-side completer that exchanges via /{provider}/token first.) + * 2. The human approves; the browse site's existing /{provider}/callback + * exchanges code→token, then POSTs the bundle to /cli/complete keyed by + * state → the session flips to ready. * 3. CLI polls /cli/poll → the token is released ONCE, then consumed. * 4. /cli/refresh re-runs the provider exchange with grant_type=refresh_token. * @@ -26,7 +22,6 @@ import { randomBytes } from "node:crypto" import { authorizeEndpoint, - exchangeCode, isProvider, normalizeHost, type Provider, @@ -45,16 +40,10 @@ export interface HttpRequest { method?: string path?: string body?: unknown - /** parsed query string — present on the GET provider-callback route */ - query?: Record - headers?: Record } export interface HttpResponse { status(code: number): unknown json(body: unknown): unknown - /** present on the real functions-framework response; used by the callback redirect */ - redirect?(code: number, url: string): unknown - setHeader?(name: string, value: string): unknown } /** The browse-site origin that hosts the OAuth authorize entry + callback. */ @@ -68,22 +57,6 @@ function browseOrigin(): string { return process.env.BROWSE_ORIGIN || allowed[0] || "https://haikumethod.ai" } -/** - * The proxy's OWN public origin — the host the provider redirected to and the - * one registered on the OAuth app (e.g. https://auth.haikumethod.ai). The - * `redirect_uri` sent at the token exchange MUST be byte-identical to the one - * the browser used at authorize, so we reconstruct it from the inbound request - * (honoring the load balancer's X-Forwarded-Proto), with an env override for - * deployments that front the function differently. - */ -function selfOrigin(req: HttpRequest): string { - if (process.env.PROXY_PUBLIC_ORIGIN) return process.env.PROXY_PUBLIC_ORIGIN - const host = headerStr(req, "host") - if (!host) return browseOrigin() - const proto = headerStr(req, "x-forwarded-proto") || "https" - return `${proto}://${host}` -} - let store: SessionStore | null = null function sessions(): SessionStore { if (!store) store = new FirestoreSessionStore() @@ -121,34 +94,6 @@ function str(v: unknown): string | undefined { return typeof v === "string" && v.length ? v : undefined } -/** Read a request header case-insensitively, collapsing the array form. */ -function headerStr(req: HttpRequest, name: string): string | undefined { - const h = req.headers - if (!h) return undefined - const v = h[name] ?? h[name.toLowerCase()] - if (Array.isArray(v)) return str(v[0]) - return str(v) -} - -/** Issue a 302 to `url`, tolerating both the functions-framework res.redirect - * and a bare status+header shape (so unit tests can assert without Express). */ -function redirectTo(res: HttpResponse, url: string): void { - if (typeof res.redirect === "function") { - res.redirect(302, url) - return - } - if (typeof res.setHeader === "function") res.setHeader("Location", url) - res.status(302) - res.json({ redirect: url }) -} - -/** Build a browse-site done-page URL, carrying an optional error code. */ -function doneUrl(error?: string): string { - const u = new URL("/oauth/cli/done", browseOrigin()) - if (error) u.searchParams.set("error", error) - return u.toString() -} - function genId(bytes = 24): string { return randomBytes(bytes).toString("base64url") } @@ -314,87 +259,6 @@ async function refresh(req: HttpRequest, res: HttpResponse): Promise { res.json(bundle) } -/** - * GET /{provider}/callback — the URL registered on the OAuth app. The provider - * redirects the human's browser here after consent with `?code&state` (or - * `?error` on denial). We exchange the code for a token SERVER-side (secret - * stays here, token never reaches the browser), flip the matching session to - * ready, and redirect the browser to the browse-site done page. Every failure - * path redirects to the done page with an `?error` code — this is a top-level - * browser navigation, so a JSON body would just be shown as raw text. - */ -async function providerCallback( - provider: Provider, - req: HttpRequest, - res: HttpResponse, -): Promise { - const q = req.query || {} - const providerErr = str(q.error) - if (providerErr) { - redirectTo(res, doneUrl(providerErr)) - return - } - const state = str(q.state) - if (!state) { - redirectTo(res, doneUrl("missing_state")) - return - } - const session = await sessions().getByState(state) - if (!session) { - redirectTo(res, doneUrl("unknown_state")) - return - } - if (session.status !== "pending") { - redirectTo(res, doneUrl("already_completed")) - return - } - if (session.provider !== provider) { - redirectTo(res, doneUrl("provider_mismatch")) - return - } - const code = str(q.code) - if (!code) { - redirectTo(res, doneUrl("missing_code")) - return - } - - try { - const bundle = await exchangeCode({ - provider: session.provider, - host: session.host, - code, - // Byte-identical to the authorize redirect_uri the website sent. - redirectUri: `${selfOrigin(req)}/${provider}/callback`, - fetchImpl, - }) - await sessions().update(session.session_id, { - status: "ready", - token: bundle, - }) - redirectTo(res, doneUrl()) - } catch (err) { - const errCode = - err instanceof ProviderError ? err.code : "exchange_failed" - redirectTo(res, doneUrl(errCode)) - } -} - -/** - * Route a GET /github/callback or /gitlab/callback. Returns true when handled - * so the caller falls through to its own routing otherwise. Mirrors - * handleCliRoute's ownership shape. - */ -export async function handleProviderCallback( - req: HttpRequest, - res: HttpResponse, -): Promise { - const path = (req.path || "/").replace(/\/+$/, "").toLowerCase() || "/" - const m = path.match(/^\/(github|gitlab)\/callback$/) - if (!m) return false - await providerCallback(m[1] as Provider, req, res) - return true -} - /** * Route a /cli/* request. Returns true if the path was a CLI route (handled), * false otherwise so the caller can fall through to its own routing. diff --git a/deploy/auth-proxy/src/index.ts b/deploy/auth-proxy/src/index.ts index 1f985fca7..9c7660fd7 100644 --- a/deploy/auth-proxy/src/index.ts +++ b/deploy/auth-proxy/src/index.ts @@ -1,12 +1,10 @@ import type { HttpFunction } from "@google-cloud/functions-framework" -import { handleCliRoute, handleProviderCallback } from "./cli.js" +import { handleCliRoute } from "./cli.js" // OAuth code→token exchange for GitHub and GitLab. // Deployed as a GCP Cloud Function (v2). // // Endpoints: -// GET /github/callback — provider redirects here after consent; server-side -// GET /gitlab/callback exchange + flips the CLI session ready (Phase 2) // POST /github/token — exchange GitHub authorization code (browse site) // POST /gitlab/token — exchange GitLab authorization code (browse site) // POST /cli/start — begin a CLI device-flow handshake (Phase 2) @@ -59,12 +57,6 @@ export const authProxy: HttpFunction = async (req, res) => { return } - // Server-side provider callback is a GET — the provider redirects the - // human's browser here after consent. Handle it before the POST-only gate. - if (req.method === "GET" && (await handleProviderCallback(req, res))) { - return - } - if (req.method !== "POST") { res.status(405).json({ error: "method_not_allowed" }) return diff --git a/deploy/auth-proxy/src/providers.ts b/deploy/auth-proxy/src/providers.ts index 9f4c82040..ea721f51a 100644 --- a/deploy/auth-proxy/src/providers.ts +++ b/deploy/auth-proxy/src/providers.ts @@ -173,37 +173,6 @@ async function postToken( return parsed } -/** - * Exchange an authorization `code` for a token bundle, server-side, using the - * held client secret. The `redirectUri` MUST be byte-identical to the one the - * browser sent at the authorize step — GitLab rejects the exchange otherwise, - * and GitHub validates it when present. This is the server-side-callback path - * (provider redirects straight to the proxy's `/{provider}/callback`), distinct - * from the Phase-1 client-side `/{provider}/token` endpoints where the browser - * holds the code. - */ -export async function exchangeCode(args: { - provider: Provider - host: string - code: string - redirectUri: string - env?: NodeJS.ProcessEnv - fetchImpl?: typeof fetch -}): Promise { - const { provider, host, code, redirectUri, env, fetchImpl } = args - const { clientId, clientSecret } = resolveCredentials(provider, host, env) - const body: Record = { - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: redirectUri, - } - // GitHub infers the grant from the code; GitLab requires it explicitly. - if (provider === "gitlab") body.grant_type = "authorization_code" - const raw = await postToken(tokenEndpoint(provider, host), body, fetchImpl) - return shapeBundle(raw) -} - /** Re-run the token exchange with grant_type=refresh_token using the held secret. */ export async function refreshToken(args: { provider: Provider diff --git a/deploy/auth-proxy/test/helpers.mjs b/deploy/auth-proxy/test/helpers.mjs index 40caba240..75612a18b 100644 --- a/deploy/auth-proxy/test/helpers.mjs +++ b/deploy/auth-proxy/test/helpers.mjs @@ -46,8 +46,6 @@ export function makeRes() { return { statusCode: 200, body: undefined, - redirectedTo: undefined, - headers: {}, status(code) { this.statusCode = code return this @@ -56,25 +54,9 @@ export function makeRes() { this.body = payload return this }, - redirect(code, url) { - this.statusCode = code - this.redirectedTo = url - return this - }, - setHeader(name, value) { - this.headers[name] = value - if (name.toLowerCase() === "location") this.redirectedTo = value - return this - }, } } -export function makeReq({ - method = "POST", - path = "/", - body = {}, - query = undefined, - headers = undefined, -} = {}) { - return { method, path, body, query, headers } +export function makeReq({ method = "POST", path = "/", body = {} } = {}) { + return { method, path, body } } diff --git a/deploy/auth-proxy/test/provider-callback.test.mjs b/deploy/auth-proxy/test/provider-callback.test.mjs deleted file mode 100644 index eb068af40..000000000 --- a/deploy/auth-proxy/test/provider-callback.test.mjs +++ /dev/null @@ -1,235 +0,0 @@ -import assert from "node:assert/strict" -import { afterEach, beforeEach, describe, it } from "node:test" -import { - handleProviderCallback, - setFetchImpl, - setSessionStore, -} from "../dist/cli.js" -import { buildSession } from "../dist/sessions.js" -import { makeReq, makeRes, MemoryStore } from "./helpers.mjs" - -// The server-side provider callback (the URL registered on the OAuth app): -// the provider redirects the browser to GET /{provider}/callback, the proxy -// exchanges the code SERVER-side and flips the session ready, then 302s to the -// browse-site done page. Token never touches the browser. - -let mem -let lastFetch - -function seedPending({ provider = "github", host, state = "ST", sessionId = "S1" } = {}) { - const rec = buildSession({ - sessionId, - state, - provider, - host: host || (provider === "github" ? "github.com" : "gitlab.com"), - }) - mem.byId.set(rec.session_id, { ...rec }) - return rec -} - -/** A fetch stub for the upstream token endpoint that records the call and - * returns a JSON token body (or an error body to exercise the failure path). */ -function stubFetch(responseBody) { - return async (url, opts) => { - lastFetch = { url, body: JSON.parse(opts.body) } - return { - ok: true, - status: 200, - text: async () => JSON.stringify(responseBody), - } - } -} - -const callbackReq = (provider, query, headers) => - makeReq({ - method: "GET", - path: `/${provider}/callback`, - query, - headers: headers || { - host: "auth.haikumethod.ai", - "x-forwarded-proto": "https", - }, - }) - -beforeEach(() => { - mem = new MemoryStore() - setSessionStore(mem) - lastFetch = undefined - process.env.HAIKU_GITHUB_OAUTH_CLIENT_ID = "gh_id" - process.env.HAIKU_GITHUB_OAUTH_CLIENT_SECRET = "gh_secret" - process.env.HAIKU_GITLAB_OAUTH_CLIENT_ID = "gl_id" - process.env.HAIKU_GITLAB_OAUTH_CLIENT_SECRET = "gl_secret" - delete process.env.PROXY_PUBLIC_ORIGIN - delete process.env.ALLOWED_ORIGIN - delete process.env.BROWSE_ORIGIN -}) -afterEach(() => { - setSessionStore(null) - setFetchImpl(undefined) -}) - -describe("GET /{provider}/callback — routing", () => { - it("ignores non-callback paths (returns false)", async () => { - const res = makeRes() - const owned = await handleProviderCallback( - makeReq({ method: "GET", path: "/github/token" }), - res, - ) - assert.equal(owned, false) - }) - - it("owns /github/callback and /gitlab/callback", async () => { - for (const p of ["github", "gitlab"]) { - seedPending({ provider: p, state: `s-${p}`, sessionId: `id-${p}` }) - setFetchImpl(stubFetch({ access_token: "tok" })) - const res = makeRes() - const owned = await handleProviderCallback( - callbackReq(p, { code: "c", state: `s-${p}` }), - res, - ) - assert.equal(owned, true) - } - }) -}) - -describe("GET /github/callback — happy path", () => { - it("exchanges server-side, flips session ready, redirects to done (no error)", async () => { - seedPending({ provider: "github", state: "ST", sessionId: "S1" }) - setFetchImpl(stubFetch({ access_token: "gho_xyz", scope: "repo" })) - const res = makeRes() - - await handleProviderCallback( - callbackReq("github", { code: "abc", state: "ST" }), - res, - ) - - assert.equal(res.statusCode, 302) - assert.equal(res.redirectedTo, "https://haikumethod.ai/oauth/cli/done") - - const stored = await mem.getById("S1") - assert.equal(stored.status, "ready") - assert.equal(stored.token.access_token, "gho_xyz") - }) - - it("sends a redirect_uri byte-identical to the proxy's own callback URL", async () => { - seedPending({ provider: "github", state: "ST", sessionId: "S1" }) - setFetchImpl(stubFetch({ access_token: "gho_xyz" })) - await handleProviderCallback( - callbackReq("github", { code: "abc", state: "ST" }), - makeRes(), - ) - assert.equal( - lastFetch.body.redirect_uri, - "https://auth.haikumethod.ai/github/callback", - ) - assert.equal(lastFetch.body.client_secret, "gh_secret") - }) - - it("honors PROXY_PUBLIC_ORIGIN override for the redirect_uri", async () => { - process.env.PROXY_PUBLIC_ORIGIN = "https://auth.example.com" - seedPending({ provider: "github", state: "ST", sessionId: "S1" }) - setFetchImpl(stubFetch({ access_token: "gho_xyz" })) - await handleProviderCallback( - callbackReq("github", { code: "abc", state: "ST" }), - makeRes(), - ) - assert.equal( - lastFetch.body.redirect_uri, - "https://auth.example.com/github/callback", - ) - }) -}) - -describe("GET /gitlab/callback — happy path", () => { - it("sends grant_type + redirect_uri and respects the session host", async () => { - seedPending({ - provider: "gitlab", - host: "git.acme.com", - state: "ST", - sessionId: "S1", - }) - setFetchImpl(stubFetch({ access_token: "glpat", refresh_token: "r1" })) - const res = makeRes() - await handleProviderCallback( - callbackReq("gitlab", { code: "abc", state: "ST" }), - res, - ) - assert.equal(lastFetch.url, "https://git.acme.com/oauth/token") - assert.equal(lastFetch.body.grant_type, "authorization_code") - assert.equal( - lastFetch.body.redirect_uri, - "https://auth.haikumethod.ai/gitlab/callback", - ) - const stored = await mem.getById("S1") - assert.equal(stored.status, "ready") - assert.equal(stored.token.refresh_token, "r1") - }) -}) - -describe("GET /{provider}/callback — failure redirects (never JSON)", () => { - it("provider error param → done?error=, session untouched", async () => { - seedPending({ state: "ST", sessionId: "S1" }) - const res = makeRes() - await handleProviderCallback( - callbackReq("github", { error: "access_denied", state: "ST" }), - res, - ) - assert.equal(res.redirectedTo, "https://haikumethod.ai/oauth/cli/done?error=access_denied") - assert.equal((await mem.getById("S1")).status, "pending") - }) - - it("missing state → done?error=missing_state", async () => { - const res = makeRes() - await handleProviderCallback(callbackReq("github", { code: "c" }), res) - assert.match(res.redirectedTo, /error=missing_state$/) - }) - - it("unknown state → done?error=unknown_state", async () => { - const res = makeRes() - await handleProviderCallback( - callbackReq("github", { code: "c", state: "nope" }), - res, - ) - assert.match(res.redirectedTo, /error=unknown_state$/) - }) - - it("already-completed session → done?error=already_completed", async () => { - const rec = seedPending({ state: "ST", sessionId: "S1" }) - await mem.update(rec.session_id, { status: "ready", token: { access_token: "t" } }) - const res = makeRes() - await handleProviderCallback( - callbackReq("github", { code: "c", state: "ST" }), - res, - ) - assert.match(res.redirectedTo, /error=already_completed$/) - }) - - it("provider mismatch → done?error=provider_mismatch", async () => { - seedPending({ provider: "github", state: "ST", sessionId: "S1" }) - const res = makeRes() - await handleProviderCallback( - callbackReq("gitlab", { code: "c", state: "ST" }), - res, - ) - assert.match(res.redirectedTo, /error=provider_mismatch$/) - }) - - it("missing code → done?error=missing_code", async () => { - seedPending({ state: "ST", sessionId: "S1" }) - const res = makeRes() - await handleProviderCallback(callbackReq("github", { state: "ST" }), res) - assert.match(res.redirectedTo, /error=missing_code$/) - }) - - it("upstream exchange error → done?error=provider_, session stays pending", async () => { - seedPending({ state: "ST", sessionId: "S1" }) - setFetchImpl(stubFetch({ error: "bad_verification_code" })) - const res = makeRes() - await handleProviderCallback( - callbackReq("github", { code: "bad", state: "ST" }), - res, - ) - assert.match(res.redirectedTo, /error=provider_bad_verification_code$/) - assert.equal((await mem.getById("S1")).status, "pending") - }) -}) diff --git a/deploy/terraform/modules/auth-proxy/main.tf b/deploy/terraform/modules/auth-proxy/main.tf index 1d577483f..ce2b626ae 100644 --- a/deploy/terraform/modules/auth-proxy/main.tf +++ b/deploy/terraform/modules/auth-proxy/main.tf @@ -110,11 +110,6 @@ resource "google_cloudfunctions2_function" "auth_proxy" { environment_variables = { ALLOWED_ORIGIN = var.allowed_origin - # The proxy's own public origin — the host the provider redirects to and - # the URL registered on the OAuth app. The server-side /{provider}/callback - # sends this as redirect_uri at the token exchange; it MUST be byte-identical - # to the one the website sent at authorize (NEXT_PUBLIC_HAIKU_AUTH_PROXY_URL). - PROXY_PUBLIC_ORIGIN = "https://auth.${var.domain}" } secret_environment_variables { diff --git a/website/app/oauth/cli/done/DoneClient.tsx b/website/app/oauth/cli/done/DoneClient.tsx deleted file mode 100644 index bdba51221..000000000 --- a/website/app/oauth/cli/done/DoneClient.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" - -// Terminal page of the CLI OAuth flow. On success the proxy has already -// exchanged the code and flipped the broker session to ready — the polling CLI -// will pick the token up, so there's nothing to do but tell the human to close -// the tab. On failure the proxy redirects here with `?error=` so the -// human (and the waiting terminal) learn why instead of staring at a 404. -const ERROR_COPY: Record = { - access_denied: "You declined the authorization.", - missing_state: "The sign-in link was missing its session token.", - unknown_state: "That sign-in session expired or was already used.", - already_completed: "That sign-in session was already completed.", - provider_mismatch: "The link's provider didn't match the session.", - missing_code: "The provider didn't return an authorization code.", - exchange_failed: "Exchanging the authorization code for a token failed.", -} - -export function DoneClient() { - const [error, setError] = useState(null) - - useEffect(() => { - const code = new URLSearchParams(window.location.search).get("error") - if (code) setError(code) - }, []) - - if (error) { - return ( -
-

Sign-in didn't complete

-

- {ERROR_COPY[error] || `Authorization failed (${error}).`} -

-

- Return to your terminal and run haiku_auth_login again. -

-
- ) - } - - return ( -
-
- - Success - - -
-

You're signed in

-

- The H·AI·K·U CLI has your authorization. You can close this tab and - return to your terminal. -

-
- ) -} diff --git a/website/app/oauth/cli/done/page.tsx b/website/app/oauth/cli/done/page.tsx index 68d98cc0d..f408eb619 100644 --- a/website/app/oauth/cli/done/page.tsx +++ b/website/app/oauth/cli/done/page.tsx @@ -1,9 +1,32 @@ -import { DoneClient } from "./DoneClient" - -// Terminal page of the CLI OAuth flow. The proxy's server-side callback has -// already exchanged the code and flipped the broker session to ready (or -// redirected here with ?error on failure). Client-only so it can read the -// error param under static export. +// Terminal page of the CLI OAuth flow. The callback has already handed the +// token to the broker's /cli/complete; the polling CLI will pick it up. Nothing +// to do here but tell the human they can close the tab. export default function CliDonePage() { - return + return ( +
+
+ + Success + + +
+

You're signed in

+

+ The H·AI·K·U CLI has your authorization. You can close this tab and + return to your terminal. +

+
+ ) } diff --git a/website/lib/browse/auth.ts b/website/lib/browse/auth.ts index 3111f72ec..973e10e2e 100644 --- a/website/lib/browse/auth.ts +++ b/website/lib/browse/auth.ts @@ -89,19 +89,25 @@ export function startOAuthFlow(config: AuthConfig, returnPath: string): void { /** Initiate the CLI OAuth flow — the browse-site half of the haikumethod.ai * broker handshake. The broker's `/cli/start` mints a `session_id` + a `state` - * and points the CLI's verification_url here. Unlike the browse flow, the CLI - * flow uses the SERVER-SIDE callback: the redirect_uri is the proxy's OWN - * `/{provider}/callback` (the URL registered on the OAuth app), so the provider - * hands the code straight to the proxy, which exchanges it server-side and - * completes the CLI session. The browser never receives the token — there is - * nothing to store here, and no website callback page is involved. */ + * and points the CLI's verification_url here. We run the SAME provider OAuth as + * the browse flow (reusing the registered `/auth/{provider}/callback/` redirect + * URI) but with the broker's `state` round-tripped through the provider, plus a + * marker so the callback POSTs the exchanged token to the broker's + * `/cli/complete` instead of only storing it for the SPA. */ export function startCliOAuthFlow( config: AuthConfig, brokerState: string, ): void { - // redirect_uri targets the PROXY, not this site — it must match the OAuth - // app's registered callback (auth.haikumethod.ai/{provider}/callback). - const redirectUri = `${AUTH_PROXY_URL}/${config.provider}/callback` + // The broker state IS the OAuth state — the callback verifies it round-trips + // and forwards it to /cli/complete so the broker matches the session. + sessionStorage.setItem(`${STORAGE_PREFIX}oauth-state`, brokerState) + sessionStorage.setItem(`${STORAGE_PREFIX}oauth-return`, "/oauth/cli/done/") + sessionStorage.setItem(`${STORAGE_PREFIX}oauth-host`, config.host) + sessionStorage.setItem(`${STORAGE_PREFIX}oauth-provider`, config.provider) + // Marker: the callback should COMPLETE the CLI session, not just store. + sessionStorage.setItem(`${STORAGE_PREFIX}cli-complete`, brokerState) + + const redirectUri = `${window.location.origin}/auth/${config.provider}/callback/` if (config.provider === "github") { const params = new URLSearchParams({ client_id: config.clientId, @@ -126,6 +132,39 @@ export function startCliOAuthFlow( } } +/** POST an exchanged token bundle to the broker's `/cli/complete`, keyed by the + * CLI session's `state`. Mirrors the broker contract in + * `deploy/auth-proxy/src/cli.ts` `complete()`. */ +async function completeCliSession( + state: string, + host: string, + data: Record, +): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch(`${AUTH_PROXY_URL}/cli/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + state, + host, + access_token: data.access_token, + ...(data.refresh_token ? { refresh_token: data.refresh_token } : {}), + ...(data.expires_at !== undefined + ? { expires_at: data.expires_at } + : {}), + ...(data.scopes !== undefined ? { scopes: data.scopes } : {}), + ...(data.account ? { account: data.account } : {}), + }), + }) + if (!res.ok) { + return { ok: false, error: `broker /cli/complete returned ${res.status}` } + } + return { ok: true } + } catch (e) { + return { ok: false, error: (e as Error).message } + } +} + /** Handle the OAuth callback — call this on the callback page */ export async function handleOAuthCallback(provider: string): Promise<{ success: boolean @@ -139,12 +178,16 @@ export async function handleOAuthCallback(provider: string): Promise<{ const host = sessionStorage.getItem(`${STORAGE_PREFIX}oauth-host`) || "" const savedProvider = sessionStorage.getItem(`${STORAGE_PREFIX}oauth-provider`) || "" + // CLI flow marker (the broker state) — present when this callback should + // complete a broker CLI session rather than only store the token. + const cliState = sessionStorage.getItem(`${STORAGE_PREFIX}cli-complete`) // Clean up session storage sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-state`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-return`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-host`) sessionStorage.removeItem(`${STORAGE_PREFIX}oauth-provider`) + sessionStorage.removeItem(`${STORAGE_PREFIX}cli-complete`) // Verify provider matches if (provider !== savedProvider) { @@ -199,7 +242,24 @@ export async function handleOAuthCallback(provider: string): Promise<{ const data = await res.json() if (data.access_token) { - setToken(host, data.access_token) + if (cliState) { + // CLI flow: hand the token to the broker so the polling CLI + // receives it. Do NOT persist it to the SPA's localStorage — the + // user opened this link from their terminal, not the browse UI, so + // silently logging their browser in would be a surprising side + // effect. The broker is the only path that matters here. + const done = await completeCliSession(cliState, host, data) + if (!done.ok) { + return { + success: false, + host, + returnPath, + error: `Authenticated, but handing the token to the CLI failed: ${done.error}`, + } + } + } else { + setToken(host, data.access_token) + } return { success: true, host, returnPath } }