@c9up/visa 0.1.2 → 0.1.4

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 (56) hide show
  1. package/README.md +82 -11
  2. package/dist/VisaManager.d.ts +49 -0
  3. package/dist/VisaManager.d.ts.map +1 -1
  4. package/dist/VisaManager.js +181 -9
  5. package/dist/VisaManager.js.map +1 -1
  6. package/dist/VisaProvider.d.ts +4 -0
  7. package/dist/VisaProvider.d.ts.map +1 -1
  8. package/dist/VisaProvider.js +87 -5
  9. package/dist/VisaProvider.js.map +1 -1
  10. package/dist/clients.d.ts +1 -1
  11. package/dist/clients.d.ts.map +1 -1
  12. package/dist/clients.js +9 -1
  13. package/dist/clients.js.map +1 -1
  14. package/dist/configure.d.ts.map +1 -1
  15. package/dist/configure.js +16 -1
  16. package/dist/configure.js.map +1 -1
  17. package/dist/guard.d.ts +2 -1
  18. package/dist/guard.d.ts.map +1 -1
  19. package/dist/guard.js +18 -14
  20. package/dist/guard.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/register.d.ts +12 -1
  25. package/dist/register.d.ts.map +1 -1
  26. package/dist/register.js +44 -9
  27. package/dist/register.js.map +1 -1
  28. package/dist/store.d.ts +10 -0
  29. package/dist/store.d.ts.map +1 -1
  30. package/dist/stores/atlas.d.ts +1 -1
  31. package/dist/stores/atlas.d.ts.map +1 -1
  32. package/dist/stores/atlas.js +6 -1
  33. package/dist/stores/atlas.js.map +1 -1
  34. package/dist/stores/memory.d.ts +1 -2
  35. package/dist/stores/memory.d.ts.map +1 -1
  36. package/dist/stores/memory.js +1 -3
  37. package/dist/stores/memory.js.map +1 -1
  38. package/dist/token.js +17 -5
  39. package/dist/token.js.map +1 -1
  40. package/dist/types.d.ts +9 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/VisaManager.ts +226 -12
  44. package/src/VisaProvider.ts +108 -6
  45. package/src/clients.ts +10 -0
  46. package/src/configure.ts +17 -1
  47. package/src/guard.ts +23 -14
  48. package/src/index.ts +1 -0
  49. package/src/register.ts +55 -9
  50. package/src/store.ts +11 -0
  51. package/src/stores/atlas.ts +6 -1
  52. package/src/stores/memory.ts +1 -3
  53. package/src/token.ts +30 -11
  54. package/src/types.ts +9 -0
  55. package/stubs/database/migrations/9000_create_visa_tables.stub +2 -0
  56. package/stubs/database/migrations/9001_add_visa_client_secret_expiry.stub +29 -0
@@ -14,6 +14,10 @@
14
14
  * decision; the page around it is yours. The README shows the ten lines.
15
15
  *
16
16
  * Duck-typed throughout: visa must not import its host's HTTP types.
17
+ *
18
+ * NAMED DEVIATION from the usual `request.all()`: these endpoints read the
19
+ * body only, and refuse a request that puts an OAuth parameter in the query
20
+ * string — see `readBody`.
17
21
  */
18
22
 
19
23
  import "./augmentations.js";
@@ -44,7 +48,10 @@ export interface VisaAppContext {
44
48
  interface VisaHttpContext {
45
49
  request: {
46
50
  header(name: string): string | undefined;
47
- all(): Record<string, unknown>;
51
+ /** The parsed body — the only place an OAuth parameter is read from. */
52
+ body(): unknown;
53
+ /** Read only to refuse a request that put parameters in the URL. */
54
+ qs(): Record<string, unknown>;
48
55
  };
49
56
  response: {
50
57
  status(code: number): unknown;
@@ -96,7 +103,7 @@ export default class VisaProvider {
96
103
 
97
104
  router.post(`${prefix}/token`, async (ctx) => {
98
105
  await this.#answer(ctx, async () => {
99
- const body = ctx.request.all();
106
+ const body = readBody(ctx);
100
107
  const credentials = manager.readCredentials({
101
108
  body,
102
109
  ...readAuthorization(ctx),
@@ -113,7 +120,7 @@ export default class VisaProvider {
113
120
 
114
121
  router.post(`${prefix}/introspect`, async (ctx) => {
115
122
  await this.#answer(ctx, async () => {
116
- const body = ctx.request.all();
123
+ const body = readBody(ctx);
117
124
  const credentials = manager.readCredentials({
118
125
  body,
119
126
  ...readAuthorization(ctx),
@@ -125,7 +132,7 @@ export default class VisaProvider {
125
132
 
126
133
  router.post(`${prefix}/revoke`, async (ctx) => {
127
134
  await this.#answer(ctx, async () => {
128
- const body = ctx.request.all();
135
+ const body = readBody(ctx);
129
136
  const credentials = manager.readCredentials({
130
137
  body,
131
138
  ...readAuthorization(ctx),
@@ -144,7 +151,10 @@ export default class VisaProvider {
144
151
  router.post(`${prefix}/register`, async (ctx) => {
145
152
  await this.#answer(ctx, async () => {
146
153
  const bearer = readBearer(ctx);
147
- const created = await manager.register(ctx.request.all(), bearer);
154
+ const created = await manager.register(
155
+ readBody(ctx, REGISTRATION_PARAMETERS),
156
+ bearer,
157
+ );
148
158
  // §3.2.1: 201, and the body carries a secret — no cache may
149
159
  // keep it.
150
160
  ctx.response.status(201);
@@ -245,12 +255,104 @@ export default class VisaProvider {
245
255
  }
246
256
  }
247
257
 
258
+ /**
259
+ * What may never travel in the URL of these endpoints.
260
+ *
261
+ * Every credential and every parameter the spec defines for them: the client's
262
+ * own (§2.3.1, RFC 7523), each grant's (§4), introspection's and revocation's
263
+ * (RFC 7662 §2.1, RFC 7009 §2.1), resource indicators (RFC 8707), a bearer
264
+ * token in its RFC 6750 §2.3 form, and the removed password grant's — a
265
+ * client still sending those sends a password.
266
+ */
267
+ const OAUTH_PARAMETERS: ReadonlySet<string> = new Set([
268
+ "client_id",
269
+ "client_secret",
270
+ "client_assertion",
271
+ "client_assertion_type",
272
+ "grant_type",
273
+ "code",
274
+ "code_verifier",
275
+ "redirect_uri",
276
+ "refresh_token",
277
+ "scope",
278
+ "resource",
279
+ "token",
280
+ "token_type_hint",
281
+ "access_token",
282
+ "username",
283
+ "password",
284
+ ]);
285
+
286
+ /** The same, plus the client metadata a registration carries (RFC 7591 §2). */
287
+ const REGISTRATION_PARAMETERS: ReadonlySet<string> = new Set([
288
+ ...OAUTH_PARAMETERS,
289
+ "redirect_uris",
290
+ "token_endpoint_auth_method",
291
+ "grant_types",
292
+ "response_types",
293
+ "client_name",
294
+ "client_uri",
295
+ "logo_uri",
296
+ "contacts",
297
+ "tos_uri",
298
+ "policy_uri",
299
+ "jwks_uri",
300
+ "jwks",
301
+ "software_id",
302
+ "software_version",
303
+ "software_statement",
304
+ ]);
305
+
306
+ /**
307
+ * The parameters of a machine endpoint, read from the body and nowhere else.
308
+ *
309
+ * Every one of these requests carries a credential — a client secret, a code,
310
+ * a refresh or access token. RFC 6749 §2.3.1 says client credentials "MUST NOT
311
+ * be included in the request URI", and §3.2, RFC 7009 §2.1, RFC 7662 §2.1 and
312
+ * RFC 7591 §3.1 all define the parameters as a POST body. A URL is kept by
313
+ * access logs, proxies and traces where a body is not.
314
+ *
315
+ * So the query string is never read, and a request that puts an OAuth
316
+ * parameter there is refused rather than answered: accepting it would teach a
317
+ * client that the URL works, and the secret it carries is already in a log.
318
+ * Anything else in the query (a tracing or cache-busting parameter a proxy
319
+ * added) is ignored, not refused.
320
+ */
321
+ function readBody(
322
+ ctx: VisaHttpContext,
323
+ forbidden: ReadonlySet<string> = OAUTH_PARAMETERS,
324
+ ): Record<string, unknown> {
325
+ const inUrl = Object.keys(ctx.request.qs()).filter((key) =>
326
+ forbidden.has(key),
327
+ );
328
+ if (inUrl.length > 0) {
329
+ throw new OAuthError(
330
+ "invalid_request",
331
+ `OAuth parameters go in the request body, never in the URL: ${inUrl.join(", ")}.`,
332
+ );
333
+ }
334
+ const body = ctx.request.body();
335
+ if (body === undefined || body === null) return {};
336
+ if (!isRecord(body)) {
337
+ throw new OAuthError(
338
+ "invalid_request",
339
+ "The request body must be a form or a JSON object.",
340
+ );
341
+ }
342
+ return body;
343
+ }
344
+
345
+ function isRecord(value: unknown): value is Record<string, unknown> {
346
+ return typeof value === "object" && value !== null && !Array.isArray(value);
347
+ }
348
+
248
349
  /** The initial access token a registration may carry (RFC 7591 §3). */
249
350
  function readBearer(ctx: VisaHttpContext): string | undefined {
250
351
  const header = ctx.request.header("authorization");
251
352
  if (header === undefined) return undefined;
252
353
  const [scheme, value] = header.split(" ");
253
- if (scheme?.toLowerCase() !== "bearer" || value === undefined)
354
+ // An empty value is no token at all, not a token that is the empty string.
355
+ if (scheme?.toLowerCase() !== "bearer" || value === undefined || value === "")
254
356
  return undefined;
255
357
  return value;
256
358
  }
package/src/clients.ts CHANGED
@@ -157,6 +157,7 @@ function stringOrUndefined(value: unknown): string | undefined {
157
157
  export async function authenticateClient(
158
158
  credentials: ClientCredentials,
159
159
  load: (id: string) => Promise<Client | null>,
160
+ now: Date = new Date(),
160
161
  ): Promise<Client> {
161
162
  if (credentials.clientId === undefined) {
162
163
  throw new OAuthError("invalid_client", "Client authentication failed.");
@@ -192,6 +193,15 @@ export async function authenticateClient(
192
193
  ) {
193
194
  throw new OAuthError("invalid_client", "Client authentication failed.");
194
195
  }
196
+
197
+ // Registration may have answered `client_secret_expires_at`. This is where
198
+ // that answer becomes true: checked after the secret matched, so a wrong
199
+ // secret and an expired one are still the same sentence to the caller.
200
+ // Every endpoint that authenticates a client — token, introspection,
201
+ // revocation — arrives here, so one check covers all three.
202
+ if (client.secretExpiresAt !== undefined && client.secretExpiresAt <= now) {
203
+ throw new OAuthError("invalid_client", "Client authentication failed.");
204
+ }
195
205
  return client;
196
206
  }
197
207
 
package/src/configure.ts CHANGED
@@ -25,6 +25,17 @@ import { stubsRoot } from "./stubs.js";
25
25
  */
26
26
  const MIGRATION_STUB = "database/migrations/9000_create_visa_tables.stub";
27
27
 
28
+ /**
29
+ * Columns added after the tables first shipped, one migration each.
30
+ *
31
+ * Written on every run: `makeUsingStub` leaves a file that already exists
32
+ * alone, so an application that configured an earlier version keeps its 9000
33
+ * and gains only what it is missing by running `ream configure` again.
34
+ */
35
+ const UPGRADE_STUBS = [
36
+ "database/migrations/9001_add_visa_client_secret_expiry.stub",
37
+ ];
38
+
28
39
  interface Codemods {
29
40
  addProvider(importPath: string): Promise<void>;
30
41
  addEnvVars(vars: Record<string, string>): Promise<void>;
@@ -45,7 +56,9 @@ export async function configure(codemods: Codemods): Promise<void> {
45
56
  // Fail fast BEFORE touching the project: a pruned tarball or an unreadable
46
57
  // stub must not leave an application with a provider registered, a config
47
58
  // written and no tables to put anything in.
48
- await access(resolve(stubsRoot, MIGRATION_STUB), constants.R_OK);
59
+ for (const stub of [MIGRATION_STUB, ...UPGRADE_STUBS]) {
60
+ await access(resolve(stubsRoot, stub), constants.R_OK);
61
+ }
49
62
 
50
63
  // The config below reads this, so it is declared here. Writing the file
51
64
  // without it leaves an application whose config asks the environment for
@@ -60,4 +73,7 @@ export async function configure(codemods: Codemods): Promise<void> {
60
73
  // data layer deletes the file and keeps its own store — which is why this
61
74
  // is a migration and not a schema the package owns.
62
75
  await codemods.makeUsingStub(stubsRoot, MIGRATION_STUB);
76
+ for (const stub of UPGRADE_STUBS) {
77
+ await codemods.makeUsingStub(stubsRoot, stub);
78
+ }
63
79
  }
package/src/guard.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * upstream rule read off `@adonisjs/auth`'s build rather than recalled.
10
10
  *
11
11
  * Two things this fixes for every application at once. A route gate no longer
12
- * has to introspect the token itself — the scopes are on the guard's token. And
12
+ * has to introspect the token itself — the scopes are on the guard's token,
13
+ * and only there: `user.permissions` stays the user's own. And
13
14
  * the user is fetched through the application's own provider on every request,
14
15
  * so an account disabled after the token was issued is refused here instead of
15
16
  * in each application that remembers to check.
@@ -364,7 +365,17 @@ export class VisaGuard {
364
365
  async verify(presented: string): Promise<GuardResult> {
365
366
  const store = this.#store();
366
367
  const now = new Date();
367
- const verified = await verifyAccessToken(presented, store, now);
368
+ // The resource this guard declares is the resource it enforces. Passing
369
+ // it is the whole of RFC 8707 on this side: without it a token minted
370
+ // for another resource server — one the same authorization server also
371
+ // serves — is honoured here, and the `resource` advertised in the
372
+ // challenge is a claim nothing backs.
373
+ const verified = await verifyAccessToken(
374
+ presented,
375
+ store,
376
+ now,
377
+ this.#config.resource,
378
+ );
368
379
  if (verified === null) {
369
380
  return { authenticated: false, error: "Invalid or expired access token" };
370
381
  }
@@ -400,19 +411,17 @@ export class VisaGuard {
400
411
 
401
412
  // A copy: the provider's object is the application's, and a guard that
402
413
  // mutated it would leak one request's token into the next.
414
+ //
415
+ // The scopes go on `currentAccessToken` and NOWHERE else — upstream's
416
+ // access-tokens guard leaves the user model untouched the same way. They
417
+ // are never merged into `permissions`: a scope is chosen by the client's
418
+ // registration and granted by consent, which never checks that the user
419
+ // holds a permission of the same name, so a merge would hand any user a
420
+ // route permission they lack the moment a scope is named like one
421
+ // (`orders:write`). And a token limited to `profile` must not carry the
422
+ // user's other rights to a third-party client either: the two axes meet
423
+ // only as an intersection, `permission && currentAccessToken.allows(...)`.
403
424
  const user: GuardUser = { ...subject, currentAccessToken: token };
404
- if (verified.scopes.length > 0) {
405
- // Scopes surface on `permissions` as well, the way warden's access-token
406
- // guard does it, so `ctx.auth.user.permissions` reads the same whichever
407
- // guard authenticated.
408
- //
409
- // They remain a SEPARATE axis from the route-level rights gate, exactly
410
- // as upstream: a token ability is checked with
411
- // `currentAccessToken.allows(...)`, and Bouncer does not consult it.
412
- user.permissions = [
413
- ...new Set([...(subject.permissions ?? []), ...verified.scopes]),
414
- ];
415
- }
416
425
  return { authenticated: true, user };
417
426
  }
418
427
  }
package/src/index.ts CHANGED
@@ -103,6 +103,7 @@ export type {
103
103
  } from "./types.js";
104
104
  export {
105
105
  type AuthorizationOutcome,
106
+ type ConsentTicket,
106
107
  type VisaConfig,
107
108
  VisaManager,
108
109
  } from "./VisaManager.js";
package/src/register.ts CHANGED
@@ -20,7 +20,8 @@
20
20
  * it decide what it may do.
21
21
  */
22
22
 
23
- import { OAuthError } from "./errors.js";
23
+ import { hashMatches, sha256 } from "./crypto.js";
24
+ import { OAuthError, VisaError } from "./errors.js";
24
25
  import type { Client, ClientAuthMethod, GrantType } from "./types.js";
25
26
 
26
27
  /** The metadata a request may send (§2). Unknown members are ignored. */
@@ -113,10 +114,16 @@ export function validateRegistration(
113
114
  );
114
115
  }
115
116
  if (options.initialAccessToken !== undefined) {
116
- // Constant-time is not the point here: the token is compared once per
117
- // registration, and a registration endpoint is not a guessing oracle
118
- // worth the machinery. Absent or wrong reads the same to the caller.
119
- if (presentedToken !== options.initialAccessToken) {
117
+ // Constant time, like every other secret here. This one is a static,
118
+ // operator-chosen string — often shorter than a minted token — that an
119
+ // anonymous caller may test once per request, as often as they like.
120
+ // Both sides are hashed first so the comparison never depends on the
121
+ // configured length either. Absent or wrong reads the same to the caller.
122
+ if (
123
+ presentedToken === undefined ||
124
+ presentedToken === "" ||
125
+ !hashMatches(sha256(presentedToken), sha256(options.initialAccessToken))
126
+ ) {
120
127
  throw new OAuthError(
121
128
  "invalid_client_metadata",
122
129
  "Registration on this server needs an initial access token.",
@@ -163,7 +170,6 @@ export function registrationResponse(
163
170
  client: Client,
164
171
  issuedAt: Date,
165
172
  secret: string | undefined,
166
- options: RegistrationOptions,
167
173
  ): ClientRegistrationResponse {
168
174
  const seconds = Math.floor(issuedAt.getTime() / 1000);
169
175
  return {
@@ -172,13 +178,18 @@ export function registrationResponse(
172
178
  client_id_issued_at: seconds,
173
179
  // REQUIRED when a secret was issued, and `0` is the spelling for "never"
174
180
  // — not an omission, which would mean the field was not answered.
181
+ //
182
+ // Read off the client rather than recomputed from the options: the
183
+ // answer and the stored expiry are then the same value by construction,
184
+ // and cannot drift into a server that announces an expiry it does not
185
+ // enforce.
175
186
  ...(secret === undefined
176
187
  ? {}
177
188
  : {
178
189
  client_secret_expires_at:
179
- options.secretTtlSeconds === undefined
190
+ client.secretExpiresAt === undefined
180
191
  ? 0
181
- : seconds + options.secretTtlSeconds,
192
+ : Math.floor(client.secretExpiresAt.getTime() / 1000),
182
193
  }),
183
194
  redirect_uris: client.redirectUris,
184
195
  grant_types: client.grantTypes,
@@ -243,6 +254,31 @@ function assertRedirectUri(value: string): void {
243
254
  );
244
255
  }
245
256
 
257
+ /**
258
+ * When a secret issued now stops working, or `undefined` for never.
259
+ *
260
+ * The TTL is the deployment's value, not the client's, so a bad one is a
261
+ * configuration mistake and says so — `VisaError`, not an OAuth error the
262
+ * caller would read as their own fault. Refused rather than rounded: a
263
+ * fractional or negative TTL means someone intended something this cannot
264
+ * carry out, and a secret quietly issued already-expired (or never expiring)
265
+ * is the worse answer either way.
266
+ */
267
+ export function secretExpiryFor(
268
+ issuedAt: Date,
269
+ ttlSeconds: number | undefined,
270
+ ): Date | undefined {
271
+ if (ttlSeconds === undefined) return undefined;
272
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
273
+ throw new VisaError(
274
+ "E_VISA_INVALID_SECRET_TTL",
275
+ `registration.secretTtlSeconds must be a positive whole number of seconds; received ${ttlSeconds}.`,
276
+ { hint: "Leave it out for a secret that never expires." },
277
+ );
278
+ }
279
+ return new Date(issuedAt.getTime() + ttlSeconds * 1000);
280
+ }
281
+
246
282
  function readStringList(value: unknown): string[] {
247
283
  if (value === undefined || value === null) return [];
248
284
  if (!Array.isArray(value)) {
@@ -251,7 +287,17 @@ function readStringList(value: unknown): string[] {
251
287
  "redirect_uris and grant_types must be arrays of strings.",
252
288
  );
253
289
  }
254
- return value.filter((entry): entry is string => typeof entry === "string");
290
+ // Refused, not filtered. Dropping the entries that are not strings answers
291
+ // 201 to a request nobody wrote: `["https://app/cb", 42]` registered one
292
+ // redirect uri while the client believed it had registered two, and the
293
+ // second one silently not working is found much later, by a user.
294
+ if (value.some((entry) => typeof entry !== "string")) {
295
+ throw new OAuthError(
296
+ "invalid_client_metadata",
297
+ "redirect_uris and grant_types must be arrays of strings.",
298
+ );
299
+ }
300
+ return [...value];
255
301
  }
256
302
 
257
303
  function readScope(value: unknown): string[] {
package/src/store.ts CHANGED
@@ -20,6 +20,17 @@ import type {
20
20
  export interface VisaStore {
21
21
  findClient(id: string): Promise<Client | null>;
22
22
 
23
+ /**
24
+ * Persist a client — what `registerClient`, and so dynamic registration
25
+ * (RFC 7591), writes through. Awaited: a 201 carrying a secret for a
26
+ * client whose insert failed would hand out a credential nothing accepts.
27
+ *
28
+ * Optional, for a store whose clients live in a table another service
29
+ * owns. Without it `registerClient` refuses, and a server with
30
+ * registration turned on refuses to boot.
31
+ */
32
+ saveClient?(client: Client): Promise<void>;
33
+
23
34
  saveAuthorizationCode(code: AuthorizationCode): Promise<void>;
24
35
  findAuthorizationCode(codeHash: string): Promise<AuthorizationCode | null>;
25
36
  /**
@@ -166,10 +166,12 @@ export class AtlasStore implements VisaStore {
166
166
  const row = await this.#db.from(this.#tables.clients).where({ id }).first();
167
167
  if (row === null) return null;
168
168
  const secretHash = optionalString(row.secret_hash);
169
+ const secretExpiresAt = decodeDate(row.secret_expires_at);
169
170
  return {
170
171
  id: requireString(row.id, "id"),
171
172
  name: requireString(row.name, "name"),
172
173
  ...(secretHash === undefined ? {} : { secretHash }),
174
+ ...(secretExpiresAt === undefined ? {} : { secretExpiresAt }),
173
175
  redirectUris: decodeList(row.redirect_uris, "redirect_uris"),
174
176
  grantTypes: decodeList(row.grant_types, "grant_types").filter(
175
177
  isGrantType,
@@ -180,12 +182,15 @@ export class AtlasStore implements VisaStore {
180
182
  };
181
183
  }
182
184
 
183
- /** Persist a registered client. Not on the contract — what `registerClient` writes through. */
185
+ /** Persist a registered client — what `registerClient` writes through. */
184
186
  async saveClient(client: Client): Promise<void> {
185
187
  await this.#db.table(this.#tables.clients).insert({
186
188
  id: client.id,
187
189
  name: client.name,
188
190
  secret_hash: client.secretHash ?? null,
191
+ // Without it an expiry announced at registration is never enforced:
192
+ // authentication reads it off the client and nowhere else.
193
+ secret_expires_at: client.secretExpiresAt ?? null,
189
194
  redirect_uris: encodeList(client.redirectUris),
190
195
  grant_types: encodeList(client.grantTypes),
191
196
  scopes: encodeList(client.scopes),
@@ -30,10 +30,8 @@ export class MemoryStore implements VisaStore {
30
30
  for (const client of clients) this.#clients.set(client.id, client);
31
31
  }
32
32
 
33
- /** Register a client. Returns it, so a caller can keep the reference. */
34
- addClient(client: Client): Client {
33
+ async saveClient(client: Client): Promise<void> {
35
34
  this.#clients.set(client.id, client);
36
- return client;
37
35
  }
38
36
 
39
37
  async findClient(id: string): Promise<Client | null> {
package/src/token.ts CHANGED
@@ -224,6 +224,13 @@ async function exchangeRefresh(
224
224
  const requested = asString(request.scope);
225
225
  const scopes =
226
226
  requested === undefined ? record.scopes : narrow(requested, record.scopes);
227
+ const resources = narrowResources(
228
+ validateResources(
229
+ readResourceParameter(request.resource),
230
+ options.resourcesSupported,
231
+ ),
232
+ record.resources ?? [],
233
+ );
227
234
 
228
235
  if (!(await store.consumeRefreshToken(record.tokenHash, now))) throw invalid;
229
236
 
@@ -233,13 +240,18 @@ async function exchangeRefresh(
233
240
  ...(record.userId === undefined ? {} : { userId: record.userId }),
234
241
  scopes,
235
242
  familyId: record.familyId,
236
- resources: narrowResources(
237
- validateResources(
238
- readResourceParameter(request.resource),
239
- options.resourcesSupported,
240
- ),
241
- record.resources ?? [],
242
- ),
243
+ resources,
244
+ // Only the ACCESS token is narrowed. RFC 6749 §6: a rotated refresh
245
+ // token's scope "MUST be identical to that of the refresh token
246
+ // included by the client in the request" — otherwise asking for a
247
+ // down-scoped token for one call would cost the rest of the user's
248
+ // consent for the whole session. Resources follow the same rule:
249
+ // RFC 8707 has a client refresh once per resource it calls, which
250
+ // only works if a token for one leaves the others reachable.
251
+ refresh: {
252
+ scopes: record.scopes,
253
+ resources: record.resources ?? [],
254
+ },
243
255
  },
244
256
  store,
245
257
  options,
@@ -306,6 +318,13 @@ interface Issued {
306
318
  familyId?: string;
307
319
  /** RFC 8707 — what the token is for. Empty means unbound. */
308
320
  resources?: string[];
321
+ /**
322
+ * What the refresh token carries, when it differs from the access token.
323
+ *
324
+ * Absent, the refresh token carries the access token's scopes and
325
+ * resources — the case of a code exchange, where both halves are the grant.
326
+ */
327
+ refresh?: { scopes: string[]; resources: string[] };
309
328
  }
310
329
 
311
330
  /** Mint the pair and store both, hashed. */
@@ -347,18 +366,18 @@ async function issue(
347
366
  const refreshTtl =
348
367
  options.refreshTokenTtlSeconds ?? DEFAULTS.refreshTokenTtlSeconds;
349
368
  const refreshToken = randomToken();
369
+ const refreshScopes = grant.refresh?.scopes ?? grant.scopes;
370
+ const refreshResources = grant.refresh?.resources ?? grant.resources ?? [];
350
371
  await store.saveRefreshToken({
351
372
  tokenHash: hashSecret(refreshToken),
352
373
  familyId: grant.familyId,
353
374
  clientId: grant.client.id,
354
375
  ...(grant.userId === undefined ? {} : { userId: grant.userId }),
355
- scopes: grant.scopes,
376
+ scopes: refreshScopes,
356
377
  expiresAt: new Date(now.getTime() + refreshTtl * 1000),
357
378
  // Carried so a rotation can narrow from the same list the code bound —
358
379
  // without it, the second token of a session would be unbound.
359
- ...(grant.resources === undefined || grant.resources.length === 0
360
- ? {}
361
- : { resources: grant.resources }),
380
+ ...(refreshResources.length === 0 ? {} : { resources: refreshResources }),
362
381
  });
363
382
  response.refresh_token = refreshToken;
364
383
  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. */
@@ -26,6 +26,8 @@ export default class CreateVisaTables extends Migration {
26
26
  t.string("name", 255).notNullable();
27
27
  // Null for a public client — a SPA or a native app has no secret to keep.
28
28
  t.string("secret_hash", 255).nullable();
29
+ // `secret_expires_at` is added by 9001, so a table created before it
30
+ // existed gets it the same way a new one does.
29
31
  t.json("redirect_uris").notNullable();
30
32
  t.json("grant_types").notNullable();
31
33
  t.json("scopes").notNullable();
@@ -0,0 +1,29 @@
1
+ ---
2
+ to: database/migrations/9001_add_visa_client_secret_expiry.ts
3
+ ---
4
+ import { Migration } from "@c9up/atlas";
5
+
6
+ /**
7
+ * When a client's secret stops working (RFC 7591 §3.2.1).
8
+ *
9
+ * Its own migration rather than a line in 9000, so an application whose
10
+ * tables already exist gets the column by running `ream configure @c9up/visa`
11
+ * again: 9000 is already there and is left alone, this one lands and adds it.
12
+ *
13
+ * Null means the secret does not expire. The store reads it at every client
14
+ * authentication: an expiry announced at registration and not stored here
15
+ * would never be enforced.
16
+ */
17
+ export default class AddVisaClientSecretExpiry extends Migration {
18
+ async up() {
19
+ this.schema.alterTable("visa_clients", (t) => {
20
+ t.timestamp("secret_expires_at").nullable();
21
+ });
22
+ }
23
+
24
+ async down() {
25
+ this.schema.alterTable("visa_clients", (t) => {
26
+ t.dropColumn("secret_expires_at");
27
+ });
28
+ }
29
+ }