@12-apps/mcp 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ import type {
2
+ McpConnectionStore,
3
+ McpOauthStores,
4
+ NewOAuthClient,
5
+ NewRefreshToken,
6
+ OAuthClientStore,
7
+ RefreshTokenStore,
8
+ StoredMcpConnection,
9
+ StoredOAuthClient,
10
+ StoredRefreshToken,
11
+ } from "./stores";
12
+
13
+ /**
14
+ * The ports of `./stores.ts`, filled by Prisma (12-23).
15
+ *
16
+ * The package owns the three models (`prisma/mcp.prisma`), so their delegate
17
+ * shapes are known and this adapter can be exact. A host with Prisma therefore
18
+ * writes ONE line —
19
+ *
20
+ * stores: createPrismaMcpStores(async () => getPrismaClient() as unknown as McpOauthPrisma)
21
+ *
22
+ * — and no host code at all beyond it. The client is duck-typed (only the
23
+ * delegates used, only the arguments used) so this file never imports a project's
24
+ * generated client, and a non-Prisma host fills the ports directly instead.
25
+ */
26
+
27
+ /** A `where` on the composite unique of `mcp_connections`. */
28
+ interface ConnectionKey {
29
+ userId_oauthClientId: { userId: string; oauthClientId: string };
30
+ }
31
+
32
+ /** The minimal Prisma surface the AS needs. Every field is one the surface writes. */
33
+ export interface McpOauthPrisma {
34
+ oAuthClient: {
35
+ create(args: { data: NewOAuthClient }): Promise<StoredOAuthClient>;
36
+ findUnique(args: { where: { clientId: string } }): Promise<StoredOAuthClient | null>;
37
+ };
38
+ oAuthRefreshToken: {
39
+ create(args: { data: NewRefreshToken }): Promise<unknown>;
40
+ findUnique(args: { where: { tokenHash: string } }): Promise<StoredRefreshToken | null>;
41
+ findFirst(args: { where: { rotatedFrom: string } }): Promise<{ tokenHash: string } | null>;
42
+ findMany(args: {
43
+ where: { userEmail: string; clientId: string };
44
+ }): Promise<StoredRefreshToken[]>;
45
+ // No single-row `update`: the rotation used to revoke its parent with one and
46
+ // that was the bug (unconditional, so two concurrent rotations both won). Every
47
+ // revoke here is now an `updateMany` with a predicate that says WHICH rows may
48
+ // move, which is also why this delegate list stays honest about what is written.
49
+ updateMany(args: {
50
+ where:
51
+ | { tokenHash: { in: string[] } }
52
+ | { userEmail: string; clientId: string; revokedAt: null };
53
+ data: { revokedAt: Date };
54
+ }): Promise<{ count: number }>;
55
+ };
56
+ mcpConnection: {
57
+ findUnique(args: {
58
+ where: ConnectionKey;
59
+ select: { lastActiveAt: true };
60
+ }): Promise<{ lastActiveAt: Date } | null>;
61
+ findFirst(args: {
62
+ where: { userId: string; revokedAt: null; host: null };
63
+ orderBy: { lastActiveAt: "desc" };
64
+ select: { id: true };
65
+ }): Promise<{ id: string } | null>;
66
+ findMany(args: {
67
+ where: { userId: string; revokedAt: null; host?: string | null };
68
+ orderBy?: { lastActiveAt: "desc" };
69
+ select: Record<string, true>;
70
+ }): Promise<Record<string, unknown>[]>;
71
+ upsert(args: {
72
+ where: ConnectionKey;
73
+ create: Record<string, unknown>;
74
+ update: Record<string, unknown>;
75
+ }): Promise<unknown>;
76
+ update(args: { where: { id: string }; data: Record<string, unknown> }): Promise<unknown>;
77
+ updateMany(args: {
78
+ where: { id: { in: string[] } } | { userId: string; revokedAt: null; host: string };
79
+ data: Record<string, unknown>;
80
+ }): Promise<{ count: number }>;
81
+ };
82
+ /**
83
+ * Prisma's INTERACTIVE transaction, used for the rotation's claim + write. The
84
+ * callback form (not the array form) is required: the successor may only be
85
+ * created once the conditional revoke has reported that it, and not a concurrent
86
+ * sibling, claimed the parent — see `RefreshTokenStore.rotate`.
87
+ */
88
+ $transaction<T>(fn: (tx: McpOauthTx) => Promise<T>): Promise<T>;
89
+ }
90
+
91
+ /**
92
+ * The delegate subset used INSIDE the rotation transaction. Not exported: it is
93
+ * reachable structurally through `McpOauthPrisma.$transaction`, so no host ever
94
+ * needs to name it, and exporting a type nobody imports is what knip flags.
95
+ */
96
+ interface McpOauthTx {
97
+ oAuthRefreshToken: {
98
+ create(args: { data: NewRefreshToken }): Promise<unknown>;
99
+ updateMany(args: {
100
+ where: { tokenHash: string; revokedAt: null };
101
+ data: { revokedAt: Date };
102
+ }): Promise<{ count: number }>;
103
+ };
104
+ }
105
+
106
+ /** A lazily-resolved client, so a host's singleton is awaited per call. */
107
+ export type McpOauthPrismaProvider = () => Promise<McpOauthPrisma>;
108
+
109
+ function clientStore(getPrisma: McpOauthPrismaProvider): OAuthClientStore {
110
+ return {
111
+ async create(client: NewOAuthClient) {
112
+ const prisma = await getPrisma();
113
+ return prisma.oAuthClient.create({ data: client });
114
+ },
115
+ async findByClientId(clientId: string) {
116
+ const prisma = await getPrisma();
117
+ return prisma.oAuthClient.findUnique({ where: { clientId } });
118
+ },
119
+ };
120
+ }
121
+
122
+ function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore {
123
+ return {
124
+ async create(token) {
125
+ const prisma = await getPrisma();
126
+ await prisma.oAuthRefreshToken.create({ data: token });
127
+ },
128
+ async findByHash(tokenHash) {
129
+ const prisma = await getPrisma();
130
+ return prisma.oAuthRefreshToken.findUnique({ where: { tokenHash } });
131
+ },
132
+ async hasSuccessor(tokenHash) {
133
+ const prisma = await getPrisma();
134
+ const successor = await prisma.oAuthRefreshToken.findFirst({
135
+ where: { rotatedFrom: tokenHash },
136
+ });
137
+ return successor !== null;
138
+ },
139
+ async listFamily(userEmail, clientId) {
140
+ const prisma = await getPrisma();
141
+ return prisma.oAuthRefreshToken.findMany({ where: { userEmail, clientId } });
142
+ },
143
+ async revokeHashes(tokenHashes, at) {
144
+ if (tokenHashes.length === 0) return;
145
+ const prisma = await getPrisma();
146
+ await prisma.oAuthRefreshToken.updateMany({
147
+ where: { tokenHash: { in: [...tokenHashes] } },
148
+ data: { revokedAt: at },
149
+ });
150
+ },
151
+ async rotate(successor, parentHash, at) {
152
+ const prisma = await getPrisma();
153
+ return prisma.$transaction(async (tx) => {
154
+ // CLAIM-ONCE. The `revokedAt: null` predicate is what makes this safe under
155
+ // concurrency, and it is load-bearing rather than defensive: on Postgres's
156
+ // default READ COMMITTED, a second transaction's `updateMany` blocks on the
157
+ // row lock, then re-evaluates this WHERE against the COMMITTED row — which
158
+ // now has a `revokedAt` — and reports 0 rows. So exactly one caller can ever
159
+ // see count 1, and it is the only one that goes on to create a successor.
160
+ // An unconditional `update` would let both through: two live successors of
161
+ // one parent, and replay detection silently defeated (it waits for a third
162
+ // use of the parent that now never comes).
163
+ const { count } = await tx.oAuthRefreshToken.updateMany({
164
+ where: { tokenHash: parentHash, revokedAt: null },
165
+ data: { revokedAt: at },
166
+ });
167
+ // Lost the claim: write NOTHING. The zero-row update commits as the no-op
168
+ // it is, so there is nothing to roll back.
169
+ if (count !== 1) return false;
170
+ // Same transaction as the claim, so a crash cannot leave a live parent AND
171
+ // a live child either.
172
+ await tx.oAuthRefreshToken.create({ data: successor });
173
+ return true;
174
+ });
175
+ },
176
+ async revokeLiveForClient(userEmail, clientId) {
177
+ const prisma = await getPrisma();
178
+ const { count } = await prisma.oAuthRefreshToken.updateMany({
179
+ where: { userEmail, clientId, revokedAt: null },
180
+ data: { revokedAt: new Date() },
181
+ });
182
+ return count;
183
+ },
184
+ };
185
+ }
186
+
187
+ /** The connection columns the account surface reads. */
188
+ const CONNECTION_SELECT = {
189
+ oauthClientId: true,
190
+ clientName: true,
191
+ host: true,
192
+ connectedAt: true,
193
+ lastActiveAt: true,
194
+ } as const;
195
+
196
+ function connectionStore(getPrisma: McpOauthPrismaProvider): McpConnectionStore {
197
+ return {
198
+ async lastActiveAt(userId, oauthClientId) {
199
+ const prisma = await getPrisma();
200
+ const row = await prisma.mcpConnection.findUnique({
201
+ where: { userId_oauthClientId: { userId, oauthClientId } },
202
+ select: { lastActiveAt: true },
203
+ });
204
+ return row?.lastActiveAt ?? null;
205
+ },
206
+ async recordActivity({ userId, oauthClientId, clientName, host, at }) {
207
+ const prisma = await getPrisma();
208
+ await prisma.mcpConnection.upsert({
209
+ where: { userId_oauthClientId: { userId, oauthClientId } },
210
+ create: { userId, oauthClientId, clientName, host, connectedAt: at, lastActiveAt: at },
211
+ // Never blank a known host on refresh — keep the existing attribution
212
+ // when this grant cannot derive one.
213
+ update: {
214
+ clientName,
215
+ lastActiveAt: at,
216
+ revokedAt: null,
217
+ ...(host ? { host } : {}),
218
+ },
219
+ });
220
+ },
221
+ async listActive(userId) {
222
+ const prisma = await getPrisma();
223
+ const rows = await prisma.mcpConnection.findMany({
224
+ where: { userId, revokedAt: null },
225
+ orderBy: { lastActiveAt: "desc" },
226
+ select: { ...CONNECTION_SELECT },
227
+ });
228
+ return rows as unknown as StoredMcpConnection[];
229
+ },
230
+ revokeByHost: (userId, host) => revokeByHost(getPrisma, userId, host),
231
+ announce: (userId, host) => announce(getPrisma, userId, host),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Disconnect one provider's connections — and ONLY that provider's.
237
+ *
238
+ * Every read and write is scoped by `userId`: a connection is per-user (an MCP
239
+ * bearer is not tenant-scoped), so the user id IS the isolation here, and the
240
+ * `id` list passed to the update comes from a query that already applied it.
241
+ */
242
+ async function revokeByHost(
243
+ getPrisma: McpOauthPrismaProvider,
244
+ userId: string,
245
+ host: string,
246
+ ): Promise<string[]> {
247
+ const prisma = await getPrisma();
248
+ const attributed = await prisma.mcpConnection.findMany({
249
+ where: { userId, revokedAt: null, host },
250
+ select: { id: true, oauthClientId: true },
251
+ });
252
+ // A legacy `host = null` row is claimed only when the provider has no row of
253
+ // its own: pre-attribution connections must stay disconnectable, but a provider
254
+ // that DID attribute can never revoke another assistant's row.
255
+ const targets =
256
+ attributed.length > 0
257
+ ? attributed
258
+ : await prisma.mcpConnection.findMany({
259
+ where: { userId, revokedAt: null, host: null },
260
+ select: { id: true, oauthClientId: true },
261
+ });
262
+ if (targets.length === 0) return [];
263
+ await prisma.mcpConnection.updateMany({
264
+ where: { id: { in: targets.map((row) => String(row.id)) } },
265
+ data: { revokedAt: new Date() },
266
+ });
267
+ return targets.map((row) => String(row.oauthClientId));
268
+ }
269
+
270
+ /** A provider's self-report: refresh its own row, or claim the unattributed one. */
271
+ async function announce(
272
+ getPrisma: McpOauthPrismaProvider,
273
+ userId: string,
274
+ host: string,
275
+ ): Promise<number> {
276
+ const prisma = await getPrisma();
277
+ const now = new Date();
278
+ const refreshed = await prisma.mcpConnection.updateMany({
279
+ where: { userId, revokedAt: null, host },
280
+ data: { lastActiveAt: now, revokedAt: null },
281
+ });
282
+ if (refreshed.count > 0) return refreshed.count;
283
+
284
+ // No row for this provider yet — attribute the just-connected one. Scoped by
285
+ // user, so a self-report can never reach another account's connection.
286
+ const candidate = await prisma.mcpConnection.findFirst({
287
+ where: { userId, revokedAt: null, host: null },
288
+ orderBy: { lastActiveAt: "desc" },
289
+ select: { id: true },
290
+ });
291
+ if (!candidate) return 0;
292
+ await prisma.mcpConnection.update({
293
+ where: { id: candidate.id },
294
+ data: { host, lastActiveAt: now, revokedAt: null },
295
+ });
296
+ return 1;
297
+ }
298
+
299
+ /** Every port, over one lazily-resolved Prisma client. */
300
+ export function createPrismaMcpStores(getPrisma: McpOauthPrismaProvider): McpOauthStores {
301
+ return {
302
+ clients: clientStore(getPrisma),
303
+ refreshTokens: refreshTokenStore(getPrisma),
304
+ connections: connectionStore(getPrisma),
305
+ };
306
+ }
@@ -0,0 +1,286 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ import type { NewRefreshToken, RefreshTokenStore, StoredRefreshToken } from "./stores";
4
+
5
+ /**
6
+ * Refresh-token issue + rotation (12-23, ported from future-pay's
7
+ * `lib/mcp/oauth/refresh.ts` — behaviour unchanged; Prisma calls became the
8
+ * `RefreshTokenStore` port).
9
+ *
10
+ * Refresh tokens are opaque high-entropy strings; only their SHA-256 HASH is ever
11
+ * stored — the plaintext is returned once at issue/rotate time and never
12
+ * persisted, never logged.
13
+ *
14
+ * Rotation-on-use with replay protection:
15
+ * - {@link issueRefreshToken} mints a root token bound to email + sub + client
16
+ * + scopes;
17
+ * - {@link rotateRefreshToken} consumes a token: it issues a NEW token chained
18
+ * via `rotatedFrom` and revokes the parent, so a token is single-use;
19
+ * - reuse of an already-rotated/revoked token is a REPLAY: rejected, AND the
20
+ * whole lineage (every ancestor + descendant reachable through `rotatedFrom`)
21
+ * is revoked — the OAuth 2.1 refresh-token replay rule;
22
+ * - CONCURRENT reuse is the same event and gets the same answer. The store's
23
+ * `rotate` is a claim-once write, so of two simultaneous rotations of one
24
+ * parent exactly one is issued a successor and the other is treated as the
25
+ * replay it is. Without that, replay protection would be bypassable by
26
+ * WINNING a race instead of arriving second (see `RefreshTokenStore.rotate`);
27
+ * - rotate may only NARROW scope (new ⊆ original); broadening is rejected and
28
+ * nothing new is stored.
29
+ */
30
+
31
+ /** Bytes of entropy per opaque refresh token (→ 64 hex chars). */
32
+ const REFRESH_TOKEN_BYTES = 32;
33
+
34
+ /** Refresh-token lifetime — long-lived relative to the 15-min access token. */
35
+ export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
36
+
37
+ /** The single failure discriminator surfaced to the token endpoint. */
38
+ export type RefreshTokenErrorCode = "invalid_grant" | "invalid_scope";
39
+
40
+ /**
41
+ * A typed refresh-token failure. Every rejection — unknown, expired, revoked,
42
+ * already-rotated (replay), wrong client, or a scope-broadening request —
43
+ * surfaces as a discriminated error the token endpoint maps to the RFC 6749
44
+ * error JSON.
45
+ */
46
+ export class RefreshTokenError extends Error {
47
+ readonly code: RefreshTokenErrorCode;
48
+
49
+ constructor(code: RefreshTokenErrorCode, message?: string) {
50
+ super(message ?? code);
51
+ this.name = "RefreshTokenError";
52
+ this.code = code;
53
+ }
54
+ }
55
+
56
+ /** The result of issuing/rotating: the plaintext token (once) + bound scopes. */
57
+ export interface IssuedRefreshToken {
58
+ /** The opaque plaintext refresh token — returned once, never persisted. */
59
+ refreshToken: string;
60
+ scopes: string[];
61
+ }
62
+
63
+ /** SHA-256 hex digest — the at-rest form of an opaque refresh token. */
64
+ export function hashToken(token: string): string {
65
+ return createHash("sha256").update(token).digest("hex");
66
+ }
67
+
68
+ /** Generate a fresh opaque refresh token (high-entropy hex). */
69
+ function generateToken(): string {
70
+ return randomBytes(REFRESH_TOKEN_BYTES).toString("hex");
71
+ }
72
+
73
+ export interface RefreshTokenContext {
74
+ store: RefreshTokenStore;
75
+ /** Lifetime of a newly stored token. Default 30 days. */
76
+ ttlMs?: number;
77
+ }
78
+
79
+ function expiryOf(context: RefreshTokenContext): Date {
80
+ return new Date(Date.now() + (context.ttlMs ?? REFRESH_TOKEN_TTL_MS));
81
+ }
82
+
83
+ /**
84
+ * Issue a fresh (root) refresh token bound to a user (email + OAuth `sub`) +
85
+ * client + scopes. The plaintext is returned once; only its hash is stored.
86
+ */
87
+ export async function issueRefreshToken(
88
+ context: RefreshTokenContext,
89
+ binding: { userEmail: string; userSub: string; clientId: string; scopes: string[] },
90
+ ): Promise<IssuedRefreshToken> {
91
+ const refreshToken = generateToken();
92
+ const row: NewRefreshToken = {
93
+ tokenHash: hashToken(refreshToken),
94
+ userEmail: binding.userEmail,
95
+ userSub: binding.userSub,
96
+ clientId: binding.clientId,
97
+ scopes: binding.scopes,
98
+ expiresAt: expiryOf(context),
99
+ rotatedFrom: null,
100
+ };
101
+ await context.store.create(row);
102
+ return { refreshToken, scopes: binding.scopes };
103
+ }
104
+
105
+ /**
106
+ * A pre-built O(1)-lookup index of one `(userEmail, clientId)` token family:
107
+ * `byHash` resolves a hash to its row (to walk ancestors via `rotatedFrom`), and
108
+ * `childrenOf` is the reverse index mapping a parent hash to its direct successor
109
+ * hashes (to walk descendants). Both are built in a single pass so the lineage
110
+ * traversal never re-scans the family (no O(n²) inner loop).
111
+ */
112
+ interface LineageIndex {
113
+ byHash: Map<string, StoredRefreshToken>;
114
+ childrenOf: Map<string, string[]>;
115
+ }
116
+
117
+ function buildLineageIndex(family: StoredRefreshToken[]): LineageIndex {
118
+ const byHash = new Map<string, StoredRefreshToken>();
119
+ const childrenOf = new Map<string, string[]>();
120
+ for (const row of family) {
121
+ byHash.set(row.tokenHash, row);
122
+ if (!row.rotatedFrom) continue;
123
+ const siblings = childrenOf.get(row.rotatedFrom) ?? [];
124
+ siblings.push(row.tokenHash);
125
+ childrenOf.set(row.rotatedFrom, siblings);
126
+ }
127
+ return { byHash, childrenOf };
128
+ }
129
+
130
+ /**
131
+ * Collect every token hash reachable from `seedHash` — its ancestors (via
132
+ * `rotatedFrom`) and its descendants (via the reverse index) — by a BFS over the
133
+ * pre-built index. Each neighbour lookup is O(1), so the walk is linear in the
134
+ * family size.
135
+ */
136
+ function collectLineage(index: LineageIndex, seedHash: string): Set<string> {
137
+ const lineage = new Set<string>();
138
+ const queue = [seedHash];
139
+ while (queue.length > 0) {
140
+ const hash = queue.shift();
141
+ if (!hash || lineage.has(hash)) continue;
142
+ lineage.add(hash);
143
+
144
+ const parent = index.byHash.get(hash)?.rotatedFrom ?? null;
145
+ if (parent && !lineage.has(parent)) queue.push(parent);
146
+
147
+ const children = (index.childrenOf.get(hash) ?? []).filter((child) => !lineage.has(child));
148
+ queue.push(...children);
149
+ }
150
+ return lineage;
151
+ }
152
+
153
+ /**
154
+ * Walk a token's rotation lineage (both directions) and revoke every token in it.
155
+ * Called on replay detection, so a leaked refresh token — once reused —
156
+ * invalidates the entire chain it belongs to.
157
+ */
158
+ async function revokeLineage(
159
+ context: RefreshTokenContext,
160
+ scopedTo: Pick<StoredRefreshToken, "userEmail" | "clientId">,
161
+ seedHash: string,
162
+ ): Promise<void> {
163
+ // The lineage is confined to one (userEmail, clientId) pair, so load that set
164
+ // once and walk the `rotatedFrom` links in memory — a small, bounded chain.
165
+ const family = await context.store.listFamily(scopedTo.userEmail, scopedTo.clientId);
166
+ const lineage = collectLineage(buildLineageIndex(family), seedHash);
167
+ await context.store.revokeHashes([...lineage], new Date());
168
+ }
169
+
170
+ /** Reject any requested scope not already on the token (narrow-only). */
171
+ function narrowedScopes(current: StoredRefreshToken, requested?: string[]): string[] {
172
+ const scopes = requested ?? current.scopes;
173
+ const original = new Set(current.scopes);
174
+ for (const scope of scopes) {
175
+ if (!original.has(scope)) {
176
+ throw new RefreshTokenError(
177
+ "invalid_scope",
178
+ `scope '${scope}' broadens the refresh token grant`,
179
+ );
180
+ }
181
+ }
182
+ return scopes;
183
+ }
184
+
185
+ /**
186
+ * Rotate a refresh token on use: validate it (must exist, be BOUND to the
187
+ * presenting client, be unexpired, unrevoked and un-rotated), then issue a NEW
188
+ * token chained via `rotatedFrom` and revoke the consumed one. Optionally NARROW
189
+ * scope; a broadening request is `invalid_scope`.
190
+ *
191
+ * Client binding (OAuth 2.1 §4.3 / RFC 6749 §10.4) is checked BEFORE any rotation
192
+ * or revocation, so client A can never redeem client B's refresh token — nor
193
+ * silently consume B's token by trying: the token stays live for its rightful
194
+ * owner.
195
+ */
196
+ export async function rotateRefreshToken(
197
+ context: RefreshTokenContext,
198
+ plaintext: string,
199
+ expectedClientId: string,
200
+ newScopes?: string[],
201
+ ): Promise<IssuedRefreshToken> {
202
+ const tokenHash = hashToken(plaintext);
203
+ const current = await context.store.findByHash(tokenHash);
204
+
205
+ if (!current) {
206
+ throw new RefreshTokenError("invalid_grant", "unknown refresh token");
207
+ }
208
+ if (current.clientId !== expectedClientId) {
209
+ throw new RefreshTokenError(
210
+ "invalid_grant",
211
+ "refresh token was not issued to this client",
212
+ );
213
+ }
214
+ // Expired → reject (not a replay; no lineage revocation needed beyond the
215
+ // expiry itself).
216
+ if (current.expiresAt.getTime() <= Date.now()) {
217
+ throw new RefreshTokenError("invalid_grant", "refresh token expired");
218
+ }
219
+ // Already revoked OR already used as the parent of a rotation → REPLAY. Revoke
220
+ // the whole lineage and reject.
221
+ if (current.revokedAt || (await context.store.hasSuccessor(tokenHash))) {
222
+ await replay(context, current, tokenHash);
223
+ }
224
+
225
+ const scopes = narrowedScopes(current, newScopes);
226
+ const successorPlaintext = generateToken();
227
+ const claimed = await context.store.rotate(
228
+ {
229
+ tokenHash: hashToken(successorPlaintext),
230
+ userEmail: current.userEmail,
231
+ userSub: current.userSub,
232
+ clientId: current.clientId,
233
+ scopes,
234
+ expiresAt: expiryOf(context),
235
+ rotatedFrom: tokenHash,
236
+ },
237
+ tokenHash,
238
+ new Date(),
239
+ );
240
+ // The checks above are a READ, so a concurrent rotation of the same parent can
241
+ // pass them too; `rotate` is the serialization point and it hands the claim to
242
+ // exactly one caller. Losing it is the SAME event as the replay branch above —
243
+ // one token used twice — so it gets the same answer, deliberately: reject, and
244
+ // revoke the lineage including the winner's fresh successor. Rejecting without
245
+ // revoking would leave a race-winning attacker holding a live family, which is
246
+ // the whole attack; and a client that legitimately double-submits already loses
247
+ // its family in the sequential case, so this is consistent rather than harsher.
248
+ if (!claimed) await replay(context, current, tokenHash);
249
+
250
+ return { refreshToken: successorPlaintext, scopes };
251
+ }
252
+
253
+ /** Detected reuse: revoke the whole lineage and reject. Never returns. */
254
+ async function replay(
255
+ context: RefreshTokenContext,
256
+ current: StoredRefreshToken,
257
+ tokenHash: string,
258
+ ): Promise<never> {
259
+ await revokeLineage(context, current, tokenHash);
260
+ throw new RefreshTokenError(
261
+ "invalid_grant",
262
+ "refresh token already used (replay) — lineage revoked",
263
+ );
264
+ }
265
+
266
+ /** The stable identity a refresh token is bound to. */
267
+ export interface RefreshTokenIdentity {
268
+ /** The user's email — the identity the AS binds to and route guards resolve by. */
269
+ userEmail: string;
270
+ /** The original OAuth subject, kept stable across every rotation. */
271
+ userSub: string;
272
+ }
273
+
274
+ /**
275
+ * Resolve the identity (`email` + original OAuth `sub`) a refresh token is bound
276
+ * to. The token endpoint uses this after rotation to mint the successor access
277
+ * token with the correct email AND the SAME stable `sub` as the initial token (no
278
+ * re-consent, no `sub` drift). `null` if the row is unexpectedly absent.
279
+ */
280
+ export async function getRefreshTokenIdentity(
281
+ context: RefreshTokenContext,
282
+ plaintext: string,
283
+ ): Promise<RefreshTokenIdentity | null> {
284
+ const row = await context.store.findByHash(hashToken(plaintext));
285
+ return row ? { userEmail: row.userEmail, userSub: row.userSub } : null;
286
+ }