diff --git a/packages/core/src/component/_generated/api.ts b/packages/core/src/component/_generated/api.ts index 5b1387ac8..e0cdb70a3 100644 --- a/packages/core/src/component/_generated/api.ts +++ b/packages/core/src/component/_generated/api.ts @@ -9,7 +9,9 @@ */ import type * as crypto from "../crypto.js"; +import type * as expiry from "../expiry.js"; import type * as public_ from "../public.js"; +import type * as sessions from "../sessions.js"; import type { ApiFromModules, @@ -20,7 +22,9 @@ import { anyApi, componentsGeneric } from "convex/server"; const fullApi: ApiFromModules<{ crypto: typeof crypto; + expiry: typeof expiry; public: typeof public_; + sessions: typeof sessions; }> = anyApi as any; /** diff --git a/packages/core/src/component/core.test.ts b/packages/core/src/component/core.test.ts index 213dc13c7..20eedf2f6 100644 --- a/packages/core/src/component/core.test.ts +++ b/packages/core/src/component/core.test.ts @@ -17,7 +17,7 @@ import { REFRESH_GRACE_MS, SPENT_TOKEN_HORIZON_MS, MAX_SESSIONS_PER_USER, -} from "./public.ts"; +} from "./sessions.ts"; /** * The component's modules, and the test-only `testApp` module. The fixture file diff --git a/packages/core/src/component/expiry.ts b/packages/core/src/component/expiry.ts new file mode 100644 index 000000000..283e92faa --- /dev/null +++ b/packages/core/src/component/expiry.ts @@ -0,0 +1,61 @@ +// Token lifetimes: the validated config of a call, and when a refresh token +// expires under it. + +import type { Doc } from "./_generated/dataModel.ts"; +import { + DEFAULT_ACCESS_TOKEN_TTL_SECONDS, + DEFAULT_REFRESH_TOKEN_TTL_SECONDS, +} from "../lib/ttl.ts"; + +// `ctx.auth.signJwt` refuses to mint a token that lives longer than an hour, +// so an access-token TTL past that is a misconfiguration. +const MAX_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; // 1 hour + +/** + * The token lifetimes for a call, with the app's overrides applied over the + * defaults. Validated so a misconfiguration fails loudly rather than minting + * nonsensical sessions. + */ +export type TtlConfig = { + accessTokenTtlSeconds: number; + refreshTokenTtlSeconds: number; +}; + +export function resolveTtlConfig(args: { + accessTokenTtlSeconds?: number; + refreshTokenTtlSeconds?: number; +}): TtlConfig { + const accessTokenTtlSeconds = + args.accessTokenTtlSeconds ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS; + const refreshTokenTtlSeconds = + args.refreshTokenTtlSeconds ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS; + if (accessTokenTtlSeconds <= 0 || refreshTokenTtlSeconds <= 0) { + throw new Error("Token TTLs must be positive."); + } + if (accessTokenTtlSeconds > MAX_ACCESS_TOKEN_TTL_SECONDS) { + throw new Error( + "Access-token TTL must be at most one hour (the deployment refuses " + + "to sign a longer-lived JWT).", + ); + } + if (accessTokenTtlSeconds >= refreshTokenTtlSeconds) { + throw new Error( + "Access-token TTL must be shorter than the refresh-token TTL.", + ); + } + return { accessTokenTtlSeconds, refreshTokenTtlSeconds }; +} + +/** + * When a refresh token expires under the given refresh-token TTL. + * + * Expiry is derived rather than stored, so a changed TTL applies to every + * outstanding token. It also means a session's newest token is always its + * last to expire. + */ +export function refreshTokenExpiresAt( + token: Pick, "_creationTime">, + refreshTokenTtlSeconds: number, +): number { + return token._creationTime + refreshTokenTtlSeconds * 1000; +} diff --git a/packages/core/src/component/public.ts b/packages/core/src/component/public.ts index 7c3206bad..6168f5fe7 100644 --- a/packages/core/src/component/public.ts +++ b/packages/core/src/component/public.ts @@ -17,42 +17,27 @@ import { type CreateUserFn, type OnSignInFn, } from "../lib/types.ts"; -import { generateRefreshToken } from "./crypto.ts"; import { sha256Hex } from "../lib/crypto.ts"; import { - DEFAULT_ACCESS_TOKEN_TTL_SECONDS, - DEFAULT_REFRESH_TOKEN_TTL_SECONDS, -} from "../lib/ttl.ts"; + type TtlConfig, + refreshTokenExpiresAt, + resolveTtlConfig, +} from "./expiry.ts"; +import { + STANDARD_ISSUED_REFRESH_TOKEN_LIMIT, + REFRESH_GRACE_MS, + deleteSession, + evictLeastRecentlyRefreshedSessions, + issueRefreshToken, + lookupRefreshTokenByHash, + newestIssuedToken, + redeem, +} from "./sessions.ts"; // --- Configuration --------------------------------------------------------- // `aud` claim; must match `applicationID` in the app's auth.config.ts. const AUDIENCE = "convex"; -// `ctx.auth.signJwt` refuses to mint a token that lives longer than an hour, -// so an access-token TTL past that is a misconfiguration. -const MAX_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; // 1 hour -// How many issued, never-presented refresh tokens a session may have. Each -// refresh presenting the redeemed token adds one, and the first issued token -// presented clears them all, so the count only climbs when responses are lost -// or refreshes race. A refresh with this many already issued revokes the -// session, unless the redeemed token was redeemed within `REFRESH_GRACE_MS`. -export const STANDARD_ISSUED_REFRESH_TOKEN_LIMIT = 3; -// How long after a token is redeemed it may be presented again without -// counting against `STANDARD_ISSUED_REFRESH_TOKEN_LIMIT`. Concurrent SSR -// requests carrying the same cookie each refresh on their own, and the first to -// land redeems the token the rest present. -export const REFRESH_GRACE_MS = 5 * 1000; // 5 seconds -// How long a spent or dropped refresh token hash is remembered. This is the -// reuse-detection horizon: past it the row is gone and a replayed token reads -// as unknown, which revokes nothing but also grants nothing. -export const SPENT_TOKEN_HORIZON_MS = 60 * 60 * 1000; // 1 hour -// An absolute cap on how many sessions a user may have. A sign-in that would -// exceed it first revokes the sessions that refreshed longest ago. This bounds -// everything that reads all of a user's sessions (`listSessions`, and revoking -// a user's other sessions), which a user whose sessions pile up (e.g. a script -// signing in over and over and not refreshing a prior session) could otherwise -// push past the transaction's limits. -export const MAX_SESSIONS_PER_USER = 100; // The `iss` claim is set by the deployment when this component calls // `ctx.auth.signJwt`: `${CONVEX_CLOUD_URL}/components/auth`, which is what the @@ -74,236 +59,6 @@ function accountByIdentity( .unique(); } -/** Look up a refresh token, in any state, by its hash. */ -function lookupRefreshTokenByHash( - ctx: QueryCtx, - hash: string, -): Promise | null> { - return ctx.db - .query("refreshTokens") - .withIndex("by_hash", (q) => q.eq("hash", hash)) - .unique(); -} - -/** A session's refresh tokens in one state. */ -function sessionTokensInState( - ctx: QueryCtx, - sessionId: Id<"sessions">, - state: Doc<"refreshTokens">["state"], -): Promise[]> { - return ctx.db - .query("refreshTokens") - .withIndex("by_session_state", (q) => - q.eq("sessionId", sessionId).eq("state", state), - ) - .collect(); -} - -/** - * The session's newest issued refresh token: the one from its sign-in or its - * latest refresh. - * - * `null` only for a session with no issued token, which the token lifecycle - * never leaves (sign-in and each refresh issue one). - */ -async function newestIssuedToken( - ctx: QueryCtx, - sessionId: Id<"sessions">, -): Promise | null> { - // Issued rows have no `retiredAt`, so the index orders them by - // `_creationTime`: the last one is the newest. - return await ctx.db - .query("refreshTokens") - .withIndex("by_session_state", (q) => - q.eq("sessionId", sessionId).eq("state", "issued"), - ) - .order("desc") - .first(); -} - -/** - * Make room for one more session of the user: while the user has - * `MAX_SESSIONS_PER_USER` sessions or more, erase the one that refreshed - * longest ago. - * - * A session's last refresh is when its newest issued token was issued (see - * `listSessions`), so an expired or abandoned session goes before one that a - * client still keeps fresh, however old that one is. - */ -async function evictLeastRecentlyRefreshedSessions( - ctx: MutationCtx, - userId: string, -): Promise { - // Reads every session of the user, which the cap keeps to - // `MAX_SESSIONS_PER_USER`. - const sessions = await ctx.db - .query("sessions") - .withIndex("by_user", (q) => q.eq("userId", userId)) - .collect(); - const excess = sessions.length - MAX_SESSIONS_PER_USER + 1; - if (excess <= 0) { - return; - } - const byLastRefresh = []; - for (const session of sessions) { - const newestIssued = await newestIssuedToken(ctx, session._id); - byLastRefresh.push({ - sessionId: session._id, - // A session with no issued token can't refresh, so it goes first. - lastRefreshedAt: newestIssued?._creationTime ?? -Infinity, - }); - } - // `sessions` is in creation order and the sort is stable, so of the - // sessions that last refreshed at the same time, the oldest goes first. - byLastRefresh.sort((a, b) => a.lastRefreshedAt - b.lastRefreshedAt); - for (const { sessionId } of byLastRefresh.slice(0, excess)) { - await deleteSession(ctx, sessionId); - } - console.warn( - `Evicted ${excess} session${excess > 1 ? "s" : ""} past the ` + - `${MAX_SESSIONS_PER_USER} session limit for a single user`, - ); -} - -/** - * Erase a session along with every refresh token that points at it. - * - * Idempotent, because callers can race: two replays of the same stolen token, - * or a sign-out arriving alongside one, can both resolve the same session. - */ -async function deleteSession( - ctx: MutationCtx, - sessionId: Id<"sessions">, -): Promise { - // Reads every token row the session has. That is fine at any plausible - // refresh rate — the horizon divided by the refresh interval, ~60 at the - // defaults — but it is unbounded in principle. A session refreshed - // pathologically often could exceed the transaction's limits and make the - // session unusable. - const tokens = await ctx.db - .query("refreshTokens") - .withIndex("by_session_state", (q) => q.eq("sessionId", sessionId)) - .collect(); - for (const row of tokens) { - await ctx.db.delete("refreshTokens", row._id); - } - // `delete` throws on an id that is already gone, and the session may have - // been deleted by whoever we raced. - if ((await ctx.db.get("sessions", sessionId)) !== null) { - await ctx.db.delete("sessions", sessionId); - } -} - -/** - * Erase this session's spent and dropped tokens that are past the detection - * horizon. - * - * Spent and dropped tokens are kept to detect illegitimate use of a refresh - * token. Only a bounded set is kept for that purpose though, thus this code to - * prune rows retired longer than `SPENT_TOKEN_HORIZON_MS` ago. - * - * Cleanup is triggered by redeeming an issued token, so a steadily refreshing - * session keeps its own set trimmed with no background job to run or mount. - */ -async function pruneRetiredTokens( - ctx: MutationCtx, - sessionId: Id<"sessions">, - now: number, -): Promise { - // A session that stops refreshing keeps its remaining rows until it is - // deleted, since nothing rotates it any more. Sweeping those orphans is what a - // background job would add; see KNOWN_ISSUES.md. - for (const state of ["spent", "dropped"] as const) { - const expired = await ctx.db - .query("refreshTokens") - .withIndex("by_session_state", (q) => - q - .eq("sessionId", sessionId) - .eq("state", state) - .lte("retiredAt", now - SPENT_TOKEN_HORIZON_MS), - ) - .collect(); - for (const row of expired) { - await ctx.db.delete("refreshTokens", row._id); - } - } -} - -/** - * When a refresh token expires under the TTL configured now. - * - * Expiry is derived rather than stored, so a changed TTL applies to every - * outstanding token. It also means a session's newest token is always its - * last to expire. - */ -function refreshTokenExpiresAt( - token: Pick, "_creationTime">, - ttl: TtlConfig, -): number { - return token._creationTime + ttl.refreshTokenTtlSeconds * 1000; -} - -/** - * Issue a new refresh token for a session. - * - * Returns the raw token, and stores its hash for later lookup. The returned - * `expiresAt` holds under the current TTL; a later change to the TTL moves it. - * It is computed from `now` rather than the row's `_creationTime`, so it can - * differ from what `refresh` enforces by a fraction of a millisecond. - */ -async function issueRefreshToken( - ctx: MutationCtx, - sessionId: Id<"sessions">, - now: number, - ttl: TtlConfig, -): Promise<{ refreshToken: string; expiresAt: number }> { - const refreshToken = generateRefreshToken(); - await ctx.db.insert("refreshTokens", { - hash: await sha256Hex(refreshToken), - sessionId, - state: "issued", - }); - return { - refreshToken, - expiresAt: now + ttl.refreshTokenTtlSeconds * 1000, - }; -} - -/** - * The token lifetimes for a call, with the app's overrides applied over the - * defaults. Validated so a misconfiguration fails loudly rather than minting - * nonsensical sessions. - */ -type TtlConfig = { - accessTokenTtlSeconds: number; - refreshTokenTtlSeconds: number; -}; - -function resolveTtlConfig(args: { - accessTokenTtlSeconds?: number; - refreshTokenTtlSeconds?: number; -}): TtlConfig { - const accessTokenTtlSeconds = - args.accessTokenTtlSeconds ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS; - const refreshTokenTtlSeconds = - args.refreshTokenTtlSeconds ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS; - if (accessTokenTtlSeconds <= 0 || refreshTokenTtlSeconds <= 0) { - throw new Error("Token TTLs must be positive."); - } - if (accessTokenTtlSeconds > MAX_ACCESS_TOKEN_TTL_SECONDS) { - throw new Error( - "Access-token TTL must be at most one hour (the deployment refuses " + - "to sign a longer-lived JWT).", - ); - } - if (accessTokenTtlSeconds >= refreshTokenTtlSeconds) { - throw new Error( - "Access-token TTL must be shorter than the refresh-token TTL.", - ); - } - return { accessTokenTtlSeconds, refreshTokenTtlSeconds }; -} - /** * Mint the access token with the deployment's signing key. The deployment * populates `iss` (`${CONVEX_CLOUD_URL}/components/auth`), `iat` and `exp`; @@ -645,48 +400,6 @@ export const getSession = query({ }, }); -/** - * Redeem an issued token, the first time it is presented. - * - * Its holder has evidently received it, so the session's previously redeemed - * token is marked as spent and the other issued tokens, which nobody should - * ever present, are marked as dropped. - */ -async function redeem( - ctx: MutationCtx, - token: Doc<"refreshTokens">, - now: number, -): Promise { - const redeemed = await sessionTokensInState(ctx, token.sessionId, "redeemed"); - if (redeemed.length > 1) { - throw new Error( - "Invariant violation: a session has more than one redeemed refresh token", - ); - } - // There will be 0 or 1 tokens in the `redeemed`` state. - for (const row of redeemed) { - await ctx.db.patch("refreshTokens", row._id, { - state: "spent", - retiredAt: now, - }); - } - for (const row of await sessionTokensInState( - ctx, - token.sessionId, - "issued", - )) { - if (row._id === token._id) continue; - await ctx.db.patch("refreshTokens", row._id, { - state: "dropped", - retiredAt: now, - }); - } - await ctx.db.patch("refreshTokens", token._id, { state: "redeemed" }); - - // Remove any spent and dropped tokens past the `SPENT_TOKEN_HORIZON_MS`. - await pruneRetiredTokens(ctx, token.sessionId, now); -} - /** * Exchange a refresh token for a fresh access token and a newly issued * refresh token. @@ -756,7 +469,7 @@ export const refresh = mutation({ break; } - if (refreshTokenExpiresAt(token, ttl) <= now) { + if (refreshTokenExpiresAt(token, ttl.refreshTokenTtlSeconds) <= now) { await deleteSession(ctx, token.sessionId); return { kind: "noSession" }; } diff --git a/packages/core/src/component/sessions.ts b/packages/core/src/component/sessions.ts new file mode 100644 index 000000000..51c96dbe7 --- /dev/null +++ b/packages/core/src/component/sessions.ts @@ -0,0 +1,255 @@ +// Sessions and the lifecycle of their refresh tokens: issuing, redeeming, +// retiring, and erasing them. The public functions in public.ts build on these. + +import type { Doc, Id } from "./_generated/dataModel.ts"; +import type { MutationCtx, QueryCtx } from "./_generated/server.ts"; +import { generateRefreshToken } from "./crypto.ts"; +import { sha256Hex } from "../lib/crypto.ts"; +import type { TtlConfig } from "./expiry.ts"; + +// How many issued, never-presented refresh tokens a session may have. Each +// refresh presenting the redeemed token adds one, and the first issued token +// presented clears them all, so the count only climbs when responses are lost +// or refreshes race. A refresh with this many already issued revokes the +// session, unless the redeemed token was redeemed within `REFRESH_GRACE_MS`. +export const STANDARD_ISSUED_REFRESH_TOKEN_LIMIT = 3; +// How long after a token is redeemed it may be presented again without +// counting against `STANDARD_ISSUED_REFRESH_TOKEN_LIMIT`. Concurrent SSR +// requests carrying the same cookie each refresh on their own, and the first to +// land redeems the token the rest present. +export const REFRESH_GRACE_MS = 5 * 1000; // 5 seconds +// How long a spent or dropped refresh token hash is remembered. This is the +// reuse-detection horizon: past it the row is gone and a replayed token reads +// as unknown, which revokes nothing but also grants nothing. +export const SPENT_TOKEN_HORIZON_MS = 60 * 60 * 1000; // 1 hour +// An absolute cap on how many sessions a user may have. A sign-in that would +// exceed it first revokes the sessions that refreshed longest ago. This bounds +// everything that reads all of a user's sessions (`listSessions`, and revoking +// a user's other sessions), which a user whose sessions pile up (e.g. a script +// signing in over and over and not refreshing a prior session) could otherwise +// push past the transaction's limits. +export const MAX_SESSIONS_PER_USER = 100; + +/** Look up a refresh token, in any state, by its hash. */ +export function lookupRefreshTokenByHash( + ctx: QueryCtx, + hash: string, +): Promise | null> { + return ctx.db + .query("refreshTokens") + .withIndex("by_hash", (q) => q.eq("hash", hash)) + .unique(); +} + +/** A session's refresh tokens in one state. */ +export function sessionTokensInState( + ctx: QueryCtx, + sessionId: Id<"sessions">, + state: Doc<"refreshTokens">["state"], +): Promise[]> { + return ctx.db + .query("refreshTokens") + .withIndex("by_session_state", (q) => + q.eq("sessionId", sessionId).eq("state", state), + ) + .collect(); +} + +/** + * The session's newest issued refresh token: the one from its sign-in or its + * latest refresh. It is the session's last token to expire, thus the session + * expires with it. + * + * `null` only for a session with no issued token, which the token lifecycle + * never leaves (sign-in and each refresh issue one). + */ +export async function newestIssuedToken( + ctx: QueryCtx, + sessionId: Id<"sessions">, +): Promise | null> { + // Issued rows have no `retiredAt`, so the index orders them by + // `_creationTime`: the last one is the newest. + return await ctx.db + .query("refreshTokens") + .withIndex("by_session_state", (q) => + q.eq("sessionId", sessionId).eq("state", "issued"), + ) + .order("desc") + .first(); +} + +/** + * Make room for one more session of the user: while the user has + * `MAX_SESSIONS_PER_USER` sessions or more, erase the one that refreshed + * longest ago. + * + * A session's last refresh is when its newest issued token was issued (see + * `listSessions`), so an expired or abandoned session goes before one that a + * client still keeps fresh, however old that one is. + */ +export async function evictLeastRecentlyRefreshedSessions( + ctx: MutationCtx, + userId: string, +): Promise { + // Reads every session of the user, which the cap keeps to + // `MAX_SESSIONS_PER_USER`. + const sessions = await ctx.db + .query("sessions") + .withIndex("by_user", (q) => q.eq("userId", userId)) + .collect(); + const excess = sessions.length - MAX_SESSIONS_PER_USER + 1; + if (excess <= 0) { + return; + } + const byLastRefresh = []; + for (const session of sessions) { + const newestIssued = await newestIssuedToken(ctx, session._id); + byLastRefresh.push({ + sessionId: session._id, + // A session with no issued token can't refresh, so it goes first. + lastRefreshedAt: newestIssued?._creationTime ?? -Infinity, + }); + } + // `sessions` is in creation order and the sort is stable, so of the + // sessions that last refreshed at the same time, the oldest goes first. + byLastRefresh.sort((a, b) => a.lastRefreshedAt - b.lastRefreshedAt); + for (const { sessionId } of byLastRefresh.slice(0, excess)) { + await deleteSession(ctx, sessionId); + } + console.warn( + `Evicted ${excess} session${excess > 1 ? "s" : ""} past the ` + + `${MAX_SESSIONS_PER_USER} session limit for a single user`, + ); +} + +/** + * Erase a session along with every refresh token that points at it. + * + * Idempotent, because callers can race: two replays of the same stolen token, + * or a sign-out arriving alongside one, can both resolve the same session. + */ +export async function deleteSession( + ctx: MutationCtx, + sessionId: Id<"sessions">, +): Promise { + // Reads every token row the session has. That is fine at any plausible + // refresh rate — the horizon divided by the refresh interval, ~60 at the + // defaults — but it is unbounded in principle. A session refreshed + // pathologically often could exceed the transaction's limits and make the + // session unusable. + const tokens = await ctx.db + .query("refreshTokens") + .withIndex("by_session_state", (q) => q.eq("sessionId", sessionId)) + .collect(); + for (const row of tokens) { + await ctx.db.delete("refreshTokens", row._id); + } + // `delete` throws on an id that is already gone, and the session may have + // been deleted by whoever we raced. + if ((await ctx.db.get("sessions", sessionId)) !== null) { + await ctx.db.delete("sessions", sessionId); + } +} + +/** + * Erase this session's spent and dropped tokens that are past the detection + * horizon. + * + * Spent and dropped tokens are kept to detect illegitimate use of a refresh + * token. Only a bounded set is kept for that purpose though, thus this code to + * prune rows retired longer than `SPENT_TOKEN_HORIZON_MS` ago. + * + * Cleanup is triggered by redeeming an issued token, so a steadily refreshing + * session keeps its own set trimmed with no background job to run or mount. + */ +export async function pruneRetiredTokens( + ctx: MutationCtx, + sessionId: Id<"sessions">, + now: number, +): Promise { + // A session that stops refreshing keeps its remaining rows until it is + // deleted, since nothing rotates it any more. Sweeping those orphans is what a + // background job would add; see KNOWN_ISSUES.md. + for (const state of ["spent", "dropped"] as const) { + const expired = await ctx.db + .query("refreshTokens") + .withIndex("by_session_state", (q) => + q + .eq("sessionId", sessionId) + .eq("state", state) + .lte("retiredAt", now - SPENT_TOKEN_HORIZON_MS), + ) + .collect(); + for (const row of expired) { + await ctx.db.delete("refreshTokens", row._id); + } + } +} + +/** + * Issue a new refresh token for a session. + * + * Returns the raw token, and stores its hash for later lookup. The returned + * `expiresAt` holds under the current TTL; a later change to the TTL moves it. + * It is computed from `now` rather than the row's `_creationTime`, so it can + * differ from what `refresh` enforces by a fraction of a millisecond. + */ +export async function issueRefreshToken( + ctx: MutationCtx, + sessionId: Id<"sessions">, + now: number, + ttl: TtlConfig, +): Promise<{ refreshToken: string; expiresAt: number }> { + const refreshToken = generateRefreshToken(); + await ctx.db.insert("refreshTokens", { + hash: await sha256Hex(refreshToken), + sessionId, + state: "issued", + }); + return { + refreshToken, + expiresAt: now + ttl.refreshTokenTtlSeconds * 1000, + }; +} + +/** + * Redeem an issued token, the first time it is presented. + * + * Its holder has evidently received it, so the session's previously redeemed + * token is marked as spent and the other issued tokens, which nobody should + * ever present, are marked as dropped. + */ +export async function redeem( + ctx: MutationCtx, + token: Doc<"refreshTokens">, + now: number, +): Promise { + const redeemed = await sessionTokensInState(ctx, token.sessionId, "redeemed"); + if (redeemed.length > 1) { + throw new Error( + "Invariant violation: a session has more than one redeemed refresh token", + ); + } + // There will be 0 or 1 tokens in the `redeemed`` state. + for (const row of redeemed) { + await ctx.db.patch("refreshTokens", row._id, { + state: "spent", + retiredAt: now, + }); + } + for (const row of await sessionTokensInState( + ctx, + token.sessionId, + "issued", + )) { + if (row._id === token._id) continue; + await ctx.db.patch("refreshTokens", row._id, { + state: "dropped", + retiredAt: now, + }); + } + await ctx.db.patch("refreshTokens", token._id, { state: "redeemed" }); + + // Remove any spent and dropped tokens past the `SPENT_TOKEN_HORIZON_MS`. + await pruneRetiredTokens(ctx, token.sessionId, now); +}