@12-apps/mcp 1.18.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.
- package/ADOPTING.md +248 -0
- package/README.md +39 -12
- package/package.json +28 -9
- package/prisma/mcp.prisma +108 -0
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +152 -0
- package/scripts/sync-mcp-schema.mjs +61 -0
- package/src/auth/authorization-server-metadata.ts +27 -8
- package/src/coverage-gate/index.ts +243 -0
- package/src/coverage-gate/route-methods.ts +86 -0
- package/src/generate/index.ts +197 -0
- package/src/hono/index.ts +43 -0
- package/src/index.ts +1 -0
- package/src/oauth/access-token.ts +186 -0
- package/src/oauth/authorization-code.ts +215 -0
- package/src/oauth/authorize.ts +260 -0
- package/src/oauth/clients.ts +158 -0
- package/src/oauth/code-replay.ts +51 -0
- package/src/oauth/config.ts +142 -0
- package/src/oauth/context.ts +253 -0
- package/src/oauth/create-api-mcp-oauth.ts +205 -0
- package/src/oauth/index.ts +117 -0
- package/src/oauth/keys.ts +107 -0
- package/src/oauth/pkce.ts +93 -0
- package/src/oauth/prisma-stores.ts +306 -0
- package/src/oauth/refresh.ts +286 -0
- package/src/oauth/register.ts +282 -0
- package/src/oauth/stores.ts +157 -0
- package/src/oauth/token-grants.ts +326 -0
- package/src/oauth/token-response.ts +154 -0
|
@@ -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
|
+
}
|