Skip to content

fix(auth): make the CLI OAuth flow work — broker Firestore bug + missing website authorize page - #379

Closed
jwaldrip wants to merge 4 commits into
mainfrom
jw/oauth-cli-flow
Closed

jwaldrip wants to merge 4 commits into
mainfrom
jw/oauth-cli-flow

Conversation

@jwaldrip

Copy link
Copy Markdown
Contributor

What

Makes the provider-OAuth CLI login work end-to-end. It was non-functional — two real gaps, both surfaced only by driving the live broker (the in-memory test mock hid both):

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 polling CLI — every ready poll 500'd. The in-memory test store accepts undefined, which is exactly why tests passed while the live flow was broken.

Fix: initialize Firestore with ignoreUndefinedProperties: true so the optional-field contract (token/account/host/refresh_token) holds instead of throwing.

Verified live: start → complete → poll now returns the token; the second poll returns expired (one-time release intact). This fix is already deployed (gcloud redeploy, revision 00012); this PR puts the source in git so deploy-auth-proxy.yml (terraform) reconciles it.

2. The website half of the handshake was never built

The broker's /cli/start points the CLI's verification_url at haikumethod.ai/oauth/cli/authorize — which 404'd. No such page existed. 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.tsstartCliOAuthFlow + completeCliSession, and a handleOAuthCallback hook that POSTs the exchanged token to the broker's /cli/complete when the callback is a CLI flow.
  • app/oauth/cli/done — "return to your terminal" page.

deploy-website.yml ships these on merge with the NEXT_PUBLIC_HAIKU_GITHUB_OAUTH_CLIENT_ID repo var baked at build (same var the live /browse/ page already uses).

Proof

  • Broker start → complete → poll-ready → poll-expired round-trip against the live Cloud Function — token released exactly once.
  • The real ensureProviderToken runs the handshake and stores the token in ~/.haiku/settings.json against the live broker.
  • next build clean; /oauth/cli/authorize + /oauth/cli/done present in the static export.
  • auth-proxy 12/12 tests pass; auth.ts biome-clean.

The only manual step in the flow is the GitHub consent click — which the new authorize page now serves (it 404'd before).

🤖 Generated with Claude Code

…ug + the missing website authorize page

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 <noreply@anthropic.com>
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jwaldrip's task in 3m 52s —— View job


Code Review — PR #379

  • Gather context and read changed files
  • Review auth-proxy changes (Firestore fix)
  • Review website OAuth pages
  • Review auth library changes
  • Check for security issues
  • Post findings

Done. Review posted — 4 inline comments and a top-level summary. No blocking issues found. Three things worth checking before merge: confirm dist/ exclusion in .gcloudignore doesn't break the deploy pipeline, document the GitLab scope escalation (read_api api vs read_api), and decide if the browser localStorage side effect of CLI auth is intentional.

Comment thread deploy/auth-proxy/src/sessions.ts
Comment thread website/lib/browse/auth.ts
Comment thread website/lib/browse/auth.ts Outdated
Comment thread deploy/auth-proxy/.gcloudignore
Comment thread website/app/oauth/cli/authorize/AuthorizeClient.tsx
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review Summary

The two-part fix is sound and the root-cause analysis in the PR description is accurate. The Firestore crash was a real gap that in-memory tests legitimately couldn't surface, and the missing authorize page was a genuine 404 that the broker's handshake depended on. Both fixes are minimal and correct.

Firestore fix (sessions.ts) — ignoreUndefinedProperties: true is the right solution for the optional-field contract. The alternative (using FieldValue.delete()) would be more semantically precise but requires changing the update() interface; not worth it here. I left a note on the inline. One-time release and consumed-check logic is correct.

Website authorize page — clean implementation that reuses the existing callback infrastructure. The startCliOAuthFlow / completeCliSession split is logical. State round-trip for CSRF protection is correctly handled: broker state → OAuth state → sessionStorage → callback verification. The session storage cleanup happens before any early-return, so there's no stale-state risk on retry.

Things worth a look before merge:

  1. .gcloudignore excluding dist/ (inline comment on the file) — if deploy-auth-proxy.yml doesn't explicitly run npm run build before uploading to Cloud Storage, manual gcloud redeploys would produce a broken function. Low risk if CI always builds first; worth confirming.

  2. GitLab CLI scope: "read_api api" (inline comment on auth.ts:121) — the regular browser flow requests "read_api"; the CLI flow escalates to "read_api api" (full API write access). If the CLI needs write access, this is correct — just add a comment so it doesn't look like a typo.

  3. Browser localStorage side effect (inline at auth.ts:241) — the CLI auth flow silently stores a token in the browser's localStorage via setToken(). Not a security issue; a user who clicks the verification URL from their terminal ends up logged into the browse site too. Possibly intentional.

No blocking issues. The security posture is solid: CORS on the broker, cryptographically strong state (24 bytes randomBytes), one-time token release, CSRF verification in the callback.

Comment thread deploy/auth-proxy/.gcloudignore
… login, scoped GitLab CLI token

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 <noreply@anthropic.com>
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jwaldrip's task in 2m 24s —— View job


Re-review — PR #379 (after follow-up commit ee05556)

  • Read full PR diff
  • Read changed files for context
  • Review Firestore fix (sessions.ts)
  • Review website OAuth pages
  • Review auth library changes (auth.ts)
  • Check security posture
  • Post findings

All four concerns from the first review are cleanly addressed. The follow-up commit (ee05556) resolves each one:

Concern Resolution
.gcloudignore excludes dist/ — deploy breakage risk "gcp-build": "tsc" added to package.json. GCF auto-compiles pre-deploy; excluding dist/ is now intentional and correct.
GitLab CLI scope "read_api api" — redundant, looked like a typo Changed to "api" alone (superset). Comment explains the write-access rationale.
CLI flow stored token in browser localStorage — surprising side effect setToken() is now skipped for CLI flows. Browser is not logged in.
provider URL param read but not validated Mismatch guard added in AuthorizeClient.tsx — catches broker misconfiguration before redirect.

No new issues found. The implementation is clean:

  • Firestore fix (sessions.ts): ignoreUndefinedProperties: true is the right solution. One-time release and consumed-check logic is correct.
  • completeCliSession (auth.ts): Correctly serializes only the fields the broker contract expects, with conditional spreading for optional fields. Error propagated to the user, not silently swallowed.
  • handleOAuthCallback: sessionStorage cleanup happens before the completeCliSession call — this is intentional since the OAuth code is single-use anyway. A retry requires restarting the flow, which is correct behavior.
  • Security posture: CSRF protection intact (broker state IS the OAuth state; verified round-trip). No open redirect risk — getAuthConfig(host) is the allowlist gate. sessionStorage (not localStorage) for mid-flow OAuth state, tab-scoped and cleared after use.

Ready to merge.

Comment thread deploy/auth-proxy/package.json
Comment thread website/lib/browse/auth.ts
Comment thread website/lib/browse/auth.ts
Comment thread website/app/oauth/cli/authorize/AuthorizeClient.tsx
…stered domain

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=<code>` (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 <noreply@anthropic.com>
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jwaldrip's task in 3m 52s —— View job


Re-review — PR #379 (after commit a5fa12a)

  • Gather context
  • Read full PR diff
  • Review latest commit changes
  • Check for new issues
  • Post findings

What changed and why

a5fa12a is a necessary architectural correction: the previous commit (ee05556) was sending redirect_uri = haikumethod.ai/auth/{provider}/callback/ to GitHub at the authorize step, but the GitHub OAuth App's registered callback URL is on the proxy (auth.haikumethod.ai). GitHub validates redirect_uri against the registered callback at authorize time, so every consent attempt would have been rejected before the user got past the consent screen.

The fix switches the CLI flow to a server-side callback model: the redirect_uri now points at the proxy's own /{provider}/callback, the provider hands the code straight to the proxy, the proxy exchanges it using the held client secret, flips the session to ready, and 302s the browser to the done page. The token never touches the browser. This is also the more secure shape — the previous client-side model had the browser holding the code and POSTing the bundle.

Review findings

No blocking issues. The implementation is clean and the security posture is solid.

One stale comment worth fixing before merge (not blocking): the start() function body at cli.ts:188–192 still reads:

"after callback POSTs the captured token to /cli/complete keyed by this state"

That was accurate for the old client-side flow. The new flow never touches /cli/complete from the website — the provider redirects straight to the proxy's /{provider}/callback which handles the exchange server-side. The file-header comment (cli.ts:9–13) was correctly updated in this commit, but the start() body comment didn't follow. It's purely a doc issue; no code is affected.

selfOrigin() fallback (cli.ts:79–85): when PROXY_PUBLIC_ORIGIN is unset AND the host header is absent, it falls back to browseOrigin() — the website domain, not the proxy. That would produce a wrong redirect_uri. In practice this path is unreachable: terraform always sets PROXY_PUBLIC_ORIGIN = "https://auth.${var.domain}" and GCF always provides the host header. No action required, just worth knowing.

Everything that looks good

  • CSRF protection: intact — broker state IS the OAuth state, round-trips provider→proxy, validated at providerCallback via sessions().getByState(state).
  • Provider mismatch guard: session.provider !== provider check in providerCallback prevents a crafted cross-provider replay.
  • Already-completed guard: session.status !== "pending" check before exchange; no replay on a consumed session.
  • Error handling: every failure path in providerCallback redirects to /oauth/cli/done?error=<code>. Never returns JSON on a browser navigation.
  • exchangeCode (providers.ts): clean minimal function. Uses the existing resolveCredentials, postToken, shapeBundle path. GitLab's grant_type: "authorization_code" requirement handled correctly.
  • DoneClient.tsx: maps all 7 error codes to human-readable copy; fallback for unknown codes is sensible.
  • Website cleanup: completeCliSession removed, CLI branch in handleOAuthCallback removed — handleOAuthCallback is now pure browse-only, setToken runs unconditionally which is correct.
  • Tests: 13 new tests covering happy paths (GitHub + GitLab), byte-identical redirect_uri, PROXY_PUBLIC_ORIGIN override, routing logic, and all 7 error-redirect paths. Session stays pending on failure is explicitly asserted. 25/25 pass.

Ready to merge.

@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jwaldrip's task in 3m 14s —— View job


Re-review — PR #379 (after revert e588301)

  • Gather context
  • Read full PR diff
  • Review changed files
  • Check current branch state (revert applied)
  • Post findings

What happened

e588301 reverts the server-side callback architecture from a5fa12a. The previous review of a5fa12a assumed the GitHub OAuth App's registered callback was on the proxy (auth.haikumethod.ai), but the existing browse flow proves otherwise — startOAuthFlow has always sent redirect_uri = haikumethod.ai/auth/{provider}/callback/ and that flow works live. The revert is correct: the client-side approach where the website callback exchanges the code and POSTs the token bundle to /cli/complete is the right architecture for the registered OAuth app, and no redirect_uri mismatch exists.

Net state of the PR (vs origin/main) is now exactly what ee05556 left, plus the Firestore and .gcloudignore fixes from the earlier commits.

What the current flow does

  1. CLI calls /cli/start → gets verification_url = haikumethod.ai/oauth/cli/authorize?provider=…&host=…&state=<broker_state>
  2. Browser opens /oauth/cli/authorizestartCliOAuthFlow redirects to provider with redirect_uri = haikumethod.ai/auth/{provider}/callback/
  3. Provider redirects to the callback page → handleOAuthCallback calls auth.haikumethod.ai/{provider}/token to exchange the code (auth proxy holds the client secret — token never exposed in the browser)
  4. Since sessionStorage has the cli-complete marker, completeCliSession POSTs the bundle to auth.haikumethod.ai/cli/complete instead of storing it in localStorage
  5. CLI polls /cli/poll → gets token, session consumed

Security posture (clean)

  • CORS: index.ts allows https://haikumethod.ai as the only origin (ALLOWED_ORIGIN env var, set by terraform). The /cli/complete route goes through the same middleware — cross-origin POST from the callback page is permitted. ✅
  • CSRF: broker state IS the OAuth state, verified round-trip by state !== savedState check. ✅
  • Client secret: never touches the browser — exchange happens at the auth proxy. ✅
  • One-time release: poll() marks consumed then deletes. already_completed guard on /cli/complete. ✅
  • Browser not silently logged in: setToken() is skipped for CLI flows. ✅
  • Provider mismatch guard: AuthorizeClient.tsx catches broker misconfiguration before redirect. ✅

One gap worth tracking (not blocking)

handleGitLab in index.ts returns only { access_token } — it drops refresh_token, expires_at, and scopes from the GitLab token response. So the CLI's stored GitLab token (~/.haiku/settings.json) won't have a refresh token, and the CLI can't auto-rotate it when it expires. This is a pre-existing limitation of the browse-site exchange endpoint — not introduced by this PR, and the browse flow has the same gap. Worth a follow-up issue but doesn't block this fix.

Everything from previous reviews

All four concerns from the first review and the re-review are still correctly addressed in ee05556. Nothing regressed by the revert.

Ready to merge.

@jwaldrip jwaldrip closed this Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant