> This is a re-do of #9418 for the `next` branch, where #8836 was merged. As discussed via Slack, backwards compatibility did not need to be maintained.
This is based on RFC 7523
It allows configuring an OAuth2 provider with a `clientAssertionProvider` instead of a `clientSecret`, so omitting long-lived credentials.
This PR is currently not concerned with what providers support (or will support) client assertions. It is also not concerned with _how_ the assertion is obtained: could be a Kubernetes token, a token from a cloud platform, etc.
This PR refactors/extends #8836, which was limited to supporting assertions generated locally with a private key (which, while having broader support in the ecosystem, still involves long-lived secrets). Support for those assertions is available by passing `createPrivateKeyJwtClientAssertionProvider(opts)` to the `clientAssertionProvider` property.
This was implemented by #8836 and it's refactored here so it works in a more generic way.
With this PR, JWTs can be signed with a local assertion:
```ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { createPrivateKeyJwtClientAssertionProvider } from "better-auth/oauth2";
genericOAuth({
config: [
{
providerId: "my-idp",
discoveryUrl: "https://idp.example.com/.well-known/openid-configuration",
clientId: process.env.IDP_CLIENT_ID!,
// Replaces clientSecret
clientAssertionProvider: createPrivateKeyJwtClientAssertionProvider({
clientId: "your-client-id",
tokenEndpoint: "https://idp.example.com/oauth/token",
privateKeyJwk: { /* your JWK */ },
kid: "my-key-1",
algorithm: "RS256",
}),
pkce: true,
},
],
});
```
For an application running on Vercel and authenticating with a generic OAuth2 provider (e.g. Pocket ID), you can now configure better-auth with:
```ts
import { getVercelOidcToken } from '@vercel/oidc'
genericOAuth({
config: [
{
providerId: "my-idp",
discoveryUrl: "https://idp.example.com/.well-known/openid-configuration",
clientId: process.env.IDP_CLIENT_ID!,
// Replaces clientSecret
clientAssertionProvider: async (): Promise<string> => {
return getVercelOidcToken()
},
pkce: true,
},
],
});
```
You then just need to configure your application in the IdP to accept federation with these values:
- Issuer: `https://oidc.vercel.com/<vercel-team>`
- Audience: `https://vercel.com/<vercel-team>`
- Subject: `owner:<vercel-team>:project:<project-name>:environment:production`
The `authorization_code` grant's verification step was a `findOne` + `deleteOne` pair, so two concurrent `POST /oauth2/token` requests sharing the same `code` both pass the find, both delete, and both mint independent access/refresh/id token sets: a CAS gap that lets an authorization code be redeemed twice. The legacy `oidc-provider` and `mcp` plugins in `better-auth` share the same primitive on their `authorization_code` paths and have the same gap.
All three call sites now use `internalAdapter.consumeVerificationValue` (the atomic primitive added in better-auth#9560 and renamed in better-auth#9568): the first concurrent caller receives the row and mints tokens, subsequent racers receive `null`. The consumed and expired paths return RFC 6749 §5.2 `invalid_grant` instead of the better-auth-internal `invalid_verification`, so spec-compliant clients can branch on the standard code. The redundant second `deleteVerificationByIdentifier` call after PKCE validation in the legacy paths is removed.
Closes GHSA-7w99-5wm4-3g79.
Co-authored-by: chdanielmueller <4051999+chdanielmueller@users.noreply.github.com>
The `authorization_code`-grant rotation in `createRefreshToken` and the explicit `revokeRefreshToken` path both updated the parent `oauthRefreshToken` row using an `id`-only predicate, so two concurrent rotations (or a rotation racing a revoke) both pass the `revoked` check and last-write-wins. Each surviving request mints a fresh refresh token, producing a forked family from one parent.
Both call sites now perform a compare-and-swap (`UPDATE ... WHERE id = ? AND revoked IS NULL`) and short-circuit with `invalid_grant` when the row was already consumed. The parent stays marked revoked, so any subsequent replay trips the existing family-invalidation guard in `handleRefreshTokenGrant`. The shared family-delete is centralized in `invalidateRefreshFamily`, which clears child access tokens before refresh rows to honor the schema's foreign-key direction; the `oauthRefreshToken.token` column also gains a `unique` constraint for parity with `oauthAccessToken.token`. Strict family invalidation on contested rotations (RFC 9700 §4.14) is tracked in a FIXME for a follow-up minor that opts into transactional rotation in the adapter contract.
Closes GHSA-392p-2q2v-4372.
Co-authored-by: chdanielmueller <4051999+chdanielmueller@users.noreply.github.com>