@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.
- 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/configure.d.ts.map +1 -1
- package/dist/configure.js +19 -0
- package/dist/configure.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 +243 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/guard.js +293 -0
- package/dist/guard.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/introspect.d.ts +11 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +27 -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 +33 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/atlas.d.ts +106 -0
- package/dist/stores/atlas.d.ts.map +1 -0
- package/dist/stores/atlas.js +408 -0
- package/dist/stores/atlas.js.map +1 -0
- package/dist/stores/memory.d.ts +5 -0
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +30 -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 +49 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -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/configure.ts +21 -0
- package/src/errors.ts +16 -2
- package/src/guard.ts +443 -0
- package/src/index.ts +41 -0
- package/src/introspect.ts +33 -2
- package/src/protectedResource.ts +171 -0
- package/src/register.ts +296 -0
- package/src/resource.ts +123 -0
- package/src/store.ts +38 -0
- package/src/stores/atlas.ts +514 -0
- package/src/stores/memory.ts +38 -0
- package/src/token.ts +45 -1
- package/src/types.ts +50 -0
- package/stubs/database/migrations/9000_create_visa_tables.stub +112 -0
package/dist/resource.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
import { OAuthError } from "./errors.js";
|
|
17
|
+
/**
|
|
18
|
+
* Normalise however the parameter arrived into a list.
|
|
19
|
+
*
|
|
20
|
+
* A form body gives one value or several under the same name, and a query
|
|
21
|
+
* string the same; both are `resource=` repeated, which parsers hand over
|
|
22
|
+
* either way.
|
|
23
|
+
*/
|
|
24
|
+
export function readResourceParameter(value) {
|
|
25
|
+
if (value === undefined || value === null)
|
|
26
|
+
return [];
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.filter((entry) => typeof entry === "string");
|
|
29
|
+
}
|
|
30
|
+
return typeof value === "string" && value !== "" ? [value] : [];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Check each one, and refuse the request if any is unusable.
|
|
34
|
+
*
|
|
35
|
+
* `invalid_target` for every rejection here, including a malformed URI: §2
|
|
36
|
+
* names that code for "cannot parse the provided value(s)" as well as for a
|
|
37
|
+
* resource the server will not issue for.
|
|
38
|
+
*/
|
|
39
|
+
export function validateResources(requested, accepted) {
|
|
40
|
+
const resources = [];
|
|
41
|
+
for (const value of requested) {
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(value);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new OAuthError("invalid_target", `resource must be an absolute URI: ${value}`);
|
|
48
|
+
}
|
|
49
|
+
if (url.hash !== "") {
|
|
50
|
+
throw new OAuthError("invalid_target", `resource must not carry a fragment: ${value}`);
|
|
51
|
+
}
|
|
52
|
+
// §2 SHOULD NOT include a query, and recognises that some cases need
|
|
53
|
+
// one — so it travels rather than being refused or stripped. Stripping
|
|
54
|
+
// would hand back a token for a resource nobody asked for.
|
|
55
|
+
if (accepted !== undefined && !accepted.includes(value)) {
|
|
56
|
+
throw new OAuthError("invalid_target", `this server does not issue tokens for ${value}`);
|
|
57
|
+
}
|
|
58
|
+
if (!resources.includes(value))
|
|
59
|
+
resources.push(value);
|
|
60
|
+
}
|
|
61
|
+
return resources;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What a token request may narrow to.
|
|
65
|
+
*
|
|
66
|
+
* A refresh or an exchange may ask for fewer resources than were authorised,
|
|
67
|
+
* never for more — the same rule scopes follow, and for the same reason: the
|
|
68
|
+
* user agreed to a set, and a later request must not widen it.
|
|
69
|
+
*/
|
|
70
|
+
export function narrowResources(requested, granted) {
|
|
71
|
+
if (requested.length === 0)
|
|
72
|
+
return [...granted];
|
|
73
|
+
if (granted.length === 0) {
|
|
74
|
+
// Nothing was bound at authorization time, so there is nothing to narrow
|
|
75
|
+
// FROM. Letting the request name one here would mint a token for an
|
|
76
|
+
// audience the user never saw.
|
|
77
|
+
throw new OAuthError("invalid_target", "this grant is not bound to any resource");
|
|
78
|
+
}
|
|
79
|
+
const outside = requested.filter((value) => !granted.includes(value));
|
|
80
|
+
if (outside.length > 0) {
|
|
81
|
+
throw new OAuthError("invalid_target", `this grant does not cover ${outside.join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
return [...requested];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Is this token for me?
|
|
87
|
+
*
|
|
88
|
+
* What a resource server asks. A token bound to nothing is accepted — that is
|
|
89
|
+
* every token issued before a client started sending `resource`, and refusing
|
|
90
|
+
* them would break each one. A token bound to SOMETHING must name this
|
|
91
|
+
* resource, or it was minted for another server and arriving here means it
|
|
92
|
+
* travelled.
|
|
93
|
+
*/
|
|
94
|
+
export function audienceAllows(audience, resource) {
|
|
95
|
+
if (audience === undefined || audience.length === 0)
|
|
96
|
+
return true;
|
|
97
|
+
if (resource === undefined)
|
|
98
|
+
return true;
|
|
99
|
+
return audience.includes(resource);
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=resource.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resource.js","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CAAC,KAAc;IACnD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,EAAE,CAAC;IACrD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,KAAK,EAAmB,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC;IAC5E,CAAC;IACD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACjE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAChC,SAA4B,EAC5B,QAA4B;IAE5B,MAAM,SAAS,GAAa,EAAE,CAAC;IAC/B,KAAK,MAAM,KAAK,IAAI,SAAS,EAAE,CAAC;QAC/B,IAAI,GAAQ,CAAC;QACb,IAAI,CAAC;YACJ,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;QAAC,MAAM,CAAC;YACR,MAAM,IAAI,UAAU,CACnB,gBAAgB,EAChB,qCAAqC,KAAK,EAAE,CAC5C,CAAC;QACH,CAAC;QACD,IAAI,GAAG,CAAC,IAAI,KAAK,EAAE,EAAE,CAAC;YACrB,MAAM,IAAI,UAAU,CACnB,gBAAgB,EAChB,uCAAuC,KAAK,EAAE,CAC9C,CAAC;QACH,CAAC;QACD,qEAAqE;QACrE,uEAAuE;QACvE,2DAA2D;QAC3D,IAAI,QAAQ,KAAK,SAAS,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;YACzD,MAAM,IAAI,UAAU,CACnB,gBAAgB,EAChB,yCAAyC,KAAK,EAAE,CAChD,CAAC;QACH,CAAC;QACD,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC;YAAE,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvD,CAAC;IACD,OAAO,SAAS,CAAC;AAClB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAC9B,SAA4B,EAC5B,OAA0B;IAE1B,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC;IAChD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,yEAAyE;QACzE,oEAAoE;QACpE,+BAA+B;QAC/B,MAAM,IAAI,UAAU,CACnB,gBAAgB,EAChB,yCAAyC,CACzC,CAAC;IACH,CAAC;IACD,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;IACtE,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,MAAM,IAAI,UAAU,CACnB,gBAAgB,EAChB,6BAA6B,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CACjD,CAAC;IACH,CAAC;IACD,OAAO,CAAC,GAAG,SAAS,CAAC,CAAC;AACvB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAC7B,QAAuC,EACvC,QAA4B;IAE5B,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACjE,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACxC,OAAO,QAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AACpC,CAAC"}
|
package/dist/store.d.ts
CHANGED
|
@@ -38,6 +38,39 @@ export interface VisaStore {
|
|
|
38
38
|
revokeFamily(familyId: string, at: Date): Promise<void>;
|
|
39
39
|
findConsent(userId: string, clientId: string): Promise<Consent | null>;
|
|
40
40
|
saveConsent(consent: Consent): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Everything this user has granted, so they can be shown it.
|
|
43
|
+
*
|
|
44
|
+
* The consent is the unit a person manages: they authorised an
|
|
45
|
+
* application, not a token. Tokens come and go under that decision.
|
|
46
|
+
*/
|
|
47
|
+
listConsents(userId: string): Promise<Consent[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Every access token of this user, EXPIRED ONES INCLUDED.
|
|
50
|
+
*
|
|
51
|
+
* Included deliberately: "last used" is most interesting precisely when
|
|
52
|
+
* nothing is live any more, and a screen that hid them would tell a user an
|
|
53
|
+
* application never touched their data when it did.
|
|
54
|
+
*/
|
|
55
|
+
listAccessTokens(userId: string): Promise<AccessToken[]>;
|
|
56
|
+
/**
|
|
57
|
+
* Cut one application off for one user: every token, both kinds.
|
|
58
|
+
*
|
|
59
|
+
* Not the consent — {@link deleteConsent} is separate, because forgetting
|
|
60
|
+
* the grant and ending the sessions are two different things and a caller
|
|
61
|
+
* revoking in a panic wants the second one first.
|
|
62
|
+
*/
|
|
63
|
+
revokeAccessFor(userId: string, clientId: string, at: Date): Promise<void>;
|
|
64
|
+
/** Forget the grant, so the user is asked again next time. */
|
|
65
|
+
deleteConsent(userId: string, clientId: string): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Record that an access token was just used.
|
|
68
|
+
*
|
|
69
|
+
* Optional, and deliberately best-effort: it is one write per authenticated
|
|
70
|
+
* request, which a store on a hot path may not want. A store without it
|
|
71
|
+
* authenticates exactly the same, it simply reports no last use.
|
|
72
|
+
*/
|
|
73
|
+
touchAccessToken?(tokenHash: string, at: Date): Promise<void>;
|
|
41
74
|
/** Drop what has expired. Optional: a store with a TTL does it itself. */
|
|
42
75
|
prune?(now: Date): Promise<void>;
|
|
43
76
|
}
|
package/dist/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EACX,WAAW,EACX,iBAAiB,EACjB,MAAM,EACN,OAAO,EACP,YAAY,EACZ,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,SAAS;IACzB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAE/C,qBAAqB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAC3E;;;;;;OAMG;IACH,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvE,eAAe,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAChE,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9D,gBAAgB,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAClE,oEAAoE;IACpE,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnE;;;;;;OAMG;IACH,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAExD,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACvE,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE7C,0EAA0E;IAC1E,KAAK,CAAC,CAAC,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjC"}
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EACX,WAAW,EACX,iBAAiB,EACjB,MAAM,EACN,OAAO,EACP,YAAY,EACZ,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,SAAS;IACzB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAE/C,qBAAqB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAC3E;;;;;;OAMG;IACH,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvE,eAAe,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAChE,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9D,gBAAgB,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAClE,oEAAoE;IACpE,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnE;;;;;;OAMG;IACH,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAExD,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;IACvE,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE7C;;;;;OAKG;IACH,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAEjD;;;;;;OAMG;IACH,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IAEzD;;;;;;OAMG;IACH,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE3E,8DAA8D;IAC9D,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/D;;;;;;OAMG;IACH,gBAAgB,CAAC,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9D,0EAA0E;IAC1E,KAAK,CAAC,CAAC,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjC"}
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
import type { VisaStore } from "../store.js";
|
|
19
|
+
import type { AccessToken, AuthorizationCode, Client, Consent, RefreshToken } from "../types.js";
|
|
20
|
+
/** A row as atlas hands it back. */
|
|
21
|
+
type Row = Record<string, unknown>;
|
|
22
|
+
/** The comparisons atlas' builder accepts. */
|
|
23
|
+
type Operator = "=" | "!=" | ">" | ">=" | "<" | "<=";
|
|
24
|
+
/**
|
|
25
|
+
* The slice of atlas' `DatabaseQueryBuilder` this store uses.
|
|
26
|
+
*
|
|
27
|
+
* Every read here fetches one row, so the slice asks for `first()` and not for
|
|
28
|
+
* the builder's thenable form. Nothing is declared that nothing calls.
|
|
29
|
+
*/
|
|
30
|
+
export interface AtlasQuery {
|
|
31
|
+
where(conditions: Row): AtlasQuery;
|
|
32
|
+
where(column: string, operator: Operator, value: unknown): AtlasQuery;
|
|
33
|
+
whereNull(column: string): AtlasQuery;
|
|
34
|
+
orderBy(column: string, direction?: "asc" | "desc"): AtlasQuery;
|
|
35
|
+
first(): Promise<Row | null>;
|
|
36
|
+
/** Run the select and return every row — atlas' `exec()`. */
|
|
37
|
+
exec(): Promise<Row[]>;
|
|
38
|
+
insert(data: Row): PromiseLike<unknown>;
|
|
39
|
+
/** Resolves to the number of rows affected, which is what the atomic paths read. */
|
|
40
|
+
update(data: Row): PromiseLike<number | Row[]>;
|
|
41
|
+
delete(): PromiseLike<number | Row[]>;
|
|
42
|
+
}
|
|
43
|
+
/** The slice of atlas' `db` service this store uses. */
|
|
44
|
+
export interface AtlasDb {
|
|
45
|
+
from(table: string): AtlasQuery;
|
|
46
|
+
table(table: string): AtlasQuery;
|
|
47
|
+
}
|
|
48
|
+
/** Where each kind of row lives. Override one to fit a schema you do not own. */
|
|
49
|
+
export interface AtlasStoreTables {
|
|
50
|
+
clients?: string;
|
|
51
|
+
authorizationCodes?: string;
|
|
52
|
+
accessTokens?: string;
|
|
53
|
+
refreshTokens?: string;
|
|
54
|
+
consents?: string;
|
|
55
|
+
}
|
|
56
|
+
export declare class AtlasStore implements VisaStore {
|
|
57
|
+
#private;
|
|
58
|
+
constructor(db: AtlasDb, tables?: AtlasStoreTables);
|
|
59
|
+
findClient(id: string): Promise<Client | null>;
|
|
60
|
+
/** Persist a registered client. Not on the contract — what `registerClient` writes through. */
|
|
61
|
+
saveClient(client: Client): Promise<void>;
|
|
62
|
+
saveAuthorizationCode(code: AuthorizationCode): Promise<void>;
|
|
63
|
+
findAuthorizationCode(codeHash: string): Promise<AuthorizationCode | null>;
|
|
64
|
+
/**
|
|
65
|
+
* One UPDATE, guarded by `consumed_at IS NULL`.
|
|
66
|
+
*
|
|
67
|
+
* The single-use guarantee lives in that one statement: two requests racing
|
|
68
|
+
* with the same code both reach the database, and only the one that finds
|
|
69
|
+
* the row still unconsumed is allowed to write it. Reading and then writing
|
|
70
|
+
* would let both through.
|
|
71
|
+
*/
|
|
72
|
+
consumeAuthorizationCode(codeHash: string, at: Date): Promise<boolean>;
|
|
73
|
+
saveAccessToken(token: AccessToken): Promise<void>;
|
|
74
|
+
findAccessToken(tokenHash: string): Promise<AccessToken | null>;
|
|
75
|
+
revokeAccessToken(tokenHash: string, at: Date): Promise<void>;
|
|
76
|
+
touchAccessToken(tokenHash: string, at: Date): Promise<void>;
|
|
77
|
+
saveRefreshToken(token: RefreshToken): Promise<void>;
|
|
78
|
+
findRefreshToken(tokenHash: string): Promise<RefreshToken | null>;
|
|
79
|
+
/** Same one-statement guarantee as a code: rotation depends on it. */
|
|
80
|
+
consumeRefreshToken(tokenHash: string, at: Date): Promise<boolean>;
|
|
81
|
+
/**
|
|
82
|
+
* End a family: the refresh tokens AND the access tokens they bought.
|
|
83
|
+
*
|
|
84
|
+
* Both, because revoking only the refresh half would leave an access token
|
|
85
|
+
* already minted alive for its full lifetime — which is most of what this
|
|
86
|
+
* revocation exists to prevent.
|
|
87
|
+
*/
|
|
88
|
+
revokeFamily(familyId: string, at: Date): Promise<void>;
|
|
89
|
+
findConsent(userId: string, clientId: string): Promise<Consent | null>;
|
|
90
|
+
/**
|
|
91
|
+
* Record a consent, replacing what was there.
|
|
92
|
+
*
|
|
93
|
+
* Delete then insert rather than an upsert: the dialects spell an upsert
|
|
94
|
+
* three different ways, and re-granting is not a hot path.
|
|
95
|
+
*/
|
|
96
|
+
saveConsent(consent: Consent): Promise<void>;
|
|
97
|
+
listConsents(userId: string): Promise<Consent[]>;
|
|
98
|
+
/** Expired ones included — see the contract for why. */
|
|
99
|
+
listAccessTokens(userId: string): Promise<AccessToken[]>;
|
|
100
|
+
revokeAccessFor(userId: string, clientId: string, at: Date): Promise<void>;
|
|
101
|
+
deleteConsent(userId: string, clientId: string): Promise<void>;
|
|
102
|
+
/** Drop what has expired and can no longer be presented. */
|
|
103
|
+
prune(now: Date): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
export {};
|
|
106
|
+
//# sourceMappingURL=atlas.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"atlas.d.ts","sourceRoot":"","sources":["../../src/stores/atlas.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,408 @@
|
|
|
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
|
+
const DEFAULT_TABLES = {
|
|
19
|
+
clients: "visa_clients",
|
|
20
|
+
authorizationCodes: "visa_authorization_codes",
|
|
21
|
+
accessTokens: "visa_access_tokens",
|
|
22
|
+
refreshTokens: "visa_refresh_tokens",
|
|
23
|
+
consents: "visa_consents",
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A list of strings, stored as JSON text.
|
|
27
|
+
*
|
|
28
|
+
* Text and not a native array type: three dialects are supported and only one
|
|
29
|
+
* of them has arrays. The round trip is explicit so a malformed value fails on
|
|
30
|
+
* read rather than becoming `[]` — a scope list that quietly empties is an
|
|
31
|
+
* authorisation bug, not a display one.
|
|
32
|
+
*/
|
|
33
|
+
function encodeList(values) {
|
|
34
|
+
return JSON.stringify(values);
|
|
35
|
+
}
|
|
36
|
+
function decodeList(value, column) {
|
|
37
|
+
if (value === null || value === undefined)
|
|
38
|
+
return [];
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
// A `json` column comes back parsed on some dialects and as text on others.
|
|
41
|
+
return value.filter((entry) => typeof entry === "string");
|
|
42
|
+
}
|
|
43
|
+
if (typeof value !== "string") {
|
|
44
|
+
throw new Error(`[visa] ${column} holds ${typeof value}, expected a list`);
|
|
45
|
+
}
|
|
46
|
+
const parsed = JSON.parse(value);
|
|
47
|
+
if (!Array.isArray(parsed)) {
|
|
48
|
+
throw new Error(`[visa] ${column} is not a JSON array: ${value}`);
|
|
49
|
+
}
|
|
50
|
+
return parsed.filter((entry) => typeof entry === "string");
|
|
51
|
+
}
|
|
52
|
+
/** A column that may be null, as a Date. */
|
|
53
|
+
function decodeDate(value) {
|
|
54
|
+
if (value === null || value === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (value instanceof Date)
|
|
57
|
+
return value;
|
|
58
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
59
|
+
const date = new Date(value);
|
|
60
|
+
if (!Number.isNaN(date.getTime()))
|
|
61
|
+
return date;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/** A column that must hold a date. */
|
|
66
|
+
function requireDate(value, column) {
|
|
67
|
+
const date = decodeDate(value);
|
|
68
|
+
if (date === undefined) {
|
|
69
|
+
throw new Error(`[visa] ${column} is not a date: ${String(value)}`);
|
|
70
|
+
}
|
|
71
|
+
return date;
|
|
72
|
+
}
|
|
73
|
+
function requireString(value, column) {
|
|
74
|
+
if (typeof value !== "string") {
|
|
75
|
+
throw new Error(`[visa] ${column} is not a string: ${String(value)}`);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
function optionalString(value) {
|
|
80
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* How many rows a write touched.
|
|
84
|
+
*
|
|
85
|
+
* `update()` answers a count, or the rows themselves when the caller asked for
|
|
86
|
+
* them. Only the count is ever asked for here, and the other shape is read as
|
|
87
|
+
* its length rather than assumed away.
|
|
88
|
+
*/
|
|
89
|
+
function affected(result) {
|
|
90
|
+
return typeof result === "number" ? result : result.length;
|
|
91
|
+
}
|
|
92
|
+
export class AtlasStore {
|
|
93
|
+
#db;
|
|
94
|
+
#tables;
|
|
95
|
+
constructor(db, tables = {}) {
|
|
96
|
+
this.#db = db;
|
|
97
|
+
this.#tables = {
|
|
98
|
+
clients: tables.clients ?? DEFAULT_TABLES.clients,
|
|
99
|
+
authorizationCodes: tables.authorizationCodes ?? DEFAULT_TABLES.authorizationCodes,
|
|
100
|
+
accessTokens: tables.accessTokens ?? DEFAULT_TABLES.accessTokens,
|
|
101
|
+
refreshTokens: tables.refreshTokens ?? DEFAULT_TABLES.refreshTokens,
|
|
102
|
+
consents: tables.consents ?? DEFAULT_TABLES.consents,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async findClient(id) {
|
|
106
|
+
const row = await this.#db.from(this.#tables.clients).where({ id }).first();
|
|
107
|
+
if (row === null)
|
|
108
|
+
return null;
|
|
109
|
+
const secretHash = optionalString(row.secret_hash);
|
|
110
|
+
return {
|
|
111
|
+
id: requireString(row.id, "id"),
|
|
112
|
+
name: requireString(row.name, "name"),
|
|
113
|
+
...(secretHash === undefined ? {} : { secretHash }),
|
|
114
|
+
redirectUris: decodeList(row.redirect_uris, "redirect_uris"),
|
|
115
|
+
grantTypes: decodeList(row.grant_types, "grant_types").filter(isGrantType),
|
|
116
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
117
|
+
tokenEndpointAuthMethod: readAuthMethod(row.token_endpoint_auth_method),
|
|
118
|
+
...(row.trusted === true || row.trusted === 1 ? { trusted: true } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Persist a registered client. Not on the contract — what `registerClient` writes through. */
|
|
122
|
+
async saveClient(client) {
|
|
123
|
+
await this.#db.table(this.#tables.clients).insert({
|
|
124
|
+
id: client.id,
|
|
125
|
+
name: client.name,
|
|
126
|
+
secret_hash: client.secretHash ?? null,
|
|
127
|
+
redirect_uris: encodeList(client.redirectUris),
|
|
128
|
+
grant_types: encodeList(client.grantTypes),
|
|
129
|
+
scopes: encodeList(client.scopes),
|
|
130
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
131
|
+
trusted: client.trusted === true,
|
|
132
|
+
created_at: new Date(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async saveAuthorizationCode(code) {
|
|
136
|
+
await this.#db.table(this.#tables.authorizationCodes).insert({
|
|
137
|
+
code_hash: code.codeHash,
|
|
138
|
+
client_id: code.clientId,
|
|
139
|
+
user_id: code.userId,
|
|
140
|
+
redirect_uri: code.redirectUri,
|
|
141
|
+
scopes: encodeList(code.scopes),
|
|
142
|
+
code_challenge: code.codeChallenge,
|
|
143
|
+
code_challenge_method: code.codeChallengeMethod,
|
|
144
|
+
expires_at: code.expiresAt,
|
|
145
|
+
consumed_at: code.consumedAt ?? null,
|
|
146
|
+
nonce: code.nonce ?? null,
|
|
147
|
+
resources: encodeList(code.resources ?? []),
|
|
148
|
+
created_at: new Date(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async findAuthorizationCode(codeHash) {
|
|
152
|
+
const row = await this.#db
|
|
153
|
+
.from(this.#tables.authorizationCodes)
|
|
154
|
+
.where({ code_hash: codeHash })
|
|
155
|
+
.first();
|
|
156
|
+
if (row === null)
|
|
157
|
+
return null;
|
|
158
|
+
const consumedAt = decodeDate(row.consumed_at);
|
|
159
|
+
const nonce = optionalString(row.nonce);
|
|
160
|
+
return {
|
|
161
|
+
codeHash: requireString(row.code_hash, "code_hash"),
|
|
162
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
163
|
+
userId: requireString(row.user_id, "user_id"),
|
|
164
|
+
redirectUri: requireString(row.redirect_uri, "redirect_uri"),
|
|
165
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
166
|
+
codeChallenge: requireString(row.code_challenge, "code_challenge"),
|
|
167
|
+
codeChallengeMethod: row.code_challenge_method === "plain" ? "plain" : "S256",
|
|
168
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
169
|
+
...(consumedAt === undefined ? {} : { consumedAt }),
|
|
170
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
171
|
+
...listOrNothing("resources", row.resources),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* One UPDATE, guarded by `consumed_at IS NULL`.
|
|
176
|
+
*
|
|
177
|
+
* The single-use guarantee lives in that one statement: two requests racing
|
|
178
|
+
* with the same code both reach the database, and only the one that finds
|
|
179
|
+
* the row still unconsumed is allowed to write it. Reading and then writing
|
|
180
|
+
* would let both through.
|
|
181
|
+
*/
|
|
182
|
+
async consumeAuthorizationCode(codeHash, at) {
|
|
183
|
+
const result = await this.#db
|
|
184
|
+
.from(this.#tables.authorizationCodes)
|
|
185
|
+
.where({ code_hash: codeHash })
|
|
186
|
+
.whereNull("consumed_at")
|
|
187
|
+
.update({ consumed_at: at });
|
|
188
|
+
return affected(result) === 1;
|
|
189
|
+
}
|
|
190
|
+
async saveAccessToken(token) {
|
|
191
|
+
await this.#db.table(this.#tables.accessTokens).insert({
|
|
192
|
+
token_hash: token.tokenHash,
|
|
193
|
+
client_id: token.clientId,
|
|
194
|
+
user_id: token.userId ?? null,
|
|
195
|
+
scopes: encodeList(token.scopes),
|
|
196
|
+
expires_at: token.expiresAt,
|
|
197
|
+
revoked_at: token.revokedAt ?? null,
|
|
198
|
+
last_used_at: token.lastUsedAt ?? null,
|
|
199
|
+
family_id: token.familyId ?? null,
|
|
200
|
+
audience: encodeList(token.audience ?? []),
|
|
201
|
+
created_at: new Date(),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async findAccessToken(tokenHash) {
|
|
205
|
+
const row = await this.#db
|
|
206
|
+
.from(this.#tables.accessTokens)
|
|
207
|
+
.where({ token_hash: tokenHash })
|
|
208
|
+
.first();
|
|
209
|
+
return row === null ? null : readAccessToken(row);
|
|
210
|
+
}
|
|
211
|
+
async revokeAccessToken(tokenHash, at) {
|
|
212
|
+
await this.#db
|
|
213
|
+
.from(this.#tables.accessTokens)
|
|
214
|
+
.where({ token_hash: tokenHash })
|
|
215
|
+
.whereNull("revoked_at")
|
|
216
|
+
.update({ revoked_at: at });
|
|
217
|
+
}
|
|
218
|
+
async touchAccessToken(tokenHash, at) {
|
|
219
|
+
await this.#db
|
|
220
|
+
.from(this.#tables.accessTokens)
|
|
221
|
+
.where({ token_hash: tokenHash })
|
|
222
|
+
.update({ last_used_at: at });
|
|
223
|
+
}
|
|
224
|
+
async saveRefreshToken(token) {
|
|
225
|
+
await this.#db.table(this.#tables.refreshTokens).insert({
|
|
226
|
+
token_hash: token.tokenHash,
|
|
227
|
+
family_id: token.familyId,
|
|
228
|
+
client_id: token.clientId,
|
|
229
|
+
user_id: token.userId ?? null,
|
|
230
|
+
scopes: encodeList(token.scopes),
|
|
231
|
+
expires_at: token.expiresAt,
|
|
232
|
+
consumed_at: token.consumedAt ?? null,
|
|
233
|
+
revoked_at: token.revokedAt ?? null,
|
|
234
|
+
resources: encodeList(token.resources ?? []),
|
|
235
|
+
created_at: new Date(),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async findRefreshToken(tokenHash) {
|
|
239
|
+
const row = await this.#db
|
|
240
|
+
.from(this.#tables.refreshTokens)
|
|
241
|
+
.where({ token_hash: tokenHash })
|
|
242
|
+
.first();
|
|
243
|
+
if (row === null)
|
|
244
|
+
return null;
|
|
245
|
+
const userId = optionalString(row.user_id);
|
|
246
|
+
const consumedAt = decodeDate(row.consumed_at);
|
|
247
|
+
const revokedAt = decodeDate(row.revoked_at);
|
|
248
|
+
return {
|
|
249
|
+
tokenHash: requireString(row.token_hash, "token_hash"),
|
|
250
|
+
familyId: requireString(row.family_id, "family_id"),
|
|
251
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
252
|
+
...(userId === undefined ? {} : { userId }),
|
|
253
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
254
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
255
|
+
...(consumedAt === undefined ? {} : { consumedAt }),
|
|
256
|
+
...(revokedAt === undefined ? {} : { revokedAt }),
|
|
257
|
+
...listOrNothing("resources", row.resources),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/** Same one-statement guarantee as a code: rotation depends on it. */
|
|
261
|
+
async consumeRefreshToken(tokenHash, at) {
|
|
262
|
+
const result = await this.#db
|
|
263
|
+
.from(this.#tables.refreshTokens)
|
|
264
|
+
.where({ token_hash: tokenHash })
|
|
265
|
+
.whereNull("consumed_at")
|
|
266
|
+
.update({ consumed_at: at });
|
|
267
|
+
return affected(result) === 1;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* End a family: the refresh tokens AND the access tokens they bought.
|
|
271
|
+
*
|
|
272
|
+
* Both, because revoking only the refresh half would leave an access token
|
|
273
|
+
* already minted alive for its full lifetime — which is most of what this
|
|
274
|
+
* revocation exists to prevent.
|
|
275
|
+
*/
|
|
276
|
+
async revokeFamily(familyId, at) {
|
|
277
|
+
await this.#db
|
|
278
|
+
.from(this.#tables.refreshTokens)
|
|
279
|
+
.where({ family_id: familyId })
|
|
280
|
+
.whereNull("revoked_at")
|
|
281
|
+
.update({ revoked_at: at });
|
|
282
|
+
await this.#db
|
|
283
|
+
.from(this.#tables.accessTokens)
|
|
284
|
+
.where({ family_id: familyId })
|
|
285
|
+
.whereNull("revoked_at")
|
|
286
|
+
.update({ revoked_at: at });
|
|
287
|
+
}
|
|
288
|
+
async findConsent(userId, clientId) {
|
|
289
|
+
const row = await this.#db
|
|
290
|
+
.from(this.#tables.consents)
|
|
291
|
+
.where({ user_id: userId, client_id: clientId })
|
|
292
|
+
.first();
|
|
293
|
+
if (row === null)
|
|
294
|
+
return null;
|
|
295
|
+
return {
|
|
296
|
+
userId: requireString(row.user_id, "user_id"),
|
|
297
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
298
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
299
|
+
grantedAt: requireDate(row.granted_at, "granted_at"),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Record a consent, replacing what was there.
|
|
304
|
+
*
|
|
305
|
+
* Delete then insert rather than an upsert: the dialects spell an upsert
|
|
306
|
+
* three different ways, and re-granting is not a hot path.
|
|
307
|
+
*/
|
|
308
|
+
async saveConsent(consent) {
|
|
309
|
+
await this.#db
|
|
310
|
+
.from(this.#tables.consents)
|
|
311
|
+
.where({ user_id: consent.userId, client_id: consent.clientId })
|
|
312
|
+
.delete();
|
|
313
|
+
await this.#db.table(this.#tables.consents).insert({
|
|
314
|
+
user_id: consent.userId,
|
|
315
|
+
client_id: consent.clientId,
|
|
316
|
+
scopes: encodeList(consent.scopes),
|
|
317
|
+
granted_at: consent.grantedAt,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
async listConsents(userId) {
|
|
321
|
+
const rows = await this.#db
|
|
322
|
+
.from(this.#tables.consents)
|
|
323
|
+
.where({ user_id: userId })
|
|
324
|
+
.orderBy("granted_at", "desc")
|
|
325
|
+
.exec();
|
|
326
|
+
return rows.map((row) => ({
|
|
327
|
+
userId: requireString(row.user_id, "user_id"),
|
|
328
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
329
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
330
|
+
grantedAt: requireDate(row.granted_at, "granted_at"),
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
/** Expired ones included — see the contract for why. */
|
|
334
|
+
async listAccessTokens(userId) {
|
|
335
|
+
const rows = await this.#db
|
|
336
|
+
.from(this.#tables.accessTokens)
|
|
337
|
+
.where({ user_id: userId })
|
|
338
|
+
.orderBy("expires_at", "desc")
|
|
339
|
+
.exec();
|
|
340
|
+
return rows.map(readAccessToken);
|
|
341
|
+
}
|
|
342
|
+
async revokeAccessFor(userId, clientId, at) {
|
|
343
|
+
for (const table of [
|
|
344
|
+
this.#tables.accessTokens,
|
|
345
|
+
this.#tables.refreshTokens,
|
|
346
|
+
]) {
|
|
347
|
+
await this.#db
|
|
348
|
+
.from(table)
|
|
349
|
+
.where({ user_id: userId, client_id: clientId })
|
|
350
|
+
.whereNull("revoked_at")
|
|
351
|
+
.update({ revoked_at: at });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async deleteConsent(userId, clientId) {
|
|
355
|
+
await this.#db
|
|
356
|
+
.from(this.#tables.consents)
|
|
357
|
+
.where({ user_id: userId, client_id: clientId })
|
|
358
|
+
.delete();
|
|
359
|
+
}
|
|
360
|
+
/** Drop what has expired and can no longer be presented. */
|
|
361
|
+
async prune(now) {
|
|
362
|
+
for (const table of [
|
|
363
|
+
this.#tables.authorizationCodes,
|
|
364
|
+
this.#tables.accessTokens,
|
|
365
|
+
this.#tables.refreshTokens,
|
|
366
|
+
]) {
|
|
367
|
+
await this.#db.from(table).where("expires_at", "<", now).delete();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** One access-token row, as the contract's shape. */
|
|
372
|
+
function readAccessToken(row) {
|
|
373
|
+
const userId = optionalString(row.user_id);
|
|
374
|
+
const revokedAt = decodeDate(row.revoked_at);
|
|
375
|
+
const lastUsedAt = decodeDate(row.last_used_at);
|
|
376
|
+
const familyId = optionalString(row.family_id);
|
|
377
|
+
return {
|
|
378
|
+
tokenHash: requireString(row.token_hash, "token_hash"),
|
|
379
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
380
|
+
...(userId === undefined ? {} : { userId }),
|
|
381
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
382
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
383
|
+
...(revokedAt === undefined ? {} : { revokedAt }),
|
|
384
|
+
...(lastUsedAt === undefined ? {} : { lastUsedAt }),
|
|
385
|
+
...(familyId === undefined ? {} : { familyId }),
|
|
386
|
+
...listOrNothing("audience", row.audience),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function listOrNothing(key, value) {
|
|
390
|
+
const list = decodeList(value, key);
|
|
391
|
+
if (list.length === 0)
|
|
392
|
+
return {};
|
|
393
|
+
return key === "audience" ? { audience: list } : { resources: list };
|
|
394
|
+
}
|
|
395
|
+
function isGrantType(value) {
|
|
396
|
+
return (value === "authorization_code" ||
|
|
397
|
+
value === "refresh_token" ||
|
|
398
|
+
value === "client_credentials");
|
|
399
|
+
}
|
|
400
|
+
function readAuthMethod(value) {
|
|
401
|
+
if (value === "client_secret_basic" || value === "client_secret_post") {
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
// A row holding nothing usable is read as a public client, which is the
|
|
405
|
+
// method that grants the least.
|
|
406
|
+
return "none";
|
|
407
|
+
}
|
|
408
|
+
//# sourceMappingURL=atlas.js.map
|