@plantops/auth-kit 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/README.md +7 -0
- package/dist/adapters/fetch/index.d.ts +38 -0
- package/dist/adapters/fetch/index.d.ts.map +1 -0
- package/dist/adapters/fetch/index.js +52 -0
- package/dist/adapters/nestjs/auth.guard.d.ts +120 -0
- package/dist/adapters/nestjs/auth.guard.d.ts.map +1 -0
- package/dist/adapters/nestjs/auth.guard.js +165 -0
- package/dist/adapters/nestjs/index.d.ts +10 -0
- package/dist/adapters/nestjs/index.d.ts.map +1 -0
- package/dist/adapters/nestjs/index.js +12 -0
- package/dist/adapters/nestjs/permission.guard.d.ts +115 -0
- package/dist/adapters/nestjs/permission.guard.d.ts.map +1 -0
- package/dist/adapters/nestjs/permission.guard.js +167 -0
- package/dist/adapters/nestjs/require-permission.decorator.d.ts +31 -0
- package/dist/adapters/nestjs/require-permission.decorator.d.ts.map +1 -0
- package/dist/adapters/nestjs/require-permission.decorator.js +41 -0
- package/dist/adapters/nestjs/scope-resolver.d.ts +12 -0
- package/dist/adapters/nestjs/scope-resolver.d.ts.map +1 -0
- package/dist/adapters/nestjs/scope-resolver.js +24 -0
- package/dist/core/claims.d.ts +114 -0
- package/dist/core/claims.d.ts.map +1 -0
- package/dist/core/claims.js +183 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +15 -0
- package/dist/core/jwks-verifier.d.ts +78 -0
- package/dist/core/jwks-verifier.d.ts.map +1 -0
- package/dist/core/jwks-verifier.js +183 -0
- package/dist/core/jws.d.ts +96 -0
- package/dist/core/jws.d.ts.map +1 -0
- package/dist/core/jws.js +183 -0
- package/dist/core/revocation-cache.d.ts +79 -0
- package/dist/core/revocation-cache.d.ts.map +1 -0
- package/dist/core/revocation-cache.js +68 -0
- package/dist/core/scope-resolver.d.ts +235 -0
- package/dist/core/scope-resolver.d.ts.map +1 -0
- package/dist/core/scope-resolver.js +206 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local token verification against a published JWKS (Doc 03 §1, Doc 06 §11).
|
|
3
|
+
*
|
|
4
|
+
* This is the class that keeps the IAM off every module's request path. A
|
|
5
|
+
* module fetches `/iam/.well-known/jwks.json` once, caches it for the `max-age`
|
|
6
|
+
* the IAM publishes, and verifies signatures itself — so an IAM restart, a
|
|
7
|
+
* network partition, or a slow IAM does not stop a gate terminal from
|
|
8
|
+
* authenticating a request it has already been handed a token for.
|
|
9
|
+
*
|
|
10
|
+
* ## Rotation, from the verifier's side
|
|
11
|
+
*
|
|
12
|
+
* Doc 03 §1 puts the obligations on the *signer*: publish the new public key
|
|
13
|
+
* first, wait a cache TTL, then switch. This class holds up the other end.
|
|
14
|
+
*
|
|
15
|
+
* - **Selection is by `kid`, never by trial.** Verifying against each published
|
|
16
|
+
* key in turn would turn key retention into a signature oracle, and would
|
|
17
|
+
* make a retired key indistinguishable from the live one in the logs.
|
|
18
|
+
* - **An unknown `kid` refetches once, then rejects.** A rotation may have
|
|
19
|
+
* published that key seconds ago and this process may be holding a cache from
|
|
20
|
+
* just before it. Rejecting without looking would turn every rotation into a
|
|
21
|
+
* burst of spurious 401s lasting one cache TTL.
|
|
22
|
+
* - **The refetch is rate-limited** ({@link JwksVerifierOptions.minRefetchIntervalSeconds}).
|
|
23
|
+
* Without that, a stream of tokens carrying a forged `kid` is a free way to
|
|
24
|
+
* make every module hammer the IAM — the unknown-`kid` path is reachable by
|
|
25
|
+
* anyone who can send a request.
|
|
26
|
+
*
|
|
27
|
+
* ## What is not here
|
|
28
|
+
*
|
|
29
|
+
* Revocation. `sid` is verified as present and well-formed; whether that
|
|
30
|
+
* session is still live is {@link RevocationCache}'s question, asked by the
|
|
31
|
+
* guard. Keeping the two apart is precisely what lets this class answer without
|
|
32
|
+
* talking to anyone.
|
|
33
|
+
*/
|
|
34
|
+
import { type JwtClaims } from '@plantops/contracts';
|
|
35
|
+
/**
|
|
36
|
+
* Anything that can turn a bearer token into claims.
|
|
37
|
+
*
|
|
38
|
+
* The IAM implements this over its own local key material — it *is* the signer,
|
|
39
|
+
* so fetching its own JWKS over HTTP would be a pointless round-trip — and
|
|
40
|
+
* every other process implements it with {@link JwksVerifier}. The guard
|
|
41
|
+
* depends on this interface and not on either one.
|
|
42
|
+
*/
|
|
43
|
+
export interface TokenVerifier {
|
|
44
|
+
/** @throws {TokenVerificationError} */
|
|
45
|
+
verify(token: string, now?: Date): JwtClaims | Promise<JwtClaims>;
|
|
46
|
+
}
|
|
47
|
+
export interface JwksVerifierOptions {
|
|
48
|
+
/** Absolute URL of the IAM's `/iam/.well-known/jwks.json`. */
|
|
49
|
+
jwksUri: string;
|
|
50
|
+
/** Expected `iss`. A token from another deployment must not verify here. */
|
|
51
|
+
issuer: string;
|
|
52
|
+
/** How long a fetched key set stays fresh. Defaults to the published max-age. */
|
|
53
|
+
cacheMaxAgeSeconds?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Floor between JWKS fetches triggered by an unknown `kid`. Defaults to 30 s.
|
|
56
|
+
* This is a DoS control, not a tuning knob — see the class comment.
|
|
57
|
+
*/
|
|
58
|
+
minRefetchIntervalSeconds?: number;
|
|
59
|
+
/** Injectable for tests and for callers with their own HTTP stack. */
|
|
60
|
+
fetch?: typeof globalThis.fetch;
|
|
61
|
+
}
|
|
62
|
+
export declare class JwksVerifier implements TokenVerifier {
|
|
63
|
+
private readonly options;
|
|
64
|
+
private cache?;
|
|
65
|
+
/** In-flight fetch, so a burst of misses makes one request, not N. */
|
|
66
|
+
private inFlight?;
|
|
67
|
+
private readonly maxAgeMs;
|
|
68
|
+
private readonly minRefetchMs;
|
|
69
|
+
private readonly http;
|
|
70
|
+
constructor(options: JwksVerifierOptions);
|
|
71
|
+
/** @throws {TokenVerificationError} */
|
|
72
|
+
verify(token: string, now?: Date): Promise<JwtClaims>;
|
|
73
|
+
private keyFor;
|
|
74
|
+
/** Fetches the key set, collapsing concurrent callers onto one request. */
|
|
75
|
+
private refresh;
|
|
76
|
+
private fetchKeys;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=jwks-verifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jwks-verifier.d.ts","sourceRoot":"","sources":["../../src/core/jwks-verifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,qBAAqB,CAAC;AAU7B;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC5B,uCAAuC;IACvC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,IAAI,GAAG,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,mBAAmB;IAClC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,sEAAsE;IACtE,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC;AASD,qBAAa,YAAa,YAAW,aAAa;IASpC,OAAO,CAAC,QAAQ,CAAC,OAAO;IARpC,OAAO,CAAC,KAAK,CAAC,CAAW;IACzB,sEAAsE;IACtE,OAAO,CAAC,QAAQ,CAAC,CAAoB;IAErC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA0B;gBAElB,OAAO,EAAE,mBAAmB;IASzD,uCAAuC;IACjC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,GAAE,IAAiB,GAAG,OAAO,CAAC,SAAS,CAAC;YAoCzD,MAAM;IAkBpB,2EAA2E;IAC3E,OAAO,CAAC,OAAO;YAYD,SAAS;CA6BxB"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Local token verification against a published JWKS (Doc 03 §1, Doc 06 §11).
|
|
4
|
+
*
|
|
5
|
+
* This is the class that keeps the IAM off every module's request path. A
|
|
6
|
+
* module fetches `/iam/.well-known/jwks.json` once, caches it for the `max-age`
|
|
7
|
+
* the IAM publishes, and verifies signatures itself — so an IAM restart, a
|
|
8
|
+
* network partition, or a slow IAM does not stop a gate terminal from
|
|
9
|
+
* authenticating a request it has already been handed a token for.
|
|
10
|
+
*
|
|
11
|
+
* ## Rotation, from the verifier's side
|
|
12
|
+
*
|
|
13
|
+
* Doc 03 §1 puts the obligations on the *signer*: publish the new public key
|
|
14
|
+
* first, wait a cache TTL, then switch. This class holds up the other end.
|
|
15
|
+
*
|
|
16
|
+
* - **Selection is by `kid`, never by trial.** Verifying against each published
|
|
17
|
+
* key in turn would turn key retention into a signature oracle, and would
|
|
18
|
+
* make a retired key indistinguishable from the live one in the logs.
|
|
19
|
+
* - **An unknown `kid` refetches once, then rejects.** A rotation may have
|
|
20
|
+
* published that key seconds ago and this process may be holding a cache from
|
|
21
|
+
* just before it. Rejecting without looking would turn every rotation into a
|
|
22
|
+
* burst of spurious 401s lasting one cache TTL.
|
|
23
|
+
* - **The refetch is rate-limited** ({@link JwksVerifierOptions.minRefetchIntervalSeconds}).
|
|
24
|
+
* Without that, a stream of tokens carrying a forged `kid` is a free way to
|
|
25
|
+
* make every module hammer the IAM — the unknown-`kid` path is reachable by
|
|
26
|
+
* anyone who can send a request.
|
|
27
|
+
*
|
|
28
|
+
* ## What is not here
|
|
29
|
+
*
|
|
30
|
+
* Revocation. `sid` is verified as present and well-formed; whether that
|
|
31
|
+
* session is still live is {@link RevocationCache}'s question, asked by the
|
|
32
|
+
* guard. Keeping the two apart is precisely what lets this class answer without
|
|
33
|
+
* talking to anyone.
|
|
34
|
+
*/
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.JwksVerifier = void 0;
|
|
37
|
+
const contracts_1 = require("@plantops/contracts");
|
|
38
|
+
const node_crypto_1 = require("node:crypto");
|
|
39
|
+
const claims_1 = require("./claims");
|
|
40
|
+
const jws_1 = require("./jws");
|
|
41
|
+
const DEFAULT_MIN_REFETCH_INTERVAL_SECONDS = 30;
|
|
42
|
+
class JwksVerifier {
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.options = options;
|
|
45
|
+
this.maxAgeMs =
|
|
46
|
+
(options.cacheMaxAgeSeconds ?? contracts_1.JWKS_CACHE_MAX_AGE_SECONDS) * 1000;
|
|
47
|
+
this.minRefetchMs =
|
|
48
|
+
(options.minRefetchIntervalSeconds ?? DEFAULT_MIN_REFETCH_INTERVAL_SECONDS) *
|
|
49
|
+
1000;
|
|
50
|
+
this.http = options.fetch ?? globalThis.fetch;
|
|
51
|
+
}
|
|
52
|
+
/** @throws {TokenVerificationError} */
|
|
53
|
+
async verify(token, now = new Date()) {
|
|
54
|
+
const parsed = parse(token);
|
|
55
|
+
let key = await this.keyFor(parsed.header.kid, now);
|
|
56
|
+
if (key === undefined) {
|
|
57
|
+
// The unknown-`kid` path: one refetch, then the rejection stands.
|
|
58
|
+
key = await this.keyFor(parsed.header.kid, now, { forceRefetch: true });
|
|
59
|
+
}
|
|
60
|
+
if (key === undefined) {
|
|
61
|
+
throw new claims_1.TokenVerificationError(claims_1.TokenRejection.UNKNOWN_KEY, 'Token was signed with a key that is not published');
|
|
62
|
+
}
|
|
63
|
+
let signatureValid;
|
|
64
|
+
try {
|
|
65
|
+
signatureValid = (0, jws_1.verifyCompactJws)(parsed, key);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new claims_1.TokenVerificationError(claims_1.TokenRejection.UNSUPPORTED_ALGORITHM, error instanceof Error ? error.message : 'Unsupported token algorithm');
|
|
69
|
+
}
|
|
70
|
+
if (!signatureValid) {
|
|
71
|
+
throw new claims_1.TokenVerificationError(claims_1.TokenRejection.BAD_SIGNATURE, 'Token signature does not verify');
|
|
72
|
+
}
|
|
73
|
+
const claims = (0, claims_1.readAccessTokenClaims)(parsed.payload);
|
|
74
|
+
(0, claims_1.assertClaimsAcceptable)(claims, this.options.issuer, now);
|
|
75
|
+
return claims;
|
|
76
|
+
}
|
|
77
|
+
async keyFor(kid, now, { forceRefetch = false } = {}) {
|
|
78
|
+
const ageMs = this.cache ? now.getTime() - this.cache.fetchedAtMs : Infinity;
|
|
79
|
+
const stale = ageMs >= this.maxAgeMs;
|
|
80
|
+
// A forced refetch still respects the floor: the trigger is attacker-
|
|
81
|
+
// reachable, so "an unknown kid arrived" must not mean "fetch now".
|
|
82
|
+
const mayRefetch = forceRefetch ? ageMs >= this.minRefetchMs : stale;
|
|
83
|
+
if (this.cache === undefined || mayRefetch) {
|
|
84
|
+
const cache = await this.refresh(now);
|
|
85
|
+
return cache.keys.get(kid);
|
|
86
|
+
}
|
|
87
|
+
return this.cache.keys.get(kid);
|
|
88
|
+
}
|
|
89
|
+
/** Fetches the key set, collapsing concurrent callers onto one request. */
|
|
90
|
+
refresh(now) {
|
|
91
|
+
this.inFlight ??= this.fetchKeys(now)
|
|
92
|
+
.then((cache) => {
|
|
93
|
+
this.cache = cache;
|
|
94
|
+
return cache;
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
this.inFlight = undefined;
|
|
98
|
+
});
|
|
99
|
+
return this.inFlight;
|
|
100
|
+
}
|
|
101
|
+
async fetchKeys(now) {
|
|
102
|
+
let payload;
|
|
103
|
+
try {
|
|
104
|
+
const response = await this.http(this.options.jwksUri, {
|
|
105
|
+
headers: { accept: 'application/jwk-set+json, application/json' },
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`JWKS endpoint answered ${response.status}`);
|
|
109
|
+
}
|
|
110
|
+
payload = await response.json();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// A fetch failure is not "no keys": clearing the cache here would turn a
|
|
114
|
+
// brief IAM outage into a fleet-wide auth outage, when the keys already
|
|
115
|
+
// held are almost certainly still correct. Keep what we have and let the
|
|
116
|
+
// caller's `kid` lookup decide.
|
|
117
|
+
if (this.cache !== undefined)
|
|
118
|
+
return this.cache;
|
|
119
|
+
throw new claims_1.TokenVerificationError(claims_1.TokenRejection.UNKNOWN_KEY, `Could not fetch JWKS: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
|
+
}
|
|
121
|
+
const keys = new Map();
|
|
122
|
+
for (const jwk of readJwks(payload)) {
|
|
123
|
+
const imported = importJwk(jwk);
|
|
124
|
+
if (imported !== undefined)
|
|
125
|
+
keys.set(jwk.kid, imported);
|
|
126
|
+
}
|
|
127
|
+
return { keys, fetchedAtMs: now.getTime() };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
exports.JwksVerifier = JwksVerifier;
|
|
131
|
+
function parse(token) {
|
|
132
|
+
try {
|
|
133
|
+
return (0, jws_1.parseCompactJws)(token);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof jws_1.JwsFormatError) {
|
|
137
|
+
throw new claims_1.TokenVerificationError(claims_1.TokenRejection.MALFORMED, error.message);
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function readJwks(payload) {
|
|
143
|
+
const keys = payload?.keys;
|
|
144
|
+
if (!Array.isArray(keys))
|
|
145
|
+
return [];
|
|
146
|
+
return keys.filter((jwk) => typeof jwk === 'object' &&
|
|
147
|
+
jwk !== null &&
|
|
148
|
+
typeof jwk.kid === 'string' &&
|
|
149
|
+
jwk.kid !== '');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Turns one JWKS entry into a usable key, or drops it.
|
|
153
|
+
*
|
|
154
|
+
* Dropping rather than throwing: one unusable entry — a future EC key, an
|
|
155
|
+
* encryption key, a malformed member — must not make the whole key set
|
|
156
|
+
* unavailable and take every token down with it.
|
|
157
|
+
*
|
|
158
|
+
* The size floor is not ceremony. RS256 is only as strong as the modulus, and a
|
|
159
|
+
* verifier that accepts whatever the endpoint serves would accept a 512-bit key
|
|
160
|
+
* that can be factored, at which point anyone can mint tokens this process
|
|
161
|
+
* trusts. A key too small to be safe is not a key.
|
|
162
|
+
*/
|
|
163
|
+
function importJwk(jwk) {
|
|
164
|
+
if (jwk.kty !== 'RSA')
|
|
165
|
+
return undefined;
|
|
166
|
+
if (jwk.use !== undefined && jwk.use !== 'sig')
|
|
167
|
+
return undefined;
|
|
168
|
+
if (jwk.alg !== undefined && jwk.alg !== contracts_1.JWT_SIGNING_ALGORITHM)
|
|
169
|
+
return undefined;
|
|
170
|
+
// A published JWKS carries public parameters only. A member that looks like a
|
|
171
|
+
// private key is a misconfigured (or hostile) endpoint, and importing it
|
|
172
|
+
// would hand this process signing capability it must never have.
|
|
173
|
+
if ('d' in jwk)
|
|
174
|
+
return undefined;
|
|
175
|
+
try {
|
|
176
|
+
const key = (0, node_crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
|
|
177
|
+
const bits = key.asymmetricKeyDetails?.modulusLength ?? 0;
|
|
178
|
+
return bits >= contracts_1.JWT_MIN_RSA_KEY_BITS ? key : undefined;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact JWS serialisation for RS256 (Doc 03 §1).
|
|
3
|
+
*
|
|
4
|
+
* Hand-written, for the same reason the RLS policies are: the failure modes
|
|
5
|
+
* here are silent. A JWT library that accepts one more algorithm than we meant
|
|
6
|
+
* it to, or that helpfully adds a claim, does not error — it succeeds, and the
|
|
7
|
+
* mistake is only visible to someone who goes looking. The surface is small
|
|
8
|
+
* enough (three base64url segments and one `crypto.verify` call) that owning it
|
|
9
|
+
* outright is cheaper than auditing a dependency's option matrix on every
|
|
10
|
+
* upgrade.
|
|
11
|
+
*
|
|
12
|
+
* It lives in `auth-kit` rather than in the IAM because the IAM signs and every
|
|
13
|
+
* consuming module verifies, and those two must agree byte for byte. Two
|
|
14
|
+
* implementations of "the same" JWS handling is how a signer and a verifier
|
|
15
|
+
* drift into disagreeing about a padded segment — which is a token that works
|
|
16
|
+
* in one process and fails in the next.
|
|
17
|
+
*
|
|
18
|
+
* ## The invariants that make this safe
|
|
19
|
+
*
|
|
20
|
+
* 1. **The algorithm is never read from the token.** {@link verifyCompactJws}
|
|
21
|
+
* verifies with RS256 and rejects any header whose `alg` is not exactly
|
|
22
|
+
* `RS256`. This is the whole of the `alg: "none"` and
|
|
23
|
+
* HS256-signed-with-the-public-key attack class, and it is closed by
|
|
24
|
+
* construction: there is no HMAC code path in this file for a forged header
|
|
25
|
+
* to select.
|
|
26
|
+
* 2. **The key is never taken from the token.** The header's `kid` selects from
|
|
27
|
+
* a closed, locally-held set (`keys.service.ts` in the IAM, the fetched JWKS
|
|
28
|
+
* in a module); it is a lookup key, never key material. `jwk`/`jku`/`x5u`
|
|
29
|
+
* headers are ignored entirely.
|
|
30
|
+
* 3. **Segments are validated before decoding.** Node's base64url decoder is
|
|
31
|
+
* lenient — it will quietly accept standard-base64 `+`/`/`, padding, and
|
|
32
|
+
* trailing junk — so two different strings can decode to the same bytes.
|
|
33
|
+
* That is a signature-stripping foothold on any system that compares tokens
|
|
34
|
+
* or caches by string. {@link parseCompactJws} rejects anything outside the
|
|
35
|
+
* canonical base64url alphabet.
|
|
36
|
+
* 4. **Verification is over the exact received bytes.** The signing input is
|
|
37
|
+
* sliced from the original token string, never re-serialised from the parsed
|
|
38
|
+
* header and payload — re-encoding would verify a signature over a
|
|
39
|
+
* *different* document than the caller was handed.
|
|
40
|
+
*/
|
|
41
|
+
import { type KeyObject } from 'node:crypto';
|
|
42
|
+
/** The JOSE header the IAM writes, and the only shape it accepts back. */
|
|
43
|
+
export interface JwsHeader {
|
|
44
|
+
alg: string;
|
|
45
|
+
typ?: string;
|
|
46
|
+
/** Identifies the verification key within the published JWKS (Doc 03 §1). */
|
|
47
|
+
kid: string;
|
|
48
|
+
}
|
|
49
|
+
/** A compact JWS taken apart, with the bytes the signature actually covers. */
|
|
50
|
+
export interface ParsedJws {
|
|
51
|
+
header: JwsHeader;
|
|
52
|
+
payload: Record<string, unknown>;
|
|
53
|
+
/** `<header>.<payload>` exactly as received — the signed document. */
|
|
54
|
+
signingInput: string;
|
|
55
|
+
signature: Buffer;
|
|
56
|
+
}
|
|
57
|
+
/** Raised for any token this module refuses. Carries no token material. */
|
|
58
|
+
export declare class JwsFormatError extends Error {
|
|
59
|
+
constructor(message: string);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Signs `payload` into a compact JWS.
|
|
63
|
+
*
|
|
64
|
+
* The header is built here and cannot be influenced by the caller: `alg` is
|
|
65
|
+
* always {@link JWT_SIGNING_ALGORITHM}, so nothing upstream can ask for a
|
|
66
|
+
* weaker one.
|
|
67
|
+
*/
|
|
68
|
+
export declare function signCompactJws(payload: Record<string, unknown>, key: KeyObject, kid: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* Splits a compact JWS without verifying it.
|
|
71
|
+
*
|
|
72
|
+
* Exposed separately because reading `kid` is what *selects* the verification
|
|
73
|
+
* key — the one thing a verifier legitimately needs from an unverified token.
|
|
74
|
+
* Nothing else in this codebase may act on the result: a parsed-but-unverified
|
|
75
|
+
* payload is attacker-controlled JSON.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseCompactJws(token: string): ParsedJws;
|
|
78
|
+
/**
|
|
79
|
+
* Verifies a parsed JWS against one public key.
|
|
80
|
+
*
|
|
81
|
+
* Returns a boolean rather than throwing so the caller decides what a bad
|
|
82
|
+
* signature means; it throws only for a header that is not RS256, because that
|
|
83
|
+
* is a refusal to *attempt* verification rather than a failed one.
|
|
84
|
+
*/
|
|
85
|
+
export declare function verifyCompactJws(parsed: ParsedJws, key: KeyObject): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Parses a PEM private key, raising a message that names the variable.
|
|
88
|
+
*
|
|
89
|
+
* The underlying OpenSSL error is deliberately *not* attached as `cause`: its
|
|
90
|
+
* message can echo fragments of the input, and this input is a signing key
|
|
91
|
+
* (Doc 10 §8). The variable name is what the operator needs anyway.
|
|
92
|
+
*/
|
|
93
|
+
export declare function privateKeyFromPem(pem: string, label: string): KeyObject;
|
|
94
|
+
/** Parses a PEM public key, raising a message that names the variable. */
|
|
95
|
+
export declare function publicKeyFromPem(pem: string, label: string): KeyObject;
|
|
96
|
+
//# sourceMappingURL=jws.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jws.d.ts","sourceRoot":"","sources":["../../src/core/jws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAGH,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,aAAa,CAAC;AAWrB,0EAA0E;AAC1E,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;CACb;AAED,+EAA+E;AAC/E,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,SAAS,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,sEAAsE;IACtE,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,2EAA2E;AAC3E,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AA8BD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,GAAG,EAAE,SAAS,EACd,GAAG,EAAE,MAAM,GACV,MAAM,CAKR;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAsCxD;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAe3E;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAMvE;AAED,0EAA0E;AAC1E,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAMtE"}
|
package/dist/core/jws.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Compact JWS serialisation for RS256 (Doc 03 §1).
|
|
4
|
+
*
|
|
5
|
+
* Hand-written, for the same reason the RLS policies are: the failure modes
|
|
6
|
+
* here are silent. A JWT library that accepts one more algorithm than we meant
|
|
7
|
+
* it to, or that helpfully adds a claim, does not error — it succeeds, and the
|
|
8
|
+
* mistake is only visible to someone who goes looking. The surface is small
|
|
9
|
+
* enough (three base64url segments and one `crypto.verify` call) that owning it
|
|
10
|
+
* outright is cheaper than auditing a dependency's option matrix on every
|
|
11
|
+
* upgrade.
|
|
12
|
+
*
|
|
13
|
+
* It lives in `auth-kit` rather than in the IAM because the IAM signs and every
|
|
14
|
+
* consuming module verifies, and those two must agree byte for byte. Two
|
|
15
|
+
* implementations of "the same" JWS handling is how a signer and a verifier
|
|
16
|
+
* drift into disagreeing about a padded segment — which is a token that works
|
|
17
|
+
* in one process and fails in the next.
|
|
18
|
+
*
|
|
19
|
+
* ## The invariants that make this safe
|
|
20
|
+
*
|
|
21
|
+
* 1. **The algorithm is never read from the token.** {@link verifyCompactJws}
|
|
22
|
+
* verifies with RS256 and rejects any header whose `alg` is not exactly
|
|
23
|
+
* `RS256`. This is the whole of the `alg: "none"` and
|
|
24
|
+
* HS256-signed-with-the-public-key attack class, and it is closed by
|
|
25
|
+
* construction: there is no HMAC code path in this file for a forged header
|
|
26
|
+
* to select.
|
|
27
|
+
* 2. **The key is never taken from the token.** The header's `kid` selects from
|
|
28
|
+
* a closed, locally-held set (`keys.service.ts` in the IAM, the fetched JWKS
|
|
29
|
+
* in a module); it is a lookup key, never key material. `jwk`/`jku`/`x5u`
|
|
30
|
+
* headers are ignored entirely.
|
|
31
|
+
* 3. **Segments are validated before decoding.** Node's base64url decoder is
|
|
32
|
+
* lenient — it will quietly accept standard-base64 `+`/`/`, padding, and
|
|
33
|
+
* trailing junk — so two different strings can decode to the same bytes.
|
|
34
|
+
* That is a signature-stripping foothold on any system that compares tokens
|
|
35
|
+
* or caches by string. {@link parseCompactJws} rejects anything outside the
|
|
36
|
+
* canonical base64url alphabet.
|
|
37
|
+
* 4. **Verification is over the exact received bytes.** The signing input is
|
|
38
|
+
* sliced from the original token string, never re-serialised from the parsed
|
|
39
|
+
* header and payload — re-encoding would verify a signature over a
|
|
40
|
+
* *different* document than the caller was handed.
|
|
41
|
+
*/
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.JwsFormatError = void 0;
|
|
44
|
+
exports.signCompactJws = signCompactJws;
|
|
45
|
+
exports.parseCompactJws = parseCompactJws;
|
|
46
|
+
exports.verifyCompactJws = verifyCompactJws;
|
|
47
|
+
exports.privateKeyFromPem = privateKeyFromPem;
|
|
48
|
+
exports.publicKeyFromPem = publicKeyFromPem;
|
|
49
|
+
const contracts_1 = require("@plantops/contracts");
|
|
50
|
+
const node_crypto_1 = require("node:crypto");
|
|
51
|
+
/** Node's digest name for the RS256 (RSASSA-PKCS1-v1_5 + SHA-256) suite. */
|
|
52
|
+
const DIGEST = 'sha256';
|
|
53
|
+
/** `typ` on every header the IAM writes. */
|
|
54
|
+
const JWT_TYPE = 'JWT';
|
|
55
|
+
/** Canonical base64url: no padding, no `+`, no `/`, nothing else. */
|
|
56
|
+
const BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
57
|
+
/** Raised for any token this module refuses. Carries no token material. */
|
|
58
|
+
class JwsFormatError extends Error {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'JwsFormatError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.JwsFormatError = JwsFormatError;
|
|
65
|
+
function encodeSegment(value) {
|
|
66
|
+
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Decodes one segment to JSON.
|
|
70
|
+
*
|
|
71
|
+
* The alphabet check happens here rather than at the caller because every
|
|
72
|
+
* segment needs it and a missed one is invisible: the token still verifies.
|
|
73
|
+
*/
|
|
74
|
+
function decodeSegment(segment, what) {
|
|
75
|
+
if (!BASE64URL_SEGMENT.test(segment)) {
|
|
76
|
+
throw new JwsFormatError(`Token ${what} is not canonical base64url`);
|
|
77
|
+
}
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new JwsFormatError(`Token ${what} is not valid JSON`);
|
|
84
|
+
}
|
|
85
|
+
// `typeof null === 'object'`, and an array would pass a naive object check
|
|
86
|
+
// and then silently produce `undefined` for every claim lookup.
|
|
87
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
88
|
+
throw new JwsFormatError(`Token ${what} is not a JSON object`);
|
|
89
|
+
}
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Signs `payload` into a compact JWS.
|
|
94
|
+
*
|
|
95
|
+
* The header is built here and cannot be influenced by the caller: `alg` is
|
|
96
|
+
* always {@link JWT_SIGNING_ALGORITHM}, so nothing upstream can ask for a
|
|
97
|
+
* weaker one.
|
|
98
|
+
*/
|
|
99
|
+
function signCompactJws(payload, key, kid) {
|
|
100
|
+
const header = { alg: contracts_1.JWT_SIGNING_ALGORITHM, typ: JWT_TYPE, kid };
|
|
101
|
+
const signingInput = `${encodeSegment(header)}.${encodeSegment(payload)}`;
|
|
102
|
+
const signature = (0, node_crypto_1.sign)(DIGEST, Buffer.from(signingInput, 'ascii'), key);
|
|
103
|
+
return `${signingInput}.${signature.toString('base64url')}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Splits a compact JWS without verifying it.
|
|
107
|
+
*
|
|
108
|
+
* Exposed separately because reading `kid` is what *selects* the verification
|
|
109
|
+
* key — the one thing a verifier legitimately needs from an unverified token.
|
|
110
|
+
* Nothing else in this codebase may act on the result: a parsed-but-unverified
|
|
111
|
+
* payload is attacker-controlled JSON.
|
|
112
|
+
*/
|
|
113
|
+
function parseCompactJws(token) {
|
|
114
|
+
const segments = token.split('.');
|
|
115
|
+
if (segments.length !== 3) {
|
|
116
|
+
throw new JwsFormatError('Token is not a compact JWS (expected 3 segments)');
|
|
117
|
+
}
|
|
118
|
+
const [headerSegment, payloadSegment, signatureSegment] = segments;
|
|
119
|
+
if (!BASE64URL_SEGMENT.test(signatureSegment)) {
|
|
120
|
+
throw new JwsFormatError('Token signature is not canonical base64url');
|
|
121
|
+
}
|
|
122
|
+
const header = decodeSegment(headerSegment, 'header');
|
|
123
|
+
const payload = decodeSegment(payloadSegment, 'payload');
|
|
124
|
+
if (typeof header['alg'] !== 'string') {
|
|
125
|
+
throw new JwsFormatError('Token header is missing "alg"');
|
|
126
|
+
}
|
|
127
|
+
if (typeof header['kid'] !== 'string' || header['kid'] === '') {
|
|
128
|
+
// Without a `kid` there is no way to choose a key, and guessing by trying
|
|
129
|
+
// every published key turns key retention during a rotation into a
|
|
130
|
+
// signature oracle. Doc 03 §1 requires the header.
|
|
131
|
+
throw new JwsFormatError('Token header is missing "kid"');
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
header: {
|
|
135
|
+
alg: header['alg'],
|
|
136
|
+
typ: typeof header['typ'] === 'string' ? header['typ'] : undefined,
|
|
137
|
+
kid: header['kid'],
|
|
138
|
+
},
|
|
139
|
+
payload,
|
|
140
|
+
signingInput: `${headerSegment}.${payloadSegment}`,
|
|
141
|
+
signature: Buffer.from(signatureSegment, 'base64url'),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Verifies a parsed JWS against one public key.
|
|
146
|
+
*
|
|
147
|
+
* Returns a boolean rather than throwing so the caller decides what a bad
|
|
148
|
+
* signature means; it throws only for a header that is not RS256, because that
|
|
149
|
+
* is a refusal to *attempt* verification rather than a failed one.
|
|
150
|
+
*/
|
|
151
|
+
function verifyCompactJws(parsed, key) {
|
|
152
|
+
if (parsed.header.alg !== contracts_1.JWT_SIGNING_ALGORITHM) {
|
|
153
|
+
throw new JwsFormatError(`Unsupported token algorithm "${parsed.header.alg}" (only ${contracts_1.JWT_SIGNING_ALGORITHM} is accepted)`);
|
|
154
|
+
}
|
|
155
|
+
// A public KeyObject, never a raw secret: `crypto.verify` will not perform an
|
|
156
|
+
// HMAC with one, so a forged `alg: HS256` header has nothing to select even
|
|
157
|
+
// if the check above were ever removed.
|
|
158
|
+
return (0, node_crypto_1.verify)(DIGEST, Buffer.from(parsed.signingInput, 'ascii'), key, parsed.signature);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Parses a PEM private key, raising a message that names the variable.
|
|
162
|
+
*
|
|
163
|
+
* The underlying OpenSSL error is deliberately *not* attached as `cause`: its
|
|
164
|
+
* message can echo fragments of the input, and this input is a signing key
|
|
165
|
+
* (Doc 10 §8). The variable name is what the operator needs anyway.
|
|
166
|
+
*/
|
|
167
|
+
function privateKeyFromPem(pem, label) {
|
|
168
|
+
try {
|
|
169
|
+
return (0, node_crypto_1.createPrivateKey)(pem);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
throw new JwsFormatError(`${label} is not a readable PEM private key`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Parses a PEM public key, raising a message that names the variable. */
|
|
176
|
+
function publicKeyFromPem(pem, label) {
|
|
177
|
+
try {
|
|
178
|
+
return (0, node_crypto_1.createPublicKey)(pem);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new JwsFormatError(`${label} is not a readable PEM public key`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The revoked-`sid` cache (Doc 03 §6).
|
|
3
|
+
*
|
|
4
|
+
* Access tokens are verified by signature, which means a revoked session's
|
|
5
|
+
* token keeps verifying until it expires. That is unacceptable for the case the
|
|
6
|
+
* spec singles out — a shared gate terminal logged out at shift end — so every
|
|
7
|
+
* request also asks whether its `sid` has been killed. The answer has to be
|
|
8
|
+
* cheap enough to ask on every request, which rules out the database.
|
|
9
|
+
*
|
|
10
|
+
* ## One key per revocation, not one set
|
|
11
|
+
*
|
|
12
|
+
* Doc 03 §6 says "a Redis set / short-TTL cache". A set is the wrong half of
|
|
13
|
+
* that: it grows forever and has no per-member expiry, so it must be pruned by
|
|
14
|
+
* something, and the day that something stops running is the day the set is
|
|
15
|
+
* either unbounded or silently emptied. One key per revoked `sid`, with a TTL,
|
|
16
|
+
* prunes itself.
|
|
17
|
+
*
|
|
18
|
+
* The TTL is the **remaining exposure**, not the session lifetime. Once every
|
|
19
|
+
* token bearing a `sid` has expired, the revocation entry protects nothing:
|
|
20
|
+
* such a token is already refused for `exp`. So the entry lives for one access
|
|
21
|
+
* token lifetime plus the clock-skew leeway, and then disappears. This is what
|
|
22
|
+
* keeps the cache proportional to *recent* revocations rather than to all of
|
|
23
|
+
* them.
|
|
24
|
+
*
|
|
25
|
+
* ## What a cache miss means, and what an outage means
|
|
26
|
+
*
|
|
27
|
+
* A miss means "not revoked" — that is the happy path and it is DB-free, which
|
|
28
|
+
* is the whole design. An *error* means something different and must not be
|
|
29
|
+
* confused with it: a Redis outage that reads as "not revoked" silently
|
|
30
|
+
* un-revokes every session in the system for the duration. So this class throws
|
|
31
|
+
* on failure rather than returning `false`, and {@link AuthGuard} decides —
|
|
32
|
+
* with the database as the authority where one is reachable.
|
|
33
|
+
*
|
|
34
|
+
* There remains one honest gap: if Redis is *up* but has lost the key (an
|
|
35
|
+
* eviction, a flush, a restart without persistence), a revoked session works
|
|
36
|
+
* until its access token expires. Doc 03 §6 accepts exactly that bound —
|
|
37
|
+
* "because access tokens are short-lived, the revocation window is bounded even
|
|
38
|
+
* without a per-request DB hit". The database row stays authoritative, and the
|
|
39
|
+
* next refresh (Session 9) consults it.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The two commands this needs, and nothing else.
|
|
43
|
+
*
|
|
44
|
+
* Structural rather than an `ioredis` import on purpose: `auth-kit` is consumed
|
|
45
|
+
* by every future module, and making it drag a Redis client in would force that
|
|
46
|
+
* choice on all of them. `ioredis`'s `Redis` satisfies this as-is.
|
|
47
|
+
*/
|
|
48
|
+
export interface RevocationStore {
|
|
49
|
+
set(key: string, value: string, mode: 'EX', ttlSeconds: number): Promise<unknown>;
|
|
50
|
+
exists(key: string): Promise<number>;
|
|
51
|
+
}
|
|
52
|
+
/** Whatever can answer "is this session dead?" — see {@link RevocationCache}. */
|
|
53
|
+
export interface RevocationChecker {
|
|
54
|
+
/** @throws when the answer is unknown. Never returns `false` on failure. */
|
|
55
|
+
isRevoked(sessionId: string): Promise<boolean>;
|
|
56
|
+
}
|
|
57
|
+
export interface RevocationCacheOptions {
|
|
58
|
+
/**
|
|
59
|
+
* How long a revocation entry is kept: one access-token lifetime plus the
|
|
60
|
+
* skew leeway. Longer wastes memory; shorter re-opens the session.
|
|
61
|
+
*/
|
|
62
|
+
ttlSeconds: number;
|
|
63
|
+
}
|
|
64
|
+
export declare class RevocationCache implements RevocationChecker {
|
|
65
|
+
private readonly store;
|
|
66
|
+
private readonly options;
|
|
67
|
+
constructor(store: RevocationStore, options: RevocationCacheOptions);
|
|
68
|
+
/**
|
|
69
|
+
* Marks a session dead for every verifier sharing this cache.
|
|
70
|
+
*
|
|
71
|
+
* Call **after** the database write commits. Publishing first would let a
|
|
72
|
+
* concurrent reader see a revoked session that a rollback then restores — the
|
|
73
|
+
* same post-commit ordering the scope-move invalidation requires (Doc 07 §7).
|
|
74
|
+
*/
|
|
75
|
+
revoke(sessionId: string): Promise<void>;
|
|
76
|
+
/** @throws when the store cannot answer — see the class comment. */
|
|
77
|
+
isRevoked(sessionId: string): Promise<boolean>;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=revocation-cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"revocation-cache.d.ts","sourceRoot":"","sources":["../../src/core/revocation-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAIH;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,GAAG,CACD,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,IAAI,EACV,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,OAAO,CAAC,CAAC;IACpB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACtC;AAED,iFAAiF;AACjF,MAAM,WAAW,iBAAiB;IAChC,4EAA4E;IAC5E,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,eAAgB,YAAW,iBAAiB;IAErD,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADP,KAAK,EAAE,eAAe,EACtB,OAAO,EAAE,sBAAsB;IAGlD;;;;;;OAMG;IACG,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO9C,oEAAoE;IAC9D,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAGrD"}
|