oauth-provider.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  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. const entry = await McpAuth.get(this.mcpName)
  51. if (entry?.clientInfo) {
  52. // Check if client secret has expired
  53. if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
  54. log.info("client secret expired, need to re-register", { mcpName: this.mcpName })
  55. return undefined
  56. }
  57. return {
  58. client_id: entry.clientInfo.clientId,
  59. client_secret: entry.clientInfo.clientSecret,
  60. }
  61. }
  62. // No client info - will trigger dynamic registration
  63. return undefined
  64. }
  65. async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
  66. await McpAuth.updateClientInfo(this.mcpName, {
  67. clientId: info.client_id,
  68. clientSecret: info.client_secret,
  69. clientIdIssuedAt: info.client_id_issued_at,
  70. clientSecretExpiresAt: info.client_secret_expires_at,
  71. })
  72. log.info("saved dynamically registered client", {
  73. mcpName: this.mcpName,
  74. clientId: info.client_id,
  75. })
  76. }
  77. async tokens(): Promise<OAuthTokens | undefined> {
  78. const entry = await McpAuth.get(this.mcpName)
  79. if (!entry?.tokens) return undefined
  80. return {
  81. access_token: entry.tokens.accessToken,
  82. token_type: "Bearer",
  83. refresh_token: entry.tokens.refreshToken,
  84. expires_in: entry.tokens.expiresAt
  85. ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
  86. : undefined,
  87. scope: entry.tokens.scope,
  88. }
  89. }
  90. async saveTokens(tokens: OAuthTokens): Promise<void> {
  91. await McpAuth.updateTokens(this.mcpName, {
  92. accessToken: tokens.access_token,
  93. refreshToken: tokens.refresh_token,
  94. expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
  95. scope: tokens.scope,
  96. })
  97. log.info("saved oauth tokens", { mcpName: this.mcpName })
  98. }
  99. async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
  100. log.info("redirecting to authorization", { mcpName: this.mcpName, url: authorizationUrl.toString() })
  101. await this.callbacks.onRedirect(authorizationUrl)
  102. }
  103. async saveCodeVerifier(codeVerifier: string): Promise<void> {
  104. await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
  105. }
  106. async codeVerifier(): Promise<string> {
  107. const entry = await McpAuth.get(this.mcpName)
  108. if (!entry?.codeVerifier) {
  109. throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
  110. }
  111. return entry.codeVerifier
  112. }
  113. }
  114. export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }