@boopkit/cap-oauth-server 0.1.0
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/capability.json +120 -0
- package/dist/authorization-code-service.d.ts +105 -0
- package/dist/authorization-code-service.d.ts.map +1 -0
- package/dist/authorization-code-service.js +142 -0
- package/dist/authorization-code-service.js.map +1 -0
- package/dist/bearer.d.ts +146 -0
- package/dist/bearer.d.ts.map +1 -0
- package/dist/bearer.js +150 -0
- package/dist/bearer.js.map +1 -0
- package/dist/body-field.d.ts +24 -0
- package/dist/body-field.d.ts.map +1 -0
- package/dist/body-field.js +43 -0
- package/dist/body-field.js.map +1 -0
- package/dist/client-registry-service.d.ts +120 -0
- package/dist/client-registry-service.d.ts.map +1 -0
- package/dist/client-registry-service.js +450 -0
- package/dist/client-registry-service.js.map +1 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +157 -0
- package/dist/config.js.map +1 -0
- package/dist/connections-controller.d.ts +106 -0
- package/dist/connections-controller.d.ts.map +1 -0
- package/dist/connections-controller.js +161 -0
- package/dist/connections-controller.js.map +1 -0
- package/dist/consent-controller.d.ts +197 -0
- package/dist/consent-controller.d.ts.map +1 -0
- package/dist/consent-controller.js +532 -0
- package/dist/consent-controller.js.map +1 -0
- package/dist/discovery-controller.d.ts +16 -0
- package/dist/discovery-controller.d.ts.map +1 -0
- package/dist/discovery-controller.js +52 -0
- package/dist/discovery-controller.js.map +1 -0
- package/dist/discovery.d.ts +95 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +101 -0
- package/dist/discovery.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/express.d.ts +66 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +3 -0
- package/dist/express.js.map +1 -0
- package/dist/grant-service.d.ts +102 -0
- package/dist/grant-service.d.ts.map +1 -0
- package/dist/grant-service.js +137 -0
- package/dist/grant-service.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata-document.d.ts +60 -0
- package/dist/metadata-document.d.ts.map +1 -0
- package/dist/metadata-document.js +148 -0
- package/dist/metadata-document.js.map +1 -0
- package/dist/oauth-error.d.ts +45 -0
- package/dist/oauth-error.d.ts.map +1 -0
- package/dist/oauth-error.js +51 -0
- package/dist/oauth-error.js.map +1 -0
- package/dist/pkce.d.ts +29 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +65 -0
- package/dist/pkce.js.map +1 -0
- package/dist/property-array.d.ts +60 -0
- package/dist/property-array.d.ts.map +1 -0
- package/dist/property-array.js +31 -0
- package/dist/property-array.js.map +1 -0
- package/dist/public-id.d.ts +35 -0
- package/dist/public-id.d.ts.map +1 -0
- package/dist/public-id.js +76 -0
- package/dist/public-id.js.map +1 -0
- package/dist/rate-limit.d.ts +56 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +55 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/redirect-uri.d.ts +89 -0
- package/dist/redirect-uri.d.ts.map +1 -0
- package/dist/redirect-uri.js +249 -0
- package/dist/redirect-uri.js.map +1 -0
- package/dist/registration-controller.d.ts +62 -0
- package/dist/registration-controller.d.ts.map +1 -0
- package/dist/registration-controller.js +79 -0
- package/dist/registration-controller.js.map +1 -0
- package/dist/request-client.d.ts +19 -0
- package/dist/request-client.d.ts.map +1 -0
- package/dist/request-client.js +32 -0
- package/dist/request-client.js.map +1 -0
- package/dist/revocation-controller.d.ts +28 -0
- package/dist/revocation-controller.d.ts.map +1 -0
- package/dist/revocation-controller.js +176 -0
- package/dist/revocation-controller.js.map +1 -0
- package/dist/revocation-service.d.ts +113 -0
- package/dist/revocation-service.d.ts.map +1 -0
- package/dist/revocation-service.js +121 -0
- package/dist/revocation-service.js.map +1 -0
- package/dist/token-controller.d.ts +79 -0
- package/dist/token-controller.d.ts.map +1 -0
- package/dist/token-controller.js +207 -0
- package/dist/token-controller.js.map +1 -0
- package/dist/token-service.d.ts +175 -0
- package/dist/token-service.d.ts.map +1 -0
- package/dist/token-service.js +251 -0
- package/dist/token-service.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/user-store.d.ts +110 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +40 -0
- package/dist/user-store.js.map +1 -0
- package/package.json +37 -0
- package/scaffold/prisma/schema/oauth-clients.prisma +80 -0
- package/scaffold/reference/oauth-server.ref.md +478 -0
- package/scaffold/src/middleware/oauth-server.ts +227 -0
- package/scaffold/src/scripts/seed-oauth-clients.ts +72 -0
- package/scaffold/src/services/oauth-server.service.ts +101 -0
- package/scaffold/styles/oauth-server.css +345 -0
- package/scaffold/views/oauth-connected-clients.hbs +98 -0
- package/scaffold/views/oauth-consent-error.hbs +22 -0
- package/scaffold/views/oauth-consent.hbs +138 -0
- package/scaffold/views/oauth-disconnect-confirm.hbs +57 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRevocationHandlers = createRevocationHandlers;
|
|
4
|
+
exports.createRevocationRouter = createRevocationRouter;
|
|
5
|
+
const express_1 = require("express");
|
|
6
|
+
const body_field_js_1 = require("./body-field.js");
|
|
7
|
+
const oauth_error_js_1 = require("./oauth-error.js");
|
|
8
|
+
const request_client_js_1 = require("./request-client.js");
|
|
9
|
+
const revocation_service_js_1 = require("./revocation-service.js");
|
|
10
|
+
const token_service_js_1 = require("./token-service.js");
|
|
11
|
+
/**
|
|
12
|
+
* `POST /oauth/revoke` — RFC 7009, the third and last way a connection ends.
|
|
13
|
+
*
|
|
14
|
+
* The other two were already here. A person disconnecting in the application
|
|
15
|
+
* goes through `/connections` and its confirmation page; an operator retiring a
|
|
16
|
+
* compromised client calls `revocationService.revokeClient`. This is the
|
|
17
|
+
* **client-driven** case: a connector handing a token back because the user
|
|
18
|
+
* disconnected on its side, which is the only one of the three that a piece of
|
|
19
|
+
* software rather than a person initiates.
|
|
20
|
+
*
|
|
21
|
+
* It exists because the discovery document advertises `revocation_endpoint`, and
|
|
22
|
+
* this package's own rule — argued in `discovery.ts`, and enforced on the device
|
|
23
|
+
* grant type by `OAUTH_DEVICE_CODE_ENABLED` — is that advertising an endpoint
|
|
24
|
+
* which answers 404 is a lie a client cannot interpret: having read the
|
|
25
|
+
* metadata, it reads the 404 as a server fault rather than as an unsupported
|
|
26
|
+
* feature. The alternative was taking the field back out. Mounting it is better,
|
|
27
|
+
* because a connector that cannot hand a token back leaves the user to discover
|
|
28
|
+
* the still-live connection in an application they may never open again.
|
|
29
|
+
*
|
|
30
|
+
* ## Which token ends what, which is the decision this file exists to record
|
|
31
|
+
*
|
|
32
|
+
* RFC 7009 §2.1 leaves it open in both directions: revoking a refresh token
|
|
33
|
+
* SHOULD also invalidate the access tokens issued from it, and revoking an
|
|
34
|
+
* access token MAY invalidate the refresh token. This server answers the two
|
|
35
|
+
* halves differently, and the second answer is the non-obvious one.
|
|
36
|
+
*
|
|
37
|
+
* **A refresh token ends the whole grant.** Take the SHOULD. The grant is this
|
|
38
|
+
* server's unit of connection — it is what the guard reads, what `/connections`
|
|
39
|
+
* lists, and what carries the revocation reason — and a connector calling this
|
|
40
|
+
* on user-disconnect wants the connection gone rather than one credential of it.
|
|
41
|
+
* So the presented refresh token routes to `revocationService`, which end-dates
|
|
42
|
+
* the grant with `REVOKED_BY_CLIENT` and sweeps every live token row the client
|
|
43
|
+
* holds for that user.
|
|
44
|
+
*
|
|
45
|
+
* **An access token ends only its own row. Decline the MAY.** This server
|
|
46
|
+
* rotates the refresh token on every use but deliberately does *not* end-date
|
|
47
|
+
* the superseded **access** row, so the access token from before a refresh stays
|
|
48
|
+
* live until its TTL runs out. A tidy client that revokes that old access token
|
|
49
|
+
* right after refreshing is doing exactly the housekeeping RFC 7009 exists for —
|
|
50
|
+
* and under the MAY it would destroy the grant it had just refreshed. That is a
|
|
51
|
+
* self-inflicted outage the specification permits and we should not build.
|
|
52
|
+
*
|
|
53
|
+
* **An already-ended grant still gets the presented row swept.**
|
|
54
|
+
* `revokeClientForUser` looks for an *active* grant and answers `null` when
|
|
55
|
+
* there is none, and rotation leaves superseded refresh rows deliberately
|
|
56
|
+
* active — so a live refresh row under a dead grant is a reachable state, and
|
|
57
|
+
* the fall-through to `revokeByKey` is what ends it. Without that, the answer
|
|
58
|
+
* would still be 200 and the row would still be there.
|
|
59
|
+
*
|
|
60
|
+
* ## §2.2, and the rule that everything is 200
|
|
61
|
+
*
|
|
62
|
+
* A revocation response says nothing about the token. Revoked, already invalid,
|
|
63
|
+
* expired, never existed, or belonging to a different client — all of them are
|
|
64
|
+
* **200 with an empty body**, because any other answer tells an unauthenticated
|
|
65
|
+
* caller (every client here is public) whether a string it holds is a real
|
|
66
|
+
* credential. The last of those is the one that feels wrong and is not: there is
|
|
67
|
+
* no way to report "that token is not yours" without first confirming the token
|
|
68
|
+
* exists.
|
|
69
|
+
*
|
|
70
|
+
* The errors are only about the request itself: `400 invalid_request` for a
|
|
71
|
+
* missing `token` or `client_id`, `400 unsupported_token_type` for a
|
|
72
|
+
* `token_type_hint` naming a species this server does not issue (§2.2.1), and
|
|
73
|
+
* `401 invalid_client` for a client that is unknown or suspended.
|
|
74
|
+
*
|
|
75
|
+
* ## The hint is advisory, so both domains are tried
|
|
76
|
+
*
|
|
77
|
+
* §2.1 says the hint is an optimisation and a server MUST fall back to searching
|
|
78
|
+
* the other types. It is honoured as an ordering and nothing more: a client that
|
|
79
|
+
* mislabels a correctly-presented token still gets it revoked. A given token
|
|
80
|
+
* exists in at most one domain — the raw value is hashed into the key — so the
|
|
81
|
+
* order only ever saves a lookup, never changes the outcome.
|
|
82
|
+
*/
|
|
83
|
+
/**
|
|
84
|
+
* RFC 7009 §2.1's registered hints, mapped to this server's token domains.
|
|
85
|
+
*
|
|
86
|
+
* A `Map` rather than an object literal, because the hint is attacker-supplied
|
|
87
|
+
* and an object's keys include the ones it inherited: `token_type_hint=toString`
|
|
88
|
+
* would read back a function from `Object.prototype` and pass for a recognised
|
|
89
|
+
* hint. A `Map` knows only what was put in it.
|
|
90
|
+
*/
|
|
91
|
+
const DOMAIN_BY_HINT = new Map([
|
|
92
|
+
['access_token', token_service_js_1.OAUTH_ACCESS_DOMAIN],
|
|
93
|
+
['refresh_token', token_service_js_1.OAUTH_REFRESH_DOMAIN],
|
|
94
|
+
]);
|
|
95
|
+
function createRevocationHandlers(deps) {
|
|
96
|
+
const { clientRegistry, tokenService, revocationService } = deps;
|
|
97
|
+
/** The hint's domain first, then the other. See the header: advisory only. */
|
|
98
|
+
function searchOrder(hint) {
|
|
99
|
+
const hinted = hint === undefined ? undefined : DOMAIN_BY_HINT.get(hint);
|
|
100
|
+
if (hint !== undefined && hinted === undefined) {
|
|
101
|
+
// §2.2.1. An unrecognised hint is the one thing about the token itself
|
|
102
|
+
// that is safe to refuse, because it says nothing about whether the token
|
|
103
|
+
// is real — it is a statement about the request's vocabulary.
|
|
104
|
+
throw new oauth_error_js_1.OAuthError('unsupported_token_type', `this server issues access_token and refresh_token, not ${hint}`);
|
|
105
|
+
}
|
|
106
|
+
if (hinted === token_service_js_1.OAUTH_REFRESH_DOMAIN)
|
|
107
|
+
return [token_service_js_1.OAUTH_REFRESH_DOMAIN, token_service_js_1.OAUTH_ACCESS_DOMAIN];
|
|
108
|
+
return [token_service_js_1.OAUTH_ACCESS_DOMAIN, token_service_js_1.OAUTH_REFRESH_DOMAIN];
|
|
109
|
+
}
|
|
110
|
+
/** End what this token stands for. See the header for why the two differ. */
|
|
111
|
+
async function endCredential(domain, userId, identityKey, clientPublicId) {
|
|
112
|
+
if (domain === token_service_js_1.OAUTH_ACCESS_DOMAIN) {
|
|
113
|
+
await tokenService.revokeByKey(token_service_js_1.OAUTH_ACCESS_DOMAIN, identityKey);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const revoked = await revocationService.revokeClientForUser(userId, clientPublicId, revocation_service_js_1.REVOKED_BY_CLIENT);
|
|
117
|
+
// No active grant to end — but the row that was presented is live, or it
|
|
118
|
+
// would not have resolved a moment ago. Sweep it on its own.
|
|
119
|
+
if (!revoked) {
|
|
120
|
+
await tokenService.revokeByKey(token_service_js_1.OAUTH_REFRESH_DOMAIN, identityKey);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const revoke = async (req, res, next) => {
|
|
124
|
+
// §2.2 sends no body worth caching, and the request carried a credential.
|
|
125
|
+
res.set('Cache-Control', 'no-store');
|
|
126
|
+
res.set('Pragma', 'no-cache');
|
|
127
|
+
try {
|
|
128
|
+
const presented = (0, body_field_js_1.required)(req.body, 'token');
|
|
129
|
+
const order = searchOrder((0, body_field_js_1.field)(req.body, 'token_type_hint'));
|
|
130
|
+
const client = await (0, request_client_js_1.resolveRequestClient)(clientRegistry, req.body);
|
|
131
|
+
// The client prefix is readable off the token before any lookup, so a
|
|
132
|
+
// token belonging to somebody else is dropped without touching the
|
|
133
|
+
// database — and, per §2.2, without being distinguishable from success.
|
|
134
|
+
const parsed = tokenService.parseToken(presented);
|
|
135
|
+
if (parsed && parsed.clientPublicId === client.publicId) {
|
|
136
|
+
for (const domain of order) {
|
|
137
|
+
const resolved = await tokenService.resolve(domain, presented);
|
|
138
|
+
if (!resolved)
|
|
139
|
+
continue;
|
|
140
|
+
await endCredential(domain, resolved.userId, resolved.identityKey, client.publicId);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// §2.2: 200 and nothing else, whatever happened above.
|
|
145
|
+
res.status(200).end();
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if ((0, oauth_error_js_1.isOAuthError)(err)) {
|
|
149
|
+
if (err.error === 'invalid_client') {
|
|
150
|
+
res.set('WWW-Authenticate', 'Bearer');
|
|
151
|
+
}
|
|
152
|
+
res.status(err.status).json(err.toBody());
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
next(err);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
return { revoke };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* `POST /oauth/revoke`, with both body parsers attached.
|
|
162
|
+
*
|
|
163
|
+
* Form and JSON, for the reason `createTokenRouter` mounts both: the RFC says
|
|
164
|
+
* form and the ecosystem sends JSON, and a client that gets this wrong on the
|
|
165
|
+
* revocation endpoint has already got it right on the token endpoint. The
|
|
166
|
+
* router parses its own body so the endpoint behaves the same whatever order the
|
|
167
|
+
* host application mounts its middleware in.
|
|
168
|
+
*/
|
|
169
|
+
function createRevocationRouter(options) {
|
|
170
|
+
const handlers = createRevocationHandlers(options);
|
|
171
|
+
const router = (0, express_1.Router)();
|
|
172
|
+
const limit = options.bodyLimit ?? '16kb';
|
|
173
|
+
router.post('/oauth/revoke', (0, express_1.urlencoded)({ extended: false, limit }), (0, express_1.json)({ limit }), handlers.revoke);
|
|
174
|
+
return router;
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=revocation-controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"revocation-controller.js","sourceRoot":"","sources":["../src/revocation-controller.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type GrantService, type OAuthGrantRecord } from './grant-service.js';
|
|
2
|
+
import type { TokenService } from './token-service.js';
|
|
3
|
+
import type { StoredIdentity } from './user-store.js';
|
|
4
|
+
/**
|
|
5
|
+
* Disconnecting a client — the write behind the disconnect button, and behind
|
|
6
|
+
* the operator's answer to a compromised client.
|
|
7
|
+
*
|
|
8
|
+
* ## Soft revocation, and why the signal lives on the grant
|
|
9
|
+
*
|
|
10
|
+
* Nothing is deleted. The grant record is end-dated with a reason and the
|
|
11
|
+
* client's token rows are end-dated too, so cap-users' active-only resolution
|
|
12
|
+
* refuses the very next request whatever the token's stated lifetime says. The
|
|
13
|
+
* rows that are left behind are the audit trail, which is the point: a user who
|
|
14
|
+
* has just disconnected something wants to see that it stuck, and an operator
|
|
15
|
+
* investigating an incident wants to see what was once connected.
|
|
16
|
+
*
|
|
17
|
+
* **But the identity rows cannot carry the answer, in either direction.** An
|
|
18
|
+
* ended row does not mean revoked: `tokenService.resolve` end-dates a row the
|
|
19
|
+
* first time it reads an expired one, so ordinary expiry produces the same
|
|
20
|
+
* tombstone that revocation does. And a *live* row does not mean connected:
|
|
21
|
+
* rotation leaves the superseded refresh row deliberately active, so that
|
|
22
|
+
* presenting it again is recognisable as reuse rather than as a typo, and a
|
|
23
|
+
* connector in daily use therefore accumulates live rows it will never use
|
|
24
|
+
* again. Neither the presence nor the absence of a row is the signal.
|
|
25
|
+
*
|
|
26
|
+
* The signal is the **grant record**, which carries `ended_at` and a reason, and
|
|
27
|
+
* that is what the connected-clients screen and the resource-server guard both
|
|
28
|
+
* read. A test covers exactly this: a client that rotated three times and was
|
|
29
|
+
* then revoked still reads as revoked, and every one of the token rows those
|
|
30
|
+
* rotations left behind stops working.
|
|
31
|
+
*
|
|
32
|
+
* ## The order of the two writes
|
|
33
|
+
*
|
|
34
|
+
* The grant is ended **first**, then the tokens. Both orders leave the client
|
|
35
|
+
* locked out — the guard needs a live grant *and* a live token row, so ending
|
|
36
|
+
* either one is enough — but ending the grant first makes the lockout a single
|
|
37
|
+
* write. If the token sweep then fails halfway, access is already refused and
|
|
38
|
+
* the sweep is safe to retry, whereas the other order leaves a window in which
|
|
39
|
+
* some tokens still resolve against a live grant.
|
|
40
|
+
*
|
|
41
|
+
* ## Client-wide revocation finds its own users
|
|
42
|
+
*
|
|
43
|
+
* `revokeClient` takes a client and nothing else. It asks cap-users for every
|
|
44
|
+
* identity row whose key starts with `"<publicId>|"` — the scan the token wire
|
|
45
|
+
* format was shaped to make cheap — and sweeps the users that come back, one
|
|
46
|
+
* grant at a time. The operator answering a compromised client therefore does
|
|
47
|
+
* not have to already know the blast radius, which was the old shape's real
|
|
48
|
+
* cost: `revokeClientForUsers(userIds, ...)` was correct and swept exactly the
|
|
49
|
+
* accounts somebody had thought to name.
|
|
50
|
+
*
|
|
51
|
+
* The scan is still cap-users' query rather than ours (`IdentityStore`, backed
|
|
52
|
+
* by `findAllByDomainAndKeyPrefix`), so the one hard invariant is unchanged —
|
|
53
|
+
* this capability does not reach into the identity table, by prefix or
|
|
54
|
+
* otherwise.
|
|
55
|
+
*
|
|
56
|
+
* Two things it deliberately does **not** do. It does not read revocation off
|
|
57
|
+
* the identity rows: they say who this client once reached, and the grant
|
|
58
|
+
* record says whether anything is still connected. And it does not narrow the
|
|
59
|
+
* scan to live rows, because an idle connection's rows are all end-dated by the
|
|
60
|
+
* expiry sweep while its grant is perfectly alive.
|
|
61
|
+
*/
|
|
62
|
+
/** The reason recorded when a person disconnects a client themselves. */
|
|
63
|
+
export declare const REVOKED_BY_USER = "user_revoked";
|
|
64
|
+
/** The reason recorded when an operator retires a client for everybody. */
|
|
65
|
+
export declare const REVOKED_CLIENT_WIDE = "client_revoked";
|
|
66
|
+
/**
|
|
67
|
+
* The reason recorded when the client itself hands a refresh token back at
|
|
68
|
+
* `POST /oauth/revoke` — RFC 7009's case, and the third of the three.
|
|
69
|
+
*
|
|
70
|
+
* It is a distinct word from `REVOKED_BY_USER` on purpose. Both are somebody
|
|
71
|
+
* choosing to disconnect, but only one of them was a person standing in front
|
|
72
|
+
* of this application's confirmation page; an operator reading the audit trail
|
|
73
|
+
* afterwards wants to know which door the disconnect came through.
|
|
74
|
+
*/
|
|
75
|
+
export declare const REVOKED_BY_CLIENT = "client_requested";
|
|
76
|
+
/** What one revocation did. */
|
|
77
|
+
export interface RevokedGrant {
|
|
78
|
+
/** The grant as it now stands, carrying `ended_at` and `ended_reason`. */
|
|
79
|
+
grant: OAuthGrantRecord;
|
|
80
|
+
/**
|
|
81
|
+
* The token rows this revocation end-dated.
|
|
82
|
+
*
|
|
83
|
+
* Empty is an ordinary outcome, not a failure: a grant whose access token has
|
|
84
|
+
* already expired and whose refresh token was rotated out has no live rows
|
|
85
|
+
* left to end.
|
|
86
|
+
*/
|
|
87
|
+
endedTokens: StoredIdentity[];
|
|
88
|
+
}
|
|
89
|
+
export interface RevocationService {
|
|
90
|
+
/** Disconnect one grant by its id. `null` when this user has no such grant. */
|
|
91
|
+
revokeGrant(userId: number, grantId: string, reason?: string): Promise<RevokedGrant | null>;
|
|
92
|
+
/** The same, addressed by client instead. `null` when nothing is connected. */
|
|
93
|
+
revokeClientForUser(userId: number, clientPublicId: string, reason?: string): Promise<RevokedGrant | null>;
|
|
94
|
+
/**
|
|
95
|
+
* Retire one client across the whole deployment — the compromised-client case.
|
|
96
|
+
*
|
|
97
|
+
* Finds its own users, through the token service's key-prefix scan. Answers
|
|
98
|
+
* with one entry per user that actually had a live grant, so the result's
|
|
99
|
+
* length is how many connections were broken rather than how many users the
|
|
100
|
+
* scan turned up.
|
|
101
|
+
*
|
|
102
|
+
* Throws when `clientPublicId` is not a well-formed public id, rather than
|
|
103
|
+
* quietly sweeping nothing: during an incident, a typo that reads as "no
|
|
104
|
+
* users are affected" is the worst answer available.
|
|
105
|
+
*/
|
|
106
|
+
revokeClient(clientPublicId: string, reason?: string): Promise<RevokedGrant[]>;
|
|
107
|
+
}
|
|
108
|
+
export interface RevocationServiceOptions {
|
|
109
|
+
grantService: GrantService;
|
|
110
|
+
tokenService: TokenService;
|
|
111
|
+
}
|
|
112
|
+
export declare function createRevocationService(options: RevocationServiceOptions): RevocationService;
|
|
113
|
+
//# sourceMappingURL=revocation-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"revocation-service.d.ts","sourceRoot":"","sources":["../src/revocation-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,YAAY,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC7F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAEH,yEAAyE;AACzE,eAAO,MAAM,eAAe,iBAAiB,CAAC;AAE9C,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAEpD;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,qBAAqB,CAAC;AAEpD,+BAA+B;AAC/B,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,KAAK,EAAE,gBAAgB,CAAC;IACxB;;;;;;OAMG;IACH,WAAW,EAAE,cAAc,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,+EAA+E;IAC/E,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC5F,+EAA+E;IAC/E,mBAAmB,CACjB,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,EACtB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAChC;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;CAChF;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;CAC5B;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,iBAAiB,CAmD5F"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.REVOKED_BY_CLIENT = exports.REVOKED_CLIENT_WIDE = exports.REVOKED_BY_USER = void 0;
|
|
4
|
+
exports.createRevocationService = createRevocationService;
|
|
5
|
+
const grant_service_js_1 = require("./grant-service.js");
|
|
6
|
+
/**
|
|
7
|
+
* Disconnecting a client — the write behind the disconnect button, and behind
|
|
8
|
+
* the operator's answer to a compromised client.
|
|
9
|
+
*
|
|
10
|
+
* ## Soft revocation, and why the signal lives on the grant
|
|
11
|
+
*
|
|
12
|
+
* Nothing is deleted. The grant record is end-dated with a reason and the
|
|
13
|
+
* client's token rows are end-dated too, so cap-users' active-only resolution
|
|
14
|
+
* refuses the very next request whatever the token's stated lifetime says. The
|
|
15
|
+
* rows that are left behind are the audit trail, which is the point: a user who
|
|
16
|
+
* has just disconnected something wants to see that it stuck, and an operator
|
|
17
|
+
* investigating an incident wants to see what was once connected.
|
|
18
|
+
*
|
|
19
|
+
* **But the identity rows cannot carry the answer, in either direction.** An
|
|
20
|
+
* ended row does not mean revoked: `tokenService.resolve` end-dates a row the
|
|
21
|
+
* first time it reads an expired one, so ordinary expiry produces the same
|
|
22
|
+
* tombstone that revocation does. And a *live* row does not mean connected:
|
|
23
|
+
* rotation leaves the superseded refresh row deliberately active, so that
|
|
24
|
+
* presenting it again is recognisable as reuse rather than as a typo, and a
|
|
25
|
+
* connector in daily use therefore accumulates live rows it will never use
|
|
26
|
+
* again. Neither the presence nor the absence of a row is the signal.
|
|
27
|
+
*
|
|
28
|
+
* The signal is the **grant record**, which carries `ended_at` and a reason, and
|
|
29
|
+
* that is what the connected-clients screen and the resource-server guard both
|
|
30
|
+
* read. A test covers exactly this: a client that rotated three times and was
|
|
31
|
+
* then revoked still reads as revoked, and every one of the token rows those
|
|
32
|
+
* rotations left behind stops working.
|
|
33
|
+
*
|
|
34
|
+
* ## The order of the two writes
|
|
35
|
+
*
|
|
36
|
+
* The grant is ended **first**, then the tokens. Both orders leave the client
|
|
37
|
+
* locked out — the guard needs a live grant *and* a live token row, so ending
|
|
38
|
+
* either one is enough — but ending the grant first makes the lockout a single
|
|
39
|
+
* write. If the token sweep then fails halfway, access is already refused and
|
|
40
|
+
* the sweep is safe to retry, whereas the other order leaves a window in which
|
|
41
|
+
* some tokens still resolve against a live grant.
|
|
42
|
+
*
|
|
43
|
+
* ## Client-wide revocation finds its own users
|
|
44
|
+
*
|
|
45
|
+
* `revokeClient` takes a client and nothing else. It asks cap-users for every
|
|
46
|
+
* identity row whose key starts with `"<publicId>|"` — the scan the token wire
|
|
47
|
+
* format was shaped to make cheap — and sweeps the users that come back, one
|
|
48
|
+
* grant at a time. The operator answering a compromised client therefore does
|
|
49
|
+
* not have to already know the blast radius, which was the old shape's real
|
|
50
|
+
* cost: `revokeClientForUsers(userIds, ...)` was correct and swept exactly the
|
|
51
|
+
* accounts somebody had thought to name.
|
|
52
|
+
*
|
|
53
|
+
* The scan is still cap-users' query rather than ours (`IdentityStore`, backed
|
|
54
|
+
* by `findAllByDomainAndKeyPrefix`), so the one hard invariant is unchanged —
|
|
55
|
+
* this capability does not reach into the identity table, by prefix or
|
|
56
|
+
* otherwise.
|
|
57
|
+
*
|
|
58
|
+
* Two things it deliberately does **not** do. It does not read revocation off
|
|
59
|
+
* the identity rows: they say who this client once reached, and the grant
|
|
60
|
+
* record says whether anything is still connected. And it does not narrow the
|
|
61
|
+
* scan to live rows, because an idle connection's rows are all end-dated by the
|
|
62
|
+
* expiry sweep while its grant is perfectly alive.
|
|
63
|
+
*/
|
|
64
|
+
/** The reason recorded when a person disconnects a client themselves. */
|
|
65
|
+
exports.REVOKED_BY_USER = 'user_revoked';
|
|
66
|
+
/** The reason recorded when an operator retires a client for everybody. */
|
|
67
|
+
exports.REVOKED_CLIENT_WIDE = 'client_revoked';
|
|
68
|
+
/**
|
|
69
|
+
* The reason recorded when the client itself hands a refresh token back at
|
|
70
|
+
* `POST /oauth/revoke` — RFC 7009's case, and the third of the three.
|
|
71
|
+
*
|
|
72
|
+
* It is a distinct word from `REVOKED_BY_USER` on purpose. Both are somebody
|
|
73
|
+
* choosing to disconnect, but only one of them was a person standing in front
|
|
74
|
+
* of this application's confirmation page; an operator reading the audit trail
|
|
75
|
+
* afterwards wants to know which door the disconnect came through.
|
|
76
|
+
*/
|
|
77
|
+
exports.REVOKED_BY_CLIENT = 'client_requested';
|
|
78
|
+
function createRevocationService(options) {
|
|
79
|
+
const { grantService, tokenService } = options;
|
|
80
|
+
/** End the grant, then sweep its tokens. See the header for the order. */
|
|
81
|
+
async function revoke(userId, grant, reason) {
|
|
82
|
+
const ended = (await grantService.end(userId, grant.grant_id, reason)) ?? grant;
|
|
83
|
+
const endedTokens = await tokenService.revokeTokensForClient(userId, grant.client_public_id);
|
|
84
|
+
return { grant: ended, endedTokens };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
async revokeGrant(userId, grantId, reason = exports.REVOKED_BY_USER) {
|
|
88
|
+
const grant = await grantService.findById(userId, grantId);
|
|
89
|
+
if (!grant)
|
|
90
|
+
return null;
|
|
91
|
+
// An already-ended grant still gets its tokens swept. `grantService.end`
|
|
92
|
+
// keeps the original reason and timestamp, so this is idempotent rather
|
|
93
|
+
// than a second revocation, and it repairs a run that failed partway.
|
|
94
|
+
return revoke(userId, grant, reason);
|
|
95
|
+
},
|
|
96
|
+
async revokeClientForUser(userId, clientPublicId, reason = exports.REVOKED_BY_USER) {
|
|
97
|
+
const grant = await grantService.findActive(userId, clientPublicId);
|
|
98
|
+
if (!grant)
|
|
99
|
+
return null;
|
|
100
|
+
return revoke(userId, grant, reason);
|
|
101
|
+
},
|
|
102
|
+
async revokeClient(clientPublicId, reason = exports.REVOKED_CLIENT_WIDE) {
|
|
103
|
+
// Ended rows included, so a user whose tokens have all expired is still
|
|
104
|
+
// found — their grant is what says they are connected. See the header.
|
|
105
|
+
const userIds = await tokenService.findUserIdsForClient(clientPublicId);
|
|
106
|
+
const revoked = [];
|
|
107
|
+
for (const userId of userIds) {
|
|
108
|
+
const grants = await grantService.list(userId);
|
|
109
|
+
// Only this client's grants, matched on the full `client_public_id`
|
|
110
|
+
// rather than on a prefix of it: a `startsWith` here would be the
|
|
111
|
+
// too-broad scan that ends a neighbouring client's connections.
|
|
112
|
+
const mine = grants.filter((grant) => grant.client_public_id === clientPublicId && (0, grant_service_js_1.isGrantActive)(grant));
|
|
113
|
+
for (const grant of mine) {
|
|
114
|
+
revoked.push(await revoke(userId, grant, reason));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return revoked;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=revocation-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"revocation-service.js","sourceRoot":"","sources":["../src/revocation-service.ts"],"names":[],"mappings":";;;AA2HA,0DAmDC;AA9KD,yDAA6F;AAI7F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAEH,yEAAyE;AAC5D,QAAA,eAAe,GAAG,cAAc,CAAC;AAE9C,2EAA2E;AAC9D,QAAA,mBAAmB,GAAG,gBAAgB,CAAC;AAEpD;;;;;;;;GAQG;AACU,QAAA,iBAAiB,GAAG,kBAAkB,CAAC;AA6CpD,SAAgB,uBAAuB,CAAC,OAAiC;IACvE,MAAM,EAAE,YAAY,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC;IAE/C,0EAA0E;IAC1E,KAAK,UAAU,MAAM,CACnB,MAAc,EACd,KAAuB,EACvB,MAAc;QAEd,MAAM,KAAK,GAAG,CAAC,MAAM,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,IAAI,KAAK,CAAC;QAChF,MAAM,WAAW,GAAG,MAAM,YAAY,CAAC,qBAAqB,CAAC,MAAM,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC7F,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC;IACvC,CAAC;IAED,OAAO;QACL,KAAK,CAAC,WAAW,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,uBAAe;YACzD,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YAC3D,IAAI,CAAC,KAAK;gBAAE,OAAO,IAAI,CAAC;YACxB,yEAAyE;YACzE,wEAAwE;YACxE,sEAAsE;YACtE,OAAO,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;QACvC,CAAC;QAED,KAAK,CAAC,mBAAmB,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,uBAAe;YACxE,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,UAAU,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;YACpE,IAAI,CAAC,KAAK;gBAAE,OAAO,IAAI,CAAC;YACxB,OAAO,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;QACvC,CAAC;QAED,KAAK,CAAC,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,2BAAmB;YAC7D,wEAAwE;YACxE,uEAAuE;YACvE,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,oBAAoB,CAAC,cAAc,CAAC,CAAC;YAExE,MAAM,OAAO,GAAmB,EAAE,CAAC;YACnC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;gBAC7B,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;gBAC/C,oEAAoE;gBACpE,kEAAkE;gBAClE,gEAAgE;gBAChE,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CACxB,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,gBAAgB,KAAK,cAAc,IAAI,IAAA,gCAAa,EAAC,KAAK,CAAC,CAC7E,CAAC;gBACF,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;oBACzB,OAAO,CAAC,IAAI,CAAC,MAAM,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;gBACpD,CAAC;YACH,CAAC;YACD,OAAO,OAAO,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Router, type RequestHandler } from 'express';
|
|
2
|
+
import type { AuthorizationCodeService } from './authorization-code-service.js';
|
|
3
|
+
import type { ClientRegistryService } from './client-registry-service.js';
|
|
4
|
+
import type { GrantService } from './grant-service.js';
|
|
5
|
+
import { type TokenService } from './token-service.js';
|
|
6
|
+
/**
|
|
7
|
+
* `POST /oauth/token` — the two grants a boop application issues tokens for.
|
|
8
|
+
*
|
|
9
|
+
* ## Both body encodings, and only one of them is the specification's
|
|
10
|
+
*
|
|
11
|
+
* RFC 6749 §4.1.3 says `application/x-www-form-urlencoded`, full stop. ChatGPT
|
|
12
|
+
* sends JSON. So both parsers are mounted, and that is **tolerated input rather
|
|
13
|
+
* than a second supported format**: a client that reads the metadata and
|
|
14
|
+
* follows the RFC sends a form, and one that does not still connects. The
|
|
15
|
+
* umbrella spec lists "JSON-only token endpoints" among the donor defects; the
|
|
16
|
+
* mirror-image defect — form-only, in an ecosystem that demonstrably sends JSON
|
|
17
|
+
* — costs a user a connection they cannot debug.
|
|
18
|
+
*
|
|
19
|
+
* ## Reuse of a rotated refresh token revokes the whole grant
|
|
20
|
+
*
|
|
21
|
+
* Presenting a refresh token that has already been rotated is evidence of
|
|
22
|
+
* theft, because the legitimate client holds the newer one. The alternative —
|
|
23
|
+
* refusing the request and leaving the grant alive — lets an attacker who lost
|
|
24
|
+
* the race keep trying, and lets a compromised client keep working beside the
|
|
25
|
+
* real one. So all of the grant's `oauth-access` and `oauth-refresh` rows are
|
|
26
|
+
* end-dated, the grant is end-dated with `reason: "refresh_reuse"`, and the
|
|
27
|
+
* answer is `400 invalid_grant`.
|
|
28
|
+
*
|
|
29
|
+
* **How reuse is detected, since it is not obvious.** A rotated refresh token's
|
|
30
|
+
* identity row is left *active* rather than end-dated, and the grant records
|
|
31
|
+
* the identity key of the token that is currently valid. Presenting an old
|
|
32
|
+
* token therefore still resolves — which is the point, because resolution is
|
|
33
|
+
* the only thing that yields a user id, and without a user id there is no way
|
|
34
|
+
* to reach the grant at all (grants are user properties, and cap-users'
|
|
35
|
+
* `(domain, key)` lookup is active-only). The old row grants nothing: its key
|
|
36
|
+
* does not match `grant.refresh_key`, so it takes the theft branch. End-dating
|
|
37
|
+
* the row instead would make the presented token resolve to nothing, and a
|
|
38
|
+
* stolen token would then be indistinguishable from a typo — which is precisely
|
|
39
|
+
* the case this design exists to tell apart.
|
|
40
|
+
*
|
|
41
|
+
* Superseded rows are bounded: they age out under the same
|
|
42
|
+
* `createdAt + refresh ttl` rule as any other token, and revoking the grant
|
|
43
|
+
* end-dates all of them at once.
|
|
44
|
+
*/
|
|
45
|
+
/** RFC 6749 §5.1's success body. */
|
|
46
|
+
export interface TokenResponse {
|
|
47
|
+
access_token: string;
|
|
48
|
+
token_type: 'Bearer';
|
|
49
|
+
expires_in: number;
|
|
50
|
+
refresh_token?: string;
|
|
51
|
+
scope: string;
|
|
52
|
+
}
|
|
53
|
+
export interface TokenHandlerDeps {
|
|
54
|
+
clientRegistry: ClientRegistryService;
|
|
55
|
+
tokenService: TokenService;
|
|
56
|
+
grantService: GrantService;
|
|
57
|
+
authorizationCodeService: AuthorizationCodeService;
|
|
58
|
+
}
|
|
59
|
+
/** The scope that earns a refresh token, per the discovery document. */
|
|
60
|
+
export declare const OFFLINE_ACCESS_SCOPE = "offline_access";
|
|
61
|
+
/** RFC 6749 §3.3: scopes are space-delimited. Tolerate any run of whitespace. */
|
|
62
|
+
export declare function parseScopes(raw: string | undefined): string[];
|
|
63
|
+
export declare function createTokenHandlers(deps: TokenHandlerDeps): {
|
|
64
|
+
token: RequestHandler;
|
|
65
|
+
};
|
|
66
|
+
export interface TokenRouterOptions extends TokenHandlerDeps {
|
|
67
|
+
/** Maximum accepted body size. A token request is a handful of fields. */
|
|
68
|
+
bodyLimit?: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `POST /oauth/token`, with both body parsers attached.
|
|
72
|
+
*
|
|
73
|
+
* The router parses its own body rather than relying on an app-level
|
|
74
|
+
* `express.urlencoded()`, so the endpoint behaves the same whatever order the
|
|
75
|
+
* host application mounts its middleware in — and so the limit here is the one
|
|
76
|
+
* that applies.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createTokenRouter(options: TokenRouterOptions): Router;
|
|
79
|
+
//# sourceMappingURL=token-controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-controller.d.ts","sourceRoot":"","sources":["../src/token-controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAkC,KAAK,cAAc,EAAiB,MAAM,SAAS,CAAC;AAErG,OAAO,KAAK,EAAE,wBAAwB,EAAiB,MAAM,iCAAiC,CAAC;AAE/F,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAC1E,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,oBAAoB,CAAC;AAIzE,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,oBAAoB,CAAC;AAI5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,oCAAoC;AACpC,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,QAAQ,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,cAAc,EAAE,qBAAqB,CAAC;IACtC,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;IAC3B,wBAAwB,EAAE,wBAAwB,CAAC;CACpD;AAED,wEAAwE;AACxE,eAAO,MAAM,oBAAoB,mBAAmB,CAAC;AAWrD,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAG7D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,gBAAgB,GAAG;IAAE,KAAK,EAAE,cAAc,CAAA;CAAE,CAiMrF;AAED,MAAM,WAAW,kBAAmB,SAAQ,gBAAgB;IAC1D,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,CAarE"}
|