Skip to content
Open
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
42 changes: 42 additions & 0 deletions packages/opencode/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,46 @@ export namespace Auth {
export async function remove(key: string) {
return runPromise((service) => service.remove(key))
}

export async function urls(): Promise<string[]> {
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(),
})
}
}
31 changes: 6 additions & 25 deletions packages/opencode/src/cli/cmd/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hooks["auth"]>

Expand Down Expand Up @@ -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
}
Expand Down
53 changes: 53 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(() => [
{
Expand Down Expand Up @@ -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 <url> to add one.",
variant: "warning",
duration: 4000,
})
dialog.clear()
return
}
if (urls.length === 1) {
await authenticate(urls[0])
dialog.clear()
return
}
dialog.replace(() => (
<DialogSelect
title="Select provider to re-authenticate"
options={urls.map((url) => ({
title: url,
value: url,
}))}
onSelect={async (option) => {
await authenticate(option.value)
dialog.clear()
}}
/>
))
},
category: "Provider",
},
{
title: "View status",
keybind: "status_view",
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> 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()
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
50 changes: 50 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import type {
AuthRemoveResponses,
AuthSetErrors,
AuthSetResponses,
AuthWellknownListResponses,
AuthWellknownRefreshErrors,
AuthWellknownRefreshResponses,
CommandListResponses,
Config as Config3,
ConfigGetResponses,
Expand Down Expand Up @@ -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<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<AuthWellknownListResponses, unknown, ThrowOnError>({
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<ThrowOnError extends boolean = false>(
parameters?: {
url?: string
},
options?: Options<never, ThrowOnError>,
) {
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
Expand Down Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
}

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
Expand Down
Loading