mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-26 02:16:23 -05:00
feat(oauth-provider): add refresh token reuse interval (#10145)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@better-auth/oauth-provider": minor
|
||||
"@better-auth/mcp": minor
|
||||
---
|
||||
|
||||
OAuth Provider can now replay the same refresh-token response for duplicate refresh requests during a configured `refreshTokenReuseInterval`. OAuth Provider keeps strict replay handling by default; set this option to opt into the overlap window.
|
||||
|
||||
The MCP plugin defaults that interval to 30 seconds for native/public clients that can retry a refresh with the old token after another local session already rotated it. Set `refreshTokenReuseInterval: 0` to keep strict replay handling.
|
||||
@@ -156,7 +156,9 @@ requireMcpAuth(auth, handler, {
|
||||
|
||||
## Configuration
|
||||
|
||||
`mcp()` extends the [OAuth Provider](/docs/plugins/oauth-provider#configuration) options. All OAuth provider options are passed flat (there is no nested `oidcConfig`). The MCP-specific option is `resource`.
|
||||
`mcp()` extends the [OAuth Provider](/docs/plugins/oauth-provider#configuration) options. All OAuth provider options are passed flat (there is no nested `oidcConfig`). The required MCP-specific option is `resource`.
|
||||
|
||||
For native/public MCP clients, `mcp()` defaults `refreshTokenReuseInterval` to 30 seconds. This lets a client retry a refresh with the old token and receive the same rotated token response when another local session already consumed that refresh token. Set `refreshTokenReuseInterval: 0` to keep strict OAuth Provider replay handling.
|
||||
|
||||
export const mcpPluginOptionsType = {
|
||||
loginPage: {
|
||||
@@ -205,6 +207,11 @@ export const mcpOauthOptionsType = {
|
||||
type: "number",
|
||||
default: 2592000,
|
||||
},
|
||||
refreshTokenReuseInterval: {
|
||||
description: "Seconds that a rotated refresh token can be reused to receive the same token response. MCP defaults this to 30 for native/public clients; set 0 for strict replay handling.",
|
||||
type: "number",
|
||||
default: 30,
|
||||
},
|
||||
codeExpiresIn: {
|
||||
description: "Lifetime of authorization codes in seconds.",
|
||||
type: "number",
|
||||
|
||||
@@ -604,6 +604,18 @@ The refresh token grant enables clients to update their access token without nee
|
||||
|
||||
This implementation currently issues a new refresh token for every refresh request.
|
||||
|
||||
Set `refreshTokenReuseInterval` to allow a rotated refresh token to be reused for a short interval and receive the same token response. This helps native and public clients recover from duplicate refresh requests, lost responses, or retrying requests without minting another token pair.
|
||||
|
||||
```ts title="auth.ts"
|
||||
oauthProvider({
|
||||
refreshTokenReuseInterval: 30, // seconds
|
||||
})
|
||||
```
|
||||
|
||||
The default is `0`, which keeps strict replay detection. During the interval, Better Auth replays the cached response only when the reused refresh token is from the same client and the request resolves to the same effective scopes, requested resources, and sender constraint (for example the same DPoP key). A mismatch during the interval returns `invalid_grant` without invalidating the whole family; once the interval expires, using the old refresh token is treated as replay and invalidates the refresh-token family.
|
||||
|
||||
The cached response is stored encrypted on the consumed refresh-token row and includes the replacement refresh token. `expires_in` is recalculated from the cached `expires_at` each time the response is replayed.
|
||||
|
||||
### Consent Endpoint
|
||||
|
||||
Accept or deny user consent for a set of scopes. Note that when denying scopes, the consent cancels and pre-existing consent remains. To remove consent, delete that user's "oauthConsent" for that client.
|
||||
@@ -1257,6 +1269,7 @@ Each token type and grant type can independently can set a default expiration.
|
||||
* `m2mAccessTokenExpiresIn` defaults 1 hour
|
||||
* `idTokenExpiresIn` defaults 10 hours
|
||||
* `refreshTokenExpiresIn` defaults 30 days
|
||||
* `refreshTokenReuseInterval` defaults 0 seconds
|
||||
* `codeExpiresIn` defaults 10 minutes
|
||||
* `assertionMaxLifetime` defaults 5 minutes — maximum allowed lifetime for `private_key_jwt` client assertions
|
||||
|
||||
@@ -2216,7 +2229,26 @@ export const oauthRefreshTokenTableFields = [
|
||||
{
|
||||
name: "revoked",
|
||||
type: "Date",
|
||||
description: "Timestamp when the token was revoked",
|
||||
description: "Timestamp when the token stopped being active",
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
name: "rotatedAt",
|
||||
type: "Date",
|
||||
description: "Timestamp when the token was consumed by rotation",
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
name: "rotationReplayResponse",
|
||||
type: "string",
|
||||
description:
|
||||
"Encrypted token response and request fingerprint replayed during the configured refresh-token reuse interval",
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
name: "rotationReplayExpiresAt",
|
||||
type: "Date",
|
||||
description: "Timestamp when the cached rotation response stops being replayable",
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -337,6 +337,8 @@ describe("mcp plugin", async () => {
|
||||
});
|
||||
const tokens = (await tokenResponse.json()) as {
|
||||
access_token?: string;
|
||||
expires_at?: number;
|
||||
expires_in?: number;
|
||||
id_token?: string;
|
||||
refresh_token?: string;
|
||||
token_type?: string;
|
||||
@@ -348,6 +350,50 @@ describe("mcp plugin", async () => {
|
||||
expect(tokens.refresh_token).toBeDefined();
|
||||
expect(tokens.token_type?.toLowerCase()).toBe("bearer");
|
||||
expect(tokens.scope).toBe("openid offline_access");
|
||||
|
||||
const { body: firstRefreshBody, headers: firstRefreshHeaders } =
|
||||
await refreshAccessTokenRequest({
|
||||
refreshToken: tokens.refresh_token!,
|
||||
options: {
|
||||
clientId: publicClientId,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
});
|
||||
const firstRefreshResponse = await customFetchImpl(
|
||||
`${baseURL}/oauth2/token`,
|
||||
{
|
||||
method: "POST",
|
||||
body: firstRefreshBody.toString(),
|
||||
headers: firstRefreshHeaders,
|
||||
},
|
||||
);
|
||||
const firstRefresh = (await firstRefreshResponse.json()) as typeof tokens;
|
||||
expect(firstRefresh.access_token).toBeDefined();
|
||||
expect(firstRefresh.refresh_token).toBeDefined();
|
||||
|
||||
const { body: replayBody, headers: replayHeaders } =
|
||||
await refreshAccessTokenRequest({
|
||||
refreshToken: tokens.refresh_token!,
|
||||
options: {
|
||||
clientId: publicClientId,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
});
|
||||
const replayResponse = await customFetchImpl(`${baseURL}/oauth2/token`, {
|
||||
method: "POST",
|
||||
body: replayBody.toString(),
|
||||
headers: replayHeaders,
|
||||
});
|
||||
const replay = (await replayResponse.json()) as typeof tokens;
|
||||
expect(replay).toMatchObject({
|
||||
access_token: firstRefresh.access_token,
|
||||
expires_at: firstRefresh.expires_at,
|
||||
refresh_token: firstRefresh.refresh_token,
|
||||
token_type: firstRefresh.token_type,
|
||||
scope: firstRefresh.scope,
|
||||
id_token: firstRefresh.id_token,
|
||||
});
|
||||
expect(replay.expires_in).toBeLessThanOrEqual(firstRefresh.expires_in!);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,20 @@ const AUTHORIZATION_SERVER_ONLY_SCOPES = new Set([
|
||||
* the MCP resource identifier.
|
||||
*/
|
||||
export interface McpOptions extends OAuthOptions<Scope[]> {
|
||||
/**
|
||||
* Seconds that a rotated refresh token can be reused to receive the same
|
||||
* token response for the same effective scopes, requested resources, and
|
||||
* sender constraint.
|
||||
*
|
||||
* MCP overrides the OAuth Provider default because native/public MCP clients
|
||||
* can have multiple local sessions racing the same refresh token. Set to `0`
|
||||
* to keep strict replay handling.
|
||||
*
|
||||
* @default 30
|
||||
*/
|
||||
refreshTokenReuseInterval?: OAuthOptions<
|
||||
Scope[]
|
||||
>["refreshTokenReuseInterval"];
|
||||
/**
|
||||
* The protected resource identifier (RFC 8707 / RFC 9728) that access tokens
|
||||
* are bound to. Published as `resource` in the protected resource metadata,
|
||||
@@ -105,6 +119,9 @@ const buildResourceServerMetadata = (
|
||||
* MCP: it enables dynamic client registration, binds issued tokens to the MCP
|
||||
* `resource`, and, as the resource server, serves the RFC 9728 protected resource
|
||||
* metadata so MCP clients discover and use it through standard OAuth discovery.
|
||||
* It also defaults `refreshTokenReuseInterval` to 30 seconds for native/public
|
||||
* MCP clients that may retry a refresh after a local process loses the rotated
|
||||
* response.
|
||||
* Because it is the OAuth provider, it cannot be combined with a separate
|
||||
* {@link oauthProvider}.
|
||||
*
|
||||
@@ -127,7 +144,7 @@ const buildResourceServerMetadata = (
|
||||
* ```
|
||||
*/
|
||||
export const mcp = (options: McpOptions): ReturnType<typeof oauthProvider> => {
|
||||
const { resource, ...oauthOptions } = options;
|
||||
const { resource, refreshTokenReuseInterval = 30, ...oauthOptions } = options;
|
||||
// RFC 8707: reject an invalid or fragment-containing resource before it is
|
||||
// published in the protected resource metadata.
|
||||
ResourceUriSchema.parse(resource);
|
||||
@@ -135,6 +152,7 @@ export const mcp = (options: McpOptions): ReturnType<typeof oauthProvider> => {
|
||||
// MCP clients self-register; public clients use PKCE without a secret.
|
||||
allowDynamicClientRegistration: true,
|
||||
allowUnauthenticatedClientRegistration: true,
|
||||
refreshTokenReuseInterval,
|
||||
...oauthOptions,
|
||||
// RFC 8707: bind issued tokens to the MCP resource so the resource server
|
||||
// can verify the token audience against its protected resource identifier.
|
||||
|
||||
@@ -171,6 +171,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
|
||||
accessTokenExpiresIn: 3600, // 1 hour
|
||||
m2mAccessTokenExpiresIn: 3600, // 1 hour
|
||||
refreshTokenExpiresIn: 2592000, // 30 days
|
||||
refreshTokenReuseInterval: 0,
|
||||
allowUnauthenticatedClientRegistration: false,
|
||||
allowDynamicClientRegistration: false,
|
||||
disableJwtPlugin: false,
|
||||
|
||||
@@ -374,6 +374,18 @@ export const schema = {
|
||||
type: "date",
|
||||
required: false,
|
||||
},
|
||||
rotatedAt: {
|
||||
type: "date",
|
||||
required: false,
|
||||
},
|
||||
rotationReplayResponse: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
rotationReplayExpiresAt: {
|
||||
type: "date",
|
||||
required: false,
|
||||
},
|
||||
authTime: {
|
||||
type: "date",
|
||||
required: false,
|
||||
|
||||
@@ -37,6 +37,18 @@ import { storeToken } from "./utils";
|
||||
|
||||
type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
||||
|
||||
type OAuthTokenResponse = {
|
||||
access_token?: string;
|
||||
id_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
expires_at?: number;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
refresh_call?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
describe("oauth token - authorization_code", async () => {
|
||||
const authServerBaseUrl = "http://localhost:3000";
|
||||
const rpBaseUrl = "http://localhost:5000";
|
||||
@@ -1611,6 +1623,353 @@ describe("oauth token - refresh_token", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth token - refresh_token reuse interval", async () => {
|
||||
const authServerBaseUrl = "http://localhost:3000";
|
||||
const rpBaseUrl = "http://localhost:5000";
|
||||
const validAudience = "https://reuse-api.example.com";
|
||||
const otherAudience = "https://other-reuse-api.example.com";
|
||||
let refreshTokenResponseCalls = 0;
|
||||
const {
|
||||
auth: authorizationServer,
|
||||
signInWithTestUser,
|
||||
customFetchImpl,
|
||||
} = await getTestInstance({
|
||||
baseURL: authServerBaseUrl,
|
||||
plugins: [
|
||||
jwt({
|
||||
jwt: {
|
||||
issuer: authServerBaseUrl,
|
||||
},
|
||||
}),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
resources: [validAudience, otherAudience],
|
||||
enforcePerClientResources: false,
|
||||
refreshTokenReuseInterval: 30,
|
||||
customTokenResponseFields({ grantType }) {
|
||||
if (grantType !== "refresh_token") {
|
||||
return {};
|
||||
}
|
||||
refreshTokenResponseCalls += 1;
|
||||
return {
|
||||
refresh_call: `${refreshTokenResponseCalls}`,
|
||||
};
|
||||
},
|
||||
silenceWarnings: {
|
||||
oauthAuthServerConfig: true,
|
||||
openidConfig: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { headers } = await signInWithTestUser();
|
||||
const client = createAuthClient({
|
||||
plugins: [oauthProviderClient()],
|
||||
baseURL: authServerBaseUrl,
|
||||
fetchOptions: {
|
||||
customFetchImpl,
|
||||
headers,
|
||||
},
|
||||
});
|
||||
|
||||
let oauthClient: OAuthClient | null;
|
||||
|
||||
const providerId = "test";
|
||||
const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`;
|
||||
const state = "123";
|
||||
|
||||
async function createOAuthClient() {
|
||||
const response = await authorizationServer.api.adminCreateOAuthClient({
|
||||
headers,
|
||||
body: {
|
||||
grant_types: [
|
||||
"authorization_code",
|
||||
"client_credentials",
|
||||
"refresh_token",
|
||||
],
|
||||
redirect_uris: [redirectUri],
|
||||
skip_consent: true,
|
||||
},
|
||||
});
|
||||
expect(response?.client_id).toBeDefined();
|
||||
expect(response?.client_secret).toBeDefined();
|
||||
return response;
|
||||
}
|
||||
|
||||
async function authorizeForRefreshToken(scopes: string[]) {
|
||||
if (!oauthClient?.client_id || !oauthClient?.client_secret) {
|
||||
throw Error("beforeAll not run properly");
|
||||
}
|
||||
const codeVerifier = generateRandomString(32);
|
||||
const authUrl = await createAuthorizationURL({
|
||||
id: providerId,
|
||||
options: {
|
||||
clientId: oauthClient.client_id,
|
||||
clientSecret: oauthClient.client_secret,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
redirectURI: "",
|
||||
authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`,
|
||||
state,
|
||||
scopes,
|
||||
codeVerifier,
|
||||
});
|
||||
|
||||
let callbackRedirectUrl = "";
|
||||
await client.$fetch(authUrl.toString(), {
|
||||
onError(context) {
|
||||
callbackRedirectUrl = context.response.headers.get("Location") || "";
|
||||
},
|
||||
});
|
||||
const callbackUrl = new URL(callbackRedirectUrl);
|
||||
const { body, headers } = await authorizationCodeRequest({
|
||||
code: callbackUrl.searchParams.get("code")!,
|
||||
codeVerifier,
|
||||
redirectURI: redirectUri,
|
||||
options: {
|
||||
clientId: oauthClient.client_id,
|
||||
clientSecret: oauthClient.client_secret,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
});
|
||||
|
||||
const tokens = await client.$fetch<OAuthTokenResponse>("/oauth2/token", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
expect(tokens.data?.refresh_token).toBeDefined();
|
||||
return tokens.data!;
|
||||
}
|
||||
|
||||
async function refresh(
|
||||
refreshToken: string,
|
||||
params?: { resource?: string | string[]; scope?: string },
|
||||
) {
|
||||
if (!oauthClient?.client_id || !oauthClient?.client_secret) {
|
||||
throw Error("beforeAll not run properly");
|
||||
}
|
||||
const { body, headers } = await refreshAccessTokenRequest({
|
||||
refreshToken,
|
||||
options: {
|
||||
clientId: oauthClient.client_id,
|
||||
clientSecret: oauthClient.client_secret,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
extraParams: params?.scope ? { scope: params.scope } : undefined,
|
||||
resource: params?.resource,
|
||||
});
|
||||
return client.$fetch<OAuthTokenResponse>("/oauth2/token", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/8512
|
||||
*/
|
||||
it("replays the same response inside the refresh token reuse interval", async () => {
|
||||
oauthClient = await createOAuthClient();
|
||||
refreshTokenResponseCalls = 0;
|
||||
const tokens = await authorizeForRefreshToken([
|
||||
"openid",
|
||||
"profile",
|
||||
"offline_access",
|
||||
]);
|
||||
|
||||
const firstRefresh = await refresh(tokens.refresh_token!);
|
||||
expect(firstRefresh.error).toBeNull();
|
||||
expect(firstRefresh.data?.refresh_token).toBeDefined();
|
||||
expect(firstRefresh.data?.refresh_call).toBe("1");
|
||||
|
||||
const replay = await refresh(tokens.refresh_token!);
|
||||
expect(replay.error).toBeNull();
|
||||
expect(replay.data).toMatchObject({
|
||||
access_token: firstRefresh.data?.access_token,
|
||||
expires_at: firstRefresh.data?.expires_at,
|
||||
token_type: firstRefresh.data?.token_type,
|
||||
refresh_token: firstRefresh.data?.refresh_token,
|
||||
scope: firstRefresh.data?.scope,
|
||||
id_token: firstRefresh.data?.id_token,
|
||||
refresh_call: "1",
|
||||
});
|
||||
expect(replay.data?.expires_in).toBeLessThanOrEqual(
|
||||
firstRefresh.data!.expires_in!,
|
||||
);
|
||||
expect(refreshTokenResponseCalls).toBe(1);
|
||||
|
||||
const next = await refresh(firstRefresh.data!.refresh_token!);
|
||||
expect(next.error).toBeNull();
|
||||
expect(next.data?.refresh_token).toBeDefined();
|
||||
expect(next.data?.refresh_token).not.toEqual(
|
||||
firstRefresh.data?.refresh_token,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/8512
|
||||
*/
|
||||
it("replays equivalent requests when scope and resource order differ", async () => {
|
||||
oauthClient = await createOAuthClient();
|
||||
refreshTokenResponseCalls = 0;
|
||||
const tokens = await authorizeForRefreshToken([
|
||||
"openid",
|
||||
"profile",
|
||||
"offline_access",
|
||||
]);
|
||||
|
||||
const firstRefresh = await refresh(tokens.refresh_token!, {
|
||||
scope: "profile openid offline_access",
|
||||
resource: [validAudience, otherAudience],
|
||||
});
|
||||
expect(firstRefresh.error).toBeNull();
|
||||
expect(firstRefresh.data?.refresh_token).toBeDefined();
|
||||
expect(firstRefresh.data?.refresh_call).toBe("1");
|
||||
|
||||
const replay = await refresh(tokens.refresh_token!, {
|
||||
scope: "offline_access profile openid",
|
||||
resource: [otherAudience, validAudience],
|
||||
});
|
||||
expect(replay.error).toBeNull();
|
||||
expect(replay.data).toMatchObject({
|
||||
access_token: firstRefresh.data?.access_token,
|
||||
expires_at: firstRefresh.data?.expires_at,
|
||||
token_type: firstRefresh.data?.token_type,
|
||||
refresh_token: firstRefresh.data?.refresh_token,
|
||||
scope: firstRefresh.data?.scope,
|
||||
id_token: firstRefresh.data?.id_token,
|
||||
refresh_call: "1",
|
||||
});
|
||||
expect(refreshTokenResponseCalls).toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/pull/10145
|
||||
*/
|
||||
it("returns the rotated token response when storing the replay cache fails", async () => {
|
||||
oauthClient = await createOAuthClient();
|
||||
refreshTokenResponseCalls = 0;
|
||||
const tokens = await authorizeForRefreshToken([
|
||||
"openid",
|
||||
"profile",
|
||||
"offline_access",
|
||||
]);
|
||||
const context = await authorizationServer.$context;
|
||||
const originalUpdate = context.adapter.update.bind(context.adapter);
|
||||
const update = vi.spyOn(context.adapter, "update");
|
||||
const loggerError = vi
|
||||
.spyOn(context.logger, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
update.mockImplementation(async (...args) => {
|
||||
const [params] = args;
|
||||
if (
|
||||
params.model === "oauthRefreshToken" &&
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
params.update,
|
||||
"rotationReplayResponse",
|
||||
)
|
||||
) {
|
||||
throw new Error("replay cache unavailable");
|
||||
}
|
||||
return originalUpdate(...args);
|
||||
});
|
||||
|
||||
try {
|
||||
const firstRefresh = await refresh(tokens.refresh_token!);
|
||||
expect(firstRefresh.error).toBeNull();
|
||||
expect(firstRefresh.data?.refresh_token).toBeDefined();
|
||||
expect(firstRefresh.data?.refresh_call).toBe("1");
|
||||
expect(loggerError).toHaveBeenCalledWith(
|
||||
"failed to store refresh token rotation replay",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
const next = await refresh(firstRefresh.data!.refresh_token!);
|
||||
expect(next.error).toBeNull();
|
||||
expect(next.data?.refresh_token).toBeDefined();
|
||||
expect(next.data?.refresh_call).toBe("2");
|
||||
} finally {
|
||||
update.mockRestore();
|
||||
loggerError.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/8512
|
||||
*/
|
||||
it("does not replay a cached response for a different resource", async () => {
|
||||
oauthClient = await createOAuthClient();
|
||||
const tokens = await authorizeForRefreshToken([
|
||||
"openid",
|
||||
"profile",
|
||||
"offline_access",
|
||||
]);
|
||||
|
||||
const firstRefresh = await refresh(tokens.refresh_token!, {
|
||||
resource: validAudience,
|
||||
});
|
||||
expect(firstRefresh.error).toBeNull();
|
||||
expect(firstRefresh.data?.refresh_token).toBeDefined();
|
||||
|
||||
const mismatchedReplay = await refresh(tokens.refresh_token!, {
|
||||
resource: otherAudience,
|
||||
});
|
||||
expect((mismatchedReplay.error as { error?: string } | null)?.error).toBe(
|
||||
"invalid_grant",
|
||||
);
|
||||
|
||||
const next = await refresh(firstRefresh.data!.refresh_token!);
|
||||
expect(next.error).toBeNull();
|
||||
expect(next.data?.refresh_token).toBeDefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/8512
|
||||
*/
|
||||
it("invalidates the family after the reuse interval expires", async () => {
|
||||
oauthClient = await createOAuthClient();
|
||||
const tokens = await authorizeForRefreshToken([
|
||||
"openid",
|
||||
"profile",
|
||||
"offline_access",
|
||||
]);
|
||||
const firstRefresh = await refresh(tokens.refresh_token!);
|
||||
expect(firstRefresh.error).toBeNull();
|
||||
expect(firstRefresh.data?.refresh_token).toBeDefined();
|
||||
|
||||
const context = await authorizationServer.$context;
|
||||
const rotatedRows = await context.adapter.findMany<{
|
||||
id: string;
|
||||
rotationReplayResponse?: string | null;
|
||||
}>({
|
||||
model: "oauthRefreshToken",
|
||||
where: [{ field: "clientId", value: oauthClient!.client_id }],
|
||||
});
|
||||
const rotatedRow = rotatedRows.find((row) => row.rotationReplayResponse);
|
||||
expect(rotatedRow?.id).toBeDefined();
|
||||
await context.adapter.update({
|
||||
model: "oauthRefreshToken",
|
||||
where: [{ field: "id", value: rotatedRow!.id }],
|
||||
update: {
|
||||
rotationReplayExpiresAt: new Date(0),
|
||||
},
|
||||
});
|
||||
|
||||
const replay = await refresh(tokens.refresh_token!);
|
||||
expect((replay.error as { error?: string } | null)?.error).toBe(
|
||||
"invalid_grant",
|
||||
);
|
||||
|
||||
const childReplay = await refresh(firstRefresh.data!.refresh_token!);
|
||||
expect((childReplay.error as { error?: string } | null)?.error).toBe(
|
||||
"invalid_grant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth token - client_credentials", async () => {
|
||||
const authServerBaseUrl = "http://localhost:3000";
|
||||
const rpBaseUrl = "http://localhost:5000";
|
||||
@@ -3830,6 +4189,7 @@ describe("oauth token - DPoP", async () => {
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
allowDynamicClientRegistration: true,
|
||||
refreshTokenReuseInterval: 30,
|
||||
resources: [
|
||||
jwtResource,
|
||||
{
|
||||
@@ -3936,6 +4296,8 @@ describe("oauth token - DPoP", async () => {
|
||||
if (dpopProof) tokenHeaders.set("DPoP", dpopProof);
|
||||
return client.$fetch<{
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
expires_at?: number;
|
||||
refresh_token?: string;
|
||||
token_type?: string;
|
||||
error?: string;
|
||||
@@ -4065,6 +4427,84 @@ describe("oauth token - DPoP", async () => {
|
||||
expect(rotatedPayload.cnf).toMatchObject({ jkt: dpopKey.jkt });
|
||||
});
|
||||
|
||||
it("replays a DPoP-bound refresh response only after validating the proof key", async () => {
|
||||
const dpopKey = await createDpopKey();
|
||||
const { code, codeVerifier } = await authorizeForCode(
|
||||
dpopKey.jkt,
|
||||
jwtResource,
|
||||
);
|
||||
const issueProof = await createDpopProof({
|
||||
privateKey: dpopKey.privateKey,
|
||||
publicJwk: dpopKey.publicJwk,
|
||||
method: "POST",
|
||||
url: tokenEndpoint,
|
||||
jti: "reuse-issue-proof",
|
||||
});
|
||||
const tokens = await exchangeCode(
|
||||
code,
|
||||
codeVerifier,
|
||||
jwtResource,
|
||||
issueProof,
|
||||
);
|
||||
expect(tokens.data?.refresh_token).toBeDefined();
|
||||
|
||||
async function refresh(dpopProof?: string) {
|
||||
const { body, headers: reqHeaders } = await refreshAccessTokenRequest({
|
||||
refreshToken: tokens.data!.refresh_token!,
|
||||
options: {
|
||||
clientId: oauthClient!.client_id,
|
||||
clientSecret: oauthClient!.client_secret!,
|
||||
redirectURI: redirectUri,
|
||||
},
|
||||
});
|
||||
const refreshHeaders = new Headers(reqHeaders);
|
||||
if (dpopProof) refreshHeaders.set("DPoP", dpopProof);
|
||||
return client.$fetch<OAuthTokenResponse>("/oauth2/token", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: refreshHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const firstProof = await createDpopProof({
|
||||
privateKey: dpopKey.privateKey,
|
||||
publicJwk: dpopKey.publicJwk,
|
||||
method: "POST",
|
||||
url: tokenEndpoint,
|
||||
jti: "reuse-refresh-proof-1",
|
||||
});
|
||||
const firstRefresh = await refresh(firstProof);
|
||||
expect(firstRefresh.error).toBeNull();
|
||||
expect(firstRefresh.data?.token_type).toBe("DPoP");
|
||||
expect(firstRefresh.data?.refresh_token).toBeDefined();
|
||||
|
||||
const prooflessReplay = await refresh();
|
||||
expect((prooflessReplay.error as { error?: string } | null)?.error).toBe(
|
||||
"invalid_dpop_proof",
|
||||
);
|
||||
|
||||
const replayProof = await createDpopProof({
|
||||
privateKey: dpopKey.privateKey,
|
||||
publicJwk: dpopKey.publicJwk,
|
||||
method: "POST",
|
||||
url: tokenEndpoint,
|
||||
jti: "reuse-refresh-proof-2",
|
||||
});
|
||||
const replay = await refresh(replayProof);
|
||||
expect(replay.error).toBeNull();
|
||||
expect(replay.data).toMatchObject({
|
||||
access_token: firstRefresh.data?.access_token,
|
||||
expires_at: firstRefresh.data?.expires_at,
|
||||
token_type: "DPoP",
|
||||
refresh_token: firstRefresh.data?.refresh_token,
|
||||
scope: firstRefresh.data?.scope,
|
||||
id_token: firstRefresh.data?.id_token,
|
||||
});
|
||||
expect(replay.data?.expires_in).toBeLessThanOrEqual(
|
||||
firstRefresh.data!.expires_in!,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a replayed DPoP proof at the token endpoint", async () => {
|
||||
const dpopKey = await createDpopKey();
|
||||
// One proof (a single jti) reused across two authorization codes. The
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { GenericEndpointContext } from "@better-auth/core";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { generateRandomString } from "better-auth/crypto";
|
||||
import {
|
||||
generateRandomString,
|
||||
symmetricDecrypt,
|
||||
symmetricEncrypt,
|
||||
} from "better-auth/crypto";
|
||||
import {
|
||||
createDpopReplayStore,
|
||||
generateCodeChallenge,
|
||||
@@ -647,9 +651,8 @@ async function createRefreshToken(
|
||||
// rotations against the same parent both observe `revoked === null` on
|
||||
// the read in `handleRefreshTokenGrant`, but only one wins this update.
|
||||
// The loser fails closed with `invalid_grant`; the parent row is now
|
||||
// revoked, so any subsequent replay of the original refresh token
|
||||
// triggers the existing family-invalidation guard in
|
||||
// `handleRefreshTokenGrant`.
|
||||
// revoked, so any subsequent reuse of the original refresh token is handled
|
||||
// by the replay/invalid-grant path in `handleRefreshTokenGrant`.
|
||||
//
|
||||
// FIXME(strict-family-invalidation): RFC 9700 §4.14 prescribes
|
||||
// immediate family invalidation on detected concurrent redemption.
|
||||
@@ -659,6 +662,17 @@ async function createRefreshToken(
|
||||
// with the winner's still-in-flight inserts. Tracked for a follow-up
|
||||
// minor once the adapter contract exposes opt-in transactional
|
||||
// rotation.
|
||||
const rotatedAt = new Date(iat * 1000);
|
||||
const rotationUpdate: Record<string, unknown> = {
|
||||
revoked: rotatedAt,
|
||||
rotatedAt,
|
||||
};
|
||||
const reuseInterval = opts.refreshTokenReuseInterval ?? 0;
|
||||
if (reuseInterval > 0) {
|
||||
rotationUpdate.rotationReplayExpiresAt = new Date(
|
||||
(iat + reuseInterval) * 1000,
|
||||
);
|
||||
}
|
||||
const won = await ctx.context.adapter.incrementOne<{ id: string }>({
|
||||
model: "oauthRefreshToken",
|
||||
where: [
|
||||
@@ -666,9 +680,7 @@ async function createRefreshToken(
|
||||
{ field: "revoked", operator: "eq", value: null },
|
||||
],
|
||||
increment: {},
|
||||
set: {
|
||||
revoked: new Date(iat * 1000),
|
||||
},
|
||||
set: rotationUpdate,
|
||||
});
|
||||
|
||||
if (!won) {
|
||||
@@ -819,6 +831,265 @@ async function resolveDpopTokenBinding(
|
||||
}
|
||||
}
|
||||
|
||||
type RefreshTokenRotationReplayRequest = {
|
||||
effectiveScopes: string[];
|
||||
requestedResources?: string[];
|
||||
confirmation?: Confirmation;
|
||||
};
|
||||
|
||||
type RefreshTokenRotationReplay = {
|
||||
request: RefreshTokenRotationReplayRequest;
|
||||
response: OAuthTokenResponse;
|
||||
};
|
||||
|
||||
function normalizeReplayValues(values: string[] | undefined) {
|
||||
return values ? [...new Set(values)].sort() : undefined;
|
||||
}
|
||||
|
||||
function sameReplayValues(
|
||||
left: string[] | undefined,
|
||||
right: string[] | undefined,
|
||||
) {
|
||||
const normalizedLeft = normalizeReplayValues(left);
|
||||
const normalizedRight = normalizeReplayValues(right);
|
||||
if (normalizedLeft === undefined || normalizedRight === undefined) {
|
||||
return normalizedLeft === normalizedRight;
|
||||
}
|
||||
if (normalizedLeft.length !== normalizedRight.length) {
|
||||
return false;
|
||||
}
|
||||
return normalizedLeft.every(
|
||||
(value, index) => value === normalizedRight[index],
|
||||
);
|
||||
}
|
||||
|
||||
function confirmationReplayKey(confirmation: Confirmation | undefined) {
|
||||
if (!confirmation) {
|
||||
return undefined;
|
||||
}
|
||||
if ("jkt" in confirmation) {
|
||||
return `jkt:${confirmation.jkt}`;
|
||||
}
|
||||
return `x5t#S256:${confirmation["x5t#S256"]}`;
|
||||
}
|
||||
|
||||
function sameConfirmation(
|
||||
left: Confirmation | undefined,
|
||||
right: Confirmation | undefined,
|
||||
) {
|
||||
return confirmationReplayKey(left) === confirmationReplayKey(right);
|
||||
}
|
||||
|
||||
function isConfirmation(value: unknown): value is Confirmation {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const confirmation = value as Record<string, unknown>;
|
||||
return (
|
||||
(typeof confirmation.jkt === "string" &&
|
||||
confirmation["x5t#S256"] === undefined) ||
|
||||
(typeof confirmation["x5t#S256"] === "string" &&
|
||||
confirmation.jkt === undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function isTokenType(value: unknown): value is TokenType {
|
||||
return value === "Bearer" || value === "DPoP";
|
||||
}
|
||||
|
||||
function isOAuthTokenResponse(value: unknown): value is OAuthTokenResponse {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const response = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof response.access_token === "string" &&
|
||||
typeof response.expires_in === "number" &&
|
||||
typeof response.expires_at === "number" &&
|
||||
isTokenType(response.token_type) &&
|
||||
typeof response.scope === "string" &&
|
||||
(response.refresh_token === undefined ||
|
||||
typeof response.refresh_token === "string") &&
|
||||
(response.id_token === undefined || typeof response.id_token === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isRefreshTokenRotationReplay(
|
||||
value: unknown,
|
||||
): value is RefreshTokenRotationReplay {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const replay = value as Record<string, unknown>;
|
||||
const request = replay.request as Record<string, unknown> | undefined;
|
||||
return (
|
||||
!!request &&
|
||||
Array.isArray(request.effectiveScopes) &&
|
||||
request.effectiveScopes.every((scope) => typeof scope === "string") &&
|
||||
(request.requestedResources === undefined ||
|
||||
(Array.isArray(request.requestedResources) &&
|
||||
request.requestedResources.every(
|
||||
(resource) => typeof resource === "string",
|
||||
))) &&
|
||||
(request.confirmation === undefined ||
|
||||
isConfirmation(request.confirmation)) &&
|
||||
isOAuthTokenResponse(replay.response)
|
||||
);
|
||||
}
|
||||
|
||||
function buildRefreshTokenRotationReplayRequest(params: {
|
||||
effectiveScopes: string[];
|
||||
requestedResources?: string[];
|
||||
confirmation?: Confirmation;
|
||||
}): RefreshTokenRotationReplayRequest {
|
||||
const requestedResources = normalizeReplayValues(params.requestedResources);
|
||||
return {
|
||||
effectiveScopes: normalizeReplayValues(params.effectiveScopes) ?? [],
|
||||
...(requestedResources ? { requestedResources } : {}),
|
||||
...(params.confirmation ? { confirmation: params.confirmation } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sameRefreshTokenRotationReplayRequest(
|
||||
left: RefreshTokenRotationReplayRequest,
|
||||
right: RefreshTokenRotationReplayRequest,
|
||||
) {
|
||||
return (
|
||||
sameReplayValues(left.effectiveScopes, right.effectiveScopes) &&
|
||||
sameReplayValues(left.requestedResources, right.requestedResources) &&
|
||||
sameConfirmation(left.confirmation, right.confirmation)
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptRefreshTokenRotationReplay(
|
||||
ctx: GenericEndpointContext,
|
||||
replay: RefreshTokenRotationReplay,
|
||||
) {
|
||||
return symmetricEncrypt({
|
||||
key: ctx.context.secretConfig,
|
||||
data: JSON.stringify(replay),
|
||||
});
|
||||
}
|
||||
|
||||
async function decryptRefreshTokenRotationReplay(
|
||||
ctx: GenericEndpointContext,
|
||||
value: string,
|
||||
) {
|
||||
const decrypted = await symmetricDecrypt({
|
||||
key: ctx.context.secretConfig,
|
||||
data: value,
|
||||
});
|
||||
const parsed: unknown = JSON.parse(decrypted);
|
||||
if (!isRefreshTokenRotationReplay(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isWithinRefreshTokenReuseInterval(
|
||||
refreshToken: OAuthRefreshToken<Scope[]>,
|
||||
) {
|
||||
return (
|
||||
!!refreshToken.rotatedAt &&
|
||||
!!refreshToken.rotationReplayExpiresAt &&
|
||||
refreshToken.rotationReplayExpiresAt >= new Date()
|
||||
);
|
||||
}
|
||||
|
||||
async function storeRefreshTokenRotationReplay(
|
||||
ctx: GenericEndpointContext,
|
||||
opts: OAuthOptions<Scope[]>,
|
||||
refreshToken: OAuthRefreshToken<Scope[]> & { id: string },
|
||||
request: RefreshTokenRotationReplayRequest,
|
||||
response: OAuthTokenResponse,
|
||||
) {
|
||||
if ((opts.refreshTokenReuseInterval ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
await ctx.context.adapter.update({
|
||||
model: "oauthRefreshToken",
|
||||
where: [{ field: "id", value: refreshToken.id }],
|
||||
update: {
|
||||
rotationReplayResponse: await encryptRefreshTokenRotationReplay(ctx, {
|
||||
request,
|
||||
response,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getRefreshTokenRotationReplay(
|
||||
ctx: GenericEndpointContext,
|
||||
refreshToken: OAuthRefreshToken<Scope[]>,
|
||||
request: RefreshTokenRotationReplayRequest,
|
||||
) {
|
||||
if (!isWithinRefreshTokenReuseInterval(refreshToken)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!refreshToken.rotationReplayResponse) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const replay = await decryptRefreshTokenRotationReplay(
|
||||
ctx,
|
||||
refreshToken.rotationReplayResponse,
|
||||
);
|
||||
if (
|
||||
!replay ||
|
||||
!sameRefreshTokenRotationReplayRequest(replay.request, request)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return replay.response;
|
||||
} catch (error) {
|
||||
ctx.context.logger.error("refresh token rotation replay failed", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRefreshTokenRotationReplayRequest(
|
||||
ctx: GenericEndpointContext,
|
||||
opts: OAuthOptions<Scope[]>,
|
||||
params: {
|
||||
client: SchemaClient<Scope[]>;
|
||||
refreshToken: OAuthRefreshToken<Scope[]> & { id: string };
|
||||
scopes: string[];
|
||||
resources?: string[];
|
||||
},
|
||||
) {
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const grantIssuance = await resolveResourceGrantIssuance(ctx, opts, {
|
||||
clientId: params.client.clientId,
|
||||
requestedScopes: params.scopes,
|
||||
resources: params.resources,
|
||||
refreshToken: params.refreshToken,
|
||||
iat,
|
||||
scopeExpiresAtSeconds: iat + (opts.accessTokenExpiresIn ?? 3600),
|
||||
});
|
||||
const confirmation = await resolveDpopTokenBinding(ctx, opts, {
|
||||
client: params.client,
|
||||
grantIssuance,
|
||||
refreshToken: params.refreshToken,
|
||||
});
|
||||
return buildRefreshTokenRotationReplayRequest({
|
||||
effectiveScopes: grantIssuance.effectiveScopes,
|
||||
requestedResources: params.resources,
|
||||
confirmation,
|
||||
});
|
||||
}
|
||||
|
||||
function replayRefreshTokenRotationResponse(
|
||||
ctx: GenericEndpointContext,
|
||||
response: OAuthTokenResponse,
|
||||
) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return ctx.json({
|
||||
...response,
|
||||
expires_in: Math.max(0, response.expires_at - now),
|
||||
});
|
||||
}
|
||||
|
||||
async function createUserTokens(
|
||||
ctx: GenericEndpointContext,
|
||||
opts: OAuthOptions<Scope[]>,
|
||||
@@ -1051,7 +1322,7 @@ async function createUserTokens(
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return ctx.json({
|
||||
const responseBody: OAuthTokenResponse = {
|
||||
...customFields,
|
||||
...(params.tokenResponse ?? {}),
|
||||
access_token: accessToken,
|
||||
@@ -1061,7 +1332,28 @@ async function createUserTokens(
|
||||
refresh_token: refreshToken?.token,
|
||||
scope: effectiveScopes.join(" "),
|
||||
id_token: idToken,
|
||||
});
|
||||
};
|
||||
if (existingRefreshToken?.id && refreshToken?.id) {
|
||||
try {
|
||||
await storeRefreshTokenRotationReplay(
|
||||
ctx,
|
||||
opts,
|
||||
existingRefreshToken,
|
||||
buildRefreshTokenRotationReplayRequest({
|
||||
effectiveScopes,
|
||||
requestedResources: params.resources,
|
||||
confirmation,
|
||||
}),
|
||||
responseBody,
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.context.logger.error(
|
||||
"failed to store refresh token rotation replay",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ctx.json(responseBody);
|
||||
}
|
||||
|
||||
/** Checks verification value */
|
||||
@@ -1565,15 +1857,6 @@ async function handleRefreshTokenGrant(
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
// Replay revoke (RFC 9700 §4.14: tear down the family)
|
||||
if (refreshToken.revoked) {
|
||||
await invalidateRefreshFamily(ctx, client_id, refreshToken.userId);
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "invalid refresh token",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
|
||||
// Check body resources against refresh token resources
|
||||
if (
|
||||
resources &&
|
||||
@@ -1612,6 +1895,38 @@ async function handleRefreshTokenGrant(
|
||||
authMethod,
|
||||
);
|
||||
|
||||
if (refreshToken.revoked) {
|
||||
if (isWithinRefreshTokenReuseInterval(refreshToken)) {
|
||||
const replayRequest = await resolveRefreshTokenRotationReplayRequest(
|
||||
ctx,
|
||||
opts,
|
||||
{
|
||||
client,
|
||||
refreshToken,
|
||||
scopes: requestedScopes ?? scopes,
|
||||
resources: resources ?? refreshToken.resources,
|
||||
},
|
||||
);
|
||||
const replay = await getRefreshTokenRotationReplay(
|
||||
ctx,
|
||||
refreshToken,
|
||||
replayRequest,
|
||||
);
|
||||
if (replay) {
|
||||
return replayRefreshTokenRotationResponse(ctx, replay);
|
||||
}
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "invalid refresh token",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
await invalidateRefreshFamily(ctx, client_id, refreshToken.userId);
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "invalid refresh token",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await ctx.context.internalAdapter.findUserById(
|
||||
refreshToken.userId,
|
||||
);
|
||||
|
||||
@@ -596,6 +596,18 @@ export interface OAuthOptions<
|
||||
* @default 2592000 (30 days)
|
||||
*/
|
||||
refreshTokenExpiresIn?: number;
|
||||
/**
|
||||
* Seconds that a rotated refresh token can be reused to receive the same
|
||||
* token response for the same effective scopes, requested resources, and
|
||||
* sender constraint.
|
||||
*
|
||||
* Matching reuse inside this interval is treated as refresh-response replay,
|
||||
* not refresh-token replay. Set to `0` to treat any rotated refresh token
|
||||
* reuse as replay.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
refreshTokenReuseInterval?: number;
|
||||
/**
|
||||
* The amount of time in seconds that the authorization code is valid for.
|
||||
*
|
||||
@@ -1828,9 +1840,25 @@ export interface OAuthRefreshToken<
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
/**
|
||||
* When token was revoked. If set, token is considered a replay attack.
|
||||
* When this token stopped being active.
|
||||
*
|
||||
* A token can be revoked by /oauth2/revoke or consumed by rotation. Use
|
||||
* `rotatedAt` to distinguish rotation from explicit revocation.
|
||||
*/
|
||||
revoked?: Date;
|
||||
/**
|
||||
* When this refresh token was consumed by rotation.
|
||||
*/
|
||||
rotatedAt?: Date;
|
||||
/**
|
||||
* Encrypted token endpoint response and request fingerprint to replay during
|
||||
* the configured refresh-token reuse interval.
|
||||
*/
|
||||
rotationReplayResponse?: string;
|
||||
/**
|
||||
* When the cached rotation response stops being replayable.
|
||||
*/
|
||||
rotationReplayExpiresAt?: Date;
|
||||
/**
|
||||
* The time the user originally authenticated.
|
||||
* Persisted so refreshed ID tokens can include a correct `auth_time` claim.
|
||||
|
||||
Reference in New Issue
Block a user