* 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>
154 lines
3.4 KiB
TypeScript
154 lines
3.4 KiB
TypeScript
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
import { dirname, join } from "node:path"
|
|
import { getOpenCodeConfigDir } from "../../shared"
|
|
|
|
export interface OAuthTokenData {
|
|
accessToken: string
|
|
refreshToken?: string
|
|
expiresAt?: number
|
|
clientInfo?: {
|
|
clientId: string
|
|
clientSecret?: string
|
|
}
|
|
}
|
|
|
|
type TokenStore = Record<string, OAuthTokenData>
|
|
|
|
const STORAGE_FILE_NAME = "mcp-oauth.json"
|
|
|
|
export function getMcpOauthStoragePath(): string {
|
|
return join(getOpenCodeConfigDir({ binary: "opencode" }), STORAGE_FILE_NAME)
|
|
}
|
|
|
|
function normalizeHost(serverHost: string): string {
|
|
let host = serverHost.trim()
|
|
if (!host) return host
|
|
|
|
if (host.includes("://")) {
|
|
try {
|
|
host = new URL(host).hostname
|
|
} catch {
|
|
host = host.split("/")[0]
|
|
}
|
|
} else {
|
|
host = host.split("/")[0]
|
|
}
|
|
|
|
if (host.startsWith("[")) {
|
|
const closing = host.indexOf("]")
|
|
if (closing !== -1) {
|
|
host = host.slice(0, closing + 1)
|
|
}
|
|
return host
|
|
}
|
|
|
|
if (host.includes(":")) {
|
|
host = host.split(":")[0]
|
|
}
|
|
|
|
return host
|
|
}
|
|
|
|
function normalizeResource(resource: string): string {
|
|
return resource.replace(/^\/+/, "")
|
|
}
|
|
|
|
function buildKey(serverHost: string, resource: string): string {
|
|
const host = normalizeHost(serverHost)
|
|
const normalizedResource = normalizeResource(resource)
|
|
return `${host}/${normalizedResource}`
|
|
}
|
|
|
|
function readStore(): TokenStore | null {
|
|
const filePath = getMcpOauthStoragePath()
|
|
if (!existsSync(filePath)) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(filePath, "utf-8")
|
|
return JSON.parse(content) as TokenStore
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function writeStore(store: TokenStore): boolean {
|
|
const filePath = getMcpOauthStoragePath()
|
|
|
|
try {
|
|
const dir = dirname(filePath)
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true })
|
|
}
|
|
|
|
writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
|
chmodSync(filePath, 0o600)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function loadToken(serverHost: string, resource: string): OAuthTokenData | null {
|
|
const store = readStore()
|
|
if (!store) return null
|
|
|
|
const key = buildKey(serverHost, resource)
|
|
return store[key] ?? null
|
|
}
|
|
|
|
export function saveToken(serverHost: string, resource: string, token: OAuthTokenData): boolean {
|
|
const store = readStore() ?? {}
|
|
const key = buildKey(serverHost, resource)
|
|
store[key] = token
|
|
return writeStore(store)
|
|
}
|
|
|
|
export function deleteToken(serverHost: string, resource: string): boolean {
|
|
const store = readStore()
|
|
if (!store) return true
|
|
|
|
const key = buildKey(serverHost, resource)
|
|
if (!(key in store)) {
|
|
return true
|
|
}
|
|
|
|
delete store[key]
|
|
|
|
if (Object.keys(store).length === 0) {
|
|
try {
|
|
const filePath = getMcpOauthStoragePath()
|
|
if (existsSync(filePath)) {
|
|
unlinkSync(filePath)
|
|
}
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return writeStore(store)
|
|
}
|
|
|
|
export function listTokensByHost(serverHost: string): TokenStore {
|
|
const store = readStore()
|
|
if (!store) return {}
|
|
|
|
const host = normalizeHost(serverHost)
|
|
const prefix = `${host}/`
|
|
const result: TokenStore = {}
|
|
|
|
for (const [key, value] of Object.entries(store)) {
|
|
if (key.startsWith(prefix)) {
|
|
result[key] = value
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
export function listAllTokens(): TokenStore {
|
|
return readStore() ?? {}
|
|
}
|