oauth-callback.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. import { Log } from "../util/log"
  2. import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
  3. const log = Log.create({ service: "mcp.oauth-callback" })
  4. const HTML_SUCCESS = `<!DOCTYPE html>
  5. <html>
  6. <head>
  7. <title>OpenCode - Authorization Successful</title>
  8. <style>
  9. body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
  10. .container { text-align: center; padding: 2rem; }
  11. h1 { color: #4ade80; margin-bottom: 1rem; }
  12. p { color: #aaa; }
  13. </style>
  14. </head>
  15. <body>
  16. <div class="container">
  17. <h1>Authorization Successful</h1>
  18. <p>You can close this window and return to OpenCode.</p>
  19. </div>
  20. <script>setTimeout(() => window.close(), 2000);</script>
  21. </body>
  22. </html>`
  23. const HTML_ERROR = (error: string) => `<!DOCTYPE html>
  24. <html>
  25. <head>
  26. <title>OpenCode - Authorization Failed</title>
  27. <style>
  28. body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
  29. .container { text-align: center; padding: 2rem; }
  30. h1 { color: #f87171; margin-bottom: 1rem; }
  31. p { color: #aaa; }
  32. .error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
  33. </style>
  34. </head>
  35. <body>
  36. <div class="container">
  37. <h1>Authorization Failed</h1>
  38. <p>An error occurred during authorization.</p>
  39. <div class="error">${error}</div>
  40. </div>
  41. </body>
  42. </html>`
  43. interface PendingAuth {
  44. resolve: (code: string) => void
  45. reject: (error: Error) => void
  46. timeout: ReturnType<typeof setTimeout>
  47. }
  48. export namespace McpOAuthCallback {
  49. let server: ReturnType<typeof Bun.serve> | undefined
  50. const pendingAuths = new Map<string, PendingAuth>()
  51. const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
  52. export async function ensureRunning(): Promise<void> {
  53. if (server) return
  54. const running = await isPortInUse()
  55. if (running) {
  56. log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
  57. return
  58. }
  59. server = Bun.serve({
  60. port: OAUTH_CALLBACK_PORT,
  61. fetch(req) {
  62. const url = new URL(req.url)
  63. if (url.pathname !== OAUTH_CALLBACK_PATH) {
  64. return new Response("Not found", { status: 404 })
  65. }
  66. const code = url.searchParams.get("code")
  67. const state = url.searchParams.get("state")
  68. const error = url.searchParams.get("error")
  69. const errorDescription = url.searchParams.get("error_description")
  70. log.info("received oauth callback", { hasCode: !!code, state, error })
  71. // Enforce state parameter presence
  72. if (!state) {
  73. const errorMsg = "Missing required state parameter - potential CSRF attack"
  74. log.error("oauth callback missing state parameter", { url: url.toString() })
  75. return new Response(HTML_ERROR(errorMsg), {
  76. status: 400,
  77. headers: { "Content-Type": "text/html" },
  78. })
  79. }
  80. if (error) {
  81. const errorMsg = errorDescription || error
  82. if (pendingAuths.has(state)) {
  83. const pending = pendingAuths.get(state)!
  84. clearTimeout(pending.timeout)
  85. pendingAuths.delete(state)
  86. pending.reject(new Error(errorMsg))
  87. }
  88. return new Response(HTML_ERROR(errorMsg), {
  89. headers: { "Content-Type": "text/html" },
  90. })
  91. }
  92. if (!code) {
  93. return new Response(HTML_ERROR("No authorization code provided"), {
  94. status: 400,
  95. headers: { "Content-Type": "text/html" },
  96. })
  97. }
  98. // Validate state parameter
  99. if (!pendingAuths.has(state)) {
  100. const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
  101. log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
  102. return new Response(HTML_ERROR(errorMsg), {
  103. status: 400,
  104. headers: { "Content-Type": "text/html" },
  105. })
  106. }
  107. const pending = pendingAuths.get(state)!
  108. clearTimeout(pending.timeout)
  109. pendingAuths.delete(state)
  110. pending.resolve(code)
  111. return new Response(HTML_SUCCESS, {
  112. headers: { "Content-Type": "text/html" },
  113. })
  114. },
  115. })
  116. log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
  117. }
  118. export function waitForCallback(oauthState: string): Promise<string> {
  119. return new Promise((resolve, reject) => {
  120. const timeout = setTimeout(() => {
  121. if (pendingAuths.has(oauthState)) {
  122. pendingAuths.delete(oauthState)
  123. reject(new Error("OAuth callback timeout - authorization took too long"))
  124. }
  125. }, CALLBACK_TIMEOUT_MS)
  126. pendingAuths.set(oauthState, { resolve, reject, timeout })
  127. })
  128. }
  129. export function cancelPending(mcpName: string): void {
  130. const pending = pendingAuths.get(mcpName)
  131. if (pending) {
  132. clearTimeout(pending.timeout)
  133. pendingAuths.delete(mcpName)
  134. pending.reject(new Error("Authorization cancelled"))
  135. }
  136. }
  137. export async function isPortInUse(): Promise<boolean> {
  138. return new Promise((resolve) => {
  139. Bun.connect({
  140. hostname: "127.0.0.1",
  141. port: OAUTH_CALLBACK_PORT,
  142. socket: {
  143. open(socket) {
  144. socket.end()
  145. resolve(true)
  146. },
  147. error() {
  148. resolve(false)
  149. },
  150. data() {},
  151. close() {},
  152. },
  153. }).catch(() => {
  154. resolve(false)
  155. })
  156. })
  157. }
  158. export async function stop(): Promise<void> {
  159. if (server) {
  160. server.stop()
  161. server = undefined
  162. log.info("oauth callback server stopped")
  163. }
  164. for (const [name, pending] of pendingAuths) {
  165. clearTimeout(pending.timeout)
  166. pending.reject(new Error("OAuth callback server stopped"))
  167. }
  168. pendingAuths.clear()
  169. }
  170. export function isRunning(): boolean {
  171. return server !== undefined
  172. }
  173. }