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 signedtoken.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.
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.