auth.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import path from "path"
  2. import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
  3. import { Identifier } from "./util/identifier"
  4. import { NonNegativeInt, withStatics } from "./schema"
  5. import { Global } from "./global"
  6. import { AppFileSystem } from "./filesystem"
  7. export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
  8. const AccountID = Schema.String.pipe(
  9. Schema.brand("AccountID"),
  10. withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
  11. )
  12. export type AccountID = typeof AccountID.Type
  13. export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
  14. export type ServiceID = typeof ServiceID.Type
  15. export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
  16. type: Schema.Literal("oauth"),
  17. refresh: Schema.String,
  18. access: Schema.String,
  19. expires: NonNegativeInt,
  20. }) {}
  21. export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
  22. type: Schema.Literal("api"),
  23. key: Schema.String,
  24. metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
  25. }) {}
  26. export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
  27. .pipe(Schema.toTaggedUnion("type"))
  28. .annotate({
  29. identifier: "AuthV2.Credential",
  30. })
  31. export type Credential = Schema.Schema.Type<typeof Credential>
  32. export class Account extends Schema.Class<Account>("AuthV2.Account")({
  33. id: AccountID,
  34. serviceID: ServiceID,
  35. description: Schema.String,
  36. credential: Credential,
  37. }) {}
  38. export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
  39. operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
  40. cause: Schema.Defect,
  41. }) {}
  42. export type AuthError = AuthFileWriteError
  43. interface Writable {
  44. version: 2
  45. accounts: Record<string, Account>
  46. active: Record<string, AccountID>
  47. }
  48. const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
  49. function migrate(old: Record<string, unknown>): Writable {
  50. const accounts: Record<string, Account> = {}
  51. const active: Record<string, AccountID> = {}
  52. for (const [serviceID, value] of Object.entries(old)) {
  53. const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
  54. const parsed = (decoded as Record<string, Credential>)[serviceID]
  55. if (!parsed) continue
  56. const id = Identifier.ascending()
  57. const accountID = AccountID.make(id)
  58. const brandedServiceID = ServiceID.make(serviceID)
  59. accounts[id] = new Account({
  60. id: accountID,
  61. serviceID: brandedServiceID,
  62. description: "default",
  63. credential: parsed,
  64. })
  65. active[brandedServiceID] = accountID
  66. }
  67. return { version: 2, accounts, active }
  68. }
  69. export interface Interface {
  70. readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
  71. readonly all: () => Effect.Effect<Account[], AuthError>
  72. readonly create: (input: {
  73. serviceID: ServiceID
  74. credential: Credential
  75. description?: string
  76. active?: boolean
  77. }) => Effect.Effect<Account, AuthError>
  78. readonly update: (
  79. accountID: AccountID,
  80. updates: Partial<Pick<Account, "description" | "credential">>,
  81. ) => Effect.Effect<void, AuthError>
  82. readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
  83. readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
  84. readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
  85. readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
  86. }
  87. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
  88. export const layer = Layer.effect(
  89. Service,
  90. Effect.gen(function* () {
  91. const fsys = yield* AppFileSystem.Service
  92. const global = yield* Global.Service
  93. const file = path.join(global.data, "auth-v2.json")
  94. const legacyFile = path.join(global.data, "auth.json")
  95. const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
  96. const migrated = migrate(raw)
  97. yield* fsys
  98. .writeJson(file, migrated, 0o600)
  99. .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
  100. return migrated
  101. })
  102. const parseAuthContent = () => {
  103. try {
  104. return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
  105. } catch {}
  106. }
  107. const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
  108. if (process.env.OPENCODE_AUTH_CONTENT) {
  109. const raw = parseAuthContent()
  110. if (raw && typeof raw === "object") {
  111. if ("version" in raw && raw.version === 2) return raw as Writable
  112. return yield* writeMigrated(raw as Record<string, unknown>)
  113. }
  114. return { version: 2, accounts: {}, active: {} }
  115. }
  116. const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
  117. if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
  118. const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
  119. if (raw && typeof raw === "object") {
  120. if ("version" in raw && raw.version === 2) return raw as Writable
  121. return yield* writeMigrated(raw as Record<string, unknown>)
  122. }
  123. return { version: 2, accounts: {}, active: {} }
  124. })
  125. const write = (data: Writable) =>
  126. fsys
  127. .writeJson(file, data, 0o600)
  128. .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
  129. const state = SynchronizedRef.makeUnsafe(yield* load())
  130. const result: Interface = {
  131. get: Effect.fn("AuthV2.get")(function* (accountID) {
  132. return (yield* SynchronizedRef.get(state)).accounts[accountID]
  133. }),
  134. all: Effect.fn("AuthV2.all")(function* () {
  135. return Object.values((yield* SynchronizedRef.get(state)).accounts)
  136. }),
  137. active: Effect.fn("AuthV2.active")(function* (serviceID) {
  138. const data = yield* SynchronizedRef.get(state)
  139. return (
  140. data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
  141. )
  142. }),
  143. forService: Effect.fn("AuthV2.list")(function* (serviceID) {
  144. return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
  145. }),
  146. create: Effect.fn("AuthV2.add")(function* (input) {
  147. return yield* SynchronizedRef.modifyEffect(
  148. state,
  149. Effect.fnUntraced(function* (data) {
  150. const account = new Account({
  151. id: AccountID.make(Identifier.ascending()),
  152. serviceID: input.serviceID,
  153. description: input.description ?? "default",
  154. credential: input.credential,
  155. })
  156. const next = {
  157. ...data,
  158. accounts: { ...data.accounts, [account.id]: account },
  159. active:
  160. (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
  161. ? { ...data.active, [input.serviceID]: account.id }
  162. : data.active,
  163. }
  164. yield* write(next)
  165. return [account, next] as const
  166. }),
  167. )
  168. }),
  169. update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
  170. yield* SynchronizedRef.modifyEffect(
  171. state,
  172. Effect.fnUntraced(function* (data) {
  173. const existing = data.accounts[accountID]
  174. if (!existing) return [undefined, data] as const
  175. const next = {
  176. ...data,
  177. accounts: {
  178. ...data.accounts,
  179. [accountID]: new Account({
  180. id: accountID,
  181. serviceID: existing.serviceID,
  182. description: updates.description ?? existing.description,
  183. credential: updates.credential ?? existing.credential,
  184. }),
  185. },
  186. }
  187. yield* write(next)
  188. return [undefined, next] as const
  189. }),
  190. )
  191. }),
  192. remove: Effect.fn("AuthV2.remove")(function* (accountID) {
  193. yield* SynchronizedRef.modifyEffect(
  194. state,
  195. Effect.fnUntraced(function* (data) {
  196. const accounts = { ...data.accounts }
  197. const active = { ...data.active }
  198. if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
  199. delete active[accounts[accountID].serviceID]
  200. delete accounts[accountID]
  201. const next = { ...data, accounts, active }
  202. yield* write(next)
  203. return [undefined, next] as const
  204. }),
  205. )
  206. }),
  207. activate: Effect.fn("AuthV2.activate")(function* (accountID) {
  208. yield* SynchronizedRef.modifyEffect(
  209. state,
  210. Effect.fnUntraced(function* (data) {
  211. const account = data.accounts[accountID]
  212. if (!account) return [undefined, data] as const
  213. const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
  214. yield* write(next)
  215. return [undefined, next] as const
  216. }),
  217. )
  218. }),
  219. }
  220. return Service.of(result)
  221. }),
  222. )
  223. export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
  224. export * as AuthV2 from "./auth"