Skip to content

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

@os-zhuang

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.ts resolves a session token from the request in two places:

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's bearer() plugin hands clients the signed token.signature form in the set-auth-token response header — the documented API-lane credential — and accepts it back. So a caller presenting that credential resolves to nothing.

On /oauth2/authorize the unresolved case is deliberately fail-open — the comment reads "Unauthenticated → fall through so the OP redirects to login", and the branch is skipped entirely when gateUserId is undefined:

if (gateUserId) {
  const allowed = await this.config.oidcAuthorizeGate({ userId: gateUserId, clientId: String(clientId) });
  if (!allowed) { ... ENV_ACCESS_DENIED ... }
}

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 getSessionFromCtx misses bearer requests at this hook.

Severity note

oidcAuthorizeGate is 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/authorize request; 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: oidcAuthorizeGate appears in auth-manager.ts only (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 the activeOrgId too. That removes the second resolution site rather than fixing it twice, which is what let these diverge in the first place. Hence Blocked-by: #8049: the shared resolver only became correct in that PR.

A regression pin should drive /oauth2/authorize with a signed bearer against a gate configured to deny, and assert the request is refused rather than falling through.

Activity

  1. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: domain:identity (fix lands in packages/plugins/plugin-auth/src/auth-manager.ts), pm:blocked on the card's own Blocked-by: #8049 line — the suggested fix (delete the inline copy, call resolveActor) 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/authorize measured). 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

  2. self-assigned this
    on Aug 12, 2026
  3. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    ContributorAuthor

    Unblocked by the unlock scan, and claimed in the same action. Blocked-by: #8049 is discharged — PR #8101 MERGED as 36c2f002d at ~17:12Z, landing-verified.

    Claim: PM loop round 6 (domain:identity seat #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/authorize branch'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-auth is free. #8017, #8101 and #8121 all merged this shift; the one live sibling dev is #7795 in plugin-sharing.

    ⚠️ Going straight from pm:blocked to pm:dispatched in 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 :1302 serves the /sso/register branch; the /oauth2/authorize branch is separate
    (b) does the inline copy still exist? Yes — gateUserId with 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: resolveActor gained 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 — oidcAuthorizeGate is 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 (oidcAuthorizeGate appears 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

  4. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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

    • main moved 5 commits under me mid-task; merged it before opening the PR. No same-file overlap (auth-manager.ts untouched 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.pinsTheDefect was 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-changeset label applied, deliberately: this PR ships a plugin-auth source change and carries its own changeset.

    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions