An OAuth 2.1 authorization server, so Claude can connect from a phone #168

Merged
lz merged 30 commits from feat/oauth-as into main 2026-09-16 14:31:38 +02:00
Owner

Nexus issues its own OAuth 2.1 tokens, so Claude — on a phone, on the desktop, or in another Nexus worker — can authorize against it as a custom connector. Closes #151.

oidc-provider@9.12.2 (OpenID Certified) runs as a Koa app on the raw Node handler in src/server.ts, beside the existing WebSocket upgrade listener. Storage is a Kysely adapter over one SQLite table. Consent is an ordinary SvelteKit page behind the normal cookie gate; only the protocol endpoints sit outside it.

Deliberately not in this PR

Say so here rather than quietly doing it:

  • The CIMD allowlist and resolver (#152). This PR reads cimdAllowlist and advertises the discovery flag from it; nothing populates it, so the flag ships false and Claude Code cannot yet complete a flow against main. That is the correct state — advertising support we cannot honour makes Claude pick a mechanism that then fails.
  • The Settings → MCP panel (#153).
  • /api/mcp, protected-resource metadata and the bearer challenge (#154).

Gates

typecheck 5000 files, 0 errors, 0 warnings
tests 1921 passing / 168 files (was 1893 / 165 on main)
lint clean

Also verified against the built server (pnpm build, node build/server.js) on an isolated DATA_DIR: /.well-known/oauth-authorization-server returns 503 {"error":"public_url is not configured"} unset, and 200 with a matching issuer once set.

Guards proven able to fail

Every one of these was broken deliberately and a named test watched go red, then restored from a file copy. This repo has shipped six tests that could not fail, so the proof is the point:

Mutation Test that went red
drop the composite primary key rejects a duplicate (model, id)
!(expires_at > now)expires_at <= now treats a NaN clock as expired
consume()destroy()'s body marks a consumed payload but still returns it
scope revokeByGrantId by model revokes every model sharing a grant id
drop .where('model',…) from findByUid / findByUserCode / destroy the three model-scoping tests
remove Math.floor from revive both consumed-timestamp tests
re-add consumed_at to doUpdateSet does not un-consume a payload that is upserted again
doUpdateSetdoNothing() upsert replaces rather than duplicating
CIMD flag → literal true reports CIMD unsupported while the allowlist is empty
allowDcr === trueallowDcr !== false keeps dynamic registration OFF by default
routes: {} the 14-route length guard
delete jwks: / delete adapter: the two wiring assertions
change the consent URL template points interactions at the consent page (via routeOwner)
decodeURIComponent as the first line of routeOwner is not bypassed by percent-encoding
delete the consent carve-out both consent-ownership tests
startsWith('/oauth/')startsWith('/oauth') does not treat a lookalike prefix as the provider
hardcode the path, change MCP_PATH composes resource from MCP_PATH
delete the wildcard-host check rejects a wildcard host
delete the ::ffff: branch treats the IPv4-mapped IPv6 loopback form as loopback
.some(isLoopback).every flags loopback when ANY redirect is local
decideConsent!== 'deny' four deny-by-default tests
consentSubmitFailure expired branch → 500 reads an expired interaction as the operator's mistake
always regenerate the JWKS returns the same key on a second call
simulate app.use('/oauth', …) serves metadata at the root well-known path
invert the res.headersSent check the three finishConsentFailure tests
expires_at > now>= now treats the exact expiry instant as expired
revert the login result flow terminates in one approval
revert addResourceScope flow terminates in one approval

The four bugs that made this feature not work

The headline finding. Every gate was green and the authorization flow could not have completed once. Four defects sat between "operator clicks Approve" and "token issued", layered so that each hid the next. Two were found by reading the library; the other two only surfaced once a test actually drove the flow.

The mutation sequence tells the story better than prose:

Code under test Result
as originally written loops on login:no_session
+ resolve the login prompt loops on consent:rs_scopes_missing
+ scopes to the resource bucket only loops on consent:op_scopes_missing
+ scopes to both buckets + ES256 green — one approval, scope = nexus:read nexus:write
  1. The login prompt was never resolved. config.ts sets only interactions.url, never interactions.policy, and oidc-provider deep-merges configuration — so the default two-prompt policy (login, then consent) was still in force. handleConsentSubmit returned { consent: { grantId } } only, so resume.js never called session.loginAccount, session.accountId was never set, and the no_session check re-fired forever. Note this is the library's own _session cookie, unrelated to Nexus's operator session — being unlocked does not satisfy it.

  2. Scopes were written to the wrong bucket. addOIDCScope writes grant.openid; the rs_scopes_missing check reads getResourceScopeEncountered(), written only by addResourceScope. With resourceIndicators enabled and a defaultResource, that check runs on every authorization.

  3. They are needed in both buckets. Because config.ts also declares the Nexus scopes in the top-level scopes list, requestParamOIDCScopes includes them, so op_scopes_missing demands them in the openid bucket too. Routing them to the resource bucket alone — which is what I instructed — still looped, just on a different check. The implementing agent proved that empirically rather than taking my word for it.

  4. JWT access tokens could not be signed. accessTokenFormat: 'jwt' takes its algorithm from clientDefaults.id_token_signed_response_alg, which defaults to RS256, while jwks.ts provisions only EC P-256. Every resource-bound token exchange 500'd. This was unreachable dead code until bugs 1–3 were fixed. One line: clientDefaults: { id_token_signed_response_alg: 'ES256' }.

In this configuration there is no path that terminates with a wrong-but-valid token — every incomplete bucket combination loops instead, because the scopes are double-declared. The fix is covered by flow.test.ts, which drives a real PKCE authorization → consent → resume → token exchange against a real provider and SQLite adapter, asserts the issued token's scope, and asserts the flow terminates in exactly one approval.

Bugs 1 and 2 came from the plan, not from the implementation. Nothing in 1906 passing unit tests touched any of the four, because every test asserted against the configuration object or a pure function and none drove the protocol.

Other things found by building it

Each of these changed the code, and none would have failed a green gate.

An undefined jwks does not throw. initialize_keystore.js:282 silently substitutes DEV_KEYSTORE — a fixed keypair (kid: 'keystore-CHANGE-ME') shipped in every install of oidc-provider, behind a warn. Both jwks and adapter are optional in the Configuration type, so TypeScript would not stop it either, and neither had a test. Anyone who has read the library source could have forged tokens. Both are now asserted.

An expired consent form crashed Nexus. handleConsentSubmit was called unawaited; interactionDetails() throws SessionNotFound on a missing or expired interaction, and Node 22 turns an unhandled rejection into a process exit. Leaving the consent page open and clicking Approve would have restarted the control plane, dropping every session terminal and preview with it. Reproduced live against the built server, then fixed and re-verified.

oidc-provider does not implement RFC 8414 §3.1. The discovery route is a literal constant (initialize_app.js:180), so a path-carrying issuer would not move the document — it would leave the library serving at the root while a compliant client looked at the path-inserted location and found nothing. The spec said the opposite; corrected in 80a628f. The regression that does move it is an express-style mount, which is what the placement test's mutation exercises.

Replay detection rested on an unpinned assumption. upsert wrote consumed_at: null unconditionally, including on the conflict path. No live bug in 9.12.2 — verified by reading all three consume() call sites — but a version bump that re-saved a consumed code would have removed the defence with every test green. Now structural.

The ::ffff: loopback form dodged the consent warning. The URL parser normalises http://[::ffff:127.0.0.1] to ::ffff:7f00:1, which matched neither the ::1 nor the 127. check. A loopback redirect URI written that way is loopback in fact and escaped the warning — the same laundering the "any registered URI" rule exists to stop, through a different door.

Decisions worth knowing

The issuer is path-less. oidc-provider's endpoint routes are configurable independently of the issuer, so the issuer stays at the origin and the endpoints live under /oauth/*. Metadata therefore sits at the standard well-known path. req.url is not rewritten — an express-style mount would break every endpoint.

The dispatcher uses a prefix, not the named allowlist this repo uses elsewhere (AGENT_CALLBACKS, TOKEN_ROUTES). The library mounts dynamic sub-paths: ${routes.authorization}/:uid is the interaction resume endpoint the browser returns to after consent, so an exact set would 404 the consent flow's own return path. It does not decode — /%6fauth/token is not /oauth/token, which is the bug class of #120.

The consent warning keys on the whole registered redirect set, while the displayed host is this request's redirect_uri. The asymmetry is deliberate: redirect_uri appears nowhere in oidc-provider's consent interaction policy, so once a Grant covers the scopes a later authorization with a different registered URI does not re-prompt. A hosted URI really can launder a loopback one past a consent already given.

The consent form carries no CSRF token and does not need one. The interaction cookie is sameSite: 'lax', which withholds it on cross-site POST, and both branches funnel through the same #getInteraction — approve via interactionDetails, deny via interactionFinishedinteractionResult. A forged submission of either kind arrives cookie-less and lands on the 400.

features.clientIdMetadataDocument stays off on purpose. Client.find() resolves static clients → adapter → CIMD, so #152 can register a client row keyed by the URL at allowlist time and never enable the feature. Enabling it would add a request-time fetch of a client-supplied URL with allowFetch/allowClient defaulting to async () => true — an SSRF primitive next to DOCKER_HOST=tcp://127.0.0.1:2375. Recorded on #152.

The signing key lives in meta, not instance_settings. listInstance() returns every instance-scoped setting, so the plan's original placement would have served the private key from GET /api/settings/instance and rendered it in the Settings panel.

Rejected review findings

  • Partial indexes on the three nullable lookup columns. Speculative optimisation on a table whose only reader did not exist yet.
  • Trimming model/id out of doUpdateSet(row). They are the conflict target and writing them back is a no-op, but passing the whole row stays correct automatically when a column is added; an explicit list is a second place to forget one.
  • Enabling the revocation and introspection features so their configured routes resolve. Out of scope; discovery correctly omits both endpoints, so no compliant client tries them. Commented as scaffolding.
  • Replacing the dispatcher's prefix with an exact allowlist. Would 404 the interaction resume endpoint — see above.
  • Hashing the stored token/interaction ids. The spec claimed unsalted SHA-256; the code stores them plaintext. Corrected the spec, because findByUid/findByUserCode return payloads without being given the id, so a hashed key could not restore the jti they must carry — and more decisively, the signing key sits unsealed in the same database, so hashing the rows buys nothing beside it. Threat model now stated honestly; the sealing trade-off is #166.
  • Unifying the 404 (GET) and 400 (POST) responses for an expired interaction. The codes differ because the methods differ; the operator-facing message is identical and nothing keys off the status.
  • A sweep for expired oauth_payloads rows. Real, but not a correctness bug and not worth widening this PR — filed as #167 with the trap noted (a sweep must not delete consumed-but-unexpired rows, or replay detection degrades from "revoke the grant" to a plain refusal).
  • Guarding userinfo-stripping and new URL's slash-collapsing in deriveIssuer. Identical pre-existing properties of public_url's own validator; consistency is the right outcome and changing the shared validator is a separate change.

Not verified

  • No Claude client has completed a flow against this, because CIMD is #152. The metadata document is asserted live; the authorization code flow, PAR and DCR are not exercised end to end.
  • The consent page was browser-verified at 1280px and 400px across hosted, loopback and mixed redirect variants, and the Approve button measures 184×44px. It has not been seen by a real phone.
  • One cosmetic observation left unaddressed: the "needs vault access — not granted" sub-line and the redirect-host line are low-contrast grey, which may read poorly on a dim phone screen.
Nexus issues its own OAuth 2.1 tokens, so Claude — on a phone, on the desktop, or in another Nexus worker — can authorize against it as a custom connector. Closes #151. `oidc-provider@9.12.2` (OpenID Certified) runs as a Koa app on the raw Node handler in `src/server.ts`, beside the existing WebSocket upgrade listener. Storage is a Kysely adapter over one SQLite table. Consent is an ordinary SvelteKit page behind the normal cookie gate; only the protocol endpoints sit outside it. ## Deliberately not in this PR Say so here rather than quietly doing it: - **The CIMD allowlist and resolver (#152).** This PR reads `cimdAllowlist` and advertises the discovery flag from it; nothing populates it, so the flag ships `false` and **Claude Code cannot yet complete a flow against `main`**. That is the correct state — advertising support we cannot honour makes Claude pick a mechanism that then fails. - **The Settings → MCP panel (#153).** - **`/api/mcp`, protected-resource metadata and the bearer challenge (#154).** ## Gates | | | |---|---| | typecheck | 5000 files, 0 errors, 0 warnings | | tests | 1921 passing / 168 files (was 1893 / 165 on `main`) | | lint | clean | Also verified against the **built** server (`pnpm build`, `node build/server.js`) on an isolated `DATA_DIR`: `/.well-known/oauth-authorization-server` returns `503 {"error":"public_url is not configured"}` unset, and `200` with a matching `issuer` once set. ## Guards proven able to fail Every one of these was broken deliberately and a *named* test watched go red, then restored from a file copy. This repo has shipped six tests that could not fail, so the proof is the point: | Mutation | Test that went red | |---|---| | drop the composite primary key | rejects a duplicate (model, id) | | `!(expires_at > now)` → `expires_at <= now` | treats a NaN clock as expired | | `consume()` → `destroy()`'s body | marks a consumed payload but still returns it | | scope `revokeByGrantId` by model | revokes every model sharing a grant id | | drop `.where('model',…)` from `findByUid` / `findByUserCode` / `destroy` | the three model-scoping tests | | remove `Math.floor` from `revive` | both consumed-timestamp tests | | re-add `consumed_at` to `doUpdateSet` | does not un-consume a payload that is upserted again | | `doUpdateSet` → `doNothing()` | upsert replaces rather than duplicating | | CIMD flag → literal `true` | reports CIMD unsupported while the allowlist is empty | | `allowDcr === true` → `allowDcr !== false` | keeps dynamic registration OFF by default | | `routes: {}` | the 14-route length guard | | delete `jwks:` / delete `adapter:` | the two wiring assertions | | change the consent URL template | points interactions at the consent page (via `routeOwner`) | | `decodeURIComponent` as the first line of `routeOwner` | is not bypassed by percent-encoding | | delete the consent carve-out | both consent-ownership tests | | `startsWith('/oauth/')` → `startsWith('/oauth')` | does not treat a lookalike prefix as the provider | | hardcode the path, change `MCP_PATH` | composes resource from MCP_PATH | | delete the wildcard-host check | rejects a wildcard host | | delete the `::ffff:` branch | treats the IPv4-mapped IPv6 loopback form as loopback | | `.some(isLoopback)` → `.every` | flags loopback when ANY redirect is local | | `decideConsent` → `!== 'deny'` | four deny-by-default tests | | `consentSubmitFailure` expired branch → 500 | reads an expired interaction as the operator's mistake | | always regenerate the JWKS | returns the same key on a second call | | simulate `app.use('/oauth', …)` | serves metadata at the root well-known path | | invert the `res.headersSent` check | the three `finishConsentFailure` tests | | `expires_at > now` → `>= now` | treats the exact expiry instant as expired | | revert the `login` result | flow terminates in one approval | | revert `addResourceScope` | flow terminates in one approval | ## The four bugs that made this feature not work The headline finding. Every gate was green and **the authorization flow could not have completed once.** Four defects sat between "operator clicks Approve" and "token issued", layered so that each hid the next. Two were found by reading the library; the other two only surfaced once a test actually drove the flow. The mutation sequence tells the story better than prose: | Code under test | Result | |---|---| | as originally written | loops on `login:no_session` | | + resolve the login prompt | loops on `consent:rs_scopes_missing` | | + scopes to the resource bucket only | loops on `consent:op_scopes_missing` | | + scopes to **both** buckets + ES256 | green — one approval, `scope = nexus:read nexus:write` | 1. **The login prompt was never resolved.** `config.ts` sets only `interactions.url`, never `interactions.policy`, and `oidc-provider` deep-merges configuration — so the default two-prompt policy (`login`, then `consent`) was still in force. `handleConsentSubmit` returned `{ consent: { grantId } }` only, so `resume.js` never called `session.loginAccount`, `session.accountId` was never set, and the `no_session` check re-fired forever. Note this is the library's own `_session` cookie, unrelated to Nexus's operator session — being unlocked does not satisfy it. 2. **Scopes were written to the wrong bucket.** `addOIDCScope` writes `grant.openid`; the `rs_scopes_missing` check reads `getResourceScopeEncountered()`, written only by `addResourceScope`. With `resourceIndicators` enabled and a `defaultResource`, that check runs on every authorization. 3. **They are needed in *both* buckets.** Because `config.ts` also declares the Nexus scopes in the top-level `scopes` list, `requestParamOIDCScopes` includes them, so `op_scopes_missing` demands them in the openid bucket too. Routing them to the resource bucket alone — which is what I instructed — still looped, just on a different check. The implementing agent proved that empirically rather than taking my word for it. 4. **JWT access tokens could not be signed.** `accessTokenFormat: 'jwt'` takes its algorithm from `clientDefaults.id_token_signed_response_alg`, which defaults to `RS256`, while `jwks.ts` provisions only EC P-256. Every resource-bound token exchange 500'd. This was unreachable dead code until bugs 1–3 were fixed. One line: `clientDefaults: { id_token_signed_response_alg: 'ES256' }`. In this configuration there is **no** path that terminates with a wrong-but-valid token — every incomplete bucket combination loops instead, because the scopes are double-declared. The fix is covered by `flow.test.ts`, which drives a real PKCE authorization → consent → resume → token exchange against a real provider and SQLite adapter, asserts the issued token's scope, and asserts the flow terminates in exactly one approval. Bugs 1 and 2 came from the plan, not from the implementation. Nothing in 1906 passing unit tests touched any of the four, because every test asserted against the configuration object or a pure function and none drove the protocol. ## Other things found by building it Each of these changed the code, and none would have failed a green gate. **An undefined `jwks` does not throw.** `initialize_keystore.js:282` silently substitutes `DEV_KEYSTORE` — a fixed keypair (`kid: 'keystore-CHANGE-ME'`) shipped in every install of `oidc-provider`, behind a `warn`. Both `jwks` and `adapter` are *optional* in the `Configuration` type, so TypeScript would not stop it either, and neither had a test. Anyone who has read the library source could have forged tokens. Both are now asserted. **An expired consent form crashed Nexus.** `handleConsentSubmit` was called unawaited; `interactionDetails()` throws `SessionNotFound` on a missing or expired interaction, and Node 22 turns an unhandled rejection into a process exit. Leaving the consent page open and clicking Approve would have restarted the control plane, dropping every session terminal and preview with it. Reproduced live against the built server, then fixed and re-verified. **`oidc-provider` does not implement RFC 8414 §3.1.** The discovery route is a literal constant (`initialize_app.js:180`), so a path-carrying issuer would not move the document — it would leave the library serving at the root while a compliant client looked at the path-inserted location and found nothing. The spec said the opposite; corrected in `80a628f`. The regression that *does* move it is an express-style mount, which is what the placement test's mutation exercises. **Replay detection rested on an unpinned assumption.** `upsert` wrote `consumed_at: null` unconditionally, including on the conflict path. No live bug in 9.12.2 — verified by reading all three `consume()` call sites — but a version bump that re-saved a consumed code would have removed the defence with every test green. Now structural. **The `::ffff:` loopback form dodged the consent warning.** The URL parser normalises `http://[::ffff:127.0.0.1]` to `::ffff:7f00:1`, which matched neither the `::1` nor the `127.` check. A loopback redirect URI written that way is loopback in fact and escaped the warning — the same laundering the "any registered URI" rule exists to stop, through a different door. ## Decisions worth knowing **The issuer is path-less.** `oidc-provider`'s endpoint `routes` are configurable independently of the issuer, so the issuer stays at the origin and the endpoints live under `/oauth/*`. Metadata therefore sits at the standard well-known path. `req.url` is **not** rewritten — an express-style mount would break every endpoint. **The dispatcher uses a prefix, not the named allowlist this repo uses elsewhere** (`AGENT_CALLBACKS`, `TOKEN_ROUTES`). The library mounts dynamic sub-paths: `${routes.authorization}/:uid` is the interaction resume endpoint the browser returns to after consent, so an exact set would 404 the consent flow's own return path. It does **not** decode — `/%6fauth/token` is not `/oauth/token`, which is the bug class of #120. **The consent warning keys on the whole registered redirect set, while the displayed host is this request's `redirect_uri`.** The asymmetry is deliberate: `redirect_uri` appears nowhere in `oidc-provider`'s consent interaction policy, so once a Grant covers the scopes a later authorization with a *different* registered URI does not re-prompt. A hosted URI really can launder a loopback one past a consent already given. **The consent form carries no CSRF token and does not need one.** The interaction cookie is `sameSite: 'lax'`, which withholds it on cross-site POST, and both branches funnel through the same `#getInteraction` — approve via `interactionDetails`, deny via `interactionFinished` → `interactionResult`. A forged submission of either kind arrives cookie-less and lands on the 400. **`features.clientIdMetadataDocument` stays off on purpose.** `Client.find()` resolves static clients → adapter → CIMD, so #152 can register a client row keyed by the URL at allowlist time and never enable the feature. Enabling it would add a request-time fetch of a *client-supplied* URL with `allowFetch`/`allowClient` defaulting to `async () => true` — an SSRF primitive next to `DOCKER_HOST=tcp://127.0.0.1:2375`. Recorded on #152. **The signing key lives in `meta`, not `instance_settings`.** `listInstance()` returns every instance-scoped setting, so the plan's original placement would have served the private key from `GET /api/settings/instance` and rendered it in the Settings panel. ## Rejected review findings - **Partial indexes on the three nullable lookup columns.** Speculative optimisation on a table whose only reader did not exist yet. - **Trimming `model`/`id` out of `doUpdateSet(row)`.** They are the conflict target and writing them back is a no-op, but passing the whole row stays correct automatically when a column is added; an explicit list is a second place to forget one. - **Enabling the `revocation` and `introspection` features** so their configured routes resolve. Out of scope; discovery correctly omits both endpoints, so no compliant client tries them. Commented as scaffolding. - **Replacing the dispatcher's prefix with an exact allowlist.** Would 404 the interaction resume endpoint — see above. - **Hashing the stored token/interaction ids.** The spec claimed unsalted SHA-256; the code stores them plaintext. Corrected the **spec**, because `findByUid`/`findByUserCode` return payloads without being given the id, so a hashed key could not restore the `jti` they must carry — and more decisively, the signing key sits unsealed in the same database, so hashing the rows buys nothing beside it. Threat model now stated honestly; the sealing trade-off is #166. - **Unifying the 404 (GET) and 400 (POST) responses** for an expired interaction. The codes differ because the methods differ; the operator-facing message is identical and nothing keys off the status. - **A sweep for expired `oauth_payloads` rows.** Real, but not a correctness bug and not worth widening this PR — filed as #167 with the trap noted (a sweep must not delete consumed-but-unexpired rows, or replay detection degrades from "revoke the grant" to a plain refusal). - **Guarding userinfo-stripping and `new URL`'s slash-collapsing in `deriveIssuer`.** Identical pre-existing properties of `public_url`'s own validator; consistency is the right outcome and changing the shared validator is a separate change. ## Not verified - **No Claude client has completed a flow against this**, because CIMD is #152. The metadata document is asserted live; the authorization code flow, PAR and DCR are not exercised end to end. - The consent page was browser-verified at 1280px and 400px across hosted, loopback and mixed redirect variants, and the Approve button measures 184×44px. It has **not** been seen by a real phone. - One cosmetic observation left unaddressed: the "needs vault access — not granted" sub-line and the redirect-host line are low-contrast grey, which may read poorly on a dim phone screen.
lz added this to the MCP support (#140) milestone 2026-09-16 09:21:44 +02:00
lz added 28 commits 2026-09-16 09:21:45 +02:00
The OAuth machinery that should not be novel: PKCE verification, single-use
codes, exact redirect-URI matching and refresh rotation are the parts with
real CVEs behind them. v9 ships no types of its own, so @types comes with it.
oidc-provider addresses everything it persists through one (model, id) pair,
so one table matches its interface. The three lookup columns are lifted out of
the JSON because the adapter queries on them; nothing else reads inside it.
Returns null rather than a guess. A wrong `resource` is the failure with no
symptom: Claude's discovery simply never matches and the connector reports
only that it could not reach the server.
The migration test suite never inserted the same (model, id) twice, so
nothing proved the primary key rejects a duplicate. Add that case, matching
0019/0020's hygiene (per-test db, destroy() in afterEach,
executeTakeFirstOrThrow). Also trim two comments that restated the
migration's own top-of-file rationale a second time.
The composition test hardcoded both sides of the equation, so a mutation
that inlined the path literally in deriveIssuer's return still passed.
Build the expectation from MCP_PATH instead so divergence is
unrepresentable, and drop the now-redundant standalone constant check.
Also cover the third arm of the (string|undefined|null) union and the
bracketed [::] wildcard form, not just 0.0.0.0.
Expiry filters at read time with a negated comparison so a NaN clock fails
closed. consume() marks rather than deletes, because the provider treats a
replayed code differently from an unknown one - it revokes the grant.
doUpdateSet only writes the columns it is given, so omitting consumed_at
leaves it untouched on conflict instead of the previous unconditional null.
Without this, anything that re-saves an id after consume() would silently
un-consume it, and the provider would stop telling a replayed authorization
code apart from an unknown one.
Configuration is where a large authorization server goes wrong, so the
settings are asserted rather than trusted. Endpoints move under /oauth while
the issuer stays at the origin, which keeps metadata at the standard
well-known path instead of RFC 8414's path-inserted form.

The plan's S256-vs-plain PKCE assertion is dropped: v9's Configuration type
has no code_challenge_methods_supported property to assert against (PKCE is
enforced unconditionally and emitted in discovery regardless of config), so
there is nothing expressible here. Task 10 asserts S256 for real against the
live emitted metadata document.
server.ts binds a port and is untested, so the routing decision lives
outside it. No decoding: a percent-encoded lookalike is never promoted
to a more privileged owner, which is the mistake that produced the
auth-gate bypass in #120.

The provider owns /oauth/ as a genuine subtree, not a fixed allowlist:
oidc-provider mounts dynamic sub-paths under it at runtime (the
interaction resume endpoint, registration management), so an exact
allowlist would 404 the consent flow's own return path.
Each writes its own where('model', ...) clause rather than sharing it the
way expiry is shared through revive(), so removing any one of them silently
collapses two models onto the same row. Also make the consumed-at rounding
assertion exercise the floor by using a NOW that isn't an exact multiple of
1000, and assert the literal expected seconds instead of restating the
implementation.
Returns null when public_url is unset so the caller can say so, rather than
emitting a metadata document whose resource will never match what the
operator typed into Claude.
getProvider never touches it during construction, so a sqliteAvailable()
guard bought nothing but a silent skip of memoization coverage on hosts
without the native binding.
The redirect hostname is displayed and a loopback set raises an extra warning,
both because the MCP authorization spec asks for them: a client metadata
document cannot prove which local process holds a port. Any loopback URI
triggers it, so a hosted one cannot launder a local one past the caution.
An IPv4-mapped IPv6 loopback address (::ffff:127.0.0.1) parses to a
normalised hex hostname the ANY-redirect loopback check never matched,
letting it escape the consent-screen warning the same way a bare
startsWith('127.') would have.

decideConsent is now a standalone deny-by-default function so the
security-relevant half of handleConsentSubmit — anything but the exact
string "approve" denies — is unit-testable without a Provider double.

Also pins that a mixed redirect set displays the first URI's host while
still raising the loopback warning, so a later change to that asymmetry
is deliberate rather than silent.
Two gaps this file doesn't enforce, per review: the /oauth/ prefix is
only correct because config.ts remaps every route key under it, and
PROVIDER_WELL_KNOWN omits openid4vci's third well-known path (feature
off today). Also tightens the trailing-slash test comment: nothing
under /oauth/consent/ is a registered provider route at all, rather
than an explicit 404.
Review found four gaps in the pure config builder. adapter and jwks had no
test coverage despite being the two fields whose omission fails silently:
oidc-provider falls back to an ephemeral in-memory adapter and a fixed,
publicly-known dev keystore rather than throwing. Only 8 of oidc-provider's
14 route keys were remapped under /oauth/; the other 6 stayed at bare-root
defaults, invisible today because their features are off, but ready to
resolve outside dispatch.ts's /oauth/ prefix and 404 the moment one is
enabled. The CIMD discovery flag lacked a comment recording that it's
metadata only - the real switch and its permissive-by-default resource-fetch
hooks are #152's job to bind to the allowlist, and skipping that binding
would open an SSRF primitive against the host's own Docker API. Two
assertions checked less than their own names promised: resource-indicator
audience binding, and the interactions URL's contract with dispatch.ts's
routeOwner.

Also: reworded a doc comment that referenced a parity test that doesn't
exist yet (it's #154's), and noted that the claims default is restated
deliberately rather than left implicit.
A second surface that bypasses hooks.server.ts, which AGENTS.md fact #23
treats as a hazard. It is defensible here and the reason has to stay written
down: the protocol endpoints are meant to sit outside the cookie gate, and
the one endpoint needing the operator's identity redirects into an ordinary
SvelteKit page that runs the normal gate. Nothing authenticated is decided in
the bypassing half.

The signing key is generated once and kept in `meta`, not instance_settings —
listInstance() returns every instance-scoped setting, which would serve the
private key from GET /api/settings/instance and render it in the UI.

The CIMD allowlist (#152) and DCR toggle (#153) are out of scope on this
branch, so the dispatcher passes their OFF defaults as literals rather than
singleton accessors that could only ever return a constant.
The prior comment said the library's clientIdMetadataDocument feature
needed to be enabled and its allowFetch/allowClient hooks bound to the
allowlist. Reading Client.find() (lib/models/client.js) shows that's
backwards: it checks the adapter before it would ever consider CIMD
resolution, so #152's design - fetch the document once at allowlist time
and write an ordinary client row keyed by that URL - never reaches that
feature at all. The feature stays off on purpose, not as a TODO for #152,
because enabling it would add a client-supplied URL fetch at request time
next to a host-networked Docker API.
Node 22 defaults to --unhandled-rejections=throw, so an uncaught rejection
from handleConsentSubmit was an uncaught exception that killed the whole
process, not just the request. The trigger is routine: interactionDetails/
interactionFinished throw SessionNotFound for a missing interaction cookie,
an expired interaction, or a session whose principal changed — an operator
leaving the consent tab open past expiry and then clicking Approve took down
every session terminal and preview listener with it. Reproduced live: a
made-up consent uid crashed the built server before this change and returns
400 without disturbing it after.

consentSubmitFailure() (oauth/consent.ts) is the testable unit deciding the
response: SessionNotFound reads as the operator's ordinary mistake (400, the
same wording the consent page uses for the same condition), anything else is
a generic 500. server.ts's catch guards on res.headersSent, since
interactionFinished may already have written the redirect before a later
throw.

provider.callback()(req, res) needed no change — Koa's callback() already
does fnMiddleware(ctx).then(handleResponse).catch(onerror) internally, so
that path was never at risk.
Adds the SvelteKit route the provider redirects to for operator consent
(/oauth/consent/[uid]): a GET load function backed by the oidc-provider
adapter, and a page rendering the client, granted/withheld scopes, and
a loopback warning.

describeClient now takes an optional activeRedirectUri: the displayed
host reflects the current request (oidc-provider's consent policy never
re-checks redirect_uri once a Grant covers the requested scopes), while
loopback still checks every registered URI so a hosted display can't
launder a registered loopback redirect past the warning. A new
activeIsLoopback field lets the page pick between "this request goes to
your own computer" and "a different, already-registered redirect does".

The load logic lives in an underscore-prefixed _loadConsentPage so it
can be exercised against a real in-memory db in tests without mocking
$server/singletons (same pattern as _buildInstanceSettingsDTO).
Planning found that oidc-provider's endpoint routes are configurable
independently of the issuer, so the issuer stays at the origin, metadata sits
at the standard well-known path, and RFC 8414 section 3.1's path insertion
never comes up. The explanation of why a path-carrying issuer is dangerous
stays, because it is why the choice was made. The spec no longer implies an
express-style mount either: req.url is not rewritten, and stripping a prefix
would break every endpoint.

The plan's Task 6 test asserted that a percent-encoded consent path routes to
sveltekit, while its own reference implementation returns provider — the two
contradicted each other and the test was the wrong half. The property is that
an encoded lookalike is never promoted to a more privileged owner, not that it
lands on one particular owner.
A misconfigured authorization server emits a correct-looking document from the
wrong place. Only a live fetch distinguishes the two.
Measured while writing the placement test: oidc-provider does not implement RFC
8414 section 3.1. initialize_app.js registers the discovery route as a literal
constant, independent of the issuer. So a path-carrying issuer would not move
the library's route — it would leave the library serving at the root while a
compliant client looked at the path-inserted location and found nothing. Same
silent failure, opposite mechanism, and the previous wording had it backwards.

The regression that does move the document is an express-style mount, which is
what the test's mutation exercises: simulating a path-prefixed mount turns the
root assertion red, where changing the issuer string alone leaves it green.
- oauth/fixtures.ts: extract testJwks()/stubDb, byte-identical across
  provider.test.ts and metadata.test.ts, into one factory (same pattern
  as mounts/fixtures.ts).
- consent.ts: export EXPIRED_INTERACTION_MESSAGE so the consent page's
  load function imports it instead of duplicating the literal, making
  "one message for one situation" structural rather than a comment.
- config.ts: merge the CIMD discovery comment's two paragraphs, which
  both opened by restating "this flag advertises CIMD in the discovery
  document" after a prior fix edited only the second one.
- server.ts: factor the two identical 503 JSON responses in the OAuth
  dispatcher into one serveUnavailable() helper.

No behaviour changes. Full suite: 167 files / 1906 tests passing,
typecheck 0 errors, lint clean.
Each of these asserted a mechanism that isn't real:
- fixtures.ts: an empty JWKS constructs fine (verified against
  initialize_keystore.js); it just can't sign anything.
- provider.ts: provider.proxy is Koa's app.proxy (Provider extends Koa),
  trusting X-Forwarded-* headers for urlFor()'s URL composition — there is
  no "plaintext connection" refusal anywhere in the library.
- adapter.ts: revoke.js already calls revokeByGrantId once per relevant
  model, so a model-scoped implementation wouldn't leave anything live
  either; the real benefit is resilience to partial failure across the
  parallel calls.
- dispatch.ts: the GET .../:clientId route is gated by registration.enabled,
  not registrationManagement (which only adds PUT/DELETE).
- singletons.ts: advertiseHost only ever consults public_url in
  unrestricted bind mode, not as a general fallback.
- issuer.test.ts, config.test.ts: trimmed comments that duplicated their
  source file's rationale verbatim.
- mcp-server-design.md: fixed the Mounting section's "closed set" framing
  to match dispatch.ts's own prefix reasoning, and rewrote the Storage
  section — tokens are stored as a plaintext id (the bearer secret itself
  for opaque models), not a SHA-256 digest.
handleConsentSubmit called grant.addOIDCScope(scope) with the whole
requested scope string. With features.resourceIndicators on, that only
populates the OIDC bucket; rs_scopes_missing reads a separate resource
bucket that addOIDCScope never touches. Approving consent never actually
satisfied it, so the operator's browser looped through the consent screen
forever with no error and no code ever issued.

config.ts declares the two Nexus scopes in BOTH the top-level `scopes`
list (so they show up in scopes_supported) and the resource server's own
`scope` (so a token can be audience-bound to the MCP resource) — so
oidc-provider checks a requested Nexus scope against BOTH buckets
(op_scopes_missing and rs_scopes_missing), and a scope satisfying only one
still re-triggers consent. A Nexus scope now goes through addOIDCScope AND
addResourceScope; openid/offline_access are genuinely OIDC-only and go
through addOIDCScope alone. Verified empirically (see flow.test.ts's
mutation proof below): routing Nexus scopes to the resource bucket only,
as source-reading the Grant model alone suggests, still loops — now on
op_scopes_missing instead.

A brand-new interaction is also a `login` prompt before it is ever a
`consent` prompt (oidc-provider checks login first, and there is no
session yet), and this handler never resolved it. Left alone, the very
first approval loops on login's no_session check before ever reaching the
scope bug at all. Nexus has exactly one identity, so approving consent now
resolves login in the same step.

Fixing the resource-scope routing surfaced a second, previously-dead bug:
getResourceServerInfo sets accessTokenFormat: 'jwt' with no explicit
signing alg, so it falls back to clientDefaults.id_token_signed_response_alg
(RS256) — but jwks.ts provisions only an EC P-256 key. Every resource-bound
token exchange 500s the moment a grant actually carries a resource scope,
which never happened before this fix. Pinned clientDefaults to ES256 to
match the real keystore.

flow.test.ts drives a real Provider through PKCE authorization, consent,
and token exchange end to end — the round trip none of the previous 1906
tests exercised, and the reason this shipped broken. Reverting to the
original addOIDCScope-only call (and no login resolution) turns it red
with a bounded, diagnosable failure — a login:no_session loop — rather
than a hang. consent.test.ts adds unit coverage for handleConsentSubmit's
deny/approve/scope-routing/accountId-fallback behaviour against a stub
Grant.

Also updates the plan doc (Task 9 Step 3) so it no longer teaches the
broken call.
The res.headersSent decision in server.ts's consent-submit catch had no
test coverage by design (server.ts binds a port). A throw inside that
catch rejects with nothing to handle it, so a slip there is the same
crash class the catch exists to prevent, one level deeper. Extracted to
consent.ts's finishConsentFailure, taking a structurally-typed stub so
it's unit-testable; server.ts now just calls it.

Also: cover findAccount/defaultResource/useGrantedResource in
config.test.ts and strengthen the getResourceServerInfo assertion
(scope + accessTokenFormat + the ES256 clientDefaults it depends on);
pin the adapter's expires_at === now expiry boundary; await server.close()
in metadata.test.ts and flow.test.ts instead of firing it unawaited; trim
duplicated loopback-warning and ::ffff normalization comments in
consent.test.ts down to pointers at their one full statement.
docs: replace the plan's Task 8 sketch with what actually shipped
All checks were successful
ci / nexus (pull_request) Successful in 8m30s
ci / images (pull_request) Successful in 11m11s
e10387b332
It referenced two singleton accessors that were never built and called
handleConsentSubmit at the wrong arity, so it taught a dispatcher that does not
compile. The shipped version differs in three ways that each cost a real bug:
the JWKS is checked before the provider is built, the consent submit is caught
rather than left to exit the process, and the CIMD allowlist and DCR toggle are
literals because #152 and #153 own those settings.
lz force-pushed feat/oauth-as from e10387b332
All checks were successful
ci / nexus (pull_request) Successful in 8m30s
ci / images (pull_request) Successful in 11m11s
to b2e285fc97
All checks were successful
ci / nexus (pull_request) Successful in 8m55s
ci / images (pull_request) Successful in 15m26s
2026-09-16 11:34:41 +02:00
Compare
fix(oauth): require an operator session to approve consent
Some checks failed
ci / nexus (pull_request) Successful in 9m33s
ci / images (pull_request) Has been cancelled
2c3c1d1bbb
The consent POST is dispatched by src/server.ts before SvelteKit, so
hooks.server.ts — and with it every locals.masterKey guard — never ran on
it, while handleConsentSubmit did no auth of its own. The GET that renders
the screen was gated; the POST that acts on the decision was not. The
_interaction cookie handed to any caller by GET /oauth/auth was therefore
the only thing needed to approve a grant and exchange the code for a
nexus:read/nexus:write token. Verified end to end against the built server,
not just the harness.

Latent on this branch: no client can be registered yet (allowDcr false,
empty CIMD allowlist, no static clients), so an unknown client_id 400s and
the attack has nothing to aim at. #152 registers Claude's client and arms
it, which is why this lands first.

handleConsentSubmit now takes a required OperatorGate and checks the session
cookie before it reads the body or touches the provider — so neither approve
nor deny can conclude someone else's interaction. An unauthenticated submit
redirects through unlockUrl(), which owns every `next` rule, so a session
that expired between render and click returns to the same consent screen
instead of dead-ending. The cookie parser moves to lib/cookies.ts, shared
with terminal/upgrade.ts, the other path that bypasses the gate.

flow.test.ts drove the unauthenticated POST and asserted it succeeded,
encoding the bypass as expected behaviour; it now authenticates, and a
negative control asserts no Grant row is written without a session. Proven
by mutation: disabling the gate fails three named tests, and accepting any
cookie without the store lookup fails a fourth.
fix(oauth): issue the refresh token the spec already depends on
All checks were successful
ci / nexus (pull_request) Successful in 10m2s
ci / images (pull_request) Successful in 10m46s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 12s
41c580da0f
rotateRefreshToken was configured for a token that was never issued.
lib/actions/authorization/scopes.js:25-33 strips offline_access from any
request that does not carry prompt=consent, and the default
issueRefreshToken then requires code.scopes.has('offline_access'). Measured
against the built server: scope goes in as "openid nexus:read nexus:write
offline_access", the stored Grant reads "openid nexus:read nexus:write", no
refresh token comes back, and the access token expires 59 minutes later with
no way to renew it — so the operator re-approves hourly and the spec's own
refresh behaviour ("revoking a client drops its refresh token", "an invalid
refresh token returns invalid_grant", "refresh within 30s") is unreachable.

issueRefreshToken now keys off the client's allowed grant types instead of a
request parameter Nexus does not control, so it holds whatever Claude sends.
The OIDC rule it sidesteps exists so offline access is never granted without
explicit consent; Nexus satisfies that structurally, since consent.ts is the
only thing that ever creates a Grant.

expiresWithSession is forced false. The default returns
!scopes.has('offline_access'), which — offline_access having been stripped —
bound every token to the operator's browser session, revoking a headless
client when they closed the tab.

ttl is deliberately left at the library defaults: RefreshTokenTTL is not a
constant, and for a rotating public web client with clientAuthMethod 'none'
it returns the rotated token's remainingTTL so a chain still dies 14 days
after the original grant. Restating that by hand to silence an advisory
notice risks minting immortal refresh tokens.

Proven by mutation: dropping issueRefreshToken, flipping rotateRefreshToken
to false, and dropping expiresWithSession each fail a named test.
lz merged commit f5251a7990 into main 2026-09-16 14:31:38 +02:00
lz deleted branch feat/oauth-as 2026-09-16 14:31:38 +02:00
Sign in to join this conversation.
No reviewers
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!168
No description provided.