OAuth 2.1 authorization server: oidc-provider, storage adapter, consent #151

Closed
opened 2026-09-15 18:23:36 +02:00 by lz · 0 comments
Owner

The authorization server half of MCP support. Nexus issues its own tokens by default so a fresh install stays one container and one compose file.

Depends on nothing but #148 (zod v4). Blocks the resource server and every tool.

Why a library

PKCE verification, single-use codes, exact redirect-URI matching, refresh rotation and audience binding is precisely the code that should not be novel. oidc-provider is OpenID Certified and implements RFC 6749, 7636, 7591, 8414, 8707, 7662 and 7009. We supply configuration, a storage adapter and the consent UI — never a token, a code, or a comparison.

Work

Mount. oidc-provider is Koa-shaped and exposes a plain Node (req, res) callback. Mount at /oauth in nexus/src/server.ts, alongside the WebSocket upgrade listener.

This is a surface that bypasses hooks.server.ts, which AGENTS.md fact #23 treats as a known hazard. It is defensible and the reasons must stay written down: the protocol endpoints are meant to sit outside the cookie gate, and the one endpoint needing the operator's identity — consent — redirects back into an ordinary SvelteKit page that runs the normal gate. Nothing authenticated is decided in the bypassing half. Note this is the only such surface MCP adds; /api/mcp stays inside the gate.

Storage. Migration 0021 (next free — migration-numbers.test.ts guards collisions, after the 0017 collision that crash-looped production). Kysely adapter for oidc-provider.

Tokens stored as unsalted SHA-256, for the reasons AGENTS.md fact #24 argues for nxs_: a CSPRNG credential is unguessable at any hash speed, Argon2id would cost ~100 ms on every authenticated request, and a salted hash cannot be looked up by digest.

Nothing here is sealed under the vault DEK — a client registration is not a secret Nexus keeps from itself, and the token store holds digests. So no encrypted_* column, and nothing for upgradeLegacyVault to cover (fact #28). sealed-columns.test.ts already enforces this direction.

Consent through interactions.

Claude → /oauth/auth?…                (Node handler, oidc-provider)
       → 302 /oauth/consent/<uid>     (SvelteKit page, normal gate)
       → locked? 303 /unlock?next=/oauth/consent/<uid>      ← needs #150
       → Approve → POST back to oidc-provider
       → 302 https://claude.ai/api/mcp/auth_callback?code=…

next only ever carries a short /oauth/consent/<uid>, never a long authorization query string, because oidc-provider already holds the request.

The consent page must display the redirect URI hostname, and show an additional warning when the registered redirect URIs are loopback addresses — the MCP authorization spec requires the first and recommends the second, because a client metadata document cannot prove which local process holds a port. Mocks B and C are the same page; the hostname and warning are the only differences.

DCR ships certified in oidc-provider. Keep it disabled by default behind a setting, labelled deprecated by the MCP spec, with a cap on stored clients — enabling it opens an unauthenticated registration endpoint on a public host.

Watch for

The metadata must advertise code_challenge_methods_supported: ["S256"] and "none" in token_endpoint_auth_methods_supported. Claude requires both and silently drops to DCR when either is missing.

Issuer path placement. With an issuer of https://a.lck.sh/oauth, RFC 8414 §3.1 puts metadata at /.well-known/oauth-authorization-server/oauth — the path is inserted after the well-known segment, not appended to the issuer. The intuitive-and-wrong placement returns a correct-looking document and fails silently: the MCP server receives the first request and the authorization server sees no traffic at all. The SDK's oauthMetadataResponse is path-aware and should serve it (#152's sibling issue) rather than a hand-rolled route.

Configuration surface is the risk here, not cryptography. Ship one grant type, one response type, two scopes (nexus:read, nexus:write), and assert the emitted metadata rather than trusting the config.

Done when

An authorization code flow with PKCE S256 completes end to end against a real client, a refresh rotates, a dead refresh returns invalid_grant (not invalid_request — Claude's refresh handling depends on the RFC 6749 code), and the live metadata document carries both required fields.

The authorization server half of MCP support. Nexus issues its own tokens by default so a fresh install stays one container and one compose file. Depends on nothing but #148 (zod v4). Blocks the resource server and every tool. ## Why a library PKCE verification, single-use codes, exact redirect-URI matching, refresh rotation and audience binding is precisely the code that should not be novel. `oidc-provider` is OpenID Certified and implements RFC 6749, 7636, 7591, 8414, 8707, 7662 and 7009. We supply configuration, a storage adapter and the consent UI — never a token, a code, or a comparison. ## Work **Mount.** `oidc-provider` is Koa-shaped and exposes a plain Node `(req, res)` callback. Mount at `/oauth` in `nexus/src/server.ts`, alongside the WebSocket upgrade listener. This is a surface that bypasses `hooks.server.ts`, which AGENTS.md fact #23 treats as a known hazard. It is defensible and the reasons must stay written down: the protocol endpoints are *meant* to sit outside the cookie gate, and the one endpoint needing the operator's identity — consent — redirects back into an ordinary SvelteKit page that runs the normal gate. **Nothing authenticated is decided in the bypassing half.** Note this is the only such surface MCP adds; `/api/mcp` stays inside the gate. **Storage.** Migration `0021` (next free — `migration-numbers.test.ts` guards collisions, after the 0017 collision that crash-looped production). Kysely adapter for `oidc-provider`. Tokens stored as **unsalted SHA-256**, for the reasons AGENTS.md fact #24 argues for `nxs_`: a CSPRNG credential is unguessable at any hash speed, Argon2id would cost ~100 ms on every authenticated request, and a salted hash cannot be looked up by digest. Nothing here is sealed under the vault DEK — a client registration is not a secret Nexus keeps from itself, and the token store holds digests. So no `encrypted_*` column, and nothing for `upgradeLegacyVault` to cover (fact #28). `sealed-columns.test.ts` already enforces this direction. **Consent through interactions.** ``` Claude → /oauth/auth?… (Node handler, oidc-provider) → 302 /oauth/consent/<uid> (SvelteKit page, normal gate) → locked? 303 /unlock?next=/oauth/consent/<uid> ← needs #150 → Approve → POST back to oidc-provider → 302 https://claude.ai/api/mcp/auth_callback?code=… ``` `next` only ever carries a short `/oauth/consent/<uid>`, never a long authorization query string, because oidc-provider already holds the request. The consent page must display the **redirect URI hostname**, and show an additional warning when the registered redirect URIs are loopback addresses — the MCP authorization spec requires the first and recommends the second, because a client metadata document cannot prove which local process holds a port. Mocks B and C are the same page; the hostname and warning are the only differences. **DCR** ships certified in oidc-provider. Keep it **disabled by default** behind a setting, labelled deprecated by the MCP spec, with a cap on stored clients — enabling it opens an unauthenticated registration endpoint on a public host. ## Watch for The metadata must advertise `code_challenge_methods_supported: ["S256"]` **and** `"none"` in `token_endpoint_auth_methods_supported`. Claude requires both and silently drops to DCR when either is missing. **Issuer path placement.** With an issuer of `https://a.lck.sh/oauth`, RFC 8414 §3.1 puts metadata at `/.well-known/oauth-authorization-server/oauth` — the path is *inserted after* the well-known segment, not appended to the issuer. The intuitive-and-wrong placement returns a correct-looking document and fails silently: the MCP server receives the first request and the authorization server sees no traffic at all. The SDK's `oauthMetadataResponse` is path-aware and should serve it (#152's sibling issue) rather than a hand-rolled route. Configuration surface is the risk here, not cryptography. Ship one grant type, one response type, two scopes (`nexus:read`, `nexus:write`), and assert the **emitted metadata** rather than trusting the config. ## Done when An authorization code flow with PKCE S256 completes end to end against a real client, a refresh rotates, a dead refresh returns `invalid_grant` (not `invalid_request` — Claude's refresh handling depends on the RFC 6749 code), and the live metadata document carries both required fields.
lz added this to the MCP support (#140) milestone 2026-09-15 18:23:36 +02:00
lz closed this issue 2026-09-16 14:31:38 +02:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#151
No description provided.