mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-25 05:33:55 -05:00
fix(oauth-provider): make private_key_jwt jti single-use atomic across processes (#9964)
This commit is contained in:
@@ -2174,6 +2174,30 @@ export const oauthConsentTableFields = [
|
||||
|
||||
<DatabaseTable name="oauthConsent" fields={oauthConsentTableFields} />
|
||||
|
||||
### OAuth Client Assertion
|
||||
|
||||
Table Name: `oauthClientAssertion`
|
||||
|
||||
Records each `private_key_jwt` client assertion `jti` so it can only be used once. The row id is a digest of the per-client assertion identifier, so a replayed or concurrent assertion collides on the primary key and the database rejects it atomically, even across multiple server processes. A row keeps blocking its id until deleted; `expiresAt` marks when removal is safe, because the assertion it guards has already expired. No scheduled job prunes these rows, so remove expired rows with your own cleanup if the table grows.
|
||||
|
||||
export const oauthClientAssertionTableFields = [
|
||||
{
|
||||
name: "id",
|
||||
type: "string",
|
||||
description:
|
||||
"Digest of the per-client assertion identifier (`private_key_jwt:<clientId>:<jti>`)",
|
||||
isPrimaryKey: true,
|
||||
},
|
||||
{
|
||||
name: "expiresAt",
|
||||
type: "Date",
|
||||
description:
|
||||
"When the guarded assertion expires and the row becomes safe to delete",
|
||||
},
|
||||
];
|
||||
|
||||
<DatabaseTable name="oauthClientAssertion" fields={oauthClientAssertionTableFields} />
|
||||
|
||||
## Options
|
||||
|
||||
### Prefix
|
||||
|
||||
@@ -348,4 +348,25 @@ export const schema = {
|
||||
},
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Single-use record for `private_key_jwt` client assertion `jti` values. The
|
||||
* row id is a digest of the per-client assertion identifier, so a replayed or
|
||||
* concurrent assertion collides on the primary key and the insert fails
|
||||
* atomically on every adapter (SQL primary key, MongoDB `_id`), including
|
||||
* across multiple server processes.
|
||||
*
|
||||
* A row keeps blocking its id until deleted; `expiresAt` marks when removal
|
||||
* is safe, since the assertion it guards has expired and is rejected earlier.
|
||||
* TODO: no scheduled job prunes expired rows yet; like the verification
|
||||
* table, they accumulate until a deployment-level sweep removes them.
|
||||
*/
|
||||
oauthClientAssertion: {
|
||||
modelName: "oauthClientAssertion",
|
||||
fields: {
|
||||
expiresAt: {
|
||||
type: "date",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies BetterAuthPluginDBSchema;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
PRIVATE_KEY_JWT_SIGNING_ALGORITHMS,
|
||||
} from "@better-auth/core/oauth2";
|
||||
import { isPublicRoutableHost } from "@better-auth/core/utils/host";
|
||||
import { base64Url } from "@better-auth/utils/base64";
|
||||
import { createHash } from "@better-auth/utils/hash";
|
||||
import { APIError } from "better-call";
|
||||
import type { JSONWebKeySet } from "jose";
|
||||
import {
|
||||
@@ -30,7 +32,6 @@ function setJwksCache(uri: string, jwks: JSONWebKeySet, fetchedAt: number) {
|
||||
}
|
||||
}
|
||||
const ALGORITHMS_LIST: string[] = [...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS];
|
||||
const pendingAssertionIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* SSRF gate for user-supplied server-side fetch targets (`jwks_uri`,
|
||||
@@ -357,51 +358,45 @@ export async function verifyClientAssertion(
|
||||
});
|
||||
}
|
||||
|
||||
const jtiIdentifier = `private_key_jwt:${clientId}:${payload.jti}`;
|
||||
if (pendingAssertionIds.has(jtiIdentifier)) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "client assertion jti has already been used",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
|
||||
pendingAssertionIds.add(jtiIdentifier);
|
||||
// Consume the jti with a single insert keyed by a digest of the per-client
|
||||
// assertion identifier. The primary key is the atomic gate on every adapter
|
||||
// (SQL primary key, MongoDB `_id`), so concurrent token requests across
|
||||
// workers cannot both pass. A duplicate-key failure means the jti was
|
||||
// already used (replay); any other failure is surfaced unchanged.
|
||||
const jtiDigest = await createHash("SHA-256").digest(
|
||||
new TextEncoder().encode(`private_key_jwt:${clientId}:${payload.jti}`),
|
||||
);
|
||||
const jtiId = base64Url.encode(new Uint8Array(jtiDigest).slice(0, 24), {
|
||||
padding: false,
|
||||
});
|
||||
try {
|
||||
const existingJti =
|
||||
await ctx.context.internalAdapter.findVerificationValue(jtiIdentifier);
|
||||
if (existingJti) {
|
||||
await ctx.context.adapter.create({
|
||||
model: "oauthClientAssertion",
|
||||
data: {
|
||||
id: jtiId,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
},
|
||||
forceAllowId: true,
|
||||
});
|
||||
} catch (createErr) {
|
||||
let alreadyUsed = false;
|
||||
try {
|
||||
alreadyUsed = Boolean(
|
||||
await ctx.context.adapter.findOne({
|
||||
model: "oauthClientAssertion",
|
||||
where: [{ field: "id", value: jtiId }],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Lookup failed, so a replay cannot be confirmed; surface the insert error.
|
||||
}
|
||||
if (alreadyUsed) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "client assertion jti has already been used",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
|
||||
// Store JTI tombstone until the assertion's actual expiry.
|
||||
// If another instance created the tombstone between our find and create
|
||||
// (race in multi-instance deployments), the create may succeed as a
|
||||
// duplicate or throw; either way the assertion is consumed.
|
||||
const jtiExpiry = new Date(payload.exp * 1000);
|
||||
try {
|
||||
await ctx.context.internalAdapter.createVerificationValue({
|
||||
identifier: jtiIdentifier,
|
||||
value: clientId,
|
||||
expiresAt: jtiExpiry,
|
||||
});
|
||||
} catch (createErr) {
|
||||
// If the tombstone already exists (created by another instance in
|
||||
// the race window), treat it as a replay.
|
||||
const doubleCheck =
|
||||
await ctx.context.internalAdapter.findVerificationValue(jtiIdentifier);
|
||||
if (doubleCheck) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "client assertion jti has already been used",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
throw createErr;
|
||||
}
|
||||
} finally {
|
||||
pendingAssertionIds.delete(jtiIdentifier);
|
||||
throw createErr;
|
||||
}
|
||||
|
||||
return { clientId, client };
|
||||
|
||||
Reference in New Issue
Block a user