auth.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. import { Auth } from "../../auth"
  2. import { cmd } from "./cmd"
  3. import * as prompts from "@clack/prompts"
  4. import { UI } from "../ui"
  5. import { ModelsDev } from "../../provider/models"
  6. import { map, pipe, sortBy, values } from "remeda"
  7. import path from "path"
  8. import os from "os"
  9. import { Config } from "../../config/config"
  10. import { Global } from "../../global"
  11. import { Plugin } from "../../plugin"
  12. import { Instance } from "../../project/instance"
  13. import type { Hooks } from "@opencode-ai/plugin"
  14. type PluginAuth = NonNullable<Hooks["auth"]>
  15. /**
  16. * Handle plugin-based authentication flow.
  17. * Returns true if auth was handled, false if it should fall through to default handling.
  18. */
  19. async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise<boolean> {
  20. let index = 0
  21. if (plugin.auth.methods.length > 1) {
  22. const method = await prompts.select({
  23. message: "Login method",
  24. options: [
  25. ...plugin.auth.methods.map((x, index) => ({
  26. label: x.label,
  27. value: index.toString(),
  28. })),
  29. ],
  30. })
  31. if (prompts.isCancel(method)) throw new UI.CancelledError()
  32. index = parseInt(method)
  33. }
  34. const method = plugin.auth.methods[index]
  35. // Handle prompts for all auth types
  36. await Bun.sleep(10)
  37. const inputs: Record<string, string> = {}
  38. if (method.prompts) {
  39. for (const prompt of method.prompts) {
  40. if (prompt.condition && !prompt.condition(inputs)) {
  41. continue
  42. }
  43. if (prompt.type === "select") {
  44. const value = await prompts.select({
  45. message: prompt.message,
  46. options: prompt.options,
  47. })
  48. if (prompts.isCancel(value)) throw new UI.CancelledError()
  49. inputs[prompt.key] = value
  50. } else {
  51. const value = await prompts.text({
  52. message: prompt.message,
  53. placeholder: prompt.placeholder,
  54. validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
  55. })
  56. if (prompts.isCancel(value)) throw new UI.CancelledError()
  57. inputs[prompt.key] = value
  58. }
  59. }
  60. }
  61. if (method.type === "oauth") {
  62. const authorize = await method.authorize(inputs)
  63. if (authorize.url) {
  64. prompts.log.info("Go to: " + authorize.url)
  65. }
  66. if (authorize.method === "auto") {
  67. if (authorize.instructions) {
  68. prompts.log.info(authorize.instructions)
  69. }
  70. const spinner = prompts.spinner()
  71. spinner.start("Waiting for authorization...")
  72. const result = await authorize.callback()
  73. if (result.type === "failed") {
  74. spinner.stop("Failed to authorize", 1)
  75. }
  76. if (result.type === "success") {
  77. const saveProvider = result.provider ?? provider
  78. if ("refresh" in result) {
  79. const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
  80. await Auth.set(saveProvider, {
  81. type: "oauth",
  82. refresh,
  83. access,
  84. expires,
  85. ...extraFields,
  86. })
  87. }
  88. if ("key" in result) {
  89. await Auth.set(saveProvider, {
  90. type: "api",
  91. key: result.key,
  92. })
  93. }
  94. spinner.stop("Login successful")
  95. }
  96. }
  97. if (authorize.method === "code") {
  98. const code = await prompts.text({
  99. message: "Paste the authorization code here: ",
  100. validate: (x) => (x && x.length > 0 ? undefined : "Required"),
  101. })
  102. if (prompts.isCancel(code)) throw new UI.CancelledError()
  103. const result = await authorize.callback(code)
  104. if (result.type === "failed") {
  105. prompts.log.error("Failed to authorize")
  106. }
  107. if (result.type === "success") {
  108. const saveProvider = result.provider ?? provider
  109. if ("refresh" in result) {
  110. const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
  111. await Auth.set(saveProvider, {
  112. type: "oauth",
  113. refresh,
  114. access,
  115. expires,
  116. ...extraFields,
  117. })
  118. }
  119. if ("key" in result) {
  120. await Auth.set(saveProvider, {
  121. type: "api",
  122. key: result.key,
  123. })
  124. }
  125. prompts.log.success("Login successful")
  126. }
  127. }
  128. prompts.outro("Done")
  129. return true
  130. }
  131. if (method.type === "api") {
  132. if (method.authorize) {
  133. const result = await method.authorize(inputs)
  134. if (result.type === "failed") {
  135. prompts.log.error("Failed to authorize")
  136. }
  137. if (result.type === "success") {
  138. const saveProvider = result.provider ?? provider
  139. await Auth.set(saveProvider, {
  140. type: "api",
  141. key: result.key,
  142. })
  143. prompts.log.success("Login successful")
  144. }
  145. prompts.outro("Done")
  146. return true
  147. }
  148. }
  149. return false
  150. }
  151. export const AuthCommand = cmd({
  152. command: "auth",
  153. describe: "manage credentials",
  154. builder: (yargs) =>
  155. yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
  156. async handler() {},
  157. })
  158. export const AuthListCommand = cmd({
  159. command: "list",
  160. aliases: ["ls"],
  161. describe: "list providers",
  162. async handler() {
  163. UI.empty()
  164. const authPath = path.join(Global.Path.data, "auth.json")
  165. const homedir = os.homedir()
  166. const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
  167. prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
  168. const results = Object.entries(await Auth.all())
  169. const database = await ModelsDev.get()
  170. for (const [providerID, result] of results) {
  171. const name = database[providerID]?.name || providerID
  172. prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
  173. }
  174. prompts.outro(`${results.length} credentials`)
  175. // Environment variables section
  176. const activeEnvVars: Array<{ provider: string; envVar: string }> = []
  177. for (const [providerID, provider] of Object.entries(database)) {
  178. for (const envVar of provider.env) {
  179. if (process.env[envVar]) {
  180. activeEnvVars.push({
  181. provider: provider.name || providerID,
  182. envVar,
  183. })
  184. }
  185. }
  186. }
  187. if (activeEnvVars.length > 0) {
  188. UI.empty()
  189. prompts.intro("Environment")
  190. for (const { provider, envVar } of activeEnvVars) {
  191. prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
  192. }
  193. prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
  194. }
  195. },
  196. })
  197. export const AuthLoginCommand = cmd({
  198. command: "login [url]",
  199. describe: "log in to a provider",
  200. builder: (yargs) =>
  201. yargs.positional("url", {
  202. describe: "opencode auth provider",
  203. type: "string",
  204. }),
  205. async handler(args) {
  206. await Instance.provide({
  207. directory: process.cwd(),
  208. async fn() {
  209. UI.empty()
  210. prompts.intro("Add credential")
  211. if (args.url) {
  212. const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
  213. prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
  214. const proc = Bun.spawn({
  215. cmd: wellknown.auth.command,
  216. stdout: "pipe",
  217. })
  218. const exit = await proc.exited
  219. if (exit !== 0) {
  220. prompts.log.error("Failed")
  221. prompts.outro("Done")
  222. return
  223. }
  224. const token = await new Response(proc.stdout).text()
  225. await Auth.set(args.url, {
  226. type: "wellknown",
  227. key: wellknown.auth.env,
  228. token: token.trim(),
  229. })
  230. prompts.log.success("Logged into " + args.url)
  231. prompts.outro("Done")
  232. return
  233. }
  234. await ModelsDev.refresh().catch(() => {})
  235. const config = await Config.get()
  236. const disabled = new Set(config.disabled_providers ?? [])
  237. const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
  238. const providers = await ModelsDev.get().then((x) => {
  239. const filtered: Record<string, (typeof x)[string]> = {}
  240. for (const [key, value] of Object.entries(x)) {
  241. if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
  242. filtered[key] = value
  243. }
  244. }
  245. return filtered
  246. })
  247. const priority: Record<string, number> = {
  248. opencode: 0,
  249. anthropic: 1,
  250. "github-copilot": 2,
  251. openai: 3,
  252. google: 4,
  253. openrouter: 5,
  254. vercel: 6,
  255. }
  256. let provider = await prompts.autocomplete({
  257. message: "Select provider",
  258. maxItems: 8,
  259. options: [
  260. ...pipe(
  261. providers,
  262. values(),
  263. sortBy(
  264. (x) => priority[x.id] ?? 99,
  265. (x) => x.name ?? x.id,
  266. ),
  267. map((x) => ({
  268. label: x.name,
  269. value: x.id,
  270. hint: {
  271. opencode: "recommended",
  272. anthropic: "Claude Max or API key",
  273. }[x.id],
  274. })),
  275. ),
  276. {
  277. value: "other",
  278. label: "Other",
  279. },
  280. ],
  281. })
  282. if (prompts.isCancel(provider)) throw new UI.CancelledError()
  283. const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
  284. if (plugin && plugin.auth) {
  285. const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
  286. if (handled) return
  287. }
  288. if (provider === "other") {
  289. provider = await prompts.text({
  290. message: "Enter provider id",
  291. validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
  292. })
  293. if (prompts.isCancel(provider)) throw new UI.CancelledError()
  294. provider = provider.replace(/^@ai-sdk\//, "")
  295. if (prompts.isCancel(provider)) throw new UI.CancelledError()
  296. // Check if a plugin provides auth for this custom provider
  297. const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
  298. if (customPlugin && customPlugin.auth) {
  299. const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
  300. if (handled) return
  301. }
  302. prompts.log.warn(
  303. `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
  304. )
  305. }
  306. if (provider === "amazon-bedrock") {
  307. prompts.log.info(
  308. "Amazon Bedrock authentication priority:\n" +
  309. " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
  310. " 2. AWS credential chain (profile, access keys, IAM roles)\n\n" +
  311. "Configure via opencode.json options (profile, region, endpoint) or\n" +
  312. "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
  313. )
  314. }
  315. if (provider === "opencode") {
  316. prompts.log.info("Create an api key at https://opencode.ai/auth")
  317. }
  318. if (provider === "vercel") {
  319. prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
  320. }
  321. if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
  322. prompts.log.info(
  323. "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
  324. )
  325. }
  326. const key = await prompts.password({
  327. message: "Enter your API key",
  328. validate: (x) => (x && x.length > 0 ? undefined : "Required"),
  329. })
  330. if (prompts.isCancel(key)) throw new UI.CancelledError()
  331. await Auth.set(provider, {
  332. type: "api",
  333. key,
  334. })
  335. prompts.outro("Done")
  336. },
  337. })
  338. },
  339. })
  340. export const AuthLogoutCommand = cmd({
  341. command: "logout",
  342. describe: "log out from a configured provider",
  343. async handler() {
  344. UI.empty()
  345. const credentials = await Auth.all().then((x) => Object.entries(x))
  346. prompts.intro("Remove credential")
  347. if (credentials.length === 0) {
  348. prompts.log.error("No credentials found")
  349. return
  350. }
  351. const database = await ModelsDev.get()
  352. const providerID = await prompts.select({
  353. message: "Select provider",
  354. options: credentials.map(([key, value]) => ({
  355. label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
  356. value: key,
  357. })),
  358. })
  359. if (prompts.isCancel(providerID)) throw new UI.CancelledError()
  360. await Auth.remove(providerID)
  361. prompts.outro("Logout successful")
  362. },
  363. })