oauth-provider.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
  2. import type {
  3. OAuthClientMetadata,
  4. OAuthTokens,
  5. OAuthClientInformation,
  6. OAuthClientInformationFull,
  7. } from "@modelcontextprotocol/sdk/shared/auth.js"
  8. import { Effect } from "effect"
  9. import { McpAuth } from "./auth"
  10. const OAUTH_CALLBACK_PORT = 19876
  11. const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback"
  12. export interface McpOAuthConfig {
  13. clientId?: string
  14. clientSecret?: string
  15. scope?: string
  16. callbackPort?: number
  17. redirectUri?: string
  18. }
  19. export interface McpOAuthCallbacks {
  20. onRedirect: (url: URL) => void | Promise<void>
  21. }
  22. export class McpOAuthProvider implements OAuthClientProvider {
  23. constructor(
  24. protected mcpName: string,
  25. protected serverUrl: string,
  26. protected config: McpOAuthConfig,
  27. private callbacks: McpOAuthCallbacks,
  28. protected auth: McpAuth.Interface,
  29. ) {}
  30. get redirectUrl(): string {
  31. if (this.config.redirectUri) {
  32. return this.config.redirectUri
  33. }
  34. const port = this.config.callbackPort ?? OAUTH_CALLBACK_PORT
  35. return `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`
  36. }
  37. get clientMetadata(): OAuthClientMetadata {
  38. return {
  39. redirect_uris: [this.redirectUrl],
  40. client_name: "KirinCode",
  41. client_uri: "https://kirincode.ai",
  42. grant_types: ["authorization_code", "refresh_token"],
  43. response_types: ["code"],
  44. token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
  45. ...(this.config.scope ? { scope: this.config.scope } : {}),
  46. }
  47. }
  48. async clientInformation(): Promise<OAuthClientInformation | undefined> {
  49. if (this.config.clientId) {
  50. return {
  51. client_id: this.config.clientId,
  52. client_secret: this.config.clientSecret,
  53. }
  54. }
  55. // Check stored client info (from dynamic registration)
  56. // Use getForUrl to validate credentials are for the current server URL
  57. const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
  58. if (entry?.clientInfo) {
  59. // Check if client secret has expired
  60. if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
  61. return undefined
  62. }
  63. return {
  64. client_id: entry.clientInfo.clientId,
  65. client_secret: entry.clientInfo.clientSecret,
  66. }
  67. }
  68. // No client info or URL changed - will trigger dynamic registration
  69. return undefined
  70. }
  71. async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
  72. await Effect.runPromise(
  73. this.auth.updateClientInfo(
  74. this.mcpName,
  75. {
  76. clientId: info.client_id,
  77. clientSecret: info.client_secret,
  78. clientIdIssuedAt: info.client_id_issued_at,
  79. clientSecretExpiresAt: info.client_secret_expires_at,
  80. },
  81. this.serverUrl,
  82. ),
  83. )
  84. }
  85. async tokens(): Promise<OAuthTokens | undefined> {
  86. // Use getForUrl to validate tokens are for the current server URL
  87. const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
  88. if (!entry?.tokens) return undefined
  89. return {
  90. access_token: entry.tokens.accessToken,
  91. token_type: "Bearer",
  92. refresh_token: entry.tokens.refreshToken,
  93. expires_in: entry.tokens.expiresAt
  94. ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
  95. : undefined,
  96. scope: entry.tokens.scope,
  97. }
  98. }
  99. async saveTokens(tokens: OAuthTokens): Promise<void> {
  100. await Effect.runPromise(
  101. this.auth.updateTokens(
  102. this.mcpName,
  103. {
  104. accessToken: tokens.access_token,
  105. refreshToken: tokens.refresh_token,
  106. expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
  107. scope: tokens.scope,
  108. },
  109. this.serverUrl,
  110. ),
  111. )
  112. }
  113. async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
  114. await this.callbacks.onRedirect(authorizationUrl)
  115. }
  116. async saveCodeVerifier(codeVerifier: string): Promise<void> {
  117. await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier))
  118. }
  119. async codeVerifier(): Promise<string> {
  120. const entry = await Effect.runPromise(this.auth.get(this.mcpName))
  121. if (!entry?.codeVerifier) {
  122. throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
  123. }
  124. return entry.codeVerifier
  125. }
  126. async saveState(state: string): Promise<void> {
  127. await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state))
  128. }
  129. async state(): Promise<string> {
  130. const entry = await Effect.runPromise(this.auth.get(this.mcpName))
  131. if (entry?.oauthState) {
  132. return entry.oauthState
  133. }
  134. // Generate a new state if none exists — the SDK calls state() as a
  135. // generator, not just a reader, so we need to produce a value even when
  136. // startAuth() hasn't pre-saved one (e.g. during automatic auth on first
  137. // connect).
  138. const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
  139. .map((b) => b.toString(16).padStart(2, "0"))
  140. .join("")
  141. await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState))
  142. return newState
  143. }
  144. async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
  145. const entry = await Effect.runPromise(this.auth.get(this.mcpName))
  146. if (!entry) return
  147. switch (type) {
  148. case "all":
  149. await Effect.runPromise(this.auth.remove(this.mcpName))
  150. break
  151. case "client":
  152. delete entry.clientInfo
  153. await Effect.runPromise(this.auth.set(this.mcpName, entry))
  154. break
  155. case "tokens":
  156. delete entry.tokens
  157. await Effect.runPromise(this.auth.set(this.mcpName, entry))
  158. break
  159. }
  160. }
  161. }
  162. export class McpOAuthPendingProvider extends McpOAuthProvider {
  163. private pendingClientInfo?: OAuthClientInformationFull
  164. private pendingTokens?: OAuthTokens
  165. override async clientInformation(): Promise<OAuthClientInformation | undefined> {
  166. if (!this.config.clientId) return this.pendingClientInfo
  167. return {
  168. client_id: this.config.clientId,
  169. client_secret: this.config.clientSecret,
  170. }
  171. }
  172. override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
  173. this.pendingClientInfo = info
  174. }
  175. override async tokens(): Promise<OAuthTokens | undefined> {
  176. return this.pendingTokens
  177. }
  178. override async saveTokens(tokens: OAuthTokens): Promise<void> {
  179. this.pendingTokens = tokens
  180. }
  181. override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
  182. if (type === "all" || type === "client") this.pendingClientInfo = undefined
  183. if (type === "all" || type === "tokens") this.pendingTokens = undefined
  184. }
  185. async commit(): Promise<void> {
  186. if (!this.pendingTokens) return
  187. await Effect.runPromise(
  188. this.auth.set(
  189. this.mcpName,
  190. {
  191. tokens: {
  192. accessToken: this.pendingTokens.access_token,
  193. refreshToken: this.pendingTokens.refresh_token,
  194. expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
  195. scope: this.pendingTokens.scope,
  196. },
  197. clientInfo:
  198. this.pendingClientInfo && !this.config.clientId
  199. ? {
  200. clientId: this.pendingClientInfo.client_id,
  201. clientSecret: this.pendingClientInfo.client_secret,
  202. clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
  203. clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
  204. }
  205. : undefined,
  206. },
  207. this.serverUrl,
  208. ),
  209. )
  210. }
  211. }
  212. export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
  213. /**
  214. * Parse a redirect URI to extract port and path for the callback server.
  215. * Returns defaults if the URI can't be parsed.
  216. */
  217. export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
  218. if (!redirectUri) {
  219. return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
  220. }
  221. try {
  222. const url = new URL(redirectUri)
  223. const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
  224. const path = url.pathname || OAUTH_CALLBACK_PATH
  225. return { port, path }
  226. } catch {
  227. return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
  228. }
  229. }