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
1 change: 1 addition & 0 deletions packages/app/script/e2e-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const serverEnv = {
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_OLLAMA_CHECK: "true",
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
XDG_DATA_HOME: path.join(sandbox, "share"),
XDG_CACHE_HOME: path.join(sandbox, "cache"),
Expand Down
45 changes: 42 additions & 3 deletions packages/app/src/components/dialog-connect-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,48 @@ export function DialogConnectProvider(props: { provider: string }) {
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
<div class="flex gap-2">
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
<Switch>
<Match when={provider().id === "google"}>
<Button
class="w-auto"
type="button"
size="large"
variant="secondary"
onClick={async () => {
setFormStore("error", undefined)
try {
const response = await fetch(
`${globalSDK.url}/auth/google/browser`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
)
const result = await response.json()
if (result.success) {
await complete()
} else {
setFormStore("error", result.error || "Authentication failed")
}
} catch (error) {
setFormStore(
"error",
error instanceof Error ? error.message : "Authentication failed",
)
}
}}
>
Login with Browser
</Button>
</Match>
</Switch>
</div>
</form>
</div>
)
Expand Down
32 changes: 16 additions & 16 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@standard-schema/spec": "1.0.0",
"@tsconfig/bun": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
"@types/bun": "1.3.9",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"typescript": "5.8.2",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
"zod-to-json-schema": "3.24.5"
Expand Down Expand Up @@ -74,11 +74,11 @@
"@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@hono/zod-validator": "0.4.2",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
Expand All @@ -87,40 +87,40 @@
"@opentui/core": "0.1.79",
"@opentui/solid": "0.1.79",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@pierre/diffs": "1.1.0-beta.13",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai": "5.0.124",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"diff": "8.0.2",
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "catalog:",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
"solid-js": "catalog:",
"remeda": "2.26.0",
"solid-js": "1.9.10",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"turndown": "7.2.0",
"ulid": "catalog:",
"ulid": "3.0.1",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
"zod": "4.1.8",
"zod-to-json-schema": "3.24.5"
}
}
143 changes: 143 additions & 0 deletions packages/opencode/src/auth/google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import * as prompts from "@clack/prompts"
import { Auth } from "./index"
import open from "open"

export namespace GoogleAuth {
const CALLBACK_PORT = 45961
const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}/oauth2callback`

// NOTE: These would typically come from an environment variable or a configuration file.
// For the purpose of this implementation, we assume the user might provide them
// or they are baked into the CLI if it's an official integration.
// If OpenCode has its own proxy for this, the URL would point there.
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
const TOKEN_URL = "https://oauth2.googleapis.com/token"

export async function loginWeb(redirectUrl?: string) {
const existing = await Auth.get("google")

// OpenCode requires client ID and secret to be provided via environment variables
// or configured in the auth provider settings.
const DEFAULT_CLIENT_ID = ""
const DEFAULT_CLIENT_SECRET = ""

let clientId = process.env.GOOGLE_CLIENT_ID || (existing?.type === "oauth" ? existing.clientId : undefined) || DEFAULT_CLIENT_ID
let clientSecret = process.env.GOOGLE_CLIENT_SECRET || (existing?.type === "oauth" ? existing.clientSecret : undefined) || DEFAULT_CLIENT_SECRET

const state = Math.random().toString(36).substring(7)
const scope = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"

const url = new URL(AUTH_URL)
url.searchParams.set("client_id", clientId)
url.searchParams.set("redirect_uri", REDIRECT_URI)
url.searchParams.set("response_type", "code")
url.searchParams.set("scope", scope)
url.searchParams.set("state", state)
url.searchParams.set("access_type", "offline")
url.searchParams.set("prompt", "consent")

prompts.log.info("Opening browser for Google Authentication...")
await open(url.toString())

const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")

return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
server.stop()
spinner.stop("Login timed out", 1)
resolve()
}, 5 * 60 * 1000) // 5 minutes timeout

const server = Bun.serve({
port: CALLBACK_PORT,
hostname: "127.0.0.1",
async fetch(req) {
const reqUrl = new URL(req.url)
if (reqUrl.pathname === "/oauth2callback") {
const code = reqUrl.searchParams.get("code")
const returnedState = reqUrl.searchParams.get("state")

if (returnedState !== state) {
const msg = "Authentication failed: State mismatch"
spinner.stop(msg, 1)
clearTimeout(timeout)
setTimeout(() => {
server.stop()
resolve()
}, 1000)
return new Response(msg, { status: 400 })
}

if (!code) {
const msg = "Authentication failed: No code received"
spinner.stop(msg, 1)
clearTimeout(timeout)
setTimeout(() => {
server.stop()
resolve()
}, 1000)
return new Response(msg, { status: 400 })
}

let success = false
let errorMsg = ""

try {
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: clientId!,
client_secret: clientSecret!,
redirect_uri: REDIRECT_URI,
grant_type: "authorization_code",
}),
})

const tokens = await tokenResponse.json() as any

if (tokens.error) {
errorMsg = tokens.error_description || tokens.error
spinner.stop(`Token exchange failed: ${errorMsg}`, 1)
} else {
await Auth.set("google", {
type: "oauth",
access: tokens.access_token,
refresh: tokens.refresh_token,
expires: Date.now() + tokens.expires_in * 1000,
clientId,
clientSecret,
})
spinner.stop("Google Login successful")
success = true
}
} catch (err) {
errorMsg = err instanceof Error ? err.message : String(err)
spinner.stop(`Error during token exchange: ${errorMsg}`, 1)
console.error(err)
}

clearTimeout(timeout)
// Small delay to ensure user sees the "Success" message in browser before server stops
setTimeout(() => {
server.stop()
resolve()
}, 1000)

if (success) {
if (redirectUrl) {
return Response.redirect(redirectUrl)
}
return new Response("Authentication successful! You can close this window and return to the CLI.")
} else {
return new Response(`Authentication failed: ${errorMsg}`, { status: 500 })
}
}
return new Response("Not Found", { status: 404 })
},
})
})
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export namespace Auth {
expires: z.number(),
accountId: z.string().optional(),
enterpriseUrl: z.string().optional(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
})
.meta({ ref: "OAuth" })

Expand Down
40 changes: 30 additions & 10 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export const AuthCommand = cmd({
describe: "manage credentials",
builder: (yargs) =>
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
async handler() {},
async handler() { },
})

export const AuthListCommand = cmd({
Expand Down Expand Up @@ -214,20 +214,34 @@ export const AuthListCommand = cmd({
},
})

import { GoogleAuth } from "../../auth/google"

export const AuthLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
yargs.positional("url", {
describe: "opencode auth provider",
type: "string",
}),
yargs
.positional("url", {
describe: "opencode auth provider",
type: "string",
})
.option("web", {
describe: "use web-based login (OAuth)",
type: "boolean",
default: false,
}),
async handler(args) {
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add credential")
if (args.url === "google" && args.web) {
await GoogleAuth.loginWeb()
prompts.outro("Done")
return
}

if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
Expand All @@ -251,7 +265,7 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done")
return
}
await ModelsDev.refresh().catch(() => {})
await ModelsDev.refresh().catch(() => { })

const config = await Config.get()

Expand Down Expand Up @@ -307,6 +321,12 @@ export const AuthLoginCommand = cmd({

if (prompts.isCancel(provider)) throw new UI.CancelledError()

if (provider === "google" && args.web) {
await GoogleAuth.loginWeb()
prompts.outro("Done")
return
}

const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
Expand Down Expand Up @@ -337,10 +357,10 @@ export const AuthLoginCommand = cmd({
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}

Expand Down
Loading
Loading