openai-auth.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import { createServer } from "node:http"
  2. import { Deferred, Effect } from "effect"
  3. import { Integration } from "../../integration"
  4. import { Credential } from "../../credential"
  5. import { InstallationVersion } from "../../installation/version"
  6. const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
  7. const issuer = "https://auth.openai.com"
  8. const callbackPort = 1455
  9. const pollingSafetyMargin = 3000
  10. type Pkce = {
  11. verifier: string
  12. challenge: string
  13. }
  14. type TokenResponse = {
  15. id_token: string
  16. access_token: string
  17. refresh_token: string
  18. expires_in?: number
  19. }
  20. type Claims = {
  21. chatgpt_account_id?: string
  22. organizations?: Array<{ id: string }>
  23. "https://api.openai.com/auth"?: { chatgpt_account_id?: string }
  24. }
  25. const browserMethodID = Integration.MethodID.make("chatgpt-browser")
  26. const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
  27. export const browser = {
  28. integrationID: Integration.ID.make("openai"),
  29. method: {
  30. id: browserMethodID,
  31. type: "oauth",
  32. label: "ChatGPT Pro/Plus (browser)",
  33. },
  34. authorize: () =>
  35. Effect.gen(function* () {
  36. const pkce = yield* Effect.promise(generatePKCE)
  37. const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
  38. const code = yield* Deferred.make<string, Error>()
  39. const redirect = `http://localhost:${callbackPort}/auth/callback`
  40. const server = createServer((request, response) => {
  41. const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
  42. if (url.pathname !== "/auth/callback") {
  43. response.writeHead(404).end("Not found")
  44. return
  45. }
  46. const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
  47. const value = url.searchParams.get("code")
  48. if (error) {
  49. Effect.runFork(Deferred.fail(code, new Error(error)))
  50. response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
  51. return
  52. }
  53. if (!value || url.searchParams.get("state") !== state) {
  54. const message = value ? "Invalid OAuth state" : "Missing authorization code"
  55. Effect.runFork(Deferred.fail(code, new Error(message)))
  56. response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
  57. return
  58. }
  59. Effect.runFork(Deferred.succeed(code, value))
  60. response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
  61. })
  62. yield* Effect.callback<void, Error>((resume) => {
  63. server.once("error", (error) => resume(Effect.fail(error)))
  64. server.listen(callbackPort, "localhost", () => resume(Effect.void))
  65. })
  66. yield* Effect.addFinalizer(() =>
  67. Effect.sync(() => {
  68. server.close()
  69. }),
  70. )
  71. return {
  72. mode: "auto" as const,
  73. url: authorizeURL(redirect, pkce, state),
  74. instructions: "Complete authorization in your browser. This window will close automatically.",
  75. callback: Deferred.await(code).pipe(
  76. Effect.flatMap((value) => exchange(value, redirect, pkce)),
  77. Effect.map((tokens) => credential(browserMethodID, tokens)),
  78. ),
  79. }
  80. }),
  81. refresh: (value) => refresh(value),
  82. } satisfies Integration.OAuthImplementation
  83. export const headless = {
  84. integrationID: Integration.ID.make("openai"),
  85. method: {
  86. id: headlessMethodID,
  87. type: "oauth",
  88. label: "ChatGPT Pro/Plus (headless)",
  89. },
  90. authorize: () =>
  91. Effect.gen(function* () {
  92. const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
  93. `${issuer}/api/accounts/deviceauth/usercode`,
  94. {
  95. method: "POST",
  96. headers: headers("application/json"),
  97. body: JSON.stringify({ client_id: clientID }),
  98. },
  99. )
  100. const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
  101. return {
  102. mode: "auto" as const,
  103. url: `${issuer}/codex/device`,
  104. instructions: `Enter code: ${device.user_code}`,
  105. callback: Effect.gen(function* () {
  106. while (true) {
  107. const response = yield* Effect.tryPromise({
  108. try: (signal) =>
  109. fetch(`${issuer}/api/accounts/deviceauth/token`, {
  110. method: "POST",
  111. headers: headers("application/json"),
  112. body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
  113. signal,
  114. }),
  115. catch: (cause) => cause,
  116. })
  117. if (response.ok) {
  118. const data = (yield* Effect.promise(() => response.json())) as {
  119. authorization_code: string
  120. code_verifier: string
  121. }
  122. return credential(
  123. headlessMethodID,
  124. yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
  125. verifier: data.code_verifier,
  126. challenge: "",
  127. }),
  128. )
  129. }
  130. if (response.status !== 403 && response.status !== 404) {
  131. return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
  132. }
  133. yield* Effect.sleep(interval + pollingSafetyMargin)
  134. }
  135. }),
  136. }
  137. }),
  138. refresh: (value) => refresh(value),
  139. } satisfies Integration.OAuthImplementation
  140. function headers(contentType: string) {
  141. return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
  142. }
  143. function exchange(code: string, redirect: string, pkce: Pkce) {
  144. return request<TokenResponse>(`${issuer}/oauth/token`, {
  145. method: "POST",
  146. headers: headers("application/x-www-form-urlencoded"),
  147. body: new URLSearchParams({
  148. grant_type: "authorization_code",
  149. code,
  150. redirect_uri: redirect,
  151. client_id: clientID,
  152. code_verifier: pkce.verifier,
  153. }).toString(),
  154. })
  155. }
  156. function refresh(value: Credential.OAuth) {
  157. return request<TokenResponse>(`${issuer}/oauth/token`, {
  158. method: "POST",
  159. headers: headers("application/x-www-form-urlencoded"),
  160. body: new URLSearchParams({
  161. grant_type: "refresh_token",
  162. refresh_token: value.refresh,
  163. client_id: clientID,
  164. }).toString(),
  165. }).pipe(
  166. Effect.map((tokens) => {
  167. const next = credential(value.methodID, tokens)
  168. return new Credential.OAuth({
  169. ...next,
  170. metadata: next.metadata ?? value.metadata,
  171. })
  172. }),
  173. )
  174. }
  175. function request<A>(url: string, init: RequestInit) {
  176. return Effect.tryPromise({
  177. try: async (signal) => {
  178. const response = await fetch(url, { ...init, signal })
  179. if (!response.ok) throw new Error(`Request failed: ${response.status}`)
  180. return response.json() as Promise<A>
  181. },
  182. catch: (cause) => cause,
  183. })
  184. }
  185. function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
  186. const accountID = extractAccountID(tokens)
  187. return new Credential.OAuth({
  188. type: "oauth",
  189. methodID,
  190. refresh: tokens.refresh_token,
  191. access: tokens.access_token,
  192. expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
  193. metadata: accountID ? { accountID } : undefined,
  194. })
  195. }
  196. async function generatePKCE(): Promise<Pkce> {
  197. const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
  198. const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
  199. const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
  200. return { verifier, challenge }
  201. }
  202. function base64UrlEncode(buffer: ArrayBuffer) {
  203. return Buffer.from(buffer).toString("base64url")
  204. }
  205. function authorizeURL(redirect: string, pkce: Pkce, state: string) {
  206. return `${issuer}/oauth/authorize?${new URLSearchParams({
  207. response_type: "code",
  208. client_id: clientID,
  209. redirect_uri: redirect,
  210. scope: "openid profile email offline_access",
  211. code_challenge: pkce.challenge,
  212. code_challenge_method: "S256",
  213. id_token_add_organizations: "true",
  214. codex_cli_simplified_flow: "true",
  215. state,
  216. originator: "opencode",
  217. })}`
  218. }
  219. function extractAccountID(tokens: TokenResponse) {
  220. return claim(tokens.id_token) ?? claim(tokens.access_token)
  221. }
  222. function claim(token: string) {
  223. const part = token.split(".")[1]
  224. if (!part) return
  225. try {
  226. const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
  227. return (
  228. claims.chatgpt_account_id ??
  229. claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
  230. claims.organizations?.[0]?.id
  231. )
  232. } catch {
  233. return
  234. }
  235. }
  236. const successPage =
  237. "<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
  238. const errorPage = (message: string) =>
  239. `<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`