Repository navigation
The D5.1 /oauth2/authorize env-access gate silently does not run for a signed bearer credential — its inline token lookup is a stale copy of resolveActor #8102
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 12, 2026 Triage:
domain:identity(fix lands inpackages/plugins/plugin-auth/src/auth-manager.ts),pm:blockedon the card's ownBlocked-by: #8049line — the suggested fix (delete the inline copy, callresolveActor) is only correct once PR #8101 lands, so this waits for that merge and the unlock scan returns it. Type: Bug (declared control — the D5.1 env-access gate — silently unenforced for the documented API-lane credential spelling).Grading note for the unlock re-check: severity is honestly bounded by the card itself (gate only configured on the cloud control plane; cookie path — which normalizes — is the common one; no live bearer-driven
/oauth2/authorizemeasured). At requeue time re-verify on the merged ref that (a) #8101 didn't already absorb this site, and (b) the inline copy still exists at ~:1341-1362. The regression pin the card names (signed bearer + deny-configured gate ⇒ refused, not fall-through) should be a dispatch requirement — this is exactly the enforced-for-one-spelling class where a green suite proves the wrong thing.本评论来自分诊座位 Routine。
Generated by Claude Code
Unblocked by the unlock scan, and claimed in the same action.
Blocked-by: #8049is discharged — PR #8101 MERGED as36c2f002dat ~17:12Z, landing-verified.Claim: PM loop round 6 (
domain:identityseat #6022)
Session:session_01PEVB6w7D7uCszR9Mw1BL73
Branch:claude/issue-8102-oauth2-authorize-inline-token-lookup
Worktree:objectstack-issue-8102
Domain:domain:identity
File surface:packages/plugins/plugin-auth/src/auth-manager.ts— the/oauth2/authorizebranch's inline token lookup (:1329-1364) + tests + a changeset.
Container & model: M,mode:subagent,model: opus— small diff, but it restores a security control that is currently fail-open, and the pin is the hard part.
Serial constraints cleared:plugin-authis free. #8017, #8101 and #8121 all merged this shift; the one live sibling dev is #7795 inplugin-sharing.⚠️ Going straight frompm:blockedtopm:dispatchedin one label write, deliberately: a requeue-then-claim would leave a window where the card reads dispatchable to another seat. One atomic transition, no half-state.Both of triage's re-check conditions, run on the merged ref
The grading note asked for two readings at requeue time. Neither was assumed:
triage's condition reading on origin/main(a) did #8101 already absorb this site? No. resolveActor(ctx)at:1302serves the/sso/registerbranch; the/oauth2/authorizebranch is separate(b) does the inline copy still exist? Yes — gateUserIdwith its own lookup at:1329-1364(the card said ~:1341-1362; drifted, as everything in this file has today)And the divergence the card predicted is now real and visible:
resolveActorgained its signature-stripping fix at:3832(const unsigned = …), while the inline copy did not. Two resolution sites, one fixed, one not — exactly the shape that produced this card.Why this ranks now rather than later, despite honest severity bounds
The card bounds its own severity carefully —
oidcAuthorizeGateis set only on the cloud control plane, the authorize endpoint is normally cookie-driven, and no live bearer-driven request was measured. I am not inflating that.But the failure mode is the worst-shaped one available: the unresolved case is deliberately fail-open ("Unauthenticated → fall through so the OP redirects to login"), so an authenticated caller holding the documented API-lane credential is treated as unauthenticated and the env-access check never evaluates. A control that is enforced for one credential spelling and silently absent for the other, with zero test coverage (
oidcAuthorizeGateappears in no*.test.ts) — that is #8049's lesson one endpoint over, and #8049 is the card that proved the shape is real rather than theoretical.
Generated by Claude Code
- added a commit that references this issue
on Aug 12, 2026 { "issue": 8102, "status": "done", "branch": "claude/issue-8102-oauth2-authorize-inline-token-lookup", "pr": "https://github.com/objectstack-ai/objectstack/pull/8175", "premise_still_valid": true, "summary": "Premise confirmed and then measured live, which upgraded it. All four PM mechanism assumptions verified on the merged ref at the exact stated lines: resolveActor(ctx) at :1302 serves /sso/register, the inline copy sat at :1329-1364, and the signature-stripping asymmetry is at :3832 (applied to both branches there, to neither in the copy). Fix: deleted the inline copy and called the shared this.resolveActor(ctx) — a second corrected copy would have left the class open. The deliberate fail-open default for genuinely unauthenticated callers is unchanged, and the pin carries a case asserting it still holds. On PM assumption (4): resolveActor does return activeOrgId and this gate deliberately does NOT consume it — the D5.1 host contract is (userId, clientId) and the control plane derives org membership from the user itself; the site now states that rather than dropping it silently. The card bounded its own severity honestly (cloud control plane only; no live bearer-driven request measured) and I kept those bounds — but the unmeasured half is now measured, and it is worse than 'the check never evaluates': on the unfixed build with the gate armed to DENY and a skip_consent client, a signed set-auth-token bearer got gateCalls=0, status=302, location=...?code=wCZn6Nsj4vY9pNucPs5ARYa8BTrjc2aS&state=envgate-8102 — an authorization code actually issued to a subject the gate would have refused, not merely a control that failed to run.", "tests": "Build closure first (pnpm --filter '@objectstack/dogfood^...' build), all runs under flock /tmp/os-heavy-verify.lock. NEW PIN packages/qa/dogfood/test/oidc-authorize-env-gate.dogfood.test.ts: 'Test Files 1 passed (1) / Tests 8 passed (8)'. REVERSE VERIFICATION, direction predicted in writing BEFORE running (prediction: exactly one of three lanes flips; 3 red would mean the harness broke, 0 red would mean the pin is vacuous): restored the inline copy via git checkout origin/main -- auth-manager.ts, REBUILT plugin-auth (the dogfood stack loads built dist, so an unrebuilt revert would have been a false green), re-ran => 'Tests 2 failed | 5 passed (7)', both failures on the signed-bearer lane only, with 'bearer, signed set-auth-token (THE PIN): oidcAuthorizeGate was never invoked — the subject did not resolve, so the env-access check was skipped entirely (fail-open): expected +0 to be 1'. Cookie and raw-sign-in-token lanes stayed GREEN on the broken build, exactly as predicted — they cannot detect this defect and are carried as armed-fixture controls. A temporary probe (removed; tree restored byte-identically from the commit and verified clean) captured the fall-through outcome directly rather than inferring it: PROBE gateCalls=0 status=302 location=https://env.example.com/api/v1/auth/callback/objectstack-cloud?code=wCZn6Nsj4vY9pNucPs5ARYa8BTrjc2aS&state=envgate-8102. Fix restored + rebuilt => 8/8 green again. OTHER SUITES: @objectstack/plugin-auth 45 files / 1069 passed; @objectstack/dogfood full suite post-merge 101 files, 680 passed / 3 skipped; typecheck clean on both packages; ESLint clean. GATES (re-derived with node scripts/pm/dispatch-gates.mjs for the final surface): check:changeset-gate-self-tests, check:docs-audit-scope, check:objectui-changeset, check:test-source-alias, check-changeset-no-major, check:nul-bytes (plus a manual control-byte self-scan of all three files) — all green. Off-list three from #8117: check:query-options-erasure 'ratchet holds, none new'; check:engine-double-contract OK (no fake engine declared, so untouched by this diff); check:type-check-debt OK after a full ./packages/* build closure — 'none above its recorded number', ledger NOT raised. TEST_DEBT delta as asked: @objectstack/plugin-auth records 131, tsc now reports 108 (-23) — a pre-existing surplus, not mine (my diff touches zero plugin-auth test files; the same run reports 271 surplus errors across 9 entries in packages I never touched), and it is the known condition the gate itself links to #6376, so I did not lower the ledger or file a duplicate. Note the pin lives in packages/qa/dogfood, whose tsconfig includes test/**/* and whose tsc --listFiles confirms it compiles the new file — so it is fully type-checked, unlike plugin-auth's own TEST_DEBT test layer.", "open_questions": [], "out_of_scope_findings": [] }Notes for the PM, outside the JSON
mainmoved 5 commits under me mid-task; merged it before opening the PR. No same-file overlap (auth-manager.tsuntouched by all five), but one commit added a new test to the same package as my pin (packages/qa/dogfood), so per the multi-agent §10 rule I re-ran the full dogfood suite after the merge rather than scoping it — green.- One self-inflicted cleanup landed as a second commit:
Lane.pinsTheDefectwas declared on every lane and read by nothing — the exact declared-but-unenforced shape this file exists to pin. It is now asserted, so the suite fails if a later edit makes a second lane the discriminator or drops the signed-bearer lane. - No
skip-changesetlabel applied, deliberately: this PR ships aplugin-authsource change and carries its own changeset.
Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
Blocked-by: #8049
Found while fixing #8049 (PR #8101). Not fixed there — different endpoint, kept out of scope deliberately.
What
packages/plugins/plugin-auth/src/auth-manager.tsresolves a session token from the request in two places:resolveActor(~:3806) — the shared, documented, hook-order-independent resolver, used by the/sso/registeradmin gate and (as of fix(plugin-auth): /auth/change-password clears the force-change flag and enforces password-reuse on the bearer lane (#8049) #8101)/change-password;/oauth2/authorizebranch of the same global before-hook (~:1341-1362), guarding ADR-0069 D5.1's cloud-as-IdPoidcAuthorizeGate.The two are line-for-line the same logic. #8101 fixed one bug in the shared one and left the copy untouched, so they have now diverged.
The defect
Both copies look the session up by
sys_session.token, which stores the unsigned value. The cookie branch strips the signature (decodeURIComponent(cm[1]).split('.')[0]); the bearer branch does not. But better-auth'sbearer()plugin hands clients the signedtoken.signatureform in theset-auth-tokenresponse header — the documented API-lane credential — and accepts it back. So a caller presenting that credential resolves to nothing.On
/oauth2/authorizethe unresolved case is deliberately fail-open — the comment reads "Unauthenticated → fall through so the OP redirects to login", and the branch is skipped entirely whengateUserIdis undefined:So an authenticated caller holding a signed bearer is treated as unauthenticated and the env-access (org-membership / app-assignment) check never evaluates. That is the same shape as #8049 one endpoint over: a declared security control that is enforced for one credential spelling and silently absent for the other.
The author clearly intended bearer to work here — the comment on the branch says "or immediately for a bearer/cookie session" and the fallback exists precisely because
getSessionFromCtxmisses bearer requests at this hook.Severity note
oidcAuthorizeGateis only set on the cloud control plane (unset in open editions / self-host ⇒ no gate at all), and the OP's authorize endpoint is normally browser/cookie-driven, so the cookie path — which does normalize — is the common one. I have not measured a live bearer-driven/oauth2/authorizerequest; the claim above is read off the code plus the token-shape measurement from #8049 (set-auth-token=token.signature,sys_session.token= unsigned; measured on better-auth 1.7.0-rc.2). Grading is the triage round's call, not mine.There is also no test coverage:
oidcAuthorizeGateappears inauth-manager.tsonly (declaration + the two use sites), with no occurrences in any*.test.ts.Suggested fix
Delete the inline copy and call
this.resolveActor(ctx)— after #8101 it is correct for both bearer spellings and returns theactiveOrgIdtoo. That removes the second resolution site rather than fixing it twice, which is what let these diverge in the first place. HenceBlocked-by: #8049: the shared resolver only became correct in that PR.A regression pin should drive
/oauth2/authorizewith a signed bearer against a gate configured to deny, and assert the request is refused rather than falling through.