@c9up/visa 0.1.0 → 0.1.2

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.
Files changed (79) hide show
  1. package/dist/VisaManager.d.ts +40 -7
  2. package/dist/VisaManager.d.ts.map +1 -1
  3. package/dist/VisaManager.js +79 -3
  4. package/dist/VisaManager.js.map +1 -1
  5. package/dist/VisaProvider.d.ts.map +1 -1
  6. package/dist/VisaProvider.js +56 -0
  7. package/dist/VisaProvider.js.map +1 -1
  8. package/dist/authorize.d.ts +18 -0
  9. package/dist/authorize.d.ts.map +1 -1
  10. package/dist/authorize.js +6 -0
  11. package/dist/authorize.js.map +1 -1
  12. package/dist/config.d.ts +35 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/configure.d.ts.map +1 -1
  16. package/dist/configure.js +19 -0
  17. package/dist/configure.js.map +1 -1
  18. package/dist/errors.d.ts +9 -3
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js.map +1 -1
  21. package/dist/guard.d.ts +243 -0
  22. package/dist/guard.d.ts.map +1 -0
  23. package/dist/guard.js +293 -0
  24. package/dist/guard.js.map +1 -0
  25. package/dist/index.d.ts +7 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/introspect.d.ts +11 -1
  30. package/dist/introspect.d.ts.map +1 -1
  31. package/dist/introspect.js +27 -1
  32. package/dist/introspect.js.map +1 -1
  33. package/dist/protectedResource.d.ts +81 -0
  34. package/dist/protectedResource.d.ts.map +1 -0
  35. package/dist/protectedResource.js +124 -0
  36. package/dist/protectedResource.js.map +1 -0
  37. package/dist/register.d.ts +96 -0
  38. package/dist/register.d.ts.map +1 -0
  39. package/dist/register.js +173 -0
  40. package/dist/register.js.map +1 -0
  41. package/dist/resource.d.ts +50 -0
  42. package/dist/resource.d.ts.map +1 -0
  43. package/dist/resource.js +101 -0
  44. package/dist/resource.js.map +1 -0
  45. package/dist/store.d.ts +33 -0
  46. package/dist/store.d.ts.map +1 -1
  47. package/dist/stores/atlas.d.ts +106 -0
  48. package/dist/stores/atlas.d.ts.map +1 -0
  49. package/dist/stores/atlas.js +408 -0
  50. package/dist/stores/atlas.js.map +1 -0
  51. package/dist/stores/memory.d.ts +5 -0
  52. package/dist/stores/memory.d.ts.map +1 -1
  53. package/dist/stores/memory.js +30 -0
  54. package/dist/stores/memory.js.map +1 -1
  55. package/dist/token.d.ts +2 -0
  56. package/dist/token.d.ts.map +1 -1
  57. package/dist/token.js +21 -1
  58. package/dist/token.js.map +1 -1
  59. package/dist/types.d.ts +49 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +5 -1
  62. package/src/VisaManager.ts +109 -3
  63. package/src/VisaProvider.ts +64 -0
  64. package/src/authorize.ts +27 -0
  65. package/src/config.ts +35 -0
  66. package/src/configure.ts +21 -0
  67. package/src/errors.ts +16 -2
  68. package/src/guard.ts +443 -0
  69. package/src/index.ts +41 -0
  70. package/src/introspect.ts +33 -2
  71. package/src/protectedResource.ts +171 -0
  72. package/src/register.ts +296 -0
  73. package/src/resource.ts +123 -0
  74. package/src/store.ts +38 -0
  75. package/src/stores/atlas.ts +514 -0
  76. package/src/stores/memory.ts +38 -0
  77. package/src/token.ts +45 -1
  78. package/src/types.ts +50 -0
  79. package/stubs/database/migrations/9000_create_visa_tables.stub +112 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * The store an application running atlas gets for free.
3
+ *
4
+ * Twelve methods, two of them atomic, is not something every application
5
+ * should write again — and the two that matter are exactly the ones a
6
+ * hand-written store gets subtly wrong. This is that code, once.
7
+ *
8
+ * It takes atlas' `db` service rather than importing atlas: the structural
9
+ * slice below is what the store calls and nothing more, so visa still installs
10
+ * without a data layer and the package stays as standalone as every other one
11
+ * here. Built on atlas' query builder rather than on SQL text, deliberately —
12
+ * dialects are atlas' problem, and it already solves them.
13
+ *
14
+ * Every credential arrives HASHED, as the contract states, so what this writes
15
+ * is what the rest of the package writes: a database copy is not a set of
16
+ * working tokens.
17
+ */
18
+
19
+ import type { VisaStore } from "../store.js";
20
+ import type {
21
+ AccessToken,
22
+ AuthorizationCode,
23
+ Client,
24
+ ClientAuthMethod,
25
+ Consent,
26
+ GrantType,
27
+ RefreshToken,
28
+ } from "../types.js";
29
+
30
+ /** A row as atlas hands it back. */
31
+ type Row = Record<string, unknown>;
32
+
33
+ /** The comparisons atlas' builder accepts. */
34
+ type Operator = "=" | "!=" | ">" | ">=" | "<" | "<=";
35
+
36
+ /**
37
+ * The slice of atlas' `DatabaseQueryBuilder` this store uses.
38
+ *
39
+ * Every read here fetches one row, so the slice asks for `first()` and not for
40
+ * the builder's thenable form. Nothing is declared that nothing calls.
41
+ */
42
+ export interface AtlasQuery {
43
+ where(conditions: Row): AtlasQuery;
44
+ where(column: string, operator: Operator, value: unknown): AtlasQuery;
45
+ whereNull(column: string): AtlasQuery;
46
+ orderBy(column: string, direction?: "asc" | "desc"): AtlasQuery;
47
+ first(): Promise<Row | null>;
48
+ /** Run the select and return every row — atlas' `exec()`. */
49
+ exec(): Promise<Row[]>;
50
+ insert(data: Row): PromiseLike<unknown>;
51
+ /** Resolves to the number of rows affected, which is what the atomic paths read. */
52
+ update(data: Row): PromiseLike<number | Row[]>;
53
+ delete(): PromiseLike<number | Row[]>;
54
+ }
55
+
56
+ /** The slice of atlas' `db` service this store uses. */
57
+ export interface AtlasDb {
58
+ from(table: string): AtlasQuery;
59
+ table(table: string): AtlasQuery;
60
+ }
61
+
62
+ /** Where each kind of row lives. Override one to fit a schema you do not own. */
63
+ export interface AtlasStoreTables {
64
+ clients?: string;
65
+ authorizationCodes?: string;
66
+ accessTokens?: string;
67
+ refreshTokens?: string;
68
+ consents?: string;
69
+ }
70
+
71
+ const DEFAULT_TABLES = {
72
+ clients: "visa_clients",
73
+ authorizationCodes: "visa_authorization_codes",
74
+ accessTokens: "visa_access_tokens",
75
+ refreshTokens: "visa_refresh_tokens",
76
+ consents: "visa_consents",
77
+ } as const;
78
+
79
+ /**
80
+ * A list of strings, stored as JSON text.
81
+ *
82
+ * Text and not a native array type: three dialects are supported and only one
83
+ * of them has arrays. The round trip is explicit so a malformed value fails on
84
+ * read rather than becoming `[]` — a scope list that quietly empties is an
85
+ * authorisation bug, not a display one.
86
+ */
87
+ function encodeList(values: readonly string[]): string {
88
+ return JSON.stringify(values);
89
+ }
90
+
91
+ function decodeList(value: unknown, column: string): string[] {
92
+ if (value === null || value === undefined) return [];
93
+ if (Array.isArray(value)) {
94
+ // A `json` column comes back parsed on some dialects and as text on others.
95
+ return value.filter((entry): entry is string => typeof entry === "string");
96
+ }
97
+ if (typeof value !== "string") {
98
+ throw new Error(`[visa] ${column} holds ${typeof value}, expected a list`);
99
+ }
100
+ const parsed: unknown = JSON.parse(value);
101
+ if (!Array.isArray(parsed)) {
102
+ throw new Error(`[visa] ${column} is not a JSON array: ${value}`);
103
+ }
104
+ return parsed.filter((entry): entry is string => typeof entry === "string");
105
+ }
106
+
107
+ /** A column that may be null, as a Date. */
108
+ function decodeDate(value: unknown): Date | undefined {
109
+ if (value === null || value === undefined) return undefined;
110
+ if (value instanceof Date) return value;
111
+ if (typeof value === "number" || typeof value === "string") {
112
+ const date = new Date(value);
113
+ if (!Number.isNaN(date.getTime())) return date;
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ /** A column that must hold a date. */
119
+ function requireDate(value: unknown, column: string): Date {
120
+ const date = decodeDate(value);
121
+ if (date === undefined) {
122
+ throw new Error(`[visa] ${column} is not a date: ${String(value)}`);
123
+ }
124
+ return date;
125
+ }
126
+
127
+ function requireString(value: unknown, column: string): string {
128
+ if (typeof value !== "string") {
129
+ throw new Error(`[visa] ${column} is not a string: ${String(value)}`);
130
+ }
131
+ return value;
132
+ }
133
+
134
+ function optionalString(value: unknown): string | undefined {
135
+ return typeof value === "string" && value !== "" ? value : undefined;
136
+ }
137
+
138
+ /**
139
+ * How many rows a write touched.
140
+ *
141
+ * `update()` answers a count, or the rows themselves when the caller asked for
142
+ * them. Only the count is ever asked for here, and the other shape is read as
143
+ * its length rather than assumed away.
144
+ */
145
+ function affected(result: number | Row[]): number {
146
+ return typeof result === "number" ? result : result.length;
147
+ }
148
+
149
+ export class AtlasStore implements VisaStore {
150
+ readonly #db: AtlasDb;
151
+ readonly #tables: Required<AtlasStoreTables>;
152
+
153
+ constructor(db: AtlasDb, tables: AtlasStoreTables = {}) {
154
+ this.#db = db;
155
+ this.#tables = {
156
+ clients: tables.clients ?? DEFAULT_TABLES.clients,
157
+ authorizationCodes:
158
+ tables.authorizationCodes ?? DEFAULT_TABLES.authorizationCodes,
159
+ accessTokens: tables.accessTokens ?? DEFAULT_TABLES.accessTokens,
160
+ refreshTokens: tables.refreshTokens ?? DEFAULT_TABLES.refreshTokens,
161
+ consents: tables.consents ?? DEFAULT_TABLES.consents,
162
+ };
163
+ }
164
+
165
+ async findClient(id: string): Promise<Client | null> {
166
+ const row = await this.#db.from(this.#tables.clients).where({ id }).first();
167
+ if (row === null) return null;
168
+ const secretHash = optionalString(row.secret_hash);
169
+ return {
170
+ id: requireString(row.id, "id"),
171
+ name: requireString(row.name, "name"),
172
+ ...(secretHash === undefined ? {} : { secretHash }),
173
+ redirectUris: decodeList(row.redirect_uris, "redirect_uris"),
174
+ grantTypes: decodeList(row.grant_types, "grant_types").filter(
175
+ isGrantType,
176
+ ),
177
+ scopes: decodeList(row.scopes, "scopes"),
178
+ tokenEndpointAuthMethod: readAuthMethod(row.token_endpoint_auth_method),
179
+ ...(row.trusted === true || row.trusted === 1 ? { trusted: true } : {}),
180
+ };
181
+ }
182
+
183
+ /** Persist a registered client. Not on the contract — what `registerClient` writes through. */
184
+ async saveClient(client: Client): Promise<void> {
185
+ await this.#db.table(this.#tables.clients).insert({
186
+ id: client.id,
187
+ name: client.name,
188
+ secret_hash: client.secretHash ?? null,
189
+ redirect_uris: encodeList(client.redirectUris),
190
+ grant_types: encodeList(client.grantTypes),
191
+ scopes: encodeList(client.scopes),
192
+ token_endpoint_auth_method: client.tokenEndpointAuthMethod,
193
+ trusted: client.trusted === true,
194
+ created_at: new Date(),
195
+ });
196
+ }
197
+
198
+ async saveAuthorizationCode(code: AuthorizationCode): Promise<void> {
199
+ await this.#db.table(this.#tables.authorizationCodes).insert({
200
+ code_hash: code.codeHash,
201
+ client_id: code.clientId,
202
+ user_id: code.userId,
203
+ redirect_uri: code.redirectUri,
204
+ scopes: encodeList(code.scopes),
205
+ code_challenge: code.codeChallenge,
206
+ code_challenge_method: code.codeChallengeMethod,
207
+ expires_at: code.expiresAt,
208
+ consumed_at: code.consumedAt ?? null,
209
+ nonce: code.nonce ?? null,
210
+ resources: encodeList(code.resources ?? []),
211
+ created_at: new Date(),
212
+ });
213
+ }
214
+
215
+ async findAuthorizationCode(
216
+ codeHash: string,
217
+ ): Promise<AuthorizationCode | null> {
218
+ const row = await this.#db
219
+ .from(this.#tables.authorizationCodes)
220
+ .where({ code_hash: codeHash })
221
+ .first();
222
+ if (row === null) return null;
223
+ const consumedAt = decodeDate(row.consumed_at);
224
+ const nonce = optionalString(row.nonce);
225
+ return {
226
+ codeHash: requireString(row.code_hash, "code_hash"),
227
+ clientId: requireString(row.client_id, "client_id"),
228
+ userId: requireString(row.user_id, "user_id"),
229
+ redirectUri: requireString(row.redirect_uri, "redirect_uri"),
230
+ scopes: decodeList(row.scopes, "scopes"),
231
+ codeChallenge: requireString(row.code_challenge, "code_challenge"),
232
+ codeChallengeMethod:
233
+ row.code_challenge_method === "plain" ? "plain" : "S256",
234
+ expiresAt: requireDate(row.expires_at, "expires_at"),
235
+ ...(consumedAt === undefined ? {} : { consumedAt }),
236
+ ...(nonce === undefined ? {} : { nonce }),
237
+ ...listOrNothing("resources", row.resources),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * One UPDATE, guarded by `consumed_at IS NULL`.
243
+ *
244
+ * The single-use guarantee lives in that one statement: two requests racing
245
+ * with the same code both reach the database, and only the one that finds
246
+ * the row still unconsumed is allowed to write it. Reading and then writing
247
+ * would let both through.
248
+ */
249
+ async consumeAuthorizationCode(codeHash: string, at: Date): Promise<boolean> {
250
+ const result = await this.#db
251
+ .from(this.#tables.authorizationCodes)
252
+ .where({ code_hash: codeHash })
253
+ .whereNull("consumed_at")
254
+ .update({ consumed_at: at });
255
+ return affected(result) === 1;
256
+ }
257
+
258
+ async saveAccessToken(token: AccessToken): Promise<void> {
259
+ await this.#db.table(this.#tables.accessTokens).insert({
260
+ token_hash: token.tokenHash,
261
+ client_id: token.clientId,
262
+ user_id: token.userId ?? null,
263
+ scopes: encodeList(token.scopes),
264
+ expires_at: token.expiresAt,
265
+ revoked_at: token.revokedAt ?? null,
266
+ last_used_at: token.lastUsedAt ?? null,
267
+ family_id: token.familyId ?? null,
268
+ audience: encodeList(token.audience ?? []),
269
+ created_at: new Date(),
270
+ });
271
+ }
272
+
273
+ async findAccessToken(tokenHash: string): Promise<AccessToken | null> {
274
+ const row = await this.#db
275
+ .from(this.#tables.accessTokens)
276
+ .where({ token_hash: tokenHash })
277
+ .first();
278
+ return row === null ? null : readAccessToken(row);
279
+ }
280
+
281
+ async revokeAccessToken(tokenHash: string, at: Date): Promise<void> {
282
+ await this.#db
283
+ .from(this.#tables.accessTokens)
284
+ .where({ token_hash: tokenHash })
285
+ .whereNull("revoked_at")
286
+ .update({ revoked_at: at });
287
+ }
288
+
289
+ async touchAccessToken(tokenHash: string, at: Date): Promise<void> {
290
+ await this.#db
291
+ .from(this.#tables.accessTokens)
292
+ .where({ token_hash: tokenHash })
293
+ .update({ last_used_at: at });
294
+ }
295
+
296
+ async saveRefreshToken(token: RefreshToken): Promise<void> {
297
+ await this.#db.table(this.#tables.refreshTokens).insert({
298
+ token_hash: token.tokenHash,
299
+ family_id: token.familyId,
300
+ client_id: token.clientId,
301
+ user_id: token.userId ?? null,
302
+ scopes: encodeList(token.scopes),
303
+ expires_at: token.expiresAt,
304
+ consumed_at: token.consumedAt ?? null,
305
+ revoked_at: token.revokedAt ?? null,
306
+ resources: encodeList(token.resources ?? []),
307
+ created_at: new Date(),
308
+ });
309
+ }
310
+
311
+ async findRefreshToken(tokenHash: string): Promise<RefreshToken | null> {
312
+ const row = await this.#db
313
+ .from(this.#tables.refreshTokens)
314
+ .where({ token_hash: tokenHash })
315
+ .first();
316
+ if (row === null) return null;
317
+ const userId = optionalString(row.user_id);
318
+ const consumedAt = decodeDate(row.consumed_at);
319
+ const revokedAt = decodeDate(row.revoked_at);
320
+ return {
321
+ tokenHash: requireString(row.token_hash, "token_hash"),
322
+ familyId: requireString(row.family_id, "family_id"),
323
+ clientId: requireString(row.client_id, "client_id"),
324
+ ...(userId === undefined ? {} : { userId }),
325
+ scopes: decodeList(row.scopes, "scopes"),
326
+ expiresAt: requireDate(row.expires_at, "expires_at"),
327
+ ...(consumedAt === undefined ? {} : { consumedAt }),
328
+ ...(revokedAt === undefined ? {} : { revokedAt }),
329
+ ...listOrNothing("resources", row.resources),
330
+ };
331
+ }
332
+
333
+ /** Same one-statement guarantee as a code: rotation depends on it. */
334
+ async consumeRefreshToken(tokenHash: string, at: Date): Promise<boolean> {
335
+ const result = await this.#db
336
+ .from(this.#tables.refreshTokens)
337
+ .where({ token_hash: tokenHash })
338
+ .whereNull("consumed_at")
339
+ .update({ consumed_at: at });
340
+ return affected(result) === 1;
341
+ }
342
+
343
+ /**
344
+ * End a family: the refresh tokens AND the access tokens they bought.
345
+ *
346
+ * Both, because revoking only the refresh half would leave an access token
347
+ * already minted alive for its full lifetime — which is most of what this
348
+ * revocation exists to prevent.
349
+ */
350
+ async revokeFamily(familyId: string, at: Date): Promise<void> {
351
+ await this.#db
352
+ .from(this.#tables.refreshTokens)
353
+ .where({ family_id: familyId })
354
+ .whereNull("revoked_at")
355
+ .update({ revoked_at: at });
356
+ await this.#db
357
+ .from(this.#tables.accessTokens)
358
+ .where({ family_id: familyId })
359
+ .whereNull("revoked_at")
360
+ .update({ revoked_at: at });
361
+ }
362
+
363
+ async findConsent(userId: string, clientId: string): Promise<Consent | null> {
364
+ const row = await this.#db
365
+ .from(this.#tables.consents)
366
+ .where({ user_id: userId, client_id: clientId })
367
+ .first();
368
+ if (row === null) return null;
369
+ return {
370
+ userId: requireString(row.user_id, "user_id"),
371
+ clientId: requireString(row.client_id, "client_id"),
372
+ scopes: decodeList(row.scopes, "scopes"),
373
+ grantedAt: requireDate(row.granted_at, "granted_at"),
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Record a consent, replacing what was there.
379
+ *
380
+ * Delete then insert rather than an upsert: the dialects spell an upsert
381
+ * three different ways, and re-granting is not a hot path.
382
+ */
383
+ async saveConsent(consent: Consent): Promise<void> {
384
+ await this.#db
385
+ .from(this.#tables.consents)
386
+ .where({ user_id: consent.userId, client_id: consent.clientId })
387
+ .delete();
388
+ await this.#db.table(this.#tables.consents).insert({
389
+ user_id: consent.userId,
390
+ client_id: consent.clientId,
391
+ scopes: encodeList(consent.scopes),
392
+ granted_at: consent.grantedAt,
393
+ });
394
+ }
395
+
396
+ async listConsents(userId: string): Promise<Consent[]> {
397
+ const rows = await this.#db
398
+ .from(this.#tables.consents)
399
+ .where({ user_id: userId })
400
+ .orderBy("granted_at", "desc")
401
+ .exec();
402
+ return rows.map((row) => ({
403
+ userId: requireString(row.user_id, "user_id"),
404
+ clientId: requireString(row.client_id, "client_id"),
405
+ scopes: decodeList(row.scopes, "scopes"),
406
+ grantedAt: requireDate(row.granted_at, "granted_at"),
407
+ }));
408
+ }
409
+
410
+ /** Expired ones included — see the contract for why. */
411
+ async listAccessTokens(userId: string): Promise<AccessToken[]> {
412
+ const rows = await this.#db
413
+ .from(this.#tables.accessTokens)
414
+ .where({ user_id: userId })
415
+ .orderBy("expires_at", "desc")
416
+ .exec();
417
+ return rows.map(readAccessToken);
418
+ }
419
+
420
+ async revokeAccessFor(
421
+ userId: string,
422
+ clientId: string,
423
+ at: Date,
424
+ ): Promise<void> {
425
+ for (const table of [
426
+ this.#tables.accessTokens,
427
+ this.#tables.refreshTokens,
428
+ ]) {
429
+ await this.#db
430
+ .from(table)
431
+ .where({ user_id: userId, client_id: clientId })
432
+ .whereNull("revoked_at")
433
+ .update({ revoked_at: at });
434
+ }
435
+ }
436
+
437
+ async deleteConsent(userId: string, clientId: string): Promise<void> {
438
+ await this.#db
439
+ .from(this.#tables.consents)
440
+ .where({ user_id: userId, client_id: clientId })
441
+ .delete();
442
+ }
443
+
444
+ /** Drop what has expired and can no longer be presented. */
445
+ async prune(now: Date): Promise<void> {
446
+ for (const table of [
447
+ this.#tables.authorizationCodes,
448
+ this.#tables.accessTokens,
449
+ this.#tables.refreshTokens,
450
+ ]) {
451
+ await this.#db.from(table).where("expires_at", "<", now).delete();
452
+ }
453
+ }
454
+ }
455
+
456
+ /** One access-token row, as the contract's shape. */
457
+ function readAccessToken(row: Row): AccessToken {
458
+ const userId = optionalString(row.user_id);
459
+ const revokedAt = decodeDate(row.revoked_at);
460
+ const lastUsedAt = decodeDate(row.last_used_at);
461
+ const familyId = optionalString(row.family_id);
462
+ return {
463
+ tokenHash: requireString(row.token_hash, "token_hash"),
464
+ clientId: requireString(row.client_id, "client_id"),
465
+ ...(userId === undefined ? {} : { userId }),
466
+ scopes: decodeList(row.scopes, "scopes"),
467
+ expiresAt: requireDate(row.expires_at, "expires_at"),
468
+ ...(revokedAt === undefined ? {} : { revokedAt }),
469
+ ...(lastUsedAt === undefined ? {} : { lastUsedAt }),
470
+ ...(familyId === undefined ? {} : { familyId }),
471
+ ...listOrNothing("audience", row.audience),
472
+ };
473
+ }
474
+
475
+ /**
476
+ * A list column, present in the object only when it holds something.
477
+ *
478
+ * Empty and absent mean the same thing for an audience — unbound — and the
479
+ * contract spells that as absent, so an empty column must not become `[]` or
480
+ * a round trip would stop matching what went in.
481
+ */
482
+ function listOrNothing(
483
+ key: "audience",
484
+ value: unknown,
485
+ ): { audience?: string[] };
486
+ function listOrNothing(
487
+ key: "resources",
488
+ value: unknown,
489
+ ): { resources?: string[] };
490
+ function listOrNothing(
491
+ key: "audience" | "resources",
492
+ value: unknown,
493
+ ): { audience?: string[]; resources?: string[] } {
494
+ const list = decodeList(value, key);
495
+ if (list.length === 0) return {};
496
+ return key === "audience" ? { audience: list } : { resources: list };
497
+ }
498
+
499
+ function isGrantType(value: string): value is GrantType {
500
+ return (
501
+ value === "authorization_code" ||
502
+ value === "refresh_token" ||
503
+ value === "client_credentials"
504
+ );
505
+ }
506
+
507
+ function readAuthMethod(value: unknown): ClientAuthMethod {
508
+ if (value === "client_secret_basic" || value === "client_secret_post") {
509
+ return value;
510
+ }
511
+ // A row holding nothing usable is read as a public client, which is the
512
+ // method that grants the least.
513
+ return "none";
514
+ }
@@ -70,6 +70,44 @@ export class MemoryStore implements VisaStore {
70
70
  if (token !== undefined) token.revokedAt = at;
71
71
  }
72
72
 
73
+ async listConsents(userId: string): Promise<Consent[]> {
74
+ return [...this.#consents.values()].filter(
75
+ (consent) => consent.userId === userId,
76
+ );
77
+ }
78
+
79
+ async listAccessTokens(userId: string): Promise<AccessToken[]> {
80
+ return [...this.#accessTokens.values()].filter(
81
+ (token) => token.userId === userId,
82
+ );
83
+ }
84
+
85
+ async revokeAccessFor(
86
+ userId: string,
87
+ clientId: string,
88
+ at: Date,
89
+ ): Promise<void> {
90
+ for (const token of this.#accessTokens.values()) {
91
+ if (token.userId !== userId || token.clientId !== clientId) continue;
92
+ if (token.revokedAt === undefined) token.revokedAt = at;
93
+ }
94
+ for (const token of this.#refreshTokens.values()) {
95
+ if (token.userId !== userId || token.clientId !== clientId) continue;
96
+ if (token.revokedAt === undefined) token.revokedAt = at;
97
+ }
98
+ }
99
+
100
+ async deleteConsent(userId: string, clientId: string): Promise<void> {
101
+ // The same NUL-separated key `saveConsent` writes: a colon would make
102
+ // `a:b` + `c` collide with `a` + `b:c`.
103
+ this.#consents.delete(`${userId}\u0000${clientId}`);
104
+ }
105
+
106
+ async touchAccessToken(tokenHash: string, at: Date): Promise<void> {
107
+ const token = this.#accessTokens.get(tokenHash);
108
+ if (token !== undefined) token.lastUsedAt = at;
109
+ }
110
+
73
111
  async saveRefreshToken(token: RefreshToken): Promise<void> {
74
112
  this.#refreshTokens.set(token.tokenHash, token);
75
113
  }
package/src/token.ts CHANGED
@@ -14,11 +14,18 @@ import {
14
14
  } from "./clients.js";
15
15
  import { hashMatches, hashSecret, randomToken, sha256 } from "./crypto.js";
16
16
  import { OAuthError } from "./errors.js";
17
+ import {
18
+ narrowResources,
19
+ readResourceParameter,
20
+ validateResources,
21
+ } from "./resource.js";
17
22
  import type { VisaStore } from "./store.js";
18
23
  import type { Client, GrantType, TokenResponse } from "./types.js";
19
24
 
20
25
  export interface TokenOptions {
21
26
  accessTokenTtlSeconds?: number;
27
+ /** The resources this server issues tokens for — see `AuthorizeOptions`. */
28
+ resourcesSupported?: readonly string[];
22
29
  refreshTokenTtlSeconds?: number;
23
30
  /** Hand out refresh tokens at all. */
24
31
  issueRefreshTokens?: boolean;
@@ -135,12 +142,23 @@ async function exchangeCode(
135
142
  throw invalid;
136
143
  }
137
144
 
145
+ // RFC 8707: a request may narrow the resources the code was bound to,
146
+ // never widen them — the user saw the list at authorization time.
147
+ const resources = narrowResources(
148
+ validateResources(
149
+ readResourceParameter(request.resource),
150
+ options.resourcesSupported,
151
+ ),
152
+ record.resources ?? [],
153
+ );
154
+
138
155
  return issue(
139
156
  {
140
157
  client,
141
158
  userId: record.userId,
142
159
  scopes: record.scopes,
143
160
  familyId: codeFamily(record.codeHash),
161
+ resources,
144
162
  },
145
163
  store,
146
164
  options,
@@ -215,6 +233,13 @@ async function exchangeRefresh(
215
233
  ...(record.userId === undefined ? {} : { userId: record.userId }),
216
234
  scopes,
217
235
  familyId: record.familyId,
236
+ resources: narrowResources(
237
+ validateResources(
238
+ readResourceParameter(request.resource),
239
+ options.resourcesSupported,
240
+ ),
241
+ record.resources ?? [],
242
+ ),
218
243
  },
219
244
  store,
220
245
  options,
@@ -258,7 +283,16 @@ async function clientCredentials(
258
283
  }
259
284
  const scopes = resolveScopes(asString(request.scope), client);
260
285
  return issue(
261
- { client, scopes },
286
+ {
287
+ client,
288
+ scopes,
289
+ // No user consented to a list here, so the request IS the list —
290
+ // checked against what this server issues for, and nothing wider.
291
+ resources: validateResources(
292
+ readResourceParameter(request.resource),
293
+ options.resourcesSupported,
294
+ ),
295
+ },
262
296
  store,
263
297
  { ...options, issueRefreshTokens: false },
264
298
  now,
@@ -270,6 +304,8 @@ interface Issued {
270
304
  userId?: string;
271
305
  scopes: string[];
272
306
  familyId?: string;
307
+ /** RFC 8707 — what the token is for. Empty means unbound. */
308
+ resources?: string[];
273
309
  }
274
310
 
275
311
  /** Mint the pair and store both, hashed. */
@@ -290,6 +326,9 @@ async function issue(
290
326
  scopes: grant.scopes,
291
327
  expiresAt: new Date(now.getTime() + accessTtl * 1000),
292
328
  ...(grant.familyId === undefined ? {} : { familyId: grant.familyId }),
329
+ ...(grant.resources === undefined || grant.resources.length === 0
330
+ ? {}
331
+ : { audience: grant.resources }),
293
332
  });
294
333
 
295
334
  const response: TokenResponse = {
@@ -315,6 +354,11 @@ async function issue(
315
354
  ...(grant.userId === undefined ? {} : { userId: grant.userId }),
316
355
  scopes: grant.scopes,
317
356
  expiresAt: new Date(now.getTime() + refreshTtl * 1000),
357
+ // Carried so a rotation can narrow from the same list the code bound —
358
+ // without it, the second token of a session would be unbound.
359
+ ...(grant.resources === undefined || grant.resources.length === 0
360
+ ? {}
361
+ : { resources: grant.resources }),
318
362
  });
319
363
  response.refresh_token = refreshToken;
320
364
  return response;