Files
oh-my-opencode/src/features/mcp-oauth/dcr.ts
T
YeonGyu-Kim a028d76144 feat(mcp-oauth): add full OAuth 2.1 authentication for MCP servers (#1169)
* 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>
2026-01-29 19:48:36 +09:00

99 lines
2.9 KiB
TypeScript

export type ClientRegistrationRequest = {
redirect_uris: string[]
client_name: string
grant_types: ["authorization_code", "refresh_token"]
response_types: ["code"]
token_endpoint_auth_method: "none" | "client_secret_post"
}
export type ClientCredentials = {
clientId: string
clientSecret?: string
}
export type ClientRegistrationStorage = {
getClientRegistration: (serverIdentifier: string) => ClientCredentials | null
setClientRegistration: (
serverIdentifier: string,
credentials: ClientCredentials
) => void
}
export type DynamicClientRegistrationOptions = {
registrationEndpoint?: string | null
serverIdentifier?: string
clientName: string
redirectUris: string[]
tokenEndpointAuthMethod: "none" | "client_secret_post"
clientId?: string | null
storage: ClientRegistrationStorage
fetch?: DcrFetch
}
export type DcrFetch = (
input: string,
init?: { method?: string; headers?: Record<string, string>; body?: string }
) => Promise<{ ok: boolean; json: () => Promise<unknown> }>
export async function getOrRegisterClient(
options: DynamicClientRegistrationOptions
): Promise<ClientCredentials | null> {
const serverIdentifier =
options.serverIdentifier ?? options.registrationEndpoint ?? "default"
const existing = options.storage.getClientRegistration(serverIdentifier)
if (existing) return existing
if (!options.registrationEndpoint) {
return options.clientId ? { clientId: options.clientId } : null
}
const fetchImpl = options.fetch ?? globalThis.fetch
const request: ClientRegistrationRequest = {
redirect_uris: options.redirectUris,
client_name: options.clientName,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: options.tokenEndpointAuthMethod,
}
try {
const response = await fetchImpl(options.registrationEndpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
})
if (!response.ok) {
return options.clientId ? { clientId: options.clientId } : null
}
const data: unknown = await response.json()
const parsed = parseRegistrationResponse(data)
if (!parsed) {
return options.clientId ? { clientId: options.clientId } : null
}
options.storage.setClientRegistration(serverIdentifier, parsed)
return parsed
} catch {
return options.clientId ? { clientId: options.clientId } : null
}
}
function parseRegistrationResponse(data: unknown): ClientCredentials | null {
if (!isRecord(data)) return null
const clientId = data.client_id
if (typeof clientId !== "string" || clientId.length === 0) return null
const clientSecret = data.client_secret
if (typeof clientSecret === "string" && clientSecret.length > 0) {
return { clientId, clientSecret }
}
return { clientId }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}