@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,167 @@
1
+ "use strict";
2
+ /**
3
+ * The guard that enforces `@RequirePermission` (Doc 04 §8, Doc 08 §4).
4
+ *
5
+ * `AuthGuard` establishes **who** is calling. This one establishes **whether
6
+ * they may** — the WHO × WHAT × WHERE question of Doc 04 §1, asked once per
7
+ * request, before the handler and before the transaction that would carry out
8
+ * whatever it was going to do.
9
+ *
10
+ * Four steps, and each one is a line of Doc 04 §8's contract:
11
+ *
12
+ * 1. Read the route's requirement. No requirement and no explicit opt-out is a
13
+ * refusal — see {@link RequirePermission} for why the direction is that way
14
+ * round.
15
+ * 2. Load the subject's resolved grants, through {@link GrantsSource}.
16
+ * 3. Confirm the permission is held, and — where the route names a scope node —
17
+ * that the subject's grants cover it.
18
+ * 4. Refuse with `PERMISSION_DENIED` or `SCOPE_DENIED`, and audit the attempt.
19
+ *
20
+ * Steps 2 and 3 are {@link ScopeResolver.decide}, which is where the rule lives
21
+ * and where it is unit-tested. What is left here is the framework adapter: read
22
+ * metadata, read the request, translate an outcome into an exception. That
23
+ * split is deliberate — a guard is the one place a test has to build an
24
+ * `ExecutionContext` to reach, and authorization logic should not be behind
25
+ * that.
26
+ *
27
+ * ## Which connection step 2 runs on
28
+ *
29
+ * Not this file's decision, and deliberately not this file's problem.
30
+ * `docs/adr/0001-permission-guard-connection-strategy.md` settles it: a guard
31
+ * runs *before* the per-request transaction exists (Nest runs guards ahead of
32
+ * interceptors), so on a grants-cache miss the IAM's {@link GrantsSource} opens
33
+ * its own `QueryRunner`, applies the RLS context from the verified claims,
34
+ * resolves on it, and commits and releases in a `finally`. It does **not** open,
35
+ * reuse or leave open the request transaction: a guard has no "after" phase in
36
+ * which to close one, so `TenantContextInterceptor` stays its sole owner.
37
+ *
38
+ * `auth-kit` may depend on `@plantops/contracts` and nothing else (Doc 08 §2),
39
+ * so it could not name a `DataSource` here in any case — which is why the port
40
+ * exists and why a future module can satisfy it with a cached HTTP call instead.
41
+ *
42
+ * ## Every denial is audited, and a failure to audit is not a failure to deny
43
+ *
44
+ * Doc 04 §8 step 5 and Doc 10 §3: the attempt is recorded with the permission
45
+ * that was wanted and the target that was named. The IAM binds
46
+ * {@link DENIAL_AUDITOR} to `AuditService.recordDenial`, which commits on its
47
+ * own connection precisely because the request it accompanies is about to be
48
+ * rolled back by its own 403 — and which never throws, because turning a lost
49
+ * audit row into a 500 would tell a caller which requests were refused for which
50
+ * reason.
51
+ *
52
+ * The auditor is optional: a downstream module has no `audit_trail` table and
53
+ * records nothing. Binding it is the IAM's business.
54
+ *
55
+ * ## The two refusals say different things, and neither says whether the target
56
+ * exists
57
+ *
58
+ * `PERMISSION_DENIED` means the subject does not hold it anywhere;
59
+ * `SCOPE_DENIED` means they hold it, but not over the node they named. A node
60
+ * belonging to another tenant produces `SCOPE_DENIED` too — indistinguishable
61
+ * from one they simply do not cover, because RLS makes it so and Doc 06 §2
62
+ * requires that a denial never reveal cross-tenant existence.
63
+ */
64
+ var PermissionGuard_1;
65
+ Object.defineProperty(exports, "__esModule", { value: true });
66
+ exports.PermissionGuard = exports.DENIAL_AUDITOR = exports.VERIFIED_CLAIMS_SOURCE = exports.AuthorizationDeniedException = void 0;
67
+ const tslib_1 = require("tslib");
68
+ const common_1 = require("@nestjs/common");
69
+ const core_1 = require("@nestjs/core");
70
+ const contracts_1 = require("@plantops/contracts");
71
+ const auth_guard_1 = require("./auth.guard");
72
+ const require_permission_decorator_1 = require("./require-permission.decorator");
73
+ const scope_resolver_1 = require("./scope-resolver");
74
+ const scope_resolver_2 = require("../../core/scope-resolver");
75
+ /**
76
+ * A 403 that knows which of Doc 06 §2's two codes it is.
77
+ *
78
+ * `ForbiddenException` alone would come back as `PERMISSION_DENIED`, because
79
+ * that is the less specific of the two and the only one a bare status can imply
80
+ * (`http-exception.filter.ts`). The distinction is worth carrying: a client that
81
+ * sees `SCOPE_DENIED` can tell its user *where* they lack access, which is the
82
+ * one actionable thing about a refusal.
83
+ *
84
+ * `IamErrorCode` is a `@plantops/contracts` export, so naming it here crosses no
85
+ * boundary — the code table is part of the published contract, not of the IAM.
86
+ */
87
+ class AuthorizationDeniedException extends common_1.ForbiddenException {
88
+ constructor(code, message) {
89
+ super(message);
90
+ this.code = code;
91
+ this.name = 'AuthorizationDeniedException';
92
+ }
93
+ /** The subject does not hold the permission at all. */
94
+ static permission() {
95
+ return new AuthorizationDeniedException(contracts_1.IamErrorCode.PERMISSION_DENIED, 'You do not have permission to perform this action');
96
+ }
97
+ /** They hold it, but not over the node they named. */
98
+ static scope() {
99
+ return new AuthorizationDeniedException(contracts_1.IamErrorCode.SCOPE_DENIED, 'You do not have permission at the requested scope');
100
+ }
101
+ }
102
+ exports.AuthorizationDeniedException = AuthorizationDeniedException;
103
+ exports.VERIFIED_CLAIMS_SOURCE = Symbol('auth-kit:VerifiedClaimsSource');
104
+ exports.DENIAL_AUDITOR = Symbol('auth-kit:DenialAuditor');
105
+ let PermissionGuard = PermissionGuard_1 = class PermissionGuard {
106
+ constructor(reflector, resolver, claims, auditor = null) {
107
+ this.reflector = reflector;
108
+ this.resolver = resolver;
109
+ this.claims = claims;
110
+ this.auditor = auditor;
111
+ this.logger = new common_1.Logger(PermissionGuard_1.name);
112
+ }
113
+ async canActivate(context) {
114
+ if (context.getType() !== 'http')
115
+ return true;
116
+ const targets = [context.getHandler(), context.getClass()];
117
+ // A `@Public()` route has no subject, so there are no grants to check. It
118
+ // is also the one case where stepping aside cannot widen anything: the
119
+ // request reaches the handler with no RLS context, and every tenant policy
120
+ // matches nothing (`tenant-context.interceptor.ts`).
121
+ if (this.reflector.getAllAndOverride(auth_guard_1.IS_PUBLIC_METADATA, targets)) {
122
+ return true;
123
+ }
124
+ const requirement = this.reflector.getAllAndOverride(require_permission_decorator_1.REQUIRE_PERMISSION_METADATA, targets);
125
+ if (requirement === undefined) {
126
+ const exemption = this.reflector.getAllAndOverride(require_permission_decorator_1.NO_PERMISSION_METADATA, targets);
127
+ if (exemption !== undefined)
128
+ return true;
129
+ // Deny-by-default, and loudly. Reaching this is a wiring mistake rather
130
+ // than a caller's, and it must not be silent: the alternative reading —
131
+ // let an undeclared route through — is the one that produces a working,
132
+ // ungated endpoint nobody notices (`require-permission.decorator.ts`).
133
+ this.logger.error(`${context.getClass().name}.${context.getHandler().name} declares neither ` +
134
+ '@RequirePermission() nor @NoPermissionRequired(); refusing the request');
135
+ throw AuthorizationDeniedException.permission();
136
+ }
137
+ const request = context.switchToHttp().getRequest();
138
+ const claims = this.claims.claimsOf(request);
139
+ // Unreachable through `AuthGuard`, which refuses an unauthenticated request
140
+ // on a non-`@Public()` route. It is the backstop for a route that ever loses
141
+ // that guard, and refusing is the only safe reading of "no subject".
142
+ if (claims === undefined)
143
+ throw AuthorizationDeniedException.permission();
144
+ const scopeNodeId = requirement.scopeFrom === undefined
145
+ ? undefined
146
+ : (0, require_permission_decorator_1.readScopeTarget)(request, requirement.scopeFrom);
147
+ const decision = await this.resolver.decide(claims, requirement, scopeNodeId);
148
+ if (decision.outcome === scope_resolver_2.AuthorizationOutcome.ALLOWED)
149
+ return true;
150
+ // Best-effort and awaited: the row commits on its own connection, so it
151
+ // survives the rollback this 403 is about to cause, and the auditor's own
152
+ // contract is that it never throws (Doc 10 §3).
153
+ await this.auditor?.recordDenial(claims, decision.outcome, decision.permissions, decision.scopeNodeId);
154
+ throw decision.outcome === scope_resolver_2.AuthorizationOutcome.PERMISSION_DENIED
155
+ ? AuthorizationDeniedException.permission()
156
+ : AuthorizationDeniedException.scope();
157
+ }
158
+ };
159
+ exports.PermissionGuard = PermissionGuard;
160
+ exports.PermissionGuard = PermissionGuard = PermissionGuard_1 = tslib_1.__decorate([
161
+ (0, common_1.Injectable)(),
162
+ tslib_1.__param(2, (0, common_1.Inject)(exports.VERIFIED_CLAIMS_SOURCE)),
163
+ tslib_1.__param(3, (0, common_1.Optional)()),
164
+ tslib_1.__param(3, (0, common_1.Inject)(exports.DENIAL_AUDITOR)),
165
+ tslib_1.__metadata("design:paramtypes", [core_1.Reflector,
166
+ scope_resolver_1.ScopeResolver, Object, Object])
167
+ ], PermissionGuard);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `@RequirePermission` — the WHAT, and optionally the WHERE, of a route
3
+ * (Doc 04 §8, Doc 08 §4).
4
+ *
5
+ * The decision vocabulary — {@link PermissionRequirement},
6
+ * {@link RequirePermissionOptions}, `readScopeTarget` — lives in the
7
+ * framework-free core (`core/scope-resolver.ts`); this adapter adds only what
8
+ * Nest needs: `SetMetadata` for the two decorators, and nothing else.
9
+ */
10
+ import type { PermissionKey } from '@plantops/contracts';
11
+ import { type RequirePermissionOptions } from '../../core/scope-resolver';
12
+ export { NO_PERMISSION_METADATA, REQUIRE_PERMISSION_METADATA, type PermissionRequirement, type RequirePermissionOptions, readScopeTarget, } from '../../core/scope-resolver';
13
+ /**
14
+ * Gates a route on `permission`, optionally at the scope node the request
15
+ * names.
16
+ *
17
+ * Applicable to a method or to a whole controller; the method wins where both
18
+ * carry one, which is Nest getAllAndOverride order and lets a controller state
19
+ * the common case once.
20
+ *
21
+ * Pass an array to admit any one of several keys — see Doc 04 §8 for when that
22
+ * is legitimate, and for why the others must not.
23
+ */
24
+ export declare const RequirePermission: (permission: PermissionKey | readonly PermissionKey[], options?: RequirePermissionOptions) => import("@nestjs/common").CustomDecorator<string>;
25
+ /**
26
+ * Opts a route out of permission gating, with the reason recorded at the call
27
+ * site. The reason is not decoration — it separates a considered exemption from
28
+ * a forgotten decorator.
29
+ */
30
+ export declare const NoPermissionRequired: (reason: string) => import("@nestjs/common").CustomDecorator<string>;
31
+ //# sourceMappingURL=require-permission.decorator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-permission.decorator.d.ts","sourceRoot":"","sources":["../../../src/adapters/nestjs/require-permission.decorator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAIL,KAAK,wBAAwB,EAC9B,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,wBAAwB,EAC7B,eAAe,GAChB,MAAM,2BAA2B,CAAC;AACnC;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC5B,YAAY,aAAa,GAAG,SAAS,aAAa,EAAE,EACpD,UAAS,wBAA6B,qDAKpC,CAAC;AAEL;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,MAAM,qDACN,CAAC"}
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ /**
3
+ * `@RequirePermission` — the WHAT, and optionally the WHERE, of a route
4
+ * (Doc 04 §8, Doc 08 §4).
5
+ *
6
+ * The decision vocabulary — {@link PermissionRequirement},
7
+ * {@link RequirePermissionOptions}, `readScopeTarget` — lives in the
8
+ * framework-free core (`core/scope-resolver.ts`); this adapter adds only what
9
+ * Nest needs: `SetMetadata` for the two decorators, and nothing else.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.NoPermissionRequired = exports.RequirePermission = exports.readScopeTarget = exports.REQUIRE_PERMISSION_METADATA = exports.NO_PERMISSION_METADATA = void 0;
13
+ const common_1 = require("@nestjs/common");
14
+ const scope_resolver_1 = require("../../core/scope-resolver");
15
+ var scope_resolver_2 = require("../../core/scope-resolver");
16
+ Object.defineProperty(exports, "NO_PERMISSION_METADATA", { enumerable: true, get: function () { return scope_resolver_2.NO_PERMISSION_METADATA; } });
17
+ Object.defineProperty(exports, "REQUIRE_PERMISSION_METADATA", { enumerable: true, get: function () { return scope_resolver_2.REQUIRE_PERMISSION_METADATA; } });
18
+ Object.defineProperty(exports, "readScopeTarget", { enumerable: true, get: function () { return scope_resolver_2.readScopeTarget; } });
19
+ /**
20
+ * Gates a route on `permission`, optionally at the scope node the request
21
+ * names.
22
+ *
23
+ * Applicable to a method or to a whole controller; the method wins where both
24
+ * carry one, which is Nest getAllAndOverride order and lets a controller state
25
+ * the common case once.
26
+ *
27
+ * Pass an array to admit any one of several keys — see Doc 04 §8 for when that
28
+ * is legitimate, and for why the others must not.
29
+ */
30
+ const RequirePermission = (permission, options = {}) => (0, common_1.SetMetadata)(scope_resolver_1.REQUIRE_PERMISSION_METADATA, {
31
+ permissions: typeof permission === 'string' ? [permission] : [...permission],
32
+ ...options,
33
+ });
34
+ exports.RequirePermission = RequirePermission;
35
+ /**
36
+ * Opts a route out of permission gating, with the reason recorded at the call
37
+ * site. The reason is not decoration — it separates a considered exemption from
38
+ * a forgotten decorator.
39
+ */
40
+ const NoPermissionRequired = (reason) => (0, common_1.SetMetadata)(scope_resolver_1.NO_PERMISSION_METADATA, reason);
41
+ exports.NoPermissionRequired = NoPermissionRequired;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Nest binding of the framework-free {@link ScopeResolverCore}: the only
3
+ * thing this class adds is receiving the `GrantsSource` port through Nest\u2019s
4
+ * injector (roadmap Session 50). Every decision method is inherited, so the
5
+ * rule lives in exactly one place — core/scope-resolver.ts — while remaining
6
+ * injectable anywhere a Nest token is.
7
+ */
8
+ import { ScopeResolverCore, type GrantsSource } from '../../core/scope-resolver';
9
+ export declare class ScopeResolver extends ScopeResolverCore {
10
+ constructor(source: GrantsSource);
11
+ }
12
+ //# sourceMappingURL=scope-resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-resolver.d.ts","sourceRoot":"","sources":["../../../src/adapters/nestjs/scope-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAiB,iBAAiB,EAAE,KAAK,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAEhG,qBACa,aAAc,SAAQ,iBAAiB;gBACf,MAAM,EAAE,YAAY;CAGxD"}
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ /**
3
+ * The Nest binding of the framework-free {@link ScopeResolverCore}: the only
4
+ * thing this class adds is receiving the `GrantsSource` port through Nest\u2019s
5
+ * injector (roadmap Session 50). Every decision method is inherited, so the
6
+ * rule lives in exactly one place — core/scope-resolver.ts — while remaining
7
+ * injectable anywhere a Nest token is.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.ScopeResolver = void 0;
11
+ const tslib_1 = require("tslib");
12
+ const common_1 = require("@nestjs/common");
13
+ const scope_resolver_1 = require("../../core/scope-resolver");
14
+ let ScopeResolver = class ScopeResolver extends scope_resolver_1.ScopeResolverCore {
15
+ constructor(source) {
16
+ super(source);
17
+ }
18
+ };
19
+ exports.ScopeResolver = ScopeResolver;
20
+ exports.ScopeResolver = ScopeResolver = tslib_1.__decorate([
21
+ (0, common_1.Injectable)(),
22
+ tslib_1.__param(0, (0, common_1.Inject)(scope_resolver_1.GRANTS_SOURCE)),
23
+ tslib_1.__metadata("design:paramtypes", [Object])
24
+ ], ScopeResolver);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The closed claim set, and the checks every verifier must apply identically
3
+ * (Doc 03 §2, §6).
4
+ *
5
+ * ## Why this is not in the IAM
6
+ *
7
+ * The IAM signs; `admin-web`, `iam-client` and every future operational module
8
+ * verify — locally, from the published JWKS, without calling the IAM (Doc 06
9
+ * §11). "Verify the same way" is therefore a property of *separate processes*,
10
+ * and the only way to hold it is for all of them to run this code. A module
11
+ * that reimplements the expiry check with a different leeway rejects tokens the
12
+ * IAM considers live for a whole minute at every token edge; one that skips the
13
+ * issuer check accepts another deployment's tokens outright.
14
+ *
15
+ * ## The claim set is closed, in both directions
16
+ *
17
+ * Doc 03 §2 lists exactly seven claims: `iss, sub, sty, cid, sid, iat, exp`.
18
+ * Not "at least" — permissions, roles and scope nodes are *deliberately* absent
19
+ * so that a grant change takes effect on cache invalidation (Doc 04 §7) rather
20
+ * than on token expiry. So {@link assertExactClaims} refuses to sign an eighth,
21
+ * and {@link readAccessTokenClaims} refuses to accept one: a token this IAM
22
+ * issued cannot carry extras, so their presence means the payload came from
23
+ * somewhere else, and silently reading the seven we recognise is how a second
24
+ * issuer goes unnoticed.
25
+ *
26
+ * ## The one exception, and why it is not a hole
27
+ *
28
+ * `JWT_TOLERATED_CLAIM_KEYS` — today just `aud` — is accepted on the way in and
29
+ * dropped, never required and never read (ADR 0005 §5.1). Signing is unchanged,
30
+ * so this IAM still mints exactly seven claims.
31
+ *
32
+ * The asymmetry is deliberate, and it is about which half can be migrated.
33
+ * Every consumer verifies locally with this published code; the signer is a
34
+ * single deployment. A claim the IAM adds later is therefore free on the
35
+ * signing side and catastrophic on the verifying side — an older verifier
36
+ * rejects **every** token rather than ignoring the field it does not know. So
37
+ * the tolerance has to be in the field *before* the first publish or it is
38
+ * worthless, which is the deadline ADR 0005 §5.1 set against #72.
39
+ *
40
+ * What it costs is the tamper signal, for exactly one registered claim. What it
41
+ * buys is that reconsidering ADR 0005 §3.3 does not require a strictly-ordered
42
+ * upgrade across repositories this one does not control.
43
+ */
44
+ import { type JwtClaims } from '@plantops/contracts';
45
+ /** Why a token was refused. Never returned to a caller — see the note below. */
46
+ export declare const TokenRejection: {
47
+ /** Not a compact JWS, or its segments do not decode. */
48
+ readonly MALFORMED: "malformed";
49
+ /** Header `alg` is not RS256 (Doc 03 §1). */
50
+ readonly UNSUPPORTED_ALGORITHM: "unsupported_algorithm";
51
+ /** `kid` is not in the published set — refetch JWKS, then reject. */
52
+ readonly UNKNOWN_KEY: "unknown_key";
53
+ /** Signature does not verify under the selected key. */
54
+ readonly BAD_SIGNATURE: "bad_signature";
55
+ /** `exp` has passed, allowing the 60 s leeway. */
56
+ readonly EXPIRED: "expired";
57
+ /** `iat` is in the future beyond the leeway — a clock or a forgery. */
58
+ readonly NOT_YET_VALID: "not_yet_valid";
59
+ /** `iss` is not the expected issuer. */
60
+ readonly WRONG_ISSUER: "wrong_issuer";
61
+ /** Claim set is not the Doc 03 §2 shape. */
62
+ readonly BAD_CLAIMS: "bad_claims";
63
+ /** The session behind `sid` has been revoked (Doc 03 §6). */
64
+ readonly REVOKED: "revoked";
65
+ };
66
+ export type TokenRejection = (typeof TokenRejection)[keyof typeof TokenRejection];
67
+ /**
68
+ * A refused token, with the reason kept **server-side**.
69
+ *
70
+ * The distinction matters at the HTTP boundary: an `unknown_key` and an
71
+ * `expired` must look identical to the caller (both are a bare 401
72
+ * `AUTH_REQUIRED`), because the difference tells an attacker whether a forged
73
+ * `kid` was a near miss. The reason is here for logs, metrics and the
74
+ * refetch-then-reject decision — not for the response body.
75
+ */
76
+ export declare class TokenVerificationError extends Error {
77
+ readonly reason: TokenRejection;
78
+ constructor(reason: TokenRejection, message: string);
79
+ }
80
+ /**
81
+ * Guards the closed claim set at signing time (Doc 03 §2).
82
+ *
83
+ * Typed input alone does not cover this: a structurally-typed object may carry
84
+ * extra properties at runtime, and `JSON.stringify` would faithfully sign them.
85
+ *
86
+ * Not relaxed by ADR 0005 §5.1: the tolerated claims are accepted on the way
87
+ * **in** only. This IAM mints seven claims, and a token of its own carrying an
88
+ * `aud` would mean something upstream put it there.
89
+ */
90
+ export declare function assertExactClaims(claims: JwtClaims): Record<string, unknown>;
91
+ /**
92
+ * Validates a **signature-verified** payload against the Doc 03 §2 shape.
93
+ *
94
+ * Call this only after the signature checks out. A token signed by a legitimate
95
+ * key is still attacker-influenced if any upstream path ever let a caller
96
+ * choose a claim value, which is why every field is re-validated on the way in
97
+ * and not merely on the way out.
98
+ *
99
+ * @throws {TokenVerificationError}
100
+ */
101
+ export declare function readAccessTokenClaims(payload: Record<string, unknown>): JwtClaims;
102
+ /**
103
+ * Checks issuer and the time window, with the shared 60 s leeway.
104
+ *
105
+ * The leeway is {@link CLOCK_SKEW_LEEWAY_SECONDS}, not a local number: the IAM
106
+ * and every consuming module must agree, or a token is live in one process and
107
+ * expired in the next at the edges (Doc 03 §6). Note that the issuer is a claim
108
+ * like any other and is worthless until the document is known to be genuine —
109
+ * so this runs after signature verification, never before it.
110
+ *
111
+ * @throws {TokenVerificationError}
112
+ */
113
+ export declare function assertClaimsAcceptable(claims: JwtClaims, expectedIssuer: string, now?: Date): void;
114
+ //# sourceMappingURL=claims.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../../src/core/claims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,qBAAqB,CAAC;AAE7B,gFAAgF;AAChF,eAAO,MAAM,cAAc;IACzB,wDAAwD;;IAExD,6CAA6C;;IAE7C,qEAAqE;;IAErE,wDAAwD;;IAExD,kDAAkD;;IAElD,uEAAuE;;IAEvE,wCAAwC;;IAExC,4CAA4C;;IAE5C,6DAA6D;;CAErD,CAAC;AACX,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,OAAO,cAAc,CAAC,CAAC;AAElF;;;;;;;;GAQG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;IAE7C,QAAQ,CAAC,MAAM,EAAE,cAAc;gBAAtB,MAAM,EAAE,cAAc,EAC/B,OAAO,EAAE,MAAM;CAKlB;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAW5E;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CA0DjF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,SAAS,EACjB,cAAc,EAAE,MAAM,EACtB,GAAG,GAAE,IAAiB,GACrB,IAAI,CAkBN"}
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ /**
3
+ * The closed claim set, and the checks every verifier must apply identically
4
+ * (Doc 03 §2, §6).
5
+ *
6
+ * ## Why this is not in the IAM
7
+ *
8
+ * The IAM signs; `admin-web`, `iam-client` and every future operational module
9
+ * verify — locally, from the published JWKS, without calling the IAM (Doc 06
10
+ * §11). "Verify the same way" is therefore a property of *separate processes*,
11
+ * and the only way to hold it is for all of them to run this code. A module
12
+ * that reimplements the expiry check with a different leeway rejects tokens the
13
+ * IAM considers live for a whole minute at every token edge; one that skips the
14
+ * issuer check accepts another deployment's tokens outright.
15
+ *
16
+ * ## The claim set is closed, in both directions
17
+ *
18
+ * Doc 03 §2 lists exactly seven claims: `iss, sub, sty, cid, sid, iat, exp`.
19
+ * Not "at least" — permissions, roles and scope nodes are *deliberately* absent
20
+ * so that a grant change takes effect on cache invalidation (Doc 04 §7) rather
21
+ * than on token expiry. So {@link assertExactClaims} refuses to sign an eighth,
22
+ * and {@link readAccessTokenClaims} refuses to accept one: a token this IAM
23
+ * issued cannot carry extras, so their presence means the payload came from
24
+ * somewhere else, and silently reading the seven we recognise is how a second
25
+ * issuer goes unnoticed.
26
+ *
27
+ * ## The one exception, and why it is not a hole
28
+ *
29
+ * `JWT_TOLERATED_CLAIM_KEYS` — today just `aud` — is accepted on the way in and
30
+ * dropped, never required and never read (ADR 0005 §5.1). Signing is unchanged,
31
+ * so this IAM still mints exactly seven claims.
32
+ *
33
+ * The asymmetry is deliberate, and it is about which half can be migrated.
34
+ * Every consumer verifies locally with this published code; the signer is a
35
+ * single deployment. A claim the IAM adds later is therefore free on the
36
+ * signing side and catastrophic on the verifying side — an older verifier
37
+ * rejects **every** token rather than ignoring the field it does not know. So
38
+ * the tolerance has to be in the field *before* the first publish or it is
39
+ * worthless, which is the deadline ADR 0005 §5.1 set against #72.
40
+ *
41
+ * What it costs is the tamper signal, for exactly one registered claim. What it
42
+ * buys is that reconsidering ADR 0005 §3.3 does not require a strictly-ordered
43
+ * upgrade across repositories this one does not control.
44
+ */
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.TokenVerificationError = exports.TokenRejection = void 0;
47
+ exports.assertExactClaims = assertExactClaims;
48
+ exports.readAccessTokenClaims = readAccessTokenClaims;
49
+ exports.assertClaimsAcceptable = assertClaimsAcceptable;
50
+ const contracts_1 = require("@plantops/contracts");
51
+ /** Why a token was refused. Never returned to a caller — see the note below. */
52
+ exports.TokenRejection = {
53
+ /** Not a compact JWS, or its segments do not decode. */
54
+ MALFORMED: 'malformed',
55
+ /** Header `alg` is not RS256 (Doc 03 §1). */
56
+ UNSUPPORTED_ALGORITHM: 'unsupported_algorithm',
57
+ /** `kid` is not in the published set — refetch JWKS, then reject. */
58
+ UNKNOWN_KEY: 'unknown_key',
59
+ /** Signature does not verify under the selected key. */
60
+ BAD_SIGNATURE: 'bad_signature',
61
+ /** `exp` has passed, allowing the 60 s leeway. */
62
+ EXPIRED: 'expired',
63
+ /** `iat` is in the future beyond the leeway — a clock or a forgery. */
64
+ NOT_YET_VALID: 'not_yet_valid',
65
+ /** `iss` is not the expected issuer. */
66
+ WRONG_ISSUER: 'wrong_issuer',
67
+ /** Claim set is not the Doc 03 §2 shape. */
68
+ BAD_CLAIMS: 'bad_claims',
69
+ /** The session behind `sid` has been revoked (Doc 03 §6). */
70
+ REVOKED: 'revoked',
71
+ };
72
+ /**
73
+ * A refused token, with the reason kept **server-side**.
74
+ *
75
+ * The distinction matters at the HTTP boundary: an `unknown_key` and an
76
+ * `expired` must look identical to the caller (both are a bare 401
77
+ * `AUTH_REQUIRED`), because the difference tells an attacker whether a forged
78
+ * `kid` was a near miss. The reason is here for logs, metrics and the
79
+ * refetch-then-reject decision — not for the response body.
80
+ */
81
+ class TokenVerificationError extends Error {
82
+ constructor(reason, message) {
83
+ super(message);
84
+ this.reason = reason;
85
+ this.name = 'TokenVerificationError';
86
+ }
87
+ }
88
+ exports.TokenVerificationError = TokenVerificationError;
89
+ /**
90
+ * Guards the closed claim set at signing time (Doc 03 §2).
91
+ *
92
+ * Typed input alone does not cover this: a structurally-typed object may carry
93
+ * extra properties at runtime, and `JSON.stringify` would faithfully sign them.
94
+ *
95
+ * Not relaxed by ADR 0005 §5.1: the tolerated claims are accepted on the way
96
+ * **in** only. This IAM mints seven claims, and a token of its own carrying an
97
+ * `aud` would mean something upstream put it there.
98
+ */
99
+ function assertExactClaims(claims) {
100
+ const allowed = new Set(contracts_1.JWT_CLAIM_KEYS);
101
+ const extra = Object.keys(claims).filter((key) => !allowed.has(key));
102
+ if (extra.length > 0) {
103
+ throw new Error(`Access token would carry claims outside Doc 03 §2: ${extra.join(', ')}. ` +
104
+ 'Permissions, roles and scopes are resolved separately and must not be ' +
105
+ 'embedded in a token.');
106
+ }
107
+ return { ...claims };
108
+ }
109
+ /**
110
+ * Validates a **signature-verified** payload against the Doc 03 §2 shape.
111
+ *
112
+ * Call this only after the signature checks out. A token signed by a legitimate
113
+ * key is still attacker-influenced if any upstream path ever let a caller
114
+ * choose a claim value, which is why every field is re-validated on the way in
115
+ * and not merely on the way out.
116
+ *
117
+ * @throws {TokenVerificationError}
118
+ */
119
+ function readAccessTokenClaims(payload) {
120
+ const missing = contracts_1.JWT_CLAIM_KEYS.filter((key) => payload[key] === undefined);
121
+ if (missing.length > 0) {
122
+ throw new TokenVerificationError(exports.TokenRejection.BAD_CLAIMS, `Token is missing required claims: ${missing.join(', ')}`);
123
+ }
124
+ // Accepted, not required, and not returned — see the header. Every other
125
+ // unrecognised claim is still a refusal, so this widens the door by exactly
126
+ // one registered name rather than opening it.
127
+ const allowed = new Set([
128
+ ...contracts_1.JWT_CLAIM_KEYS,
129
+ ...contracts_1.JWT_TOLERATED_CLAIM_KEYS,
130
+ ]);
131
+ const extra = Object.keys(payload).filter((key) => !allowed.has(key));
132
+ if (extra.length > 0) {
133
+ throw new TokenVerificationError(exports.TokenRejection.BAD_CLAIMS, `Token carries claims outside Doc 03 §2: ${extra.join(', ')}`);
134
+ }
135
+ for (const key of ['iss', 'sub', 'cid', 'sid']) {
136
+ if (typeof payload[key] !== 'string' || payload[key] === '') {
137
+ throw new TokenVerificationError(exports.TokenRejection.BAD_CLAIMS, `Token claim "${key}" is not a non-empty string`);
138
+ }
139
+ }
140
+ for (const key of ['iat', 'exp']) {
141
+ if (typeof payload[key] !== 'number' || !Number.isInteger(payload[key])) {
142
+ throw new TokenVerificationError(exports.TokenRejection.BAD_CLAIMS, `Token claim "${key}" is not an integer`);
143
+ }
144
+ }
145
+ if (payload['sty'] !== contracts_1.SubjectType.USER && payload['sty'] !== contracts_1.SubjectType.SERVICE) {
146
+ throw new TokenVerificationError(exports.TokenRejection.BAD_CLAIMS, 'Token claim "sty" is not a known subject type');
147
+ }
148
+ // The seven, and only the seven. A tolerated claim is dropped here rather
149
+ // than passed through, so no caller downstream can come to depend on a value
150
+ // this system does not mint and does not validate.
151
+ return {
152
+ iss: payload['iss'],
153
+ sub: payload['sub'],
154
+ sty: payload['sty'],
155
+ cid: payload['cid'],
156
+ sid: payload['sid'],
157
+ iat: payload['iat'],
158
+ exp: payload['exp'],
159
+ };
160
+ }
161
+ /**
162
+ * Checks issuer and the time window, with the shared 60 s leeway.
163
+ *
164
+ * The leeway is {@link CLOCK_SKEW_LEEWAY_SECONDS}, not a local number: the IAM
165
+ * and every consuming module must agree, or a token is live in one process and
166
+ * expired in the next at the edges (Doc 03 §6). Note that the issuer is a claim
167
+ * like any other and is worthless until the document is known to be genuine —
168
+ * so this runs after signature verification, never before it.
169
+ *
170
+ * @throws {TokenVerificationError}
171
+ */
172
+ function assertClaimsAcceptable(claims, expectedIssuer, now = new Date()) {
173
+ if (claims.iss !== expectedIssuer) {
174
+ throw new TokenVerificationError(exports.TokenRejection.WRONG_ISSUER, 'Token was issued by a different issuer');
175
+ }
176
+ const seconds = Math.floor(now.getTime() / 1000);
177
+ if (claims.exp + contracts_1.CLOCK_SKEW_LEEWAY_SECONDS <= seconds) {
178
+ throw new TokenVerificationError(exports.TokenRejection.EXPIRED, 'Token has expired');
179
+ }
180
+ if (claims.iat - contracts_1.CLOCK_SKEW_LEEWAY_SECONDS > seconds) {
181
+ throw new TokenVerificationError(exports.TokenRejection.NOT_YET_VALID, 'Token was issued in the future');
182
+ }
183
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The framework-free core of `auth-kit` (roadmap Session 50): token
3
+ * verification (JWS primitives + the JWKS verifier), the closed claim set,
4
+ * revocation caching, and the pure scope-coverage vocabulary. Nothing in this
5
+ * directory may import the Nest packages — enforced by
6
+ * `core/core-is-framework-free.spec.ts`.
7
+ */
8
+ export * from './claims';
9
+ export * from './jws';
10
+ export * from './jwks-verifier';
11
+ export * from './revocation-cache';
12
+ export * from './scope-resolver';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,cAAc,UAAU,CAAC;AACzB,cAAc,OAAO,CAAC;AACtB,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC"}
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ /**
5
+ * The framework-free core of `auth-kit` (roadmap Session 50): token
6
+ * verification (JWS primitives + the JWKS verifier), the closed claim set,
7
+ * revocation caching, and the pure scope-coverage vocabulary. Nothing in this
8
+ * directory may import the Nest packages — enforced by
9
+ * `core/core-is-framework-free.spec.ts`.
10
+ */
11
+ tslib_1.__exportStar(require("./claims"), exports);
12
+ tslib_1.__exportStar(require("./jws"), exports);
13
+ tslib_1.__exportStar(require("./jwks-verifier"), exports);
14
+ tslib_1.__exportStar(require("./revocation-cache"), exports);
15
+ tslib_1.__exportStar(require("./scope-resolver"), exports);