@c9up/visa 0.1.1 → 0.1.3

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 (80) hide show
  1. package/dist/VisaManager.d.ts +42 -7
  2. package/dist/VisaManager.d.ts.map +1 -1
  3. package/dist/VisaManager.js +89 -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/clients.d.ts +1 -1
  13. package/dist/clients.d.ts.map +1 -1
  14. package/dist/clients.js +9 -1
  15. package/dist/clients.js.map +1 -1
  16. package/dist/config.d.ts +35 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js.map +1 -1
  19. package/dist/errors.d.ts +9 -3
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js.map +1 -1
  22. package/dist/guard.d.ts +21 -0
  23. package/dist/guard.d.ts.map +1 -1
  24. package/dist/guard.js +25 -1
  25. package/dist/guard.js.map +1 -1
  26. package/dist/index.d.ts +5 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/introspect.d.ts +9 -1
  31. package/dist/introspect.d.ts.map +1 -1
  32. package/dist/introspect.js +18 -1
  33. package/dist/introspect.js.map +1 -1
  34. package/dist/protectedResource.d.ts +81 -0
  35. package/dist/protectedResource.d.ts.map +1 -0
  36. package/dist/protectedResource.js +124 -0
  37. package/dist/protectedResource.js.map +1 -0
  38. package/dist/register.d.ts +107 -0
  39. package/dist/register.d.ts.map +1 -0
  40. package/dist/register.js +203 -0
  41. package/dist/register.js.map +1 -0
  42. package/dist/resource.d.ts +50 -0
  43. package/dist/resource.d.ts.map +1 -0
  44. package/dist/resource.js +101 -0
  45. package/dist/resource.js.map +1 -0
  46. package/dist/store.d.ts +25 -0
  47. package/dist/store.d.ts.map +1 -1
  48. package/dist/stores/atlas.d.ts +7 -0
  49. package/dist/stores/atlas.d.ts.map +1 -1
  50. package/dist/stores/atlas.js +70 -16
  51. package/dist/stores/atlas.js.map +1 -1
  52. package/dist/stores/memory.d.ts +4 -0
  53. package/dist/stores/memory.d.ts.map +1 -1
  54. package/dist/stores/memory.js +25 -0
  55. package/dist/stores/memory.js.map +1 -1
  56. package/dist/token.d.ts +2 -0
  57. package/dist/token.d.ts.map +1 -1
  58. package/dist/token.js +21 -1
  59. package/dist/token.js.map +1 -1
  60. package/dist/types.d.ts +51 -0
  61. package/dist/types.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/VisaManager.ts +122 -3
  64. package/src/VisaProvider.ts +64 -0
  65. package/src/authorize.ts +27 -0
  66. package/src/clients.ts +10 -0
  67. package/src/config.ts +35 -0
  68. package/src/errors.ts +16 -2
  69. package/src/guard.ts +41 -1
  70. package/src/index.ts +25 -0
  71. package/src/introspect.ts +17 -0
  72. package/src/protectedResource.ts +171 -0
  73. package/src/register.ts +335 -0
  74. package/src/resource.ts +123 -0
  75. package/src/store.ts +29 -0
  76. package/src/stores/atlas.ts +99 -15
  77. package/src/stores/memory.ts +33 -0
  78. package/src/token.ts +45 -1
  79. package/src/types.ts +52 -0
  80. package/stubs/database/migrations/9000_create_visa_tables.stub +9 -0
@@ -45,6 +45,8 @@ export interface AtlasQuery {
45
45
  whereNull(column: string): AtlasQuery;
46
46
  orderBy(column: string, direction?: "asc" | "desc"): AtlasQuery;
47
47
  first(): Promise<Row | null>;
48
+ /** Run the select and return every row — atlas' `exec()`. */
49
+ exec(): Promise<Row[]>;
48
50
  insert(data: Row): PromiseLike<unknown>;
49
51
  /** Resolves to the number of rows affected, which is what the atomic paths read. */
50
52
  update(data: Row): PromiseLike<number | Row[]>;
@@ -205,6 +207,7 @@ export class AtlasStore implements VisaStore {
205
207
  expires_at: code.expiresAt,
206
208
  consumed_at: code.consumedAt ?? null,
207
209
  nonce: code.nonce ?? null,
210
+ resources: encodeList(code.resources ?? []),
208
211
  created_at: new Date(),
209
212
  });
210
213
  }
@@ -231,6 +234,7 @@ export class AtlasStore implements VisaStore {
231
234
  expiresAt: requireDate(row.expires_at, "expires_at"),
232
235
  ...(consumedAt === undefined ? {} : { consumedAt }),
233
236
  ...(nonce === undefined ? {} : { nonce }),
237
+ ...listOrNothing("resources", row.resources),
234
238
  };
235
239
  }
236
240
 
@@ -261,6 +265,7 @@ export class AtlasStore implements VisaStore {
261
265
  revoked_at: token.revokedAt ?? null,
262
266
  last_used_at: token.lastUsedAt ?? null,
263
267
  family_id: token.familyId ?? null,
268
+ audience: encodeList(token.audience ?? []),
264
269
  created_at: new Date(),
265
270
  });
266
271
  }
@@ -270,21 +275,7 @@ export class AtlasStore implements VisaStore {
270
275
  .from(this.#tables.accessTokens)
271
276
  .where({ token_hash: tokenHash })
272
277
  .first();
273
- if (row === null) return null;
274
- const userId = optionalString(row.user_id);
275
- const revokedAt = decodeDate(row.revoked_at);
276
- const lastUsedAt = decodeDate(row.last_used_at);
277
- const familyId = optionalString(row.family_id);
278
- return {
279
- tokenHash: requireString(row.token_hash, "token_hash"),
280
- clientId: requireString(row.client_id, "client_id"),
281
- ...(userId === undefined ? {} : { userId }),
282
- scopes: decodeList(row.scopes, "scopes"),
283
- expiresAt: requireDate(row.expires_at, "expires_at"),
284
- ...(revokedAt === undefined ? {} : { revokedAt }),
285
- ...(lastUsedAt === undefined ? {} : { lastUsedAt }),
286
- ...(familyId === undefined ? {} : { familyId }),
287
- };
278
+ return row === null ? null : readAccessToken(row);
288
279
  }
289
280
 
290
281
  async revokeAccessToken(tokenHash: string, at: Date): Promise<void> {
@@ -312,6 +303,7 @@ export class AtlasStore implements VisaStore {
312
303
  expires_at: token.expiresAt,
313
304
  consumed_at: token.consumedAt ?? null,
314
305
  revoked_at: token.revokedAt ?? null,
306
+ resources: encodeList(token.resources ?? []),
315
307
  created_at: new Date(),
316
308
  });
317
309
  }
@@ -334,6 +326,7 @@ export class AtlasStore implements VisaStore {
334
326
  expiresAt: requireDate(row.expires_at, "expires_at"),
335
327
  ...(consumedAt === undefined ? {} : { consumedAt }),
336
328
  ...(revokedAt === undefined ? {} : { revokedAt }),
329
+ ...listOrNothing("resources", row.resources),
337
330
  };
338
331
  }
339
332
 
@@ -400,6 +393,54 @@ export class AtlasStore implements VisaStore {
400
393
  });
401
394
  }
402
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
+
403
444
  /** Drop what has expired and can no longer be presented. */
404
445
  async prune(now: Date): Promise<void> {
405
446
  for (const table of [
@@ -412,6 +453,49 @@ export class AtlasStore implements VisaStore {
412
453
  }
413
454
  }
414
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
+
415
499
  function isGrantType(value: string): value is GrantType {
416
500
  return (
417
501
  value === "authorization_code" ||
@@ -70,6 +70,39 @@ 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
+
73
106
  async touchAccessToken(tokenHash: string, at: Date): Promise<void> {
74
107
  const token = this.#accessTokens.get(tokenHash);
75
108
  if (token !== undefined) token.lastUsedAt = at;
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;
package/src/types.ts CHANGED
@@ -32,6 +32,15 @@ export interface Client {
32
32
  name: string;
33
33
  /** Hashed. The plaintext is shown once, at registration, and never stored. */
34
34
  secretHash?: string;
35
+ /**
36
+ * When the secret stops working (RFC 7591 §3.2.1).
37
+ *
38
+ * Absent means it does not expire, which is the common case. Present, it is
39
+ * enforced at authentication — a `client_secret_expires_at` answered at
40
+ * registration and never checked afterwards is a promise the server makes
41
+ * and does not keep.
42
+ */
43
+ secretExpiresAt?: Date;
35
44
  redirectUris: string[];
36
45
  grantTypes: GrantType[];
37
46
  /** What this client may ever ask for; a request narrows it, never widens it. */
@@ -57,6 +66,13 @@ export interface AuthorizationCode {
57
66
  expiresAt: Date;
58
67
  /** Set the moment it is exchanged, so a replay is detectable (§7.5.1). */
59
68
  consumedAt?: Date;
69
+ /**
70
+ * The resources this code may buy a token for (RFC 8707).
71
+ *
72
+ * Bound here rather than at the token endpoint: the user saw the request
73
+ * that named them, and the exchange may narrow the list but never widen it.
74
+ */
75
+ resources?: string[];
60
76
  /** Carried through to the id_token when OIDC lands. */
61
77
  nonce?: string;
62
78
  }
@@ -76,6 +92,15 @@ export interface AccessToken {
76
92
  * and what tells a dormant token from a live one.
77
93
  */
78
94
  lastUsedAt?: Date;
95
+ /**
96
+ * Which resources this token is for (RFC 8707).
97
+ *
98
+ * Absent means unbound — a token for everything, which is what every token
99
+ * was before a client started asking. A resource server checks it against
100
+ * its own identifier, so a token that travelled to the wrong one is refused
101
+ * there instead of being honoured.
102
+ */
103
+ audience?: string[];
79
104
  /**
80
105
  * The refresh family this token was minted in, when there is one.
81
106
  *
@@ -102,6 +127,8 @@ export interface RefreshToken {
102
127
  expiresAt: Date;
103
128
  consumedAt?: Date;
104
129
  revokedAt?: Date;
130
+ /** The resources a token minted from this one may be for (RFC 8707). */
131
+ resources?: string[];
105
132
  }
106
133
 
107
134
  /** What a user agreed to give a client, so they are asked once. */
@@ -112,6 +139,29 @@ export interface Consent {
112
139
  grantedAt: Date;
113
140
  }
114
141
 
142
+ /**
143
+ * One application a user has authorised, as a "connected applications" screen
144
+ * shows it.
145
+ *
146
+ * The unit is the APPLICATION, not the token: a person authorised an
147
+ * application, and the tokens come and go under that one decision. Upstream's
148
+ * equivalent listing is per token because it has no clients; here a user with
149
+ * four live tokens for one application has authorised one thing, and seeing
150
+ * four rows would be a lie about what revoking does.
151
+ */
152
+ export interface AuthorizedApplication {
153
+ clientId: string;
154
+ /** The client's registered name, or its id when the client is gone. */
155
+ name: string;
156
+ /** What was granted — the consent, not what any one token happens to carry. */
157
+ scopes: string[];
158
+ grantedAt: Date;
159
+ /** The most recent use across this application's tokens, if any was used. */
160
+ lastUsedAt?: Date;
161
+ /** Whether anything issued to it is still usable right now. */
162
+ active: boolean;
163
+ }
164
+
115
165
  /** The token endpoint's success body (§4.1.4). */
116
166
  export interface TokenResponse {
117
167
  access_token: string;
@@ -138,4 +188,6 @@ export interface IntrospectionResponse {
138
188
  exp?: number;
139
189
  iat?: number;
140
190
  token_type?: "Bearer";
191
+ /** Which resources the token is for — RFC 8707 §3 names introspection as where this travels. */
192
+ aud?: string[];
141
193
  }
@@ -47,6 +47,10 @@ export default class CreateVisaTables extends Migration {
47
47
  // UPDATE guarded on this column being null.
48
48
  t.timestamp("consumed_at").nullable();
49
49
  t.string("nonce", 255).nullable();
50
+ // RFC 8707 — which resources a token bought with this code may be
51
+ // for. Empty means unbound, which is every code a client sent no
52
+ // `resource` with.
53
+ t.json("resources").notNullable();
50
54
  t.timestamp("created_at").notNullable();
51
55
  t.index("user_id");
52
56
  });
@@ -63,6 +67,9 @@ export default class CreateVisaTables extends Migration {
63
67
  // The refresh family this token was minted in. Indexed because
64
68
  // revoking a leaked family reads every token by it.
65
69
  t.string("family_id", 64).nullable();
70
+ // RFC 8707 — what this token is for. Empty means unbound, and a
71
+ // resource server accepts it; bound, it is refused anywhere else.
72
+ t.json("audience").notNullable();
66
73
  t.timestamp("created_at").notNullable();
67
74
  t.index("family_id");
68
75
  t.index("user_id");
@@ -77,6 +84,8 @@ export default class CreateVisaTables extends Migration {
77
84
  t.timestamp("expires_at").notNullable();
78
85
  t.timestamp("consumed_at").nullable();
79
86
  t.timestamp("revoked_at").nullable();
87
+ // Carried so a rotation narrows from the same list the code bound.
88
+ t.json("resources").notNullable();
80
89
  t.timestamp("created_at").notNullable();
81
90
  t.index("family_id");
82
91
  t.index("user_id");