From aa4ded2b16ed1028dcf5679b3e1efc6b1131645a Mon Sep 17 00:00:00 2001 From: dallatt-cursor Date: Sat, 29 Aug 2026 19:26:17 +1000 Subject: [PATCH] feat(plugin): add Fireworks AI native authentication Adds a built-in auth plugin for the fireworks-ai provider so opencode auth login (CLI) and /connect (TUI) offer a browser-based 'Log in with Fireworks Connect' flow: PKCE localhost-callback sign-in against the Fireworks SSO, then gateway key minting, stored as a native credential. An API-key paste method remains as fallback. The sign-in protocol is ported from the MIT-licensed fw-ai/fireconnect CLI (Cognito PKCE flow + gRPC-web gateway calls). Includes 23 unit tests covering the helpers and the full flow with dependency-injected fetch. --- packages/opencode/src/cli/cmd/providers.ts | 1 + packages/opencode/src/plugin/fireworks.ts | 398 ++++++++++++++++++ packages/opencode/src/plugin/index.ts | 2 + .../opencode/test/plugin/fireworks.test.ts | 390 +++++++++++++++++ 4 files changed, 791 insertions(+) create mode 100644 packages/opencode/src/plugin/fireworks.ts create mode 100644 packages/opencode/test/plugin/fireworks.test.ts diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 3775123d83bd..cd78f5b8dbb0 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -376,6 +376,7 @@ export const ProvidersLoginCommand = effectCmd({ anthropic: 4, openrouter: 5, vercel: 6, + "fireworks-ai": 7, } const pluginProviders = resolvePluginProviders({ hooks, diff --git a/packages/opencode/src/plugin/fireworks.ts b/packages/opencode/src/plugin/fireworks.ts new file mode 100644 index 000000000000..da3c003642f4 --- /dev/null +++ b/packages/opencode/src/plugin/fireworks.ts @@ -0,0 +1,398 @@ +import type { AuthOAuthResult, Hooks, PluginInput } from "@opencode-ai/plugin" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" +import { createServer } from "http" +import os from "os" +import open from "open" + +const PROVIDER = "fireworks-ai" +const CLIENT_ID = "sueas7prsfrdp16nantbeqcjv" +const COGNITO_URL = "https://fireworks.auth.us-west-2.amazoncognito.com/oauth2" +const GATEWAY_URL = "https://gateway.fireworks.ai/web/gateway.Gateway" +const VERIFY_URL = "https://api.fireworks.ai/verifyApiKey" +const OAUTH_PORT = 18000 +const REDIRECT_URI = `http://localhost:${OAUTH_PORT}` +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000 + +type FetchFn = typeof fetch + +export interface FireworksAuthDeps { + fetchFn?: FetchFn + openUrl?: (url: string) => Promise + hostname?: string + timeoutMs?: number +} + +function base64url(bytes: Uint8Array | ArrayBuffer) { + return Buffer.from(bytes as Uint8Array).toString("base64url") +} + +function text(value: string) { + return new TextEncoder().encode(value) +} + +function concat(...parts: Uint8Array[]) { + const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out +} + +export async function generatePkce() { + const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))) + const challenge = base64url(await crypto.subtle.digest("SHA-256", text(verifier))) + return { verifier, challenge } +} + +export function generateState() { + return base64url(crypto.getRandomValues(new Uint8Array(16))) +} + +export function buildAuthorizeUrl(state: string, challenge: string) { + const params = new URLSearchParams({ + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: REDIRECT_URI, + state, + code_challenge_method: "S256", + code_challenge: challenge, + }) + return `${COGNITO_URL}/authorize?${params}` +} + +export function buildTokenRequestBody(code: string, verifier: string) { + return new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }).toString() +} + +export function extractEmail(token: string) { + const parts = token.split(".") + if (parts.length !== 3) return undefined + try { + const payload: unknown = JSON.parse(Buffer.from(parts[1], "base64url").toString()) + if (!payload || typeof payload !== "object") return undefined + const email = (payload as { email?: unknown }).email + return typeof email === "string" ? email : undefined + } catch { + return undefined + } +} + +export function encodeVarint(value: number) { + const bytes: number[] = [] + let current = value + while (current > 0x7f) { + bytes.push((current & 0x7f) | 0x80) + current = Math.floor(current / 128) + } + bytes.push(current) + return new Uint8Array(bytes) +} + +export function encodeField(field: number, value: Uint8Array) { + return concat(encodeVarint((field << 3) | 2), encodeVarint(value.length), value) +} + +export function encodeGrpcWebFrame(message: Uint8Array) { + const header = new Uint8Array(5) + new DataView(header.buffer).setUint32(1, message.length) + return concat(header, message) +} + +export function decodeGrpcWebFrames(data: Uint8Array) { + const messages: Uint8Array[] = [] + const trailers: Record = {} + let offset = 0 + while (offset + 5 <= data.length) { + const flag = data[offset] + const length = new DataView(data.buffer, data.byteOffset + offset + 1, 4).getUint32(0) + const payload = data.subarray(offset + 5, offset + 5 + length) + offset += 5 + length + if (flag === 0x80) { + for (const line of Buffer.from(payload).toString().split("\r\n")) { + const index = line.indexOf(":") + if (index > 0) trailers[line.slice(0, index).trim().toLowerCase()] = line.slice(index + 1).trim() + } + continue + } + messages.push(payload) + } + return { messages, trailers } +} + +export type ProtobufField = { field: number; wire: number; value: Uint8Array | number } + +function readVarint(data: Uint8Array, offset: number): [number, number] { + let value = 0 + let shift = 0 + let index = offset + while (index < data.length) { + const byte = data[index] + value += (byte & 0x7f) * 2 ** shift + index++ + if (!(byte & 0x80)) return [value, index] + shift += 7 + } + throw new Error("Truncated protobuf varint") +} + +export function readFields(data: Uint8Array): ProtobufField[] { + const fields: ProtobufField[] = [] + let offset = 0 + while (offset < data.length) { + const [tag, next] = readVarint(data, offset) + const field = Math.floor(tag / 8) + const wire = tag & 7 + if (wire === 0) { + const [value, after] = readVarint(data, next) + fields.push({ field, wire, value }) + offset = after + continue + } + if (wire === 2) { + const [length, start] = readVarint(data, next) + fields.push({ field, wire, value: data.subarray(start, start + length) }) + offset = start + length + continue + } + throw new Error(`Unsupported protobuf wire type ${wire}`) + } + return fields +} + +export function readString(data: Uint8Array, field: number) { + const entry = readFields(data).find((item) => item.field === field && item.wire === 2) + if (!entry || typeof entry.value === "number") return undefined + return Buffer.from(entry.value).toString() +} + +export function parseResourceNames(message: Uint8Array) { + return readFields(message) + .filter((field) => field.field === 1 && field.wire === 2 && typeof field.value !== "number") + .map((field) => readString(field.value as Uint8Array, 1)) + .filter((name): name is string => name !== undefined) +} + +export function encodeListUsersRequest(parent: string, email?: string) { + const parts = [encodeField(1, text(parent))] + if (email) parts.push(encodeField(4, text(`email="${email}"`))) + return concat(...parts) +} + +export function encodeCreateApiKeyRequest(parent: string, displayName: string) { + return concat(encodeField(1, text(parent)), encodeField(2, encodeField(2, text(displayName)))) +} + +export function buildKeyDisplayName(hostname: string) { + const label = hostname + .split(".")[0] + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + return /[a-z0-9]/.test(label) ? `opencode-${label}` : "opencode-cli" +} + +async function exchangeCode(fetchFn: FetchFn, code: string, verifier: string) { + const response = await fetchFn(`${COGNITO_URL}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: buildTokenRequestBody(code, verifier), + }) + if (!response.ok) throw new Error(`Fireworks token exchange failed: ${response.status}`) + const data = (await response.json()) as { id_token?: string } + if (!data.id_token) throw new Error("Fireworks token exchange did not return an id_token") + return data.id_token +} + +async function gateway(fetchFn: FetchFn, idToken: string, method: string, message: Uint8Array) { + const response = await fetchFn(`${GATEWAY_URL}/${method}`, { + method: "POST", + headers: { + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + authorization: `bearer ${idToken}`, + }, + body: encodeGrpcWebFrame(message), + }) + if (!response.ok) throw new Error(`Fireworks ${method} failed: ${response.status}`) + const status = response.headers.get("grpc-status") + if (status && status !== "0") throw new Error(`Fireworks ${method} failed with grpc-status ${status}`) + const frames = decodeGrpcWebFrames(new Uint8Array(await response.arrayBuffer())) + const trailer = frames.trailers["grpc-status"] + if (trailer && trailer !== "0") throw new Error(`Fireworks ${method} failed with grpc-status ${trailer}`) + const reply = frames.messages[0] + if (!reply) throw new Error(`Fireworks ${method} returned no message`) + return reply +} + +async function mintApiKey(fetchFn: FetchFn, idToken: string, hostname: string) { + const accounts = parseResourceNames(await gateway(fetchFn, idToken, "ListAccounts", new Uint8Array())) + const account = accounts[0] + if (!account) throw new Error("No Fireworks account found for this login") + const email = extractEmail(idToken) + const users = parseResourceNames(await gateway(fetchFn, idToken, "ListUsers", encodeListUsersRequest(account, email))) + if (users.length === 0) { + throw new Error(email ? `No Fireworks user found for ${email}` : "No Fireworks user found for this login") + } + if (!email && users.length > 1) { + throw new Error("Multiple Fireworks users found and the login token has no email to select one") + } + const reply = await gateway( + fetchFn, + idToken, + "CreateApiKey", + encodeCreateApiKeyRequest(users[0], buildKeyDisplayName(hostname)), + ) + const key = readString(reply, 3) + if (!key) throw new Error("Fireworks did not return an API key") + return key +} + +async function verifyApiKey(fetchFn: FetchFn, key: string) { + const response = await fetchFn(VERIFY_URL, { headers: { Authorization: `Bearer ${key}` } }) + if (response.ok) return + if (response.status === 401 || response.status === 403) throw new Error("Fireworks rejected the minted API key") + throw new Error(`Fireworks API key verification failed: ${response.status}`) +} + +function startCallbackServer(state: string, timeoutMs: number) { + let settled = false + let resolveDone: (code: string) => void = () => undefined + let rejectDone: (error: Error) => void = () => undefined + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + // The promise is always awaited (or already settled) by callback(); an early + // rejection must not surface as an unhandled rejection before then. + done.catch(() => undefined) + + const finish = (code: string) => { + if (settled) return + settled = true + resolveDone(code) + } + const fail = (message: string) => { + if (settled) return + settled = true + rejectDone(new Error(message)) + } + + // "Connection: close" keeps server.close() from waiting on keep-alive sockets + // held open by browser or fetch connection pools. + const html = { "Content-Type": "text/html; charset=utf-8", Connection: "close" } + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${OAUTH_PORT}`) + if (url.pathname !== "/") { + res.writeHead(404, { Connection: "close" }) + res.end("Not found") + return + } + const error = url.searchParams.get("error") + if (error) { + const message = + error === "access_denied" ? "Fireworks sign-in was cancelled" : `Fireworks sign-in failed: ${error}` + res.writeHead(200, html) + res.end(OauthCallbackPage.error(message, { provider: "Fireworks" })) + fail(message) + return + } + const code = url.searchParams.get("code") + if (!code) { + res.writeHead(400, html) + res.end(OauthCallbackPage.error("Missing authorization code", { provider: "Fireworks" })) + fail("Fireworks callback is missing the authorization code") + return + } + if (url.searchParams.get("state") !== state) { + res.writeHead(400, html) + res.end(OauthCallbackPage.error("Invalid state", { provider: "Fireworks" })) + fail("Fireworks callback state mismatch") + return + } + res.writeHead(200, html) + res.end(OauthCallbackPage.success({ provider: "Fireworks" })) + finish(code) + }) + + const ready = new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(OAUTH_PORT, "127.0.0.1", () => { + server.off("error", reject) + resolve() + }) + }) + + const timeout = setTimeout(() => fail("Fireworks login timed out"), timeoutMs) + timeout.unref() + + const close = async () => { + clearTimeout(timeout) + server.closeIdleConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + + return { ready, done, close } +} + +export function createFireworksAuthHooks(deps: FireworksAuthDeps = {}): Hooks { + const fetchFn = deps.fetchFn ?? fetch + const openUrl = deps.openUrl ?? ((url: string) => open(url)) + const hostname = deps.hostname ?? os.hostname() + const timeoutMs = deps.timeoutMs ?? OAUTH_TIMEOUT_MS + + return { + auth: { + provider: PROVIDER, + methods: [ + { + type: "oauth", + label: "Log in with Fireworks Connect", + async authorize(): Promise { + const pkce = await generatePkce() + const state = generateState() + const server = startCallbackServer(state, timeoutMs) + await server.ready.catch(async (error: unknown) => { + await server.close() + throw error + }) + const url = buildAuthorizeUrl(state, pkce.challenge) + await openUrl(url).catch(() => undefined) + return { + url, + instructions: "Complete sign-in in your browser. This window will close automatically.", + method: "auto", + async callback() { + try { + const code = await server.done + const idToken = await exchangeCode(fetchFn, code, pkce.verifier) + const key = await mintApiKey(fetchFn, idToken, hostname) + await verifyApiKey(fetchFn, key) + return { type: "success" as const, key, provider: PROVIDER } + } catch { + return { type: "failed" as const } + } finally { + await server.close() + } + }, + } + }, + }, + { + type: "api", + label: "Manually enter a Fireworks API key", + }, + ], + }, + } +} + +export async function FireworksAuthPlugin(_input: PluginInput): Promise { + return createFireworksAuthHooks() +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 6f05329a0833..8c00d5195796 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -22,6 +22,7 @@ import { DigitalOceanAuthPlugin } from "./digitalocean" import { XaiAuthPlugin } from "./xai" import { CerebrasPlugin } from "./cerebras" import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex" +import { FireworksAuthPlugin } from "./fireworks" import { Effect, Layer, Context } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" @@ -82,6 +83,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { SnowflakeCortexAuthPlugin, XaiAuthPlugin, CerebrasPlugin, + FireworksAuthPlugin, ] } diff --git a/packages/opencode/test/plugin/fireworks.test.ts b/packages/opencode/test/plugin/fireworks.test.ts new file mode 100644 index 000000000000..156c1b3f2ed4 --- /dev/null +++ b/packages/opencode/test/plugin/fireworks.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, test } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import { + FireworksAuthPlugin, + buildAuthorizeUrl, + buildKeyDisplayName, + buildTokenRequestBody, + createFireworksAuthHooks, + decodeGrpcWebFrames, + encodeCreateApiKeyRequest, + encodeField, + encodeGrpcWebFrame, + encodeListUsersRequest, + encodeVarint, + extractEmail, + generatePkce, + generateState, + parseResourceNames, + readFields, + readString, +} from "../../src/plugin/fireworks" + +const CLIENT_ID = "sueas7prsfrdp16nantbeqcjv" +const AUTHORIZE_URL = "https://fireworks.auth.us-west-2.amazoncognito.com/oauth2/authorize" +const TOKEN_URL = "https://fireworks.auth.us-west-2.amazoncognito.com/oauth2/token" +const GATEWAY_URL = "https://gateway.fireworks.ai/web/gateway.Gateway" +const VERIFY_URL = "https://api.fireworks.ai/verifyApiKey" +const CALLBACK_URL = "http://127.0.0.1:18000" + +function text(value: string) { + return new TextEncoder().encode(value) +} + +function concat(...parts: Uint8Array[]) { + const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out +} + +function jwt(payload: Record) { + return ["eyJhbGciOiJSUzI1NiJ9", Buffer.from(JSON.stringify(payload)).toString("base64url"), "signature"].join(".") +} + +function grpcResponse(message: Uint8Array, init?: { status?: number; headers?: Record }) { + return new Response(encodeGrpcWebFrame(message), { + status: init?.status ?? 200, + headers: { "Content-Type": "application/grpc-web+proto", "grpc-status": "0", ...init?.headers }, + }) +} + +type FetchCall = { url: string; init?: RequestInit } + +function fireworksFetch(options: { + email?: string | null + accounts?: string[] + users?: string[] + verifyStatus?: number + calls?: FetchCall[] +}) { + const email = options.email === undefined ? "dev@example.com" : options.email + const accounts = options.accounts ?? ["accounts/acct-1"] + const users = options.users ?? ["accounts/acct-1/users/user-1"] + const calls = options.calls ?? [] + const payload: Record = { sub: "user-1" } + if (email) payload.email = email + const idToken = jwt(payload) + const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + calls.push({ url, init }) + if (url === TOKEN_URL) { + return Response.json({ id_token: idToken, access_token: "access-token", token_type: "Bearer" }) + } + if (url === `${GATEWAY_URL}/ListAccounts`) { + return grpcResponse(concat(...accounts.map((account) => encodeField(1, encodeField(1, text(account)))))) + } + if (url === `${GATEWAY_URL}/ListUsers`) { + return grpcResponse(concat(...users.map((user) => encodeField(1, encodeField(1, text(user)))))) + } + if (url === `${GATEWAY_URL}/CreateApiKey`) { + return grpcResponse(concat(encodeField(1, text("key-id-1")), encodeField(3, text("fw_test_key")))) + } + if (url === VERIFY_URL) return new Response(null, { status: options.verifyStatus ?? 200 }) + return new Response("unexpected request", { status: 500 }) + } + return { fn: fn as unknown as typeof fetch, calls, idToken } +} + +function oauthMethod(hooks: Awaited>) { + const method = hooks.auth?.methods.find((method) => method.type === "oauth") + if (!method || method.type !== "oauth") throw new Error("Fireworks OAuth method is missing") + return method +} + +async function startLogin(fetchFn: typeof fetch) { + const hooks = createFireworksAuthHooks({ + fetchFn, + openUrl: async () => undefined, + hostname: "Test-Host", + }) + const authorization = await oauthMethod(hooks).authorize() + if (authorization.method !== "auto") throw new Error("Unexpected Fireworks authorization method") + const state = new URL(authorization.url).searchParams.get("state") + if (!state) throw new Error("Fireworks authorize URL is missing state") + return { authorization, state } +} + +function requestBody(call: FetchCall) { + const body = call.init?.body + if (!(body instanceof Uint8Array)) throw new Error(`Expected a binary request body for ${call.url}`) + return decodeGrpcWebFrames(body).messages[0] +} + +describe("plugin.fireworks", () => { + test("exposes the fireworks-ai provider with browser and api-key methods", async () => { + const hooks = await FireworksAuthPlugin({} as PluginInput) + + expect(hooks.auth?.provider).toBe("fireworks-ai") + expect(hooks.auth?.methods.map((method) => [method.type, method.label])).toEqual([ + ["oauth", "Log in with Fireworks Connect"], + ["api", "Manually enter a Fireworks API key"], + ]) + }) + + test("generates a PKCE verifier and S256 challenge", async () => { + const pkce = await generatePkce() + + expect(pkce.verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + const digest = await crypto.subtle.digest("SHA-256", text(pkce.verifier)) + expect(pkce.challenge).toBe(Buffer.from(digest).toString("base64url")) + expect(await generatePkce()).not.toEqual(pkce) + }) + + test("generates URL-safe state", () => { + expect(generateState()).toMatch(/^[A-Za-z0-9_-]{22}$/) + expect(generateState()).not.toBe(generateState()) + }) + + test("builds the Cognito authorize URL without a scope parameter", () => { + expect(buildAuthorizeUrl("test-state", "test-challenge")).toBe( + `${AUTHORIZE_URL}?client_id=${CLIENT_ID}&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A18000&state=test-state&code_challenge_method=S256&code_challenge=test-challenge`, + ) + }) + + test("builds the token exchange body", () => { + const params = new URLSearchParams(buildTokenRequestBody("the-code", "the-verifier")) + + expect(params.get("grant_type")).toBe("authorization_code") + expect(params.get("client_id")).toBe(CLIENT_ID) + expect(params.get("code")).toBe("the-code") + expect(params.get("redirect_uri")).toBe("http://localhost:18000") + expect(params.get("code_verifier")).toBe("the-verifier") + expect([...params.keys()]).toEqual(["grant_type", "client_id", "code", "redirect_uri", "code_verifier"]) + }) + + test("extracts the email claim from a JWT payload", () => { + expect(extractEmail(jwt({ email: "dev@example.com", sub: "user-1" }))).toBe("dev@example.com") + expect(extractEmail(jwt({ sub: "user-1" }))).toBeUndefined() + expect(extractEmail("not-a-jwt")).toBeUndefined() + expect(extractEmail("a.@@.c")).toBeUndefined() + }) + + test("encodes unsigned varints", () => { + expect([...encodeVarint(0)]).toEqual([0]) + expect([...encodeVarint(127)]).toEqual([0x7f]) + expect([...encodeVarint(128)]).toEqual([0x80, 0x01]) + expect([...encodeVarint(300)]).toEqual([0xac, 0x02]) + }) + + test("encodes length-delimited protobuf fields", () => { + expect([...encodeField(1, text("ab"))]).toEqual([0x0a, 0x02, 0x61, 0x62]) + expect([...encodeField(4, text("x"))]).toEqual([0x22, 0x01, 0x78]) + }) + + test("round-trips gRPC-web frames", () => { + const frames = decodeGrpcWebFrames( + concat( + encodeGrpcWebFrame(text("first")), + encodeGrpcWebFrame(text("second")), + concat(new Uint8Array([0x80, 0, 0, 0, 15]), text("grpc-status:0\r\n")), + ), + ) + + expect(frames.messages.map((message) => Buffer.from(message).toString())).toEqual(["first", "second"]) + expect(frames.trailers["grpc-status"]).toBe("0") + }) + + test("parses repeated resource names from a gateway response", () => { + const message = concat( + encodeField(1, encodeField(1, text("accounts/a1"))), + encodeField(1, encodeField(1, text("accounts/a2"))), + ) + + expect(parseResourceNames(message)).toEqual(["accounts/a1", "accounts/a2"]) + }) + + test("reads strings and sub-messages from protobuf messages", () => { + const message = concat(encodeField(1, text("parent")), encodeField(2, encodeField(2, text("nested")))) + const sub = readFields(message).find((field) => field.field === 2) + + expect(readString(message, 1)).toBe("parent") + expect(readString(message, 9)).toBeUndefined() + expect(sub?.wire).toBe(2) + expect(readString(sub?.value as Uint8Array, 2)).toBe("nested") + }) + + test("encodes ListUsers requests with and without an email filter", () => { + const filtered = encodeListUsersRequest("accounts/a1", "dev@example.com") + const unfiltered = encodeListUsersRequest("accounts/a1") + + expect(readString(filtered, 1)).toBe("accounts/a1") + expect(readString(filtered, 4)).toBe('email="dev@example.com"') + expect(readString(unfiltered, 1)).toBe("accounts/a1") + expect(readString(unfiltered, 4)).toBeUndefined() + }) + + test("encodes CreateApiKey requests with a nested display name", () => { + const message = encodeCreateApiKeyRequest("accounts/a1/users/u1", "opencode-cli") + const sub = readFields(message).find((field) => field.field === 2) + + expect(readString(message, 1)).toBe("accounts/a1/users/u1") + expect(readString(sub?.value as Uint8Array, 2)).toBe("opencode-cli") + }) + + test("builds API key display names from the hostname", () => { + expect(buildKeyDisplayName("Darrens-MacBook-Pro.local")).toBe("opencode-darrens-macbook-pro") + expect(buildKeyDisplayName("weird_host")).toBe("opencode-weird-host") + expect(buildKeyDisplayName("!!!")).toBe("opencode-cli") + expect(buildKeyDisplayName("")).toBe("opencode-cli") + }) + + test("completes the browser login flow and stores the minted API key", async () => { + const calls: FetchCall[] = [] + const { fn, idToken } = fireworksFetch({ calls }) + const { authorization, state } = await startLogin(fn) + + const url = new URL(authorization.url) + expect(url.origin + url.pathname).toBe(AUTHORIZE_URL) + expect(url.searchParams.get("client_id")).toBe(CLIENT_ID) + expect(url.searchParams.get("response_type")).toBe("code") + expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:18000") + expect(url.searchParams.get("code_challenge_method")).toBe("S256") + expect(url.searchParams.get("code_challenge")).toBeTruthy() + expect(url.searchParams.has("scope")).toBe(false) + expect(authorization.instructions).toContain("browser") + + expect((await fetch(`${CALLBACK_URL}/favicon.ico`)).status).toBe(404) + const page = await fetch(`${CALLBACK_URL}/?code=test-code&state=${state}`) + expect(page.status).toBe(200) + expect(await page.text()).toContain("Authorization successful") + + expect(await authorization.callback()).toEqual({ + type: "success", + key: "fw_test_key", + provider: "fireworks-ai", + }) + + const token = calls.find((call) => call.url === TOKEN_URL) + const body = new URLSearchParams(String(token?.init?.body)) + expect(body.get("grant_type")).toBe("authorization_code") + expect(body.get("client_id")).toBe(CLIENT_ID) + expect(body.get("code")).toBe("test-code") + expect(body.get("redirect_uri")).toBe("http://localhost:18000") + const verifier = body.get("code_verifier") ?? "" + const digest = await crypto.subtle.digest("SHA-256", text(verifier)) + expect(url.searchParams.get("code_challenge")).toBe(Buffer.from(digest).toString("base64url")) + + const listAccounts = calls.find((call) => call.url === `${GATEWAY_URL}/ListAccounts`) + expect(requestBody(listAccounts!).length).toBe(0) + const listUsers = calls.find((call) => call.url === `${GATEWAY_URL}/ListUsers`) + expect(readString(requestBody(listUsers!), 1)).toBe("accounts/acct-1") + expect(readString(requestBody(listUsers!), 4)).toBe('email="dev@example.com"') + const createKey = calls.find((call) => call.url === `${GATEWAY_URL}/CreateApiKey`) + const createBody = requestBody(createKey!) + expect(readString(createBody, 1)).toBe("accounts/acct-1/users/user-1") + const keyMessage = readFields(createBody).find((field) => field.field === 2) + expect(readString(keyMessage?.value as Uint8Array, 2)).toBe("opencode-test-host") + + for (const call of [listAccounts, listUsers, createKey]) { + const headers = new Headers(call?.init?.headers) + expect(headers.get("content-type")).toBe("application/grpc-web+proto") + expect(headers.get("x-grpc-web")).toBe("1") + expect(headers.get("authorization")).toBe(`bearer ${idToken}`) + } + + const verify = calls.find((call) => call.url === VERIFY_URL) + expect(new Headers(verify?.init?.headers).get("authorization")).toBe("Bearer fw_test_key") + }) + + test("uses the first account when several exist", async () => { + const calls: FetchCall[] = [] + const { fn } = fireworksFetch({ accounts: ["accounts/first", "accounts/second"], calls }) + const { authorization, state } = await startLogin(fn) + + await fetch(`${CALLBACK_URL}/?code=test-code&state=${state}`) + + expect((await authorization.callback()).type).toBe("success") + const listUsers = calls.find((call) => call.url.endsWith("/ListUsers")) + expect(readString(requestBody(listUsers!), 1)).toBe("accounts/first") + }) + + test("fails when the user denies access", async () => { + const { fn } = fireworksFetch({}) + const { authorization, state } = await startLogin(fn) + + const page = await fetch(`${CALLBACK_URL}/?error=access_denied&state=${state}`) + expect(page.status).toBe(200) + + expect(await authorization.callback()).toEqual({ type: "failed" }) + }) + + test("fails when the callback state does not match", async () => { + const { fn } = fireworksFetch({}) + const { authorization } = await startLogin(fn) + + const page = await fetch(`${CALLBACK_URL}/?code=test-code&state=forged-state`) + expect(page.status).toBe(400) + + expect(await authorization.callback()).toEqual({ type: "failed" }) + }) + + test("fails when no user matches and no email is available", async () => { + const calls: FetchCall[] = [] + const { fn } = fireworksFetch({ email: null, users: ["accounts/acct-1/users/u1", "accounts/acct-1/users/u2"], calls }) + const { authorization, state } = await startLogin(fn) + + await fetch(`${CALLBACK_URL}/?code=test-code&state=${state}`) + + expect(await authorization.callback()).toEqual({ type: "failed" }) + const listUsers = calls.find((call) => call.url.endsWith("/ListUsers")) + expect(readString(requestBody(listUsers!), 4)).toBeUndefined() + }) + + test("fails when the token response has no id_token", async () => { + const fn = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url === TOKEN_URL) return Response.json({ access_token: "access-token" }) + return new Response("unexpected request", { status: 500 }) + }) as typeof fetch + const { authorization, state } = await startLogin(fn) + + await fetch(`${CALLBACK_URL}/?code=test-code&state=${state}`) + + expect(await authorization.callback()).toEqual({ type: "failed" }) + }) + + test("fails when Fireworks rejects the minted key", async () => { + const { fn } = fireworksFetch({ verifyStatus: 401 }) + const { authorization, state } = await startLogin(fn) + + await fetch(`${CALLBACK_URL}/?code=test-code&state=${state}`) + + expect(await authorization.callback()).toEqual({ type: "failed" }) + }) + + test("fails when the browser never completes the login", async () => { + const hooks = createFireworksAuthHooks({ + fetchFn: fireworksFetch({}).fn, + openUrl: async () => undefined, + hostname: "Test-Host", + timeoutMs: 50, + }) + const authorization = await oauthMethod(hooks).authorize() + if (authorization.method !== "auto") throw new Error("Unexpected Fireworks authorization method") + + expect(await authorization.callback()).toEqual({ type: "failed" }) + }) + + test("surfaces non-zero grpc statuses", async () => { + const fn = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url === TOKEN_URL) return Response.json({ id_token: jwt({ email: "dev@example.com" }) }) + if (url.endsWith("/ListAccounts")) { + return new Response(encodeGrpcWebFrame(new Uint8Array()), { + status: 200, + headers: { "grpc-status": "7", "grpc-message": "permission%20denied" }, + }) + } + return new Response("unexpected request", { status: 500 }) + }) as typeof fetch + const { authorization, state } = await startLogin(fn) + + await fetch(`${CALLBACK_URL}/?code=test-code&state=${state}`) + + expect(await authorization.callback()).toEqual({ type: "failed" }) + }) +})