OSV 1.4.0 · github-reviewed · 修改于 2026-07-21 03:24
发布时间
2026-07-08 04:55
GitHub 审查时间
2026-07-08 04:55
NVD 发布时间
2026-07-16 02:16
源文件
advisories/github-reviewed/2026/07/GHSA-392p-2q2v-4372/GHSA-392p-2q2v-4372.json
Users are affected if all of the following are true:
@better-auth/oauth-provider at a version >= 1.6.0, < 1.6.11, or uses the embedded plugin in better-auth >= 1.4.8-beta.7, < 1.6.0.offline_access scope, so refresh tokens are minted.If developer applications do not request offline_access for any client, no refresh tokens are minted and they are not exposed.
Fix:
@better-auth/[email protected] or later.The OAuth provider's POST /oauth2/token endpoint, on the refresh_token grant, performs a non-atomic read / validate / revoke / mint sequence on the oauthRefreshToken row. Two concurrent requests presenting the same parent refresh token both pass the revocation check before either revoke completes, so each mints a fresh refresh token. The replay-detection branch only fires when revoked is already truthy at read time, which is exactly the state concurrent attackers race past. The result is a forked refresh-token family from a single parent token.
The adapter.update predicate on the parent row is keyed on id only; it does not include revoked IS NULL, so two concurrent updates both succeed (last-write-wins, no error path). The schema does not declare unique on oauthRefreshToken.token, so concurrent creates do not collide on a unique-key violation either.
RFC 9700 §4.14 (OAuth Security Best Current Practice) prescribes refresh-token family invalidation on detected reuse; this implementation tries to enforce that contract through the check, but the check is not atomic with the consumption step. Token rotation issues a new refresh token with each call, so a single stolen refresh token grants indefinite access until the row is revoked or its (default 7 days) passes. Rotation refreshes that window each call.
revokedrefreshTokenExpiresAtThe fix lands an atomic compare-and-swap on the parent row inside the rotation primitive (UPDATE ... WHERE id = ? AND revoked IS NULL with a rowcount check), so the losing rotation fails closed with invalid_grant and the parent row stays marked revoked. Subsequent replay of the original refresh token then trips the existing family-invalidation guard. The schema gains a unique constraint on oauthRefreshToken.token for parity with oauthAccessToken.token.
Fixed in @better-auth/[email protected]. The refresh-token rotation primitive now performs an atomic compare-and-swap on the parent row, and the explicit revokeRefreshToken path uses the same CAS. On a contested rotation, exactly one caller wins and mints a fresh refresh token; the loser receives invalid_grant. Subsequent replay of the original refresh token trips the existing family-invalidation guard because the parent row stays marked revoked.
@better-auth/[email protected] ships a compatibility fix in the same wave: the in-memory where clause now treats undefined and null as equivalent under an eq null predicate, mirroring SQL IS NULL and Mongo's missing-or-null semantics. Without this change, the CAS predicate WHERE revoked IS NULL falls through on every call against a row whose optional revoked field is absent (the adapter factory's transformInput skips writing undefined when no default exists), so the rotation above is broken for any deployment using the in-memory adapter.
Strict refresh-token family invalidation on a contested rotation, per RFC 9700 §4.14 (which calls for invalidating the winner's tokens too when reuse is detected at rotation time), is deferred to a follow-up minor on the next channel. Closing it cleanly requires an opt-in transactional rotation in the adapter contract so the family-delete cannot interleave with the winner's in-flight access-token insert. The deferred site carries a FIXME(strict-family-invalidation) marker.
Schema-migration note: the better-auth migration generator only emits UNIQUE for newly-created columns. Existing installs will not pick up the new oauthRefreshToken.token unique constraint from migrate / generate; add it manually if an application's operational tooling depends on it (CREATE UNIQUE INDEX oauth_refresh_token_token_uniq ON "oauthRefreshToken" (token);). The CAS fix above does not depend on the database-level constraint to be correct; the constraint is defense-in-depth so collisions from a buggy custom generateRefreshToken callback fail loudly.
None of these close the bug fully without a code patch.
adapter.update on oauthRefreshToken with a row-level pessimistic lock (SELECT ... FOR UPDATE). Narrows the window without closing it.oauthProvider({ refreshTokenExpiresIn: 60 }) to expire forked families within one minute. Trades attacker persistence for shorter user sessions.offline_access scope. Closes the surface but breaks long-lived sessions.Reported by @chdanielmueller.