fix(auth): the browser-JWT branch now carries username, email and role - #1231
lilyshen0722 wants to merge 1 commit into
Conversation
`middleware/auth.ts` dispatched on the token prefix and left two different
shapes on `req.user`: the `cm_` API-token branch assigned
`{ id, username, email, role }`, the browser-JWT branch assigned `{ id }`.
Nothing errored — consumers simply read `undefined` for every browser session.
Two live consequences, both confirmed at 799e0d7:
- `github.ts:146` refuses genuine admins with `403 Admin only`; the same
admin holding an API token gets through.
- registry publish/install persist `publisher.name: undefined` (#1211).
Three call sites had already grown a private DB re-read to work around it —
`podController.isGlobalAdminRequest`, `agentProfile.canEditAgentAvatar` and
`marketplace-api.resolveUsername`, the last of which #1211 duplicates rather
than moves. `github.ts` was the site that never got its copy.
The JWT branch already runs one indexed `User.findById` per request for ban
enforcement, so widening that projection makes the two branches shape-identical
at zero extra round-trips. The existing local re-reads stay correct and become
redundant fast-paths; removing them is separate work.
The test drives the real middleware over a real JWT, because the suites
covering the affected routes inject `{ _id, role }` from a fake auth
middleware — i.e. the API-token shape — and so cannot see this defect. Its
`User` mock honours the projection string: with a `select()` that ignores its
argument, narrowing the middleware back to `.select('banned')` leaves all seven
tests green. Both mutations (narrowed projection, restored `{ id }`) redden
four of seven.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed at What I reproduced
The CodeQL red is not yours198 alerts, all 1. "shape-identical" holds for the key set, not for valuesThe So on a role-less row the two branches still disagree — and No live consequence today — every consumer compares against 2.
|
lilyshen0722
left a comment
There was a problem hiding this comment.
PASS on the code at c9cce6010. Not mergeable as it stands — two operational blockers below, neither of them about the diff.
The fix is right, and the test earns its keep
Widening a projection this branch already runs is the correct shape: both auth branches now assign { id, username, email, role }, at zero extra round-trips, so no consumer has to know which token type it was called with. I checked the parity claim rather than taking it — the cm_ branch at :50-55 assigns exactly those four keys from _id.toString(), username, email, role, and all four fields exist on the schema (models/User.ts:177, :178, :203).
Two things I went looking for and did not find, both of which would have made this riskier than it looks:
- No consumer sniffs the shape. I grepped for code inferring the auth type from
req.user.username/role/emailbeing undefined; there is none, and the onlyreq.authTypereaders areauth.tsitself andapiTokenScopes.ts. So no branch silently flips direction when the field starts arriving. - The widened projection does not disarm the guard beside it.
.select('banned username email role')still carriesbanned, and the suite pins it — a projection that drops a field its own predicate reads is a fail-open this repo has been bitten by before.
Ran the suite at this head under node 22: 7/7 pass.
Mutation-checked it, and it discriminates precisely. Reverting the projection to .select('banned') — the exact regression a future editor would introduce while "tidying an over-wide query" — reds 4 of 7:
✕ the browser-JWT branch carries username, email and role
✕ the two branches agree on the key set
✕ the github.ts:146 predicate now admits a browser-session admin
✓ CONTROL: the predicate still refuses a browser-session non-admin
✕ a user row without a role leaves role undefined, not defaulted
✓ the ban check still fires on the same widened read
✓ a deleted account is still refused
The three survivors are the right three: they do not depend on the widening, so the control genuinely controls rather than the suite being globally sensitive to any edit.
Blocker 1: behind = 82, on a green stale-base tick
Stale-base merge guard shows pass. Recomputed just now against main 8ca1ef60: the merge-base is 82 commits behind, against MAX_BEHIND: 40. The tick is a property of (PR, main-as-it-was) — it ran when the PR was fresh and has not been re-evaluated since; the guard only fires on opened, synchronize, reopened, edited. Read the number, not the checkmark. This needs a rebase before it can merge, independent of anything else here.
Blocker 2: CodeQL is red, and I cannot clear it
title: "198 new alerts including 198 high severity security vulnerabilities"
summary: "New alerts in code changed by this pull request"
ran: 2026-08-25T11:00:16Z (2s)
198 high-severity alerts attributed to a 24-line projection widening in one file is not credible on its face, and zero of the repo's open alerts are located in backend/middleware/auth.ts. Three other equally stale ungated PRs (#1171, #1207, #1236) are green on the same gate, so it is not a global outage either.
I looked for the obvious explanation — that a 5-day-old run against an 82-commit-stale base is attributing main's drift to the PR — and it does not fit cleanly: the repo carries 505 open alerts, 491 of them high, so 198 is a subset of something rather than "everything." I could not determine the attribution mechanism, and I am not going to invent one. What I can say is that the gate is red, its stated content is implausible, and the cheapest way to find out is the rebase that Blocker 1 already requires — a fresh run against a current base either reproduces the 198 (in which case it is real and this becomes a block on substance) or clears.
Follow-up, not a defect
The three private DB re-reads the description names as workarounds (podController.isGlobalAdminRequest and the two others) are untouched by this diff — correctly, since a fix should not also remove the thing that was compensating for it. They are now redundant reads rather than load-bearing ones. Worth a separate PR once this lands, and worth it soon: a workaround left in place after its cause is fixed is the kind of code whose comment teaches the next reader something false.
middleware/auth.tsdispatches on the token prefix and leaves two different shapes onreq.user:cm_API token (:51){ id, username, email, role }:81){ id }Nothing errors. Consumers just read
undefinedfor every browser session.Two live consequences, both confirmed at
799e0d7dgithub.ts:146—if (req.user?.role !== 'admin') return res.status(403). A genuine admin in a browser session is refused403 Admin only; the same admin holding an API token gets through. Fails closed, loudly.registrypublish/install —publisher.namepersists asundefined. Fails open, silently. This is fix(registry): publisher.name is undefined for every browser-session publish #1211's defect.Why one fix rather than two
Three call sites had already grown a private DB re-read to work around this, each with its own comment rediscovering the same cause:
podController.isGlobalAdminRequest(:43)agentProfile.canEditAgentAvatar(:250) — "JWT auth populates req.user = { id } WITHOUT role (middleware/auth.ts:81)"marketplace-api.resolveUsername(:11) — which fix(registry): publisher.name is undefined for every browser-session publish #1211 copies intoregistry/helpers.tsunder a comment saying it was movedgithub.tsis the fourth site, and the one that never got its copy. Fixing it locally would make four.The JWT branch already runs one indexed
User.findById(id)per request for ban enforcement (#636). Widening that projection frombannedtobanned username email rolemakes the branches shape-identical at zero extra round-trips, so no consumer has to know which token type it was called with. The three existing local re-reads stay correct and become redundant fast-paths — removing them is separate work, deliberately not in this PR.On the test
It drives the real middleware over a real JWT. The suites covering the affected routes cannot see this defect: their fake auth middleware injects
req.user = { _id, role }directly — the API-token shape — so #809's/statustests pass today against the broken predicate.The
Usermock honours the projection string, and that is load-bearing rather than tidiness. With a naiveselect()that ignores its argument and returns the whole fixture, narrowing the middleware back to.select('banned')leaves all seven tests green — the fields arrive from the fixture, not from the query. Verified by mutation, both ways:.select('banned username email role')→.select('banned')req.user = { id, username, email, role }→req.user = { id }The
CONTROL:case stays green under both, by design — it guards against over-granting (role: 'admin'hardcoded), not against the defect.a user row without a role leaves role undefined, not defaultedpins that absence stays absent: a default here would grant or deny on data the row does not contain.Verification
backend/__tests__/unit/middleware/— 38/38, 7 suitesbackend/__tests__/unit/routes+controllers— 650/650, 99 suitestsc --noEmit— no errors in either touched file (pre-existing errors inscripts/are untouched)npm run lint— the two remaining errors on the new file are the repo-wideimport/no-unresolved+import/extensionspattern that every sibling middleware test carriesNot verified: no runtime exercise of
/api/github/statusor the registry routes against a live browser session — both findings are source-level plus the middleware unit test. I also did not count how many registry rows actually lackpublisher.name; no Mongo read path from this seat.Relates to #1211 (which fixes the registry sites at the consumer) and #809 (which touches
/statusbut leaves:146unchanged).🤖 Generated with Claude Code