@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.
Files changed (42) hide show
  1. package/README.md +7 -0
  2. package/dist/adapters/fetch/index.d.ts +38 -0
  3. package/dist/adapters/fetch/index.d.ts.map +1 -0
  4. package/dist/adapters/fetch/index.js +52 -0
  5. package/dist/adapters/nestjs/auth.guard.d.ts +120 -0
  6. package/dist/adapters/nestjs/auth.guard.d.ts.map +1 -0
  7. package/dist/adapters/nestjs/auth.guard.js +165 -0
  8. package/dist/adapters/nestjs/index.d.ts +10 -0
  9. package/dist/adapters/nestjs/index.d.ts.map +1 -0
  10. package/dist/adapters/nestjs/index.js +12 -0
  11. package/dist/adapters/nestjs/permission.guard.d.ts +115 -0
  12. package/dist/adapters/nestjs/permission.guard.d.ts.map +1 -0
  13. package/dist/adapters/nestjs/permission.guard.js +167 -0
  14. package/dist/adapters/nestjs/require-permission.decorator.d.ts +31 -0
  15. package/dist/adapters/nestjs/require-permission.decorator.d.ts.map +1 -0
  16. package/dist/adapters/nestjs/require-permission.decorator.js +41 -0
  17. package/dist/adapters/nestjs/scope-resolver.d.ts +12 -0
  18. package/dist/adapters/nestjs/scope-resolver.d.ts.map +1 -0
  19. package/dist/adapters/nestjs/scope-resolver.js +24 -0
  20. package/dist/core/claims.d.ts +114 -0
  21. package/dist/core/claims.d.ts.map +1 -0
  22. package/dist/core/claims.js +183 -0
  23. package/dist/core/index.d.ts +13 -0
  24. package/dist/core/index.d.ts.map +1 -0
  25. package/dist/core/index.js +15 -0
  26. package/dist/core/jwks-verifier.d.ts +78 -0
  27. package/dist/core/jwks-verifier.d.ts.map +1 -0
  28. package/dist/core/jwks-verifier.js +183 -0
  29. package/dist/core/jws.d.ts +96 -0
  30. package/dist/core/jws.d.ts.map +1 -0
  31. package/dist/core/jws.js +183 -0
  32. package/dist/core/revocation-cache.d.ts +79 -0
  33. package/dist/core/revocation-cache.d.ts.map +1 -0
  34. package/dist/core/revocation-cache.js +68 -0
  35. package/dist/core/scope-resolver.d.ts +235 -0
  36. package/dist/core/scope-resolver.d.ts.map +1 -0
  37. package/dist/core/scope-resolver.js +206 -0
  38. package/dist/index.d.ts +16 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +18 -0
  41. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  42. 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"}
@@ -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"}