oauth-provider.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  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 { McpAuth } from "./auth"
  9. import { Log } from "../util/log"
  10. const log = Log.create({ service: "mcp.oauth" })
  11. const OAUTH_CALLBACK_PORT = 19876
  12. const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback"
  13. export interface McpOAuthConfig {
  14. clientId?: string
  15. clientSecret?: string
  16. scope?: string
  17. }
  18. export interface McpOAuthCallbacks {
  19. onRedirect: (url: URL) => void | Promise<void>
  20. }
  21. export class McpOAuthProvider implements OAuthClientProvider {
  22. constructor(
  23. private mcpName: string,
  24. private serverUrl: string,
  25. private config: McpOAuthConfig,
  26. private callbacks: McpOAuthCallbacks,
  27. ) {}
  28. get redirectUrl(): string {
  29. return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
  30. }
  31. get clientMetadata(): OAuthClientMetadata {
  32. return {
  33. redirect_uris: [this.redirectUrl],
  34. client_name: "OpenCode",
  35. client_uri: "https://opencode.ai",
  36. grant_types: ["authorization_code", "refresh_token"],
  37. response_types: ["code"],
  38. token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
  39. }
  40. }
  41. async clientInformation(): Promise<OAuthClientInformation | undefined> {
  42. // Check config first (pre-registered client)
  43. if (this.config.clientId) {
  44. return {
  45. client_id: this.config.clientId,
  46. client_secret: this.config.clientSecret,
  47. }
  48. }
  49. // Check stored client info (from dynamic registration)
  50. // Use getForUrl to validate credentials are for the current server URL
  51. const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
  52. if (entry?.clientInfo) {
  53. // Check if client secret has expired
  54. if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
  55. log.info("client secret expired, need to re-register", { mcpName: this.mcpName })
  56. return undefined
  57. }
  58. return {
  59. client_id: entry.clientInfo.clientId,
  60. client_secret: entry.clientInfo.clientSecret,
  61. }
  62. }
  63. // No client info or URL changed - will trigger dynamic registration
  64. return undefined
  65. }
  66. async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
  67. await McpAuth.updateClientInfo(
  68. this.mcpName,
  69. {
  70. clientId: info.client_id,
  71. clientSecret: info.client_secret,
  72. clientIdIssuedAt: info.client_id_issued_at,
  73. clientSecretExpiresAt: info.client_secret_expires_at,
  74. },
  75. this.serverUrl,
  76. )
  77. log.info("saved dynamically registered client", {
  78. mcpName: this.mcpName,
  79. clientId: info.client_id,
  80. })
  81. }
  82. async tokens(): Promise<OAuthTokens | undefined> {
  83. // Use getForUrl to validate tokens are for the current server URL
  84. const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
  85. if (!entry?.tokens) return undefined
  86. return {
  87. access_token: entry.tokens.accessToken,
  88. token_type: "Bearer",
  89. refresh_token: entry.tokens.refreshToken,
  90. expires_in: entry.tokens.expiresAt
  91. ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
  92. : undefined,
  93. scope: entry.tokens.scope,
  94. }
  95. }
  96. async saveTokens(tokens: OAuthTokens): Promise<void> {
  97. await McpAuth.updateTokens(
  98. this.mcpName,
  99. {
  100. accessToken: tokens.access_token,
  101. refreshToken: tokens.refresh_token,
  102. expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
  103. scope: tokens.scope,
  104. },
  105. this.serverUrl,
  106. )
  107. log.info("saved oauth tokens", { mcpName: this.mcpName })
  108. }
  109. async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
  110. log.info("redirecting to authorization", { mcpName: this.mcpName, url: authorizationUrl.toString() })
  111. await this.callbacks.onRedirect(authorizationUrl)
  112. }
  113. async saveCodeVerifier(codeVerifier: string): Promise<void> {
  114. await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
  115. }
  116. async codeVerifier(): Promise<string> {
  117. const entry = await McpAuth.get(this.mcpName)
  118. if (!entry?.codeVerifier) {
  119. throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
  120. }
  121. return entry.codeVerifier
  122. }
  123. async saveState(state: string): Promise<void> {
  124. await McpAuth.updateOAuthState(this.mcpName, state)
  125. }
  126. async state(): Promise<string> {
  127. const entry = await McpAuth.get(this.mcpName)
  128. if (!entry?.oauthState) {
  129. throw new Error(`No OAuth state saved for MCP server: ${this.mcpName}`)
  130. }
  131. return entry.oauthState
  132. }
  133. }
  134. export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }