Skip to content

fix(plugin-auth): run the D5.1 /oauth2/authorize env-access gate for a signed bearer (#8102) - #8175

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-8102-oauth2-authorize-inline-token-lookup
Aug 12, 2026
Merged

fix(plugin-auth): run the D5.1 /oauth2/authorize env-access gate for a signed bearer (#8102)#8175
os-zhuang merged 3 commits into
mainfrom
claude/issue-8102-oauth2-authorize-inline-token-lookup

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8102

ADR-0069 D5.1's cloud-as-IdP gate (oidcAuthorizeGate) is what enforces org-membership / app-assignment before the OIDC OP issues an authorization code. Its branch resolved the acting subject with an inline copy of the shared resolveActor — line for line the same logic, in a second place — and the two diverged the moment one of them was fixed.

#8101 taught resolveActor that a bearer credential must have its signature stripped before lookup: bearer() hands clients the signed form in the set-auth-token response header (the documented API-lane credential) and accepts it back, while session.token stores the unsigned value. The copy guarding /oauth2/authorize kept looking the signed credential up verbatim, and so resolved nothing.

Why this is security and not a lookup miss

The unresolved case at this endpoint is deliberately fail-open — a genuinely anonymous caller must fall through so the OP can redirect them to log in. So an authenticated caller holding the signed bearer was read as unauthenticated, and the env-access check was not denied but never evaluated at all.

Measured on the unfixed build, with the gate armed to DENY and a skip_consent client:

gateCalls=0 status=302
location=https://env.example.com/api/v1/auth/callback/objectstack-cloud?code=wCZn6Nsj4vY9pNucPs5ARYa8BTrjc2aS&state=envgate-8102

The gate was never invoked and an authorization code was actually issued to a subject it would have refused. The issue was written from a code reading and explicitly flagged that no live bearer-driven /oauth2/authorize had been measured; this closes that gap with a real request.

Impact stays bounded, as the issue itself said: oidcAuthorizeGate is set only on the cloud control plane (unset in open editions / self-host, where there is no gate at all), and the OP's authorize endpoint is normally browser/cookie-driven — the cookie branch always normalized and was never affected.

The fix

Delete the inline copy; call the shared this.resolveActor(ctx). Two resolution sites are what let them diverge, so correcting the copy in place would have left the same class open.

The fail-open default for genuinely unauthenticated callers is unchanged and deliberately preserved — that was never the defect, and the pin has a case asserting it still holds. resolveActor also returns activeOrgId; 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), and the site now says so rather than dropping it silently.

The pin

oidcAuthorizeGate previously appeared in no test file at all. The new dogfood gate drives /oauth2/authorize across three credential lanes against an armed, denying gate:

laneon this buildon the unfixed build
cookierefusedrefused — control, cannot detect the defect
bearer, signed set-auth-tokenrefusedcode issued — the pin
bearer, raw sign-in tokenrefusedrefused — control

It closes the three ways this surface can be green while proving nothing:

  • A cookie-driven pin passes before and after the fix, so the cookie and raw-token lanes are carried explicitly as armed-fixture controls. Exactly one lane flips.
  • A gate that ALLOWS makes refusal and fall-through the same observation, so this one denies — "refused" and "issued a code" are then opposite outcomes.
  • With no gate configured at all the file would be vacuously green, so it installs one through applyConfigPatch and asserts it was actually called, with the right userId. A status code alone cannot separate "denied" from "never evaluated".

Verification

  • New pin: 7/7 green. Reverse-verified by restoring the inline copy from origin/main and rebuilding — exactly one lane went red, with the predicted diagnosis.
  • @objectstack/dogfood full suite: 101 files, 680 passed / 3 skipped (re-run after merging main).
  • @objectstack/plugin-auth: 45 files, 1069 passed.
  • typecheck clean on both packages; ESLint clean.
  • Gates: check:nul-bytes, check:changeset-gate-self-tests, check:docs-audit-scope, check:objectui-changeset, check:test-source-alias, check-changeset-no-major, plus the three off-list ratchets — check:query-options-erasure, check:type-check-debt (ledger not raised) and check:engine-double-contract.

Generated by Claude Code

…a signed bearer (#8102)
ADR-0069 D5.1's cloud-as-IdP gate resolved its subject with an inline copy of
resolveActor -- line for line the same logic, in a second place. #8101 fixed one
bug in the shared resolver and left the copy untouched, so the two diverged.
The shared resolver learned that a bearer credential must have its signature
stripped before lookup: bearer() hands clients the signed form in set-auth-token
(the documented API-lane credential) and accepts it back, while session.token
stores the unsigned value. The copy guarding /oauth2/authorize kept looking the
signed credential up verbatim and resolved nothing.
The unresolved case here is deliberately fail-open, so that miss did not deny
the request -- it skipped the check entirely. An authenticated caller on the
documented API lane was read as unauthenticated, and against a skip_consent
client was issued an authorization code the gate would have refused.
Delete the copy and call resolveActor. Two resolution sites are what let them
diverge, so a second corrected copy would not have fixed the class. The
fail-open default for genuinely unauthenticated callers is preserved unchanged.
resolveActor also returns activeOrgId; 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.
Pinned by a dogfood gate that arms a DENYING gate and drives /oauth2/authorize
over the cookie lane and both accepted bearer spellings, asserting the gate was
actually invoked with the caller as its subject and the request refused rather
than issued a code. The cookie and raw-token lanes are controls that pass on the
broken build too; the signed-bearer lane is the pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 6:51pm

Request Review

@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx(via @objectstack/plugin-auth)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/contracts/cache-service.mdx(via @objectstack/plugin-auth)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/authentication.mdx(via @objectstack/plugin-auth)
  • content/docs/permissions/sso.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/index.mdx(via @objectstack/plugin-auth)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-auth)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/plugin-auth)
  • content/docs/releases/v9.mdx(via @objectstack/plugin-auth)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

The Lane.pinsTheDefect field was declared on every lane and read by nothing --
the same declared-but-unenforced shape this file exists to pin. Assert it: the
suite now fails if a later edit makes a second lane the discriminator or drops
the signed-bearer lane, either of which would leave the file green while
measuring something other than #8102.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude