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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions deploy/auth-proxy/.gcloudignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
Comment thread
jwaldrip marked this conversation as resolved.
Comment thread
jwaldrip marked this conversation as resolved.
.tmp
1 change: 1 addition & 0 deletions deploy/auth-proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"gcp-build": "tsc",
Comment thread
jwaldrip marked this conversation as resolved.
"start": "functions-framework --target=authProxy",
"test": "tsc && node --test test/*.test.mjs"
},
Expand Down
9 changes: 8 additions & 1 deletion deploy/auth-proxy/src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,15 @@ export class FirestoreSessionStore implements SessionStore {
private async client(): Promise<Firestore> {
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 }),
Comment thread
jwaldrip marked this conversation as resolved.
)
}
return this.dbPromise
Expand Down
88 changes: 88 additions & 0 deletions website/app/oauth/cli/authorize/AuthorizeClient.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)

useEffect(() => {
const q = new URLSearchParams(window.location.search)
const host = q.get("host") || ""
const state = q.get("state") || ""
const provider = q.get("provider") || ""

Comment thread
jwaldrip marked this conversation as resolved.
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(
Comment thread
jwaldrip marked this conversation as resolved.
`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 (
<div className="mx-auto max-w-md px-4 py-20 text-center">
{error ? (
<>
<h1 className="mb-2 text-xl font-bold">Couldn't start sign-in</h1>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<p className="mt-4 text-sm text-stone-500">
Return to your terminal and try <code>haiku_auth_login</code> again.
</p>
</>
) : (
<>
<div className="mb-4">
<svg
className="mx-auto h-12 w-12 animate-spin text-teal-500"
fill="none"
viewBox="0 0 24 24"
role="img"
aria-label="Loading"
>
<title>Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<h1 className="text-xl font-bold">Redirecting to sign in…</h1>
<p className="mt-2 text-sm text-stone-500">
Authorizing the H·AI·K·U CLI for your Git provider.
</p>
</>
)}
</div>
)
}
10 changes: 10 additions & 0 deletions website/app/oauth/cli/authorize/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <AuthorizeClient />
}
32 changes: 32 additions & 0 deletions website/app/oauth/cli/done/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-md px-4 py-20 text-center">
<div className="mb-4 text-green-500">
<svg
className="mx-auto h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="Success"
>
<title>Success</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 className="mb-2 text-xl font-bold">You're signed in</h1>
<p className="text-stone-500">
The H·AI·K·U CLI has your authorization. You can close this tab and
return to your terminal.
</p>
</div>
)
}
101 changes: 100 additions & 1 deletion website/lib/browse/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jwaldrip marked this conversation as resolved.
// already uses `repo` in both flows — full read/write — so no delta there.)
const params = new URLSearchParams({
client_id: config.clientId,
Comment thread
jwaldrip marked this conversation as resolved.
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<string, unknown>,
): 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
Expand All @@ -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) {
Expand Down Expand Up @@ -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 }
}

Expand Down
Loading