Skip to content

Refactor OAuth validation to support approved unregistered clients #230

Description

@davidruzicka

Agent authored:

Summary

This issue tracks a bounded OAuth refactor plus a phase-1 feature addition so HTTP OAuth authorization can succeed for unregistered clients when - and only when - the redirect URI matches an explicitly approved allowlist.

Today, compatibility is partly anchored to special-case client_id behavior (notably the VS Code MCP proxy path). The goal is to replace that narrow exception with a generalized, security-first model that supports localhost and approved custom-scheme callbacks without requiring local client registration on every pod.

This issue is intentionally scoped to phase 1 only. Redis/shared-state support should be implemented later on top of the same boundaries.

Agent-authored by Hermes.

Problem statement

Current OAuth validation is split across multiple layers:

  • transport request handling and client lookup
  • provider-side redirect checks
  • env/profile/runtime config assembly
  • legacy compatibility fallback paths

That split makes it harder to reason about the trust boundary and increases the risk that authorize and token flows diverge.

In addition, the current compatibility behavior for VS Code is tied to a special-case client_id, while the desired behavior is broader and more explicit:

  • registered clients continue to work normally
  • unregistered clients can be accepted only when their redirect_uri matches a configured allowlist

Goals

  1. Support phase-1 OAuth authorization for unregistered clients with an explicit redirect allowlist.
  2. Replace client_id-specific compatibility as the primary mechanism with redirect-based policy.
  3. Centralize OAuth validation decisions into explicit reusable components.
  4. Keep the implementation low-risk, strongly tested, and ready for later Redis/shared-state support.

Non-goals

  • No Redis/shared-state implementation in this issue
  • No broad OAuth redesign beyond the validation/config boundaries needed here
  • No broadening of unregistered-client trust beyond approved redirect URIs
  • No permissive wildcard host matching for unregistered clients in phase 1

Desired behavior

Registered clients

  • Continue to use the normal registration-based flow.
  • Registered redirect URIs must still match existing client registration rules.
  • Existing defense-in-depth redirect host checks remain in place.

Unregistered clients

  • Are rejected by default.
  • Can be accepted only when all of the following are true:
    • unregistered-client support is enabled
    • redirect_uri is present
    • redirect_uri matches an explicitly approved allowlist
    • token exchange later matches the same client_id, redirect_uri, and registration mode metadata stored with the authorization code

Configuration

Introduce new OAuth config inputs:

  • MCP4_OAUTH_ALLOW_UNREGISTERED_CLIENTS
  • MCP4_OAUTH_ALLOWED_UNREGISTERED_REDIRECTS

Expected semantics:

  • MCP4_OAUTH_ALLOW_UNREGISTERED_CLIENTS=false by default
  • invalid boolean values fail fast at startup
  • if unregistered-client support is enabled but the allowlist is empty, startup fails fast
  • unregistered redirect patterns are parsed and normalized once at startup, not per request

Approved redirect policy for phase 1

Support a conservative allowlist model only:

  • http://localhost:*
  • http://127.0.0.1:*
  • optionally http://[::1]:* if IPv6 localhost handling is already consistent
  • exact approved custom-scheme callbacks such as cursor://oauth/callback or vscode://mcp-auth/callback

Do not support in phase 1:

  • wildcard hosts for unregistered clients
  • CIDR-based unregistered redirect patterns
  • broad custom-scheme wildcards such as cursor://*
  • substring or prefix matching instead of parsed URI matching

Architectural direction

Implement the change around three explicit components.

1. src/auth/oauth-runtime-config.ts

Responsibilities:

  • resolve final OAuth runtime config from env/profile inputs
  • apply precedence in one place
  • validate config at startup
  • parse and normalize approved unregistered redirect patterns once

Expected output should become the one authoritative runtime OAuth shape used by the transport/provider path.

2. src/auth/redirect-uri-validator.ts

Responsibilities:

  • shared redirect parsing, normalization, and dangerous-scheme rejection
  • support two explicit validation modes:
    • registered
    • unregistered
  • handle exact registered redirect matching plus existing host allowlist defense-in-depth
  • handle unregistered redirect allowlist matching

This must be the single shared redirect validation component to avoid duplicated parsing and edge-case drift.

3. src/auth/oauth-client-validation-policy.ts

Responsibilities:

  • act as the single source of truth for OAuth client acceptance rules
  • decide registered vs unregistered client mode during authorize
  • validate token exchange against stored authorization-code metadata
  • keep unregistered-client trust anchored to redirect allowlist + authorization-code metadata, not registry lookup

Trust model

The implementation must keep the trust boundary explicit:

  • registered client trust is anchored in the registry
  • unregistered client trust is anchored only in approved redirect validation plus authorization-code metadata

Unregistered clients must not be silently promoted into normal registered clients.
Pseudo-registration or temporary insertion into the client registry is out of scope.

Authorization-code metadata

Store the following authoritative metadata with authorization codes:

interface AuthorizationCodeMetadata {
  clientId: string;
  redirectUri?: string;
  registrationMode: 'registered' | 'unregistered';
}

This metadata must be used by the token flow so unregistered-client token exchange does not depend on later registry lookup.

Legacy VS Code compatibility

Keep existing VS Code compatibility working during migration, but isolate it into a clearly bounded compatibility helper/adapter rather than leaving fallback branches scattered across transport/provider logic.

The new generalized unregistered-client flow should become the primary path.
The legacy special-case path should remain temporary and explicitly marked for later cleanup.

Implementation plan

Step 1 - Centralize runtime OAuth config

  • Add src/auth/oauth-runtime-config.ts
  • Move OAuth runtime config assembly/validation into that module
  • Keep behavior unchanged where possible
  • Wire transport/provider consumers to use the new final config shape

Step 2 - Extract redirect validation

  • Add src/auth/redirect-uri-validator.ts
  • Move shared redirect parsing, normalization, dangerous-scheme checks, and matching there
  • Reuse one validator for both registered and unregistered modes

Step 3 - Introduce canonical client-validation policy

  • Add src/auth/oauth-client-validation-policy.ts
  • Move registered/unregistered client acceptance rules there
  • Keep transport focused on request parsing and response mapping
  • Keep provider focused on protocol execution

Step 4 - Switch authorize flow to policy-driven resolution

  • Resolve registered vs unregistered mode through the policy layer
  • Reject unregistered clients unless redirect allowlist policy passes
  • Create only minimal internal ephemeral client shapes for unregistered flows

Step 5 - Switch token flow to metadata-driven validation

  • Persist clientId, redirectUri, and registrationMode in authorization-code metadata
  • Validate token exchange against that metadata for both registered and unregistered modes
  • Do not require registry lookup for unregistered flows

Step 6 - Add generalized approved unregistered-client support

  • Enable localhost/custom-scheme approved redirect support for unregistered clients
  • Ensure legacy VS Code behavior still works through the isolated compatibility layer

Step 7 - Add full regression coverage

  • Unit tests for runtime config
  • Unit tests for redirect validation
  • Unit tests for client-validation policy
  • Integration tests for authorize/token parity
  • Legacy VS Code regression tests
  • Generalized unregistered localhost regression tests

Files likely affected

  • src/core/cli-config.ts
  • src/transport/http-transport-config.ts
  • src/transport/http-transport.ts
  • src/auth/oauth-provider.ts
  • src/core/errors.ts if new typed error coverage is needed
  • docs/OAUTH.md
  • docs/HTTP-TRANSPORT.md
  • README.md
  • CHANGELOG.md
  • related OAuth and transport test files

New files:

  • src/auth/oauth-runtime-config.ts
  • src/auth/oauth-runtime-config.test.ts
  • src/auth/redirect-uri-validator.ts
  • src/auth/redirect-uri-validator.test.ts
  • src/auth/oauth-client-validation-policy.ts
  • src/auth/oauth-client-validation-policy.test.ts

Testing requirements

Runtime config

  • precedence between env/profile/defaults
  • invalid booleans fail fast
  • malformed redirect patterns fail fast
  • empty allowlist with unregistered-client support enabled fails fast

Redirect validation

  • table-driven success/failure coverage for:
    • localhost allow
    • 127.0.0.1 allow
    • optional IPv6 localhost allow
    • exact custom-scheme allow
    • dangerous scheme rejection
    • malformed URI rejection
    • userinfo/host confusion rejection
    • localhost.evil.com rejection
    • localhost@evil.com rejection

Flow parity

  • registered authorize + token parity
  • unregistered authorize + token parity
  • token rejection when client_id mismatches code metadata
  • token rejection when redirect_uri mismatches code metadata

Regression

  • legacy VS Code compatibility still works during migration
  • new generalized unregistered localhost flow works without relying on the old special-case client_id

Acceptance criteria

  • OAuth decision logic is centralized into the three explicit components above
  • registered and unregistered client trust models are explicit and separate
  • unregistered clients are denied by default
  • unregistered clients can be authorized only through the explicit approved redirect allowlist
  • authorize and token flows share the same registration-mode model through authorization-code metadata
  • redirect parsing/normalization is not duplicated and is not re-parsed per request
  • legacy VS Code compatibility still works during migration through an isolated compat path
  • docs are updated for the new env/config behavior
  • targeted tests pass, plus npm run typecheck

Related work

This issue should align with those directions and avoid duplicating low-level matcher logic again.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions