A CIMD allowlist, so Claude can identify itself #169
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus!169
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/cimd-allowlist"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #152.
The operator allowlists a Client ID Metadata Document URL; Claude can then identify itself to the OAuth server #168 added. Verified end to end against the built server with Claude Code's real document — including a callback on a loopback port the document does not register.
The design differs from the issue, and the issue should be updated
#152 specifies a resolver we write: fetch at allowlist time, register a static client row, never enable
features.clientIdMetadataDocument. This ships the opposite — the library's own CIMD feature, with both gating hooks bound to the allowlist. Two things found while planning changed the conclusion.The library already implements every piece, more thoroughly.
client_id_metadata_document.jsdoes URL validation,redirect: 'manual', a body cap,client_idequality, shared-secret rejection and an LRU honouringCache-Control.fetch_request.jscarries a special-use address table for both families and checkssocket.remoteAddresson undici'sconnectevent — i.e. the peer actually connected to, leaving no TOCTOU window. An earlier draft of this plan hand-wrote that guard and would have passed2002:7f00:1::(a 6to4 address embedding 127.0.0.1) as public.The issue's objection is removable by configuration. It rejects the feature because
allowFetch/allowClientdefault permissive next to a host-networked Docker API (fact #17). Bound to the allowlist, the fetch target is operator-controlled — the same end the resolve-at-add-time design was reaching for.What genuinely changes is when the fetch happens. The issue says "at request time there is no fetch at all"; here there is one, on a cold cache, against a URL the operator allowlisted. Accepted knowingly: if
claude.aiis unreachable, Claude is not connecting anyway.Three library facts the feature rests on
Each was verified against the installed
oidc-provider@9.12.2, and each fails silently if got wrong.application_type: 'native'is required, and it is the whole ballgame. Claude Code's live document declares noapplication_typeand registers portless loopback redirects (http://localhost/callback,http://127.0.0.1/callback) — the RFC 8252 §7.3 "any port" idiom.#redirectAllowedreturnsfalsebefore stripping the port unless the client is native, and the default isweb. Registered as-is, the client validates fine and then refuses every callback Claude Code can make.clientDefaultsis the only lever:resolveClientByMetadataDocumentbuilds the client verbatim andallowClientruns after construction.enabledis gated on the allowlist being non-empty.discovery.jssetsclient_id_metadata_document_supportedwhen the feature is on, and thedefaults()after it only fills absent keys — so thediscoveryentry is consulted only in the off state. Gatingenabledgives the issue's required behaviour and means a fresh instance has no CIMD fetch path in existence.Both hooks are bound.
allowFetchruns only on a cache miss;allowClientis what refuses a client whose entry was removed while its document sat in the LRU.Two consequences worth knowing
Native clients re-prompt for consent on every authorization (
native_client_prompt) rather than silently reusing a Grant. Defensible for a control-plane credential; observed in the live run, not inferred.ttl.RefreshTokenis now ours.application_type: 'native'disables the library's refresh-chain cap, which is gated onapplicationType === 'web'— measured: web 42s (inherits the rotated token's remaining life), native a fresh 14 days on every rotation, i.e. an unbounded chain. The override is the library's own logic minus that clause.ttl-parity.test.tspins the vendor branch so we learn if upstream fixes it.Defects this branch fixes in already-merged #168
The consent page never worked.
_loadConsentPageresolved interactions withfindByUid, butInteraction.uidis a getter overjtiand absent fromIN_PAYLOAD, so the adapter writes NULL and the lookup can never match. Every render returned 404 "This sign-in request has expired" — with 2008 tests green. The fixture populated auidcolumn the library never writes; one helper is why it survived review, a/simplify, and a staging pass. The replacement test drives a real authorization and parses the uid from the redirect, so no fixture chooses the payload.A
Providerconstructor throw crashed the control plane.getProvideris called synchronously inside thecreateServerlistener. Now returnsnull, which the caller already turns into a 503.getProvider's memo was bundle-local. A module-levellet, while SvelteKit andsrc/server.tsevaluate the module separately (fact #25). Once the consent page began calling it, that meant a second Provider with a cold LRU. Now behind aglobalThisslot.Also: an inert
claimsline whose comment claimed it pinned us against a library default change — it cannot,merge({}, defaults, input)— and a falseAGENTS.mdcitation.Failure handling
Both mutators refresh the cache after committing, so a refresh failure means the row is written and the provider has not picked it up.
POSTandDELETEboth distinguish that from "the write never took" and return 207 with the URL named (matchingartifacts/…/submit's existing use for durable-write-ok/propagation-failed).DELETEmatters most: a stale cache there keeps a revoked client authenticating while the UI shows it gone.Consent-page client resolution catches
OIDCProviderErrorand separates "not allowlisted / not resolvable" (400) from "could not reach the document" (503, retryable).provider.Client.findthrows on every CIMD failure path rather than returningundefined, so the previousif (!client) 400was unreachable for exactly these clients and every transient blip rendered as a 500.Construction failures are retried but backed off 5s per memo key: the failure is persistent, the paths are unauthenticated, and the library emits four
console.infolines before throwing on an ack mismatch — so suppressing only our own line silenced the useful one and left four times the volume.Verification
Live, against the built server: flag
false→truewithout a restart (theserver.tswiring line no unit test can reach), Claude Code's real document fetched fromclaude.ai, PKCE flow accepted on port 54321 which the document does not register, token carryingnexus:read nexus:writeplus a refresh token, consent page rendering 200 with the client name and loopback warning, un-allowlisted client refused (with an allowlisted positive control in the same run), removal restoringfalse. Zero server errors.182 test files, 2061 tests, typecheck 5102/0, lint clean.
cimd-ssrf-gate.test.tsasserts zerofetchcalls when a non-allowlisted client is resolved — the ordering is the security property, and a refusal after the socket opens would still throw and still pass a naive test.Known and deliberate
/api/mcpis #154.ready(the phase gate bounces them). That is why thefindByUidbug survived two staging passes; worth a look but out of scope.b616c3econtains two agents' work under one message —git addwrites to a shared index and a concurrent commit swept up staged files. Both diffs are correct; the message covers half. Not rewritten, since untangling a shared branch with live writers risks more than it fixes.Mirrors isValidClientIdUrl's authority.includes('@') exactly. url.username/ url.password read as empty strings for a bare https://@a.example/m (empty userinfo on both sides of the @), so that shape fell through to the canonical-form backstop instead of the credentials check. Also corrects a comment that overstated how the empty-authority case is caught: only the triple-slash shape goes through the canonical-form check; https://, https://?x and https://#x all throw at new URL() itself.WHATWG resolves a percent-encoded dot segment during parsing -- the segment is fully removed, not left encoded/hidden. Verified directly: pathname of '/x/%2e%2e/m' is '/m', not '/x/%2e%2e/m'; %2e only survives when it isn't itself a whole dot segment (e.g. '/a%2e/m' -> '/a%2e/m' unchanged). The conclusion (check the raw string, not url.pathname) was already correct, for the opposite reason: the traversal has already happened and the evidence is gone by the time you have a pathname, not hidden under encoding a naive ./.. check would miss. Also drops two stale exact-count claims ('the one place ...', 'the one deep lib/ import ...') now that ttl-parity.test.ts does the same thing for a different oidc-provider internal. No behaviour or assertion changes -- comments only.`s in SCOPE_LABELS` asks whether the prototype chain has the key, not whether we have a label for the scope. Measured: `constructor` and `toString` both pass, so either one reached `data.granted` as { scope, label: <function> } — a function where ConsentPageData promises a string, which the Record<..., string> type hides rather than catches. Object.hasOwn asks the question the filter means. The scope fixture gains `constructor`, the case the old check let through; the existing `not-a-real-scope` is a safe unknown that could never have caught it. Low consequence — Svelte escapes output, and oidc-provider filters against scopes_supported upstream, so it is unconfirmed such a scope reaches here at all. Worth it for the code saying what it means. Also merges two adjacent comment blocks above the client lookup that had accumulated into overlapping explanations of the same call.