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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions