Repository navigation
fix(plugin-auth): every in-process auth bridge hands handleRequest the request's transport - #24931
Conversation
…uest's transport The client-address trust rule (ADR-0069 D5/D7) establishes a re-dispatched request's address from the transport's peer when no trusted proxy is declared. Eight raw mounts re-dispatched through AuthManager.handleRequest with the request alone; each now hands over transportOf(c), as the catch-all does. Pinned per bridge. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 927190cdc53f13d9485584db27a0020a9fd75d4b && git checkout 927190cdc53f13d9485584db27a0020a9fd75d4b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin feb09db14a6b84dd91ca756160ef101222bdd1dc fbd92ad310df75fe938242f6d4af858f05b90f6b && git checkout -B drift-repro feb09db14a6b84dd91ca756160ef101222bdd1dc && git merge --no-ff fbd92ad310df75fe938242f6d4af858f05b90f6b
node scripts/docs-audit/affected-docs.mjs --json feb09db14a6b84dd91ca756160ef101222bdd1dc |
席位核对REWORK (patch round 1) · #24877 · The diff matches the card. Every bridge now reaches
The dev's second note stays an Acceptance note, carrier none: Generated by Claude Code |
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
… the trust rule reads The six bridges whose helper rebuilds the request before re-dispatching it now carry the incoming request's forwarding headers onto it, read from the one list the client-address trust rule reads (FORWARDED_CLIENT_ADDRESS_HEADERS, exported from client-address.ts, which resolveClientAddress now iterates). Under a declared server.trustProxy the re-dispatched request is judged on the forwarded client address; without one the rule ignores these headers. Pins extended with the declared-trust branch per bridge. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
The declared-trust pins passed an inferred union of two object literals (each with the other key optional-undefined) where through() takes a Record of strings: TS2345 twice under tsconfig.test.json. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
席位核对REWORK (patch round 2) · #24877 · Patch round 1 is accepted on its content:
One item is owed before ACCEPT:
The seat writes the PR body from the dev's replacement ( Generated by Claude Code |
…ve in an internal module FORWARDED_CLIENT_ADDRESS_HEADERS moves off the published ./client-address entry into client-address-forwarding.ts, which is on no exports map and re-exported from neither the package root nor ./client-address. resolveClientAddress imports the list from it and decides as before. The three module-local copies of carryForwardedClientAddress are replaced by the one shared function beside the list. The changeset no longer names a subpath export. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
席位核对ACCEPT · #24877 ·
Generated by Claude Code |
Fixes #24877
Clause-②: no
What changes
AuthManager.handleRequest(request, transport?)establishes a request's client address by the platform's client-address trust rule (ADR-0069 D5/D7,resolveClientAddress,server.trustProxy): forwarded headers only when the deployment declares a trusted proxy, the transport's peer address otherwise. Every in-process bridge inauth-plugin.tsthat re-dispatches throughhandleRequestnow judges the re-dispatched request on the same facts as the request it serves, on both branches of that rule:handleRequestthe transport of the request it serves, as the catch-all auth mount does (transportOf(c)).FORWARDED_CLIENT_ADDRESS_HEADERS.resolveClientAddressnow iterates that same list, and the helpers copy it through one shared function, so no helper names a header itself. Without a declared trusted proxy, the rule ignores those headers and the re-dispatched request is judged on the connection peer. With one, it is judged on the forwarded client address.handleRequestPOST /admin/remove-userhandleRequest(c.req.raw, transportOf(c))POST /admin/has-permission(delegate lane)handleRequest(c.req.raw, transportOf(c))POST /admin/sso/registerrunRegisterSsoProviderFromForm(handle, …)handleishandleWithTransportOf(c)forwardAuthHeaders(now also used here), and the session readPOST /admin/sso/register-samlrunRegisterSamlProviderFromForm(handle, …)handleishandleWithTransportOf(c)forwardAuthHeaders(now also used here), and the session readPOST /admin/sso/request-domain-verificationrunRequestDomainVerification(handle, …)handleishandleWithTransportOf(c)forwardAuthHeadersPOST /admin/sso/verify-domainrunVerifyDomain(handle, …)handleishandleWithTransportOf(c)forwardAuthHeadersPOST /send-verification-emailrunResendVerificationEmail(handle, …)handleishandleWithTransportOf(c)POST /sys-session/revokesysSessionRevokeDepsFor(manager, …), whosehandlecallsmanager.handleRequesthandleRequestishandleWithTransportOf(c)handleWithTransportOf(c)is one helper besidegateAdmin:(request) => this.authManager!.handleRequest(request, transportOf(c)). The list and the one copy function,carryForwardedClientAddress(from, to), live in a new internal module,client-address-forwarding.ts. That module is import-free and is not on the package'sexportsmap. Neither the package root nor the published./client-addressentry re-exports it, so this PR adds no published export: the built./client-addressentry still exportsresolveClientAddressalone.client-address.tsimports the list forresolveClientAddress. The three helper modules call the shared function: twice inregister-sso-provider.ts(forwardAuthHeadersand the session read), twice insend-verification-email.ts(the session read and the send), and once insys-session-revoke.ts(the owner lane). The OIDC and SAML register builders were byte-identical toforwardAuthHeadersand now call it.transportOfandhandleRequestare unchanged.resolveClientAddressdecides exactly as before: it readsX-Forwarded-For, thenX-Real-IP, now from the list rather than from two literals.The eighth bridge
The card lists seven sites, from
git grep -n "handleRequest(" -- packages/plugins/plugin-auth/src/auth-plugin.ts.POST /sys-session/revokeis an eighth. Its owner lane re-dispatches throughhandleRequestone hop away, insidesysSessionRevokeDepsFor(sys-session-revoke.ts:handle: (request) => manager.handleRequest(request)), so that grep does not list it. It is the same rule and the same mechanical fix, so it is fixed here. The mount hands the factory a manager slice whosehandleRequestcarries the transport. The seat accepted it and amended the claim's surface.Pins
packages/plugins/plugin-auth/src/bridge-redispatch-transport.pin.test.tsuses the D5/D7 real-mount fixture fromclient-address-vendor-trust-rule.test.ts:AuthPlugin.registerAuthRouteson a real Hono app in front of a realAuthManageron the installed better-auth, with the peer onc.env.incoming. It runs two deployments: one declaring no trusted proxy, one declaringtrustProxy. Each pin reads the client address carried by every request the bridge hands the vendored library's handler. That address is the platform-owned headerhandleRequestwrites from the address it established. The bridge's own inner route must be among those requests, so a bridge that never reached the library cannot pass vacuously. Every request also carries the platform-owned header naming another address, and on neither branch may it decide anything.No trusted proxy declared (the caller's forwarding headers name another address):
A trusted proxy declared (the connection peer is the proxy hop; each pin sends
X-Forwarded-Forand, separately,X-Real-IP):Ablation
Each leg ran through
scripts/ablation-replace.mjswith the anchor hit count declared and the mutation confirmed on disk. Legs A to C ran at486e46d1b;auth-plugin.tsand the pin file are byte-identical at this PR's head. Legs D to G ran at this PR's head,fbd92ad31. Each restore was proven: blob equals HEAD's,git diff HEADis empty, and the tree is clean. The test imports its subjects fromsrc/, so nodist/sits on the path.Tests N failed | M passed (28))handleWithTransportOfdrops the transporttransportOf(c)(2 hits)sysSessionRevokeDepsFor(this.authManager!, basePath)register-sso-provider.tsstops calling the shared copy (2 calls)send-verification-email.tsstops calling it (2 calls)sys-session-revoke.tsstops calling it (1 call)carryForwardedClientAddresscopies nothingEvery other pin, and both controls, stayed green in every leg.
Verification
All readings at
fbd92ad31: this branch withorigin/main74fcec1edmerged. No latermaincommit touchesplugin-author the lockfile.pnpm --filter '@objectstack/plugin-auth...' build, thenpnpm --filter @objectstack/plugin-auth run typecheck: exit 0. That coverstsc --noEmit, the examples config andcheck:test-typecheck: OK, whose debt ledger is unchanged at 10 files / 88 errors / 23 signatures. The pin file is inside that program and has no entry in the ledger.dist/client-address.jsanddist/client-address.mjsexportresolveClientAddressalone and require nothing. The package root exports neither the list nor the copy. Spot checks ofresolveClientAddressgave the same answers as before: the chain's first entry, thenX-Real-IP, the peer when trust is undeclared, andundefinedwhen neither is present.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2:Test Files 172 passed (172),Tests 3309 passed | 10 skipped (3319).node scripts/pm/dispatch-gates.mjs --commandswith no paths printed 72 commands. Each ran with its exit code captured before any pipe. 71 exited 0 on the first pass.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: no workspacedist/yet in this fresh worktree). After a later gate in the same pass had built the workspace, it re-ran and exited 0.--ranreconciled them:72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED.check:published-readme-exportsexited 0, with 0 entries in its baseline. The roster gates under touched directories also exited 0:check-changeset-fixed,check:authz-resolver,check:error-code-casingandcheck:filter-alias-parity. eslint (--no-inline-config) on the seven changed source files exited 0.fbd92ad31: 41 check-runs, 34 success and 7 skipped, among themLint & Repo Gates,TypeScript Type Check,Test Core,Build CoreandGoverned Surface Queue Guard.Acceptance notes
sysSessionRevokeDepsForcould take the transport itself. That edit lands in the factory's signature, outside this card's surface. The slice keeps the transport change inauth-plugin.ts.IAuthService.handleRequest(request)in the spec contract has no transport parameter, and two callers outsideplugin-authhand the request alone. Their reach is unmeasured, and both sit in landings this card excludes. Carrier: none.Generated by Claude Code