diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 6f588e93751..7b63fd15612 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -54,4 +54,46 @@ export namespace Auth { export async function remove(key: string) { return runPromise((service) => service.remove(key)) } + + export async function urls(): Promise { + const data = await all() + return Object.entries(data) + .filter(([, value]) => value.type === "wellknown") + .map(([key]) => key) + } + + const WellKnownConfig = z.object({ + auth: z.object({ + command: z.array(z.string()), + env: z.string(), + }), + }) + + export async function wellknown(url: string) { + const normalized = url.replace(/\/+$/, "") + const response = await fetch(`${normalized}/.well-known/opencode`) + if (!response.ok) { + throw new Error(`failed to fetch well-known config from ${normalized}: ${response.status}`) + } + const parsed = WellKnownConfig.safeParse(await response.json()) + if (!parsed.success) { + throw new Error(`invalid well-known config from ${normalized}: ${parsed.error.message}`) + } + const proc = Bun.spawn({ + cmd: parsed.data.auth.command, + stdout: "pipe", + stderr: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + const stderr = await new Response(proc.stderr).text() + throw new Error(`auth command failed with exit code ${exit}${stderr ? ": " + stderr.trim() : ""}`) + } + const token = await new Response(proc.stdout).text() + await set(normalized, { + type: "wellknown", + key: parsed.data.auth.env, + token: token.trim(), + }) + } } diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 581809e90eb..d943379c00a 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -11,8 +11,6 @@ import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" -import { Process } from "../../util/process" -import { text } from "node:stream/consumers" type PluginAuth = NonNullable @@ -277,29 +275,12 @@ export const ProvidersLoginCommand = cmd({ UI.empty() prompts.intro("Add credential") if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any) - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await Auth.set(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) + const result = await Auth.wellknown(args.url).then( + () => true as const, + (e) => e, + ) + if (result === true) prompts.log.success("Logged into " + args.url) + else prompts.log.error(result instanceof Error ? result.message : "Failed") prompts.outro("Done") return } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8bb17ff1336..6c1cbe3f6c8 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -9,6 +9,7 @@ import { Installation } from "@/installation" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" +import { DialogSelect } from "@tui/ui/dialog-select" import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" @@ -356,6 +357,19 @@ function App() { ), ) + async function authenticate(url: string) { + toast.show({ message: "Authenticating...", variant: "info" }) + const { error } = await sdk.client.auth.wellknown.refresh({ url }) + if (error) { + toast.show({ message: "Authentication failed", variant: "error", duration: 4000 }) + return false + } + await sdk.client.instance.dispose() + await sync.bootstrap() + toast.show({ message: "Authenticated with " + url, variant: "success", duration: 3000 }) + return true + } + const connected = useConnected() command.register(() => [ { @@ -530,6 +544,45 @@ function App() { }, category: "Provider", }, + { + title: "Re-authenticate", + value: "auth.login", + slash: { + name: "auth", + }, + async onSelect() { + const result = await sdk.client.auth.wellknown.list() + const urls = result.data + if (!urls || urls.length === 0) { + toast.show({ + message: "No well-known auth entries found. Use /auth to add one.", + variant: "warning", + duration: 4000, + }) + dialog.clear() + return + } + if (urls.length === 1) { + await authenticate(urls[0]) + dialog.clear() + return + } + dialog.replace(() => ( + ({ + title: url, + value: url, + }))} + onSelect={async (option) => { + await authenticate(option.value) + dialog.clear() + }} + /> + )) + }, + category: "Provider", + }, { title: "View status", keybind: "status_view", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c85426cc247..994779b7c37 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -583,6 +583,29 @@ export function Prompt(props: PromptProps) { // Filter out text parts (pasted content) since they're now expanded inline const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + // Handle /auth and /auth commands + if (inputText === "/auth" || inputText.startsWith("/auth ")) { + const url = inputText.slice("/auth".length).trim() + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + input.clear() + if (!url) { + command.trigger("auth.login") + return + } + toast.show({ message: "Authenticating...", variant: "info" }) + const { error } = await sdk.client.auth.wellknown.refresh({ url }) + if (error) { + toast.show({ message: "Authentication failed", variant: "error", duration: 4000 }) + return + } + await sdk.client.instance.dispose() + await sync.bootstrap() + toast.show({ message: "Authenticated with " + url, variant: "success", duration: 3000 }) + return + } + // Capture mode before it gets reset const currentMode = store.mode const variant = local.model.variant.current() diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c485654fdf8..ae3a9cd9272 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -192,6 +192,57 @@ export namespace Server { return c.json(true) }, ) + .get( + "/auth/wellknown", + describeRoute({ + summary: "List well-known auth URLs", + description: "Get a list of all well-known authentication URLs stored in credentials.", + operationId: "auth.wellknown.list", + responses: { + 200: { + description: "List of well-known URLs", + content: { + "application/json": { + schema: resolver(z.array(z.string())), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Auth.urls()) + }, + ) + .post( + "/auth/wellknown", + describeRoute({ + summary: "Authenticate with well-known URL", + description: + "Fetch a well-known config from the given URL, run its auth command, and store the resulting token.", + operationId: "auth.wellknown.refresh", + responses: { + 200: { + description: "Successfully authenticated", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + url: z.string(), + }), + ), + async (c) => { + await Auth.wellknown(c.req.valid("json").url) + return c.json(true) + }, + ) .use(async (c, next) => { if (c.req.path === "/log") return next() const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index aa759bb1e09..ef1b6f3b4ab 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -13,6 +13,9 @@ import type { AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + AuthWellknownListResponses, + AuthWellknownRefreshErrors, + AuthWellknownRefreshResponses, CommandListResponses, Config as Config3, ConfigGetResponses, @@ -309,6 +312,48 @@ export class Global extends HeyApiClient { } } +export class Wellknown extends HeyApiClient { + /** + * List well-known auth URLs + * + * Get a list of all well-known authentication URLs stored in credentials. + */ + public list(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/auth/wellknown", + ...options, + }) + } + + /** + * Authenticate with well-known URL + * + * Fetch a well-known config from the given URL, run its auth command, and store the resulting token. + */ + public refresh( + parameters?: { + url?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "url" }] }]) + return (options?.client ?? this.client).post< + AuthWellknownRefreshResponses, + AuthWellknownRefreshErrors, + ThrowOnError + >({ + url: "/auth/wellknown", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Auth extends HeyApiClient { /** * Remove auth credentials @@ -363,6 +408,11 @@ export class Auth extends HeyApiClient { }, }) } + + private _wellknown?: Wellknown + get wellknown(): Wellknown { + return (this._wellknown ??= new Wellknown({ client: this.client })) + } } export class Project extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 41aa248171c..92415d7f3a6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2084,6 +2084,49 @@ export type AuthSetResponses = { export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] +export type AuthWellknownListData = { + body?: never + path?: never + query?: never + url: "/auth/wellknown" +} + +export type AuthWellknownListResponses = { + /** + * List of well-known URLs + */ + 200: Array +} + +export type AuthWellknownListResponse = AuthWellknownListResponses[keyof AuthWellknownListResponses] + +export type AuthWellknownRefreshData = { + body?: { + url: string + } + path?: never + query?: never + url: "/auth/wellknown" +} + +export type AuthWellknownRefreshErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthWellknownRefreshError = AuthWellknownRefreshErrors[keyof AuthWellknownRefreshErrors] + +export type AuthWellknownRefreshResponses = { + /** + * Successfully authenticated + */ + 200: boolean +} + +export type AuthWellknownRefreshResponse = AuthWellknownRefreshResponses[keyof AuthWellknownRefreshResponses] + export type ProjectListData = { body?: never path?: never