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/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/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..4e02a10b6 --- /dev/null +++ b/website/app/oauth/cli/authorize/AuthorizeClient.tsx @@ -0,0 +1,88 @@ +"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 + } + // 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) + }, []) + + 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..973e10e2e 100644 --- a/website/lib/browse/auth.ts +++ b/website/lib/browse/auth.ts @@ -87,6 +87,84 @@ 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 { + // 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: "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 +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) { @@ -160,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 } }