dcda8769cc
* feat(mcp-oauth): add oauth field to ClaudeCodeMcpServer schema Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * feat(mcp-oauth): add RFC 7591 Dynamic Client Registration * feat(mcp-oauth): add RFC 9728 PRM + RFC 8414 AS discovery * feat(mcp-oauth): add secure token storage with {host}/{resource} key format * feat(mcp-oauth): add dynamic port OAuth callback server * feat(mcp-oauth): add RFC 8707 Resource Indicators * feat(mcp-oauth): implement full-spec McpOAuthProvider * feat(mcp-oauth): add step-up authorization handler * feat(mcp-oauth): integrate authProvider into SkillMcpManager * feat(doctor): add MCP OAuth token status check * feat(cli): add mcp oauth subcommand structure * feat(cli): implement mcp oauth login command * fix(mcp-oauth): address cubic review — security, correctness, and test issues - Remove @ts-nocheck from provider.ts, storage.ts, provider.test.ts - Fix server resource leak on missing code/state (close + reject) - Fix command injection in openBrowser (spawn array args, cross-platform) - Mock McpOAuthProvider in login.test.ts for deterministic CI - Recreate auth provider with merged scopes in step-up flow - Add listAllTokens() for global status listing - Fix logout to accept --server-url for correct token deletion - Support both quoted and unquoted WWW-Authenticate params (RFC 2617) - Save/restore OPENCODE_CONFIG_DIR in storage.test.ts - Fix index.test.ts: vitest → bun:test * fix(mcp-oauth): use explorer instead of cmd /c start on Windows to prevent shell injection * fix(mcp-oauth): address remaining cubic review issues - Add 5-minute timeout to provider callback server to prevent indefinite hangs - Persist client registration from token storage across process restarts - Require --server-url for logout to match token storage key format - Use listTokensByHost for server-specific status lookups - Fix callback-server test to handle promise rejection ordering - Fix provider test port expectations (8912 → 19877) - Fix cli-guide.md duplicate Section 7 numbering - Fix manager test for login-on-missing-tokens behavior * fix(mcp-oauth): address final review issues - P1: Redact token values in status.ts output to prevent credential leakage - P2: Read OAuth error response body before throwing in token exchange - Test: Fix mcp-oauth doctor test to use epoch seconds (not milliseconds) --------- Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
296 lines
9.0 KiB
TypeScript
296 lines
9.0 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto"
|
|
import { createServer } from "node:http"
|
|
import { spawn } from "node:child_process"
|
|
import type { OAuthTokenData } from "./storage"
|
|
import { loadToken, saveToken } from "./storage"
|
|
import { discoverOAuthServerMetadata } from "./discovery"
|
|
import type { OAuthServerMetadata } from "./discovery"
|
|
import { getOrRegisterClient } from "./dcr"
|
|
import type { ClientCredentials, ClientRegistrationStorage } from "./dcr"
|
|
import { findAvailablePort } from "./callback-server"
|
|
|
|
export type McpOAuthProviderOptions = {
|
|
serverUrl: string
|
|
clientId?: string
|
|
scopes?: string[]
|
|
}
|
|
|
|
type CallbackResult = {
|
|
code: string
|
|
state: string
|
|
}
|
|
|
|
function generateCodeVerifier(): string {
|
|
return randomBytes(32).toString("base64url")
|
|
}
|
|
|
|
function generateCodeChallenge(verifier: string): string {
|
|
return createHash("sha256").update(verifier).digest("base64url")
|
|
}
|
|
|
|
function buildAuthorizationUrl(
|
|
authorizationEndpoint: string,
|
|
options: {
|
|
clientId: string
|
|
redirectUri: string
|
|
codeChallenge: string
|
|
state: string
|
|
scopes?: string[]
|
|
resource?: string
|
|
}
|
|
): string {
|
|
const url = new URL(authorizationEndpoint)
|
|
url.searchParams.set("response_type", "code")
|
|
url.searchParams.set("client_id", options.clientId)
|
|
url.searchParams.set("redirect_uri", options.redirectUri)
|
|
url.searchParams.set("code_challenge", options.codeChallenge)
|
|
url.searchParams.set("code_challenge_method", "S256")
|
|
url.searchParams.set("state", options.state)
|
|
if (options.scopes && options.scopes.length > 0) {
|
|
url.searchParams.set("scope", options.scopes.join(" "))
|
|
}
|
|
if (options.resource) {
|
|
url.searchParams.set("resource", options.resource)
|
|
}
|
|
return url.toString()
|
|
}
|
|
|
|
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
|
|
|
|
function startCallbackServer(port: number): Promise<CallbackResult> {
|
|
return new Promise((resolve, reject) => {
|
|
let timeoutId: ReturnType<typeof setTimeout>
|
|
|
|
const server = createServer((request, response) => {
|
|
clearTimeout(timeoutId)
|
|
|
|
const requestUrl = new URL(request.url ?? "/", `http://localhost:${port}`)
|
|
const code = requestUrl.searchParams.get("code")
|
|
const state = requestUrl.searchParams.get("state")
|
|
const error = requestUrl.searchParams.get("error")
|
|
|
|
if (error) {
|
|
const errorDescription = requestUrl.searchParams.get("error_description") ?? error
|
|
response.writeHead(400, { "content-type": "text/html" })
|
|
response.end("<html><body><h1>Authorization failed</h1></body></html>")
|
|
server.close()
|
|
reject(new Error(`OAuth authorization error: ${errorDescription}`))
|
|
return
|
|
}
|
|
|
|
if (!code || !state) {
|
|
response.writeHead(400, { "content-type": "text/html" })
|
|
response.end("<html><body><h1>Missing code or state</h1></body></html>")
|
|
server.close()
|
|
reject(new Error("OAuth callback missing code or state parameter"))
|
|
return
|
|
}
|
|
|
|
response.writeHead(200, { "content-type": "text/html" })
|
|
response.end("<html><body><h1>Authorization successful. You can close this tab.</h1></body></html>")
|
|
server.close()
|
|
resolve({ code, state })
|
|
})
|
|
|
|
timeoutId = setTimeout(() => {
|
|
server.close()
|
|
reject(new Error("OAuth callback timed out after 5 minutes"))
|
|
}, CALLBACK_TIMEOUT_MS)
|
|
|
|
server.listen(port, "127.0.0.1")
|
|
server.on("error", (err) => {
|
|
clearTimeout(timeoutId)
|
|
reject(err)
|
|
})
|
|
})
|
|
}
|
|
|
|
function openBrowser(url: string): void {
|
|
const platform = process.platform
|
|
let cmd: string
|
|
let args: string[]
|
|
|
|
if (platform === "darwin") {
|
|
cmd = "open"
|
|
args = [url]
|
|
} else if (platform === "win32") {
|
|
cmd = "explorer"
|
|
args = [url]
|
|
} else {
|
|
cmd = "xdg-open"
|
|
args = [url]
|
|
}
|
|
|
|
try {
|
|
const child = spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
child.on("error", () => {})
|
|
child.unref()
|
|
} catch {
|
|
// Browser open failed — user must navigate manually
|
|
}
|
|
}
|
|
|
|
export class McpOAuthProvider {
|
|
private readonly serverUrl: string
|
|
private readonly configClientId: string | undefined
|
|
private readonly scopes: string[]
|
|
private storedCodeVerifier: string | null = null
|
|
private storedClientInfo: ClientCredentials | null = null
|
|
private callbackPort: number | null = null
|
|
|
|
constructor(options: McpOAuthProviderOptions) {
|
|
this.serverUrl = options.serverUrl
|
|
this.configClientId = options.clientId
|
|
this.scopes = options.scopes ?? []
|
|
}
|
|
|
|
tokens(): OAuthTokenData | null {
|
|
return loadToken(this.serverUrl, this.serverUrl)
|
|
}
|
|
|
|
saveTokens(tokenData: OAuthTokenData): boolean {
|
|
return saveToken(this.serverUrl, this.serverUrl, tokenData)
|
|
}
|
|
|
|
clientInformation(): ClientCredentials | null {
|
|
if (this.storedClientInfo) return this.storedClientInfo
|
|
const tokenData = this.tokens()
|
|
if (tokenData?.clientInfo) {
|
|
this.storedClientInfo = tokenData.clientInfo
|
|
return this.storedClientInfo
|
|
}
|
|
return null
|
|
}
|
|
|
|
redirectUrl(): string {
|
|
return `http://127.0.0.1:${this.callbackPort ?? 19877}/callback`
|
|
}
|
|
|
|
saveCodeVerifier(verifier: string): void {
|
|
this.storedCodeVerifier = verifier
|
|
}
|
|
|
|
codeVerifier(): string | null {
|
|
return this.storedCodeVerifier
|
|
}
|
|
|
|
async redirectToAuthorization(metadata: OAuthServerMetadata): Promise<CallbackResult> {
|
|
const verifier = generateCodeVerifier()
|
|
this.saveCodeVerifier(verifier)
|
|
const challenge = generateCodeChallenge(verifier)
|
|
const state = randomBytes(16).toString("hex")
|
|
|
|
const clientInfo = this.clientInformation()
|
|
if (!clientInfo) {
|
|
throw new Error("No client information available. Run login() or register a client first.")
|
|
}
|
|
|
|
if (this.callbackPort === null) {
|
|
this.callbackPort = await findAvailablePort()
|
|
}
|
|
|
|
const authUrl = buildAuthorizationUrl(metadata.authorizationEndpoint, {
|
|
clientId: clientInfo.clientId,
|
|
redirectUri: this.redirectUrl(),
|
|
codeChallenge: challenge,
|
|
state,
|
|
scopes: this.scopes,
|
|
resource: metadata.resource,
|
|
})
|
|
|
|
const callbackPromise = startCallbackServer(this.callbackPort)
|
|
openBrowser(authUrl)
|
|
|
|
const result = await callbackPromise
|
|
if (result.state !== state) {
|
|
throw new Error("OAuth state mismatch")
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
async login(): Promise<OAuthTokenData> {
|
|
const metadata = await discoverOAuthServerMetadata(this.serverUrl)
|
|
|
|
const clientRegistrationStorage: ClientRegistrationStorage = {
|
|
getClientRegistration: () => this.storedClientInfo,
|
|
setClientRegistration: (_serverIdentifier: string, credentials: ClientCredentials) => {
|
|
this.storedClientInfo = credentials
|
|
},
|
|
}
|
|
|
|
const clientInfo = await getOrRegisterClient({
|
|
registrationEndpoint: metadata.registrationEndpoint,
|
|
serverIdentifier: this.serverUrl,
|
|
clientName: "oh-my-opencode",
|
|
redirectUris: [this.redirectUrl()],
|
|
tokenEndpointAuthMethod: "none",
|
|
clientId: this.configClientId,
|
|
storage: clientRegistrationStorage,
|
|
})
|
|
|
|
if (!clientInfo) {
|
|
throw new Error("Failed to obtain client credentials. Provide a clientId or ensure the server supports DCR.")
|
|
}
|
|
|
|
this.storedClientInfo = clientInfo
|
|
|
|
const { code } = await this.redirectToAuthorization(metadata)
|
|
const verifier = this.codeVerifier()
|
|
if (!verifier) {
|
|
throw new Error("Code verifier not found")
|
|
}
|
|
|
|
const tokenResponse = await fetch(metadata.tokenEndpoint, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
code,
|
|
redirect_uri: this.redirectUrl(),
|
|
client_id: clientInfo.clientId,
|
|
code_verifier: verifier,
|
|
...(metadata.resource ? { resource: metadata.resource } : {}),
|
|
}).toString(),
|
|
})
|
|
|
|
if (!tokenResponse.ok) {
|
|
let errorDetail = `${tokenResponse.status}`
|
|
try {
|
|
const body = (await tokenResponse.json()) as Record<string, unknown>
|
|
if (body.error) {
|
|
errorDetail = `${tokenResponse.status} ${body.error}`
|
|
if (body.error_description) {
|
|
errorDetail += `: ${body.error_description}`
|
|
}
|
|
}
|
|
} catch {
|
|
// Response body not JSON
|
|
}
|
|
throw new Error(`Token exchange failed: ${errorDetail}`)
|
|
}
|
|
|
|
const tokenData = (await tokenResponse.json()) as Record<string, unknown>
|
|
const accessToken = tokenData.access_token
|
|
if (typeof accessToken !== "string") {
|
|
throw new Error("Token response missing access_token")
|
|
}
|
|
|
|
const oauthTokenData: OAuthTokenData = {
|
|
accessToken,
|
|
refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : undefined,
|
|
expiresAt:
|
|
typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
|
clientInfo: {
|
|
clientId: clientInfo.clientId,
|
|
clientSecret: clientInfo.clientSecret,
|
|
},
|
|
}
|
|
|
|
this.saveTokens(oauthTokenData)
|
|
return oauthTokenData
|
|
}
|
|
}
|
|
|
|
export { generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl, startCallbackServer }
|