@c9up/visa 0.1.1 → 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.
- package/dist/VisaManager.d.ts +40 -7
- package/dist/VisaManager.d.ts.map +1 -1
- package/dist/VisaManager.js +79 -3
- package/dist/VisaManager.js.map +1 -1
- package/dist/VisaProvider.d.ts.map +1 -1
- package/dist/VisaProvider.js +56 -0
- package/dist/VisaProvider.js.map +1 -1
- package/dist/authorize.d.ts +18 -0
- package/dist/authorize.d.ts.map +1 -1
- package/dist/authorize.js +6 -0
- package/dist/authorize.js.map +1 -1
- package/dist/config.d.ts +35 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +9 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/guard.d.ts +21 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +19 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/introspect.d.ts +9 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +18 -1
- package/dist/introspect.js.map +1 -1
- package/dist/protectedResource.d.ts +81 -0
- package/dist/protectedResource.d.ts.map +1 -0
- package/dist/protectedResource.js +124 -0
- package/dist/protectedResource.js.map +1 -0
- package/dist/register.d.ts +96 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +173 -0
- package/dist/register.js.map +1 -0
- package/dist/resource.d.ts +50 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +101 -0
- package/dist/resource.js.map +1 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/atlas.d.ts +7 -0
- package/dist/stores/atlas.d.ts.map +1 -1
- package/dist/stores/atlas.js +70 -16
- package/dist/stores/atlas.js.map +1 -1
- package/dist/stores/memory.d.ts +4 -0
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +25 -0
- package/dist/stores/memory.js.map +1 -1
- package/dist/token.d.ts +2 -0
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +21 -1
- package/dist/token.js.map +1 -1
- package/dist/types.d.ts +42 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/VisaManager.ts +109 -3
- package/src/VisaProvider.ts +64 -0
- package/src/authorize.ts +27 -0
- package/src/config.ts +35 -0
- package/src/errors.ts +16 -2
- package/src/guard.ts +30 -0
- package/src/index.ts +25 -0
- package/src/introspect.ts +17 -0
- package/src/protectedResource.ts +171 -0
- package/src/register.ts +296 -0
- package/src/resource.ts +123 -0
- package/src/store.ts +29 -0
- package/src/stores/atlas.ts +99 -15
- package/src/stores/memory.ts +33 -0
- package/src/token.ts +45 -1
- package/src/types.ts +43 -0
- package/stubs/database/migrations/9000_create_visa_tables.stub +9 -0
package/src/resource.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which resource a token is for (RFC 8707).
|
|
3
|
+
*
|
|
4
|
+
* A token with no stated audience is a token for everything. Send it to the
|
|
5
|
+
* wrong server — or let a compromised one replay it — and every other resource
|
|
6
|
+
* that trusts the same issuer accepts it. The `resource` parameter is how a
|
|
7
|
+
* client says "this one is for the calendar API", and how the calendar API
|
|
8
|
+
* refuses one minted for somebody else.
|
|
9
|
+
*
|
|
10
|
+
* Read off the RFC rather than recalled: the value MUST be an absolute URI
|
|
11
|
+
* without a fragment (§2), it MAY repeat, and a resource the server does not
|
|
12
|
+
* accept is refused with `invalid_target` (§2) — not `invalid_request`, which
|
|
13
|
+
* would tell a client its request was malformed when it was merely aimed
|
|
14
|
+
* somewhere it may not go.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { OAuthError } from "./errors.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalise however the parameter arrived into a list.
|
|
21
|
+
*
|
|
22
|
+
* A form body gives one value or several under the same name, and a query
|
|
23
|
+
* string the same; both are `resource=` repeated, which parsers hand over
|
|
24
|
+
* either way.
|
|
25
|
+
*/
|
|
26
|
+
export function readResourceParameter(value: unknown): string[] {
|
|
27
|
+
if (value === undefined || value === null) return [];
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
30
|
+
}
|
|
31
|
+
return typeof value === "string" && value !== "" ? [value] : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check each one, and refuse the request if any is unusable.
|
|
36
|
+
*
|
|
37
|
+
* `invalid_target` for every rejection here, including a malformed URI: §2
|
|
38
|
+
* names that code for "cannot parse the provided value(s)" as well as for a
|
|
39
|
+
* resource the server will not issue for.
|
|
40
|
+
*/
|
|
41
|
+
export function validateResources(
|
|
42
|
+
requested: readonly string[],
|
|
43
|
+
accepted?: readonly string[],
|
|
44
|
+
): string[] {
|
|
45
|
+
const resources: string[] = [];
|
|
46
|
+
for (const value of requested) {
|
|
47
|
+
let url: URL;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(value);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new OAuthError(
|
|
52
|
+
"invalid_target",
|
|
53
|
+
`resource must be an absolute URI: ${value}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (url.hash !== "") {
|
|
57
|
+
throw new OAuthError(
|
|
58
|
+
"invalid_target",
|
|
59
|
+
`resource must not carry a fragment: ${value}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
// §2 SHOULD NOT include a query, and recognises that some cases need
|
|
63
|
+
// one — so it travels rather than being refused or stripped. Stripping
|
|
64
|
+
// would hand back a token for a resource nobody asked for.
|
|
65
|
+
if (accepted !== undefined && !accepted.includes(value)) {
|
|
66
|
+
throw new OAuthError(
|
|
67
|
+
"invalid_target",
|
|
68
|
+
`this server does not issue tokens for ${value}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!resources.includes(value)) resources.push(value);
|
|
72
|
+
}
|
|
73
|
+
return resources;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What a token request may narrow to.
|
|
78
|
+
*
|
|
79
|
+
* A refresh or an exchange may ask for fewer resources than were authorised,
|
|
80
|
+
* never for more — the same rule scopes follow, and for the same reason: the
|
|
81
|
+
* user agreed to a set, and a later request must not widen it.
|
|
82
|
+
*/
|
|
83
|
+
export function narrowResources(
|
|
84
|
+
requested: readonly string[],
|
|
85
|
+
granted: readonly string[],
|
|
86
|
+
): string[] {
|
|
87
|
+
if (requested.length === 0) return [...granted];
|
|
88
|
+
if (granted.length === 0) {
|
|
89
|
+
// Nothing was bound at authorization time, so there is nothing to narrow
|
|
90
|
+
// FROM. Letting the request name one here would mint a token for an
|
|
91
|
+
// audience the user never saw.
|
|
92
|
+
throw new OAuthError(
|
|
93
|
+
"invalid_target",
|
|
94
|
+
"this grant is not bound to any resource",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const outside = requested.filter((value) => !granted.includes(value));
|
|
98
|
+
if (outside.length > 0) {
|
|
99
|
+
throw new OAuthError(
|
|
100
|
+
"invalid_target",
|
|
101
|
+
`this grant does not cover ${outside.join(", ")}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return [...requested];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Is this token for me?
|
|
109
|
+
*
|
|
110
|
+
* What a resource server asks. A token bound to nothing is accepted — that is
|
|
111
|
+
* every token issued before a client started sending `resource`, and refusing
|
|
112
|
+
* them would break each one. A token bound to SOMETHING must name this
|
|
113
|
+
* resource, or it was minted for another server and arriving here means it
|
|
114
|
+
* travelled.
|
|
115
|
+
*/
|
|
116
|
+
export function audienceAllows(
|
|
117
|
+
audience: readonly string[] | undefined,
|
|
118
|
+
resource: string | undefined,
|
|
119
|
+
): boolean {
|
|
120
|
+
if (audience === undefined || audience.length === 0) return true;
|
|
121
|
+
if (resource === undefined) return true;
|
|
122
|
+
return audience.includes(resource);
|
|
123
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -51,6 +51,35 @@ export interface VisaStore {
|
|
|
51
51
|
findConsent(userId: string, clientId: string): Promise<Consent | null>;
|
|
52
52
|
saveConsent(consent: Consent): Promise<void>;
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Everything this user has granted, so they can be shown it.
|
|
56
|
+
*
|
|
57
|
+
* The consent is the unit a person manages: they authorised an
|
|
58
|
+
* application, not a token. Tokens come and go under that decision.
|
|
59
|
+
*/
|
|
60
|
+
listConsents(userId: string): Promise<Consent[]>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every access token of this user, EXPIRED ONES INCLUDED.
|
|
64
|
+
*
|
|
65
|
+
* Included deliberately: "last used" is most interesting precisely when
|
|
66
|
+
* nothing is live any more, and a screen that hid them would tell a user an
|
|
67
|
+
* application never touched their data when it did.
|
|
68
|
+
*/
|
|
69
|
+
listAccessTokens(userId: string): Promise<AccessToken[]>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Cut one application off for one user: every token, both kinds.
|
|
73
|
+
*
|
|
74
|
+
* Not the consent — {@link deleteConsent} is separate, because forgetting
|
|
75
|
+
* the grant and ending the sessions are two different things and a caller
|
|
76
|
+
* revoking in a panic wants the second one first.
|
|
77
|
+
*/
|
|
78
|
+
revokeAccessFor(userId: string, clientId: string, at: Date): Promise<void>;
|
|
79
|
+
|
|
80
|
+
/** Forget the grant, so the user is asked again next time. */
|
|
81
|
+
deleteConsent(userId: string, clientId: string): Promise<void>;
|
|
82
|
+
|
|
54
83
|
/**
|
|
55
84
|
* Record that an access token was just used.
|
|
56
85
|
*
|
package/src/stores/atlas.ts
CHANGED
|
@@ -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
|
-
|
|
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" ||
|
package/src/stores/memory.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
@@ -57,6 +57,13 @@ export interface AuthorizationCode {
|
|
|
57
57
|
expiresAt: Date;
|
|
58
58
|
/** Set the moment it is exchanged, so a replay is detectable (§7.5.1). */
|
|
59
59
|
consumedAt?: Date;
|
|
60
|
+
/**
|
|
61
|
+
* The resources this code may buy a token for (RFC 8707).
|
|
62
|
+
*
|
|
63
|
+
* Bound here rather than at the token endpoint: the user saw the request
|
|
64
|
+
* that named them, and the exchange may narrow the list but never widen it.
|
|
65
|
+
*/
|
|
66
|
+
resources?: string[];
|
|
60
67
|
/** Carried through to the id_token when OIDC lands. */
|
|
61
68
|
nonce?: string;
|
|
62
69
|
}
|
|
@@ -76,6 +83,15 @@ export interface AccessToken {
|
|
|
76
83
|
* and what tells a dormant token from a live one.
|
|
77
84
|
*/
|
|
78
85
|
lastUsedAt?: Date;
|
|
86
|
+
/**
|
|
87
|
+
* Which resources this token is for (RFC 8707).
|
|
88
|
+
*
|
|
89
|
+
* Absent means unbound — a token for everything, which is what every token
|
|
90
|
+
* was before a client started asking. A resource server checks it against
|
|
91
|
+
* its own identifier, so a token that travelled to the wrong one is refused
|
|
92
|
+
* there instead of being honoured.
|
|
93
|
+
*/
|
|
94
|
+
audience?: string[];
|
|
79
95
|
/**
|
|
80
96
|
* The refresh family this token was minted in, when there is one.
|
|
81
97
|
*
|
|
@@ -102,6 +118,8 @@ export interface RefreshToken {
|
|
|
102
118
|
expiresAt: Date;
|
|
103
119
|
consumedAt?: Date;
|
|
104
120
|
revokedAt?: Date;
|
|
121
|
+
/** The resources a token minted from this one may be for (RFC 8707). */
|
|
122
|
+
resources?: string[];
|
|
105
123
|
}
|
|
106
124
|
|
|
107
125
|
/** What a user agreed to give a client, so they are asked once. */
|
|
@@ -112,6 +130,29 @@ export interface Consent {
|
|
|
112
130
|
grantedAt: Date;
|
|
113
131
|
}
|
|
114
132
|
|
|
133
|
+
/**
|
|
134
|
+
* One application a user has authorised, as a "connected applications" screen
|
|
135
|
+
* shows it.
|
|
136
|
+
*
|
|
137
|
+
* The unit is the APPLICATION, not the token: a person authorised an
|
|
138
|
+
* application, and the tokens come and go under that one decision. Upstream's
|
|
139
|
+
* equivalent listing is per token because it has no clients; here a user with
|
|
140
|
+
* four live tokens for one application has authorised one thing, and seeing
|
|
141
|
+
* four rows would be a lie about what revoking does.
|
|
142
|
+
*/
|
|
143
|
+
export interface AuthorizedApplication {
|
|
144
|
+
clientId: string;
|
|
145
|
+
/** The client's registered name, or its id when the client is gone. */
|
|
146
|
+
name: string;
|
|
147
|
+
/** What was granted — the consent, not what any one token happens to carry. */
|
|
148
|
+
scopes: string[];
|
|
149
|
+
grantedAt: Date;
|
|
150
|
+
/** The most recent use across this application's tokens, if any was used. */
|
|
151
|
+
lastUsedAt?: Date;
|
|
152
|
+
/** Whether anything issued to it is still usable right now. */
|
|
153
|
+
active: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
115
156
|
/** The token endpoint's success body (§4.1.4). */
|
|
116
157
|
export interface TokenResponse {
|
|
117
158
|
access_token: string;
|
|
@@ -138,4 +179,6 @@ export interface IntrospectionResponse {
|
|
|
138
179
|
exp?: number;
|
|
139
180
|
iat?: number;
|
|
140
181
|
token_type?: "Bearer";
|
|
182
|
+
/** Which resources the token is for — RFC 8707 §3 names introspection as where this travels. */
|
|
183
|
+
aud?: string[];
|
|
141
184
|
}
|
|
@@ -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");
|