@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
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fetch adapter (roadmap Session 50): authorization for runtimes that
|
|
3
|
+
* have a Request and no Nest container — Next.js route handlers, workers,
|
|
4
|
+
* any WinterCG runtime.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately smaller than the Nest guard: no decorators, no metadata, no
|
|
7
|
+
* denial audit. It answers the question a route handler actually has — who is
|
|
8
|
+
* this, and may they proceed? — and leaves policy to the caller. The pieces it
|
|
9
|
+
* delegates to are the same framework-free core the Nest guard uses, so a
|
|
10
|
+
* module and the IAM can never disagree about what a token means.
|
|
11
|
+
*/
|
|
12
|
+
import { type RevocationChecker, type TokenVerifier } from '../../core';
|
|
13
|
+
export interface FetchAuthOptions {
|
|
14
|
+
/** Verifies the bearer token. JwksVerifier for every non-IAM process. */
|
|
15
|
+
verifier: TokenVerifier;
|
|
16
|
+
/**
|
|
17
|
+
* Answers whether a session is still live. Optional: without one,
|
|
18
|
+
* revocation is not checked and the decision rests on signature alone.
|
|
19
|
+
*/
|
|
20
|
+
revocations?: RevocationChecker;
|
|
21
|
+
}
|
|
22
|
+
export type FetchDecision<V extends TokenVerifier = TokenVerifier> = {
|
|
23
|
+
ok: true;
|
|
24
|
+
claims: Awaited<ReturnType<V['verify']>>;
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
status: 401;
|
|
28
|
+
code: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Verifies the bearer token of a fetch-style Request, and checks its session
|
|
32
|
+
* against revocations when a checker was provided.
|
|
33
|
+
*
|
|
34
|
+
* Every refusal is a bare 401 with the closed error code — the reason stays
|
|
35
|
+
* server-side (Doc 03 §6), exactly as the Nest guard draws the boundary.
|
|
36
|
+
*/
|
|
37
|
+
export declare function authorizeRequest(request: Request, options: FetchAuthOptions): Promise<FetchDecision>;
|
|
38
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/adapters/fetch/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,aAAa,EACnB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,gBAAgB;IAC/B,yEAAyE;IACzE,QAAQ,EAAE,aAAa,CAAC;IACxB;;;OAGG;IACH,WAAW,CAAC,EAAE,iBAAiB,CAAC;CACjC;AAED,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,aAAa,GAAG,aAAa,IAC7D;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAA;CAAE,GACtD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,GAAG,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAS7C;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,aAAa,CAAC,CAuBxB"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The fetch adapter (roadmap Session 50): authorization for runtimes that
|
|
4
|
+
* have a Request and no Nest container — Next.js route handlers, workers,
|
|
5
|
+
* any WinterCG runtime.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately smaller than the Nest guard: no decorators, no metadata, no
|
|
8
|
+
* denial audit. It answers the question a route handler actually has — who is
|
|
9
|
+
* this, and may they proceed? — and leaves policy to the caller. The pieces it
|
|
10
|
+
* delegates to are the same framework-free core the Nest guard uses, so a
|
|
11
|
+
* module and the IAM can never disagree about what a token means.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.authorizeRequest = authorizeRequest;
|
|
15
|
+
const core_1 = require("../../core");
|
|
16
|
+
function bearerOf(request) {
|
|
17
|
+
const header = request.headers.get('authorization');
|
|
18
|
+
if (header === null)
|
|
19
|
+
return undefined;
|
|
20
|
+
const match = /^Bearer (.+)$/.exec(header);
|
|
21
|
+
return match === null ? undefined : match[1];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Verifies the bearer token of a fetch-style Request, and checks its session
|
|
25
|
+
* against revocations when a checker was provided.
|
|
26
|
+
*
|
|
27
|
+
* Every refusal is a bare 401 with the closed error code — the reason stays
|
|
28
|
+
* server-side (Doc 03 §6), exactly as the Nest guard draws the boundary.
|
|
29
|
+
*/
|
|
30
|
+
async function authorizeRequest(request, options) {
|
|
31
|
+
const token = bearerOf(request);
|
|
32
|
+
if (token === undefined) {
|
|
33
|
+
return { ok: false, status: 401, code: "no-bearer-token" };
|
|
34
|
+
}
|
|
35
|
+
let claims;
|
|
36
|
+
try {
|
|
37
|
+
claims = await options.verifier.verify(token);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof core_1.TokenVerificationError) {
|
|
41
|
+
void core_1.TokenRejection;
|
|
42
|
+
return { ok: false, status: 401, code: "invalid-token" };
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (options.revocations !== undefined) {
|
|
47
|
+
const revoked = await options.revocations.isRevoked(claims.sid);
|
|
48
|
+
if (revoked)
|
|
49
|
+
return { ok: false, status: 401, code: "session-revoked" };
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, claims };
|
|
52
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard every authenticated request passes through (Doc 03 §6).
|
|
3
|
+
*
|
|
4
|
+
* Three checks, in this order, and the order is the design:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Signature and claims** — delegated to a {@link TokenVerifier}. The IAM
|
|
7
|
+
* verifies with its own keys; a module verifies against the fetched JWKS.
|
|
8
|
+
* 2. **Revocation** — `sid` against the shared cache, so a force-logout lands
|
|
9
|
+
* within seconds without a database round-trip on the happy path.
|
|
10
|
+
* 3. **Hand-off** — the verified claims are given to a {@link VerifiedClaimsSink},
|
|
11
|
+
* which is the *only* way claims reach the RLS context (Doc 07 §5).
|
|
12
|
+
*
|
|
13
|
+
* Revocation runs after verification, never before: a `sid` read out of an
|
|
14
|
+
* unverified token is attacker-chosen, and looking it up would leak whether a
|
|
15
|
+
* guessed session exists through response timing.
|
|
16
|
+
*
|
|
17
|
+
* ## Deny by default
|
|
18
|
+
*
|
|
19
|
+
* The guard is registered app-wide and a route is authenticated unless it says
|
|
20
|
+
* otherwise with {@link Public}. That direction matters. An opt-in guard makes
|
|
21
|
+
* every new controller unprotected until someone remembers a decorator, and the
|
|
22
|
+
* symptom of forgetting is a route that works — which is the failure mode no
|
|
23
|
+
* test ever catches, because working is what tests assert.
|
|
24
|
+
*
|
|
25
|
+
* `@Public()` is for the routes that genuinely cannot carry a token: login and
|
|
26
|
+
* refresh (there is none yet), the JWKS (the thing that verifies tokens), and
|
|
27
|
+
* the ops probes.
|
|
28
|
+
*
|
|
29
|
+
* ## Everything is a bare 401
|
|
30
|
+
*
|
|
31
|
+
* A malformed token, a forged `kid`, an expired token and a revoked session all
|
|
32
|
+
* produce the same `AUTH_REQUIRED`. The reason is kept server-side on
|
|
33
|
+
* {@link TokenVerificationError} for logs and metrics — telling the caller
|
|
34
|
+
* which check failed tells an attacker whether a forged `kid` was a near miss,
|
|
35
|
+
* or whether a guessed `sid` exists.
|
|
36
|
+
*/
|
|
37
|
+
import { type CanActivate, type ExecutionContext } from '@nestjs/common';
|
|
38
|
+
import { Reflector } from '@nestjs/core';
|
|
39
|
+
import type { JwtClaims } from '@plantops/contracts';
|
|
40
|
+
import type { TokenVerifier } from '../../core/jwks-verifier';
|
|
41
|
+
import type { RevocationChecker } from '../../core/revocation-cache';
|
|
42
|
+
/** Marks a route reachable without a bearer token. */
|
|
43
|
+
export declare const IS_PUBLIC_METADATA = "auth-kit:public";
|
|
44
|
+
/**
|
|
45
|
+
* Opts a route out of authentication.
|
|
46
|
+
*
|
|
47
|
+
* Use sparingly and say why at the call site: every one of these is a route
|
|
48
|
+
* with no subject, and therefore no RLS context and no tenant.
|
|
49
|
+
*/
|
|
50
|
+
export declare const Public: () => import("@nestjs/common").CustomDecorator<string>;
|
|
51
|
+
/**
|
|
52
|
+
* Receives claims the guard has fully verified.
|
|
53
|
+
*
|
|
54
|
+
* The indirection exists because of a boundary: `auth-kit` may not import
|
|
55
|
+
* `@plantops/db` (Doc 08 §2), and it is `@plantops/db` that owns the branded
|
|
56
|
+
* `VerifiedClaims` type the RLS context accepts. So the host application
|
|
57
|
+
* supplies the one adapter that brands them, and that adapter is the single
|
|
58
|
+
* place in the system where an unverified object could ever become a verified
|
|
59
|
+
* one — a thing far easier to review in one file than in a guard shared by
|
|
60
|
+
* every module.
|
|
61
|
+
*/
|
|
62
|
+
export interface VerifiedClaimsSink {
|
|
63
|
+
accept(request: unknown, claims: JwtClaims): void;
|
|
64
|
+
}
|
|
65
|
+
/** What to do when the revocation cache cannot answer. */
|
|
66
|
+
export interface RevocationFallback {
|
|
67
|
+
/**
|
|
68
|
+
* The authoritative answer, from the database.
|
|
69
|
+
*
|
|
70
|
+
* Present only in the IAM, which owns the `session` table. A module has no
|
|
71
|
+
* such table and therefore no fallback — see {@link AuthGuardOptions}.
|
|
72
|
+
*
|
|
73
|
+
* It receives the whole claim set, not just `sid`, because the right answer
|
|
74
|
+
* depends on `sty`: a service token's `sid` is ephemeral and backed by no
|
|
75
|
+
* row at all (Doc 03 §5), so looking it up would find nothing and read that
|
|
76
|
+
* absence as revoked — killing every machine identity the moment Redis
|
|
77
|
+
* blinked.
|
|
78
|
+
*
|
|
79
|
+
* @throws when it too cannot answer.
|
|
80
|
+
*/
|
|
81
|
+
isRevoked(claims: JwtClaims): Promise<boolean>;
|
|
82
|
+
}
|
|
83
|
+
export interface AuthGuardOptions {
|
|
84
|
+
/**
|
|
85
|
+
* What a request means when neither the cache nor the fallback can answer.
|
|
86
|
+
*
|
|
87
|
+
* `'deny'` (the default) is correct: uncertainty about revocation must fall
|
|
88
|
+
* to refusal, or a Redis outage becomes a window in which every revoked
|
|
89
|
+
* session works again. `'allow'` exists for a deployment that would rather
|
|
90
|
+
* survive a cache outage than hold revocation — a real trade, but one that
|
|
91
|
+
* has to be made explicitly and written down, never inherited from a default.
|
|
92
|
+
*/
|
|
93
|
+
onRevocationUnavailable?: 'deny' | 'allow';
|
|
94
|
+
}
|
|
95
|
+
export declare const TOKEN_VERIFIER: unique symbol;
|
|
96
|
+
export declare const REVOCATION_CHECKER: unique symbol;
|
|
97
|
+
export declare const VERIFIED_CLAIMS_SINK: unique symbol;
|
|
98
|
+
export declare const REVOCATION_FALLBACK: unique symbol;
|
|
99
|
+
export declare const AUTH_GUARD_OPTIONS: unique symbol;
|
|
100
|
+
export declare class AuthGuard implements CanActivate {
|
|
101
|
+
private readonly reflector;
|
|
102
|
+
private readonly verifier;
|
|
103
|
+
private readonly revocations;
|
|
104
|
+
private readonly sink;
|
|
105
|
+
private readonly fallback;
|
|
106
|
+
private readonly options;
|
|
107
|
+
private readonly logger;
|
|
108
|
+
constructor(reflector: Reflector, verifier: TokenVerifier, revocations: RevocationChecker, sink: VerifiedClaimsSink, fallback?: RevocationFallback | null, options?: AuthGuardOptions);
|
|
109
|
+
canActivate(context: ExecutionContext): Promise<boolean>;
|
|
110
|
+
/**
|
|
111
|
+
* Cache first; database only when the cache could not answer.
|
|
112
|
+
*
|
|
113
|
+
* The happy path is one Redis `EXISTS` and no SQL, which is what makes this
|
|
114
|
+
* affordable on every request (Doc 03 §6).
|
|
115
|
+
*/
|
|
116
|
+
private isRevoked;
|
|
117
|
+
/** One shape of refusal, with the reason kept out of the response. */
|
|
118
|
+
private refuse;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=auth.guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.guard.d.ts","sourceRoot":"","sources":["../../../src/adapters/nestjs/auth.guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EAOtB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAErE,sDAAsD;AACtD,eAAO,MAAM,kBAAkB,oBAAoB,CAAC;AAEpD;;;;;GAKG;AACH,eAAO,MAAM,MAAM,wDAA8C,CAAC;AAElE;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAAC;CACnD;AAED,0DAA0D;AAC1D,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;OAQG;IACH,uBAAuB,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CAC5C;AAED,eAAO,MAAM,cAAc,eAAmC,CAAC;AAC/D,eAAO,MAAM,kBAAkB,eAAuC,CAAC;AACvE,eAAO,MAAM,oBAAoB,eAAwC,CAAC;AAC1E,eAAO,MAAM,mBAAmB,eAAwC,CAAC;AACzE,eAAO,MAAM,kBAAkB,eAAsC,CAAC;AAEtE,qBACa,SAAU,YAAW,WAAW;IAIzC,OAAO,CAAC,QAAQ,CAAC,SAAS;IACF,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACrB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC1B,OAAO,CAAC,QAAQ,CAAC,IAAI;IAOnD,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAGzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAhB1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA8B;gBAGlC,SAAS,EAAE,SAAS,EACI,QAAQ,EAAE,aAAa,EACnB,WAAW,EAAE,iBAAiB,EAC5B,IAAI,EAAE,kBAAkB,EAOtD,QAAQ,GAAE,kBAAkB,GAAG,IAAW,EAG1C,OAAO,GAAE,gBAAqB;IAG3C,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;IAoC9D;;;;;OAKG;YACW,SAAS;IAwBvB,sEAAsE;IACtE,OAAO,CAAC,MAAM;CAQf"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The guard every authenticated request passes through (Doc 03 §6).
|
|
4
|
+
*
|
|
5
|
+
* Three checks, in this order, and the order is the design:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Signature and claims** — delegated to a {@link TokenVerifier}. The IAM
|
|
8
|
+
* verifies with its own keys; a module verifies against the fetched JWKS.
|
|
9
|
+
* 2. **Revocation** — `sid` against the shared cache, so a force-logout lands
|
|
10
|
+
* within seconds without a database round-trip on the happy path.
|
|
11
|
+
* 3. **Hand-off** — the verified claims are given to a {@link VerifiedClaimsSink},
|
|
12
|
+
* which is the *only* way claims reach the RLS context (Doc 07 §5).
|
|
13
|
+
*
|
|
14
|
+
* Revocation runs after verification, never before: a `sid` read out of an
|
|
15
|
+
* unverified token is attacker-chosen, and looking it up would leak whether a
|
|
16
|
+
* guessed session exists through response timing.
|
|
17
|
+
*
|
|
18
|
+
* ## Deny by default
|
|
19
|
+
*
|
|
20
|
+
* The guard is registered app-wide and a route is authenticated unless it says
|
|
21
|
+
* otherwise with {@link Public}. That direction matters. An opt-in guard makes
|
|
22
|
+
* every new controller unprotected until someone remembers a decorator, and the
|
|
23
|
+
* symptom of forgetting is a route that works — which is the failure mode no
|
|
24
|
+
* test ever catches, because working is what tests assert.
|
|
25
|
+
*
|
|
26
|
+
* `@Public()` is for the routes that genuinely cannot carry a token: login and
|
|
27
|
+
* refresh (there is none yet), the JWKS (the thing that verifies tokens), and
|
|
28
|
+
* the ops probes.
|
|
29
|
+
*
|
|
30
|
+
* ## Everything is a bare 401
|
|
31
|
+
*
|
|
32
|
+
* A malformed token, a forged `kid`, an expired token and a revoked session all
|
|
33
|
+
* produce the same `AUTH_REQUIRED`. The reason is kept server-side on
|
|
34
|
+
* {@link TokenVerificationError} for logs and metrics — telling the caller
|
|
35
|
+
* which check failed tells an attacker whether a forged `kid` was a near miss,
|
|
36
|
+
* or whether a guessed `sid` exists.
|
|
37
|
+
*/
|
|
38
|
+
var AuthGuard_1;
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.AuthGuard = exports.AUTH_GUARD_OPTIONS = exports.REVOCATION_FALLBACK = exports.VERIFIED_CLAIMS_SINK = exports.REVOCATION_CHECKER = exports.TOKEN_VERIFIER = exports.Public = exports.IS_PUBLIC_METADATA = void 0;
|
|
41
|
+
const tslib_1 = require("tslib");
|
|
42
|
+
const common_1 = require("@nestjs/common");
|
|
43
|
+
const core_1 = require("@nestjs/core");
|
|
44
|
+
const claims_1 = require("../../core/claims");
|
|
45
|
+
/** Marks a route reachable without a bearer token. */
|
|
46
|
+
exports.IS_PUBLIC_METADATA = 'auth-kit:public';
|
|
47
|
+
/**
|
|
48
|
+
* Opts a route out of authentication.
|
|
49
|
+
*
|
|
50
|
+
* Use sparingly and say why at the call site: every one of these is a route
|
|
51
|
+
* with no subject, and therefore no RLS context and no tenant.
|
|
52
|
+
*/
|
|
53
|
+
const Public = () => (0, common_1.SetMetadata)(exports.IS_PUBLIC_METADATA, true);
|
|
54
|
+
exports.Public = Public;
|
|
55
|
+
exports.TOKEN_VERIFIER = Symbol('auth-kit:TokenVerifier');
|
|
56
|
+
exports.REVOCATION_CHECKER = Symbol('auth-kit:RevocationChecker');
|
|
57
|
+
exports.VERIFIED_CLAIMS_SINK = Symbol('auth-kit:VerifiedClaimsSink');
|
|
58
|
+
exports.REVOCATION_FALLBACK = Symbol('auth-kit:RevocationFallback');
|
|
59
|
+
exports.AUTH_GUARD_OPTIONS = Symbol('auth-kit:AuthGuardOptions');
|
|
60
|
+
let AuthGuard = AuthGuard_1 = class AuthGuard {
|
|
61
|
+
constructor(reflector, verifier, revocations, sink, fallback = null, options = {}) {
|
|
62
|
+
this.reflector = reflector;
|
|
63
|
+
this.verifier = verifier;
|
|
64
|
+
this.revocations = revocations;
|
|
65
|
+
this.sink = sink;
|
|
66
|
+
this.fallback = fallback;
|
|
67
|
+
this.options = options;
|
|
68
|
+
this.logger = new common_1.Logger(AuthGuard_1.name);
|
|
69
|
+
}
|
|
70
|
+
async canActivate(context) {
|
|
71
|
+
if (context.getType() !== 'http')
|
|
72
|
+
return true;
|
|
73
|
+
const isPublic = this.reflector.getAllAndOverride(exports.IS_PUBLIC_METADATA, [
|
|
74
|
+
context.getHandler(),
|
|
75
|
+
context.getClass(),
|
|
76
|
+
]);
|
|
77
|
+
// A public route is let through with **no** claims attached, so the
|
|
78
|
+
// transaction wrapper applies no RLS context and every tenant policy
|
|
79
|
+
// matches nothing. Public means unauthenticated, not privileged.
|
|
80
|
+
if (isPublic)
|
|
81
|
+
return true;
|
|
82
|
+
const request = context.switchToHttp().getRequest();
|
|
83
|
+
const token = bearerTokenOf(request.headers['authorization']);
|
|
84
|
+
if (token === undefined)
|
|
85
|
+
throw new common_1.UnauthorizedException('Authentication is required');
|
|
86
|
+
let claims;
|
|
87
|
+
try {
|
|
88
|
+
claims = await this.verifier.verify(token);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
throw this.refuse(error);
|
|
92
|
+
}
|
|
93
|
+
if (await this.isRevoked(claims)) {
|
|
94
|
+
throw this.refuse(new claims_1.TokenVerificationError(claims_1.TokenRejection.REVOKED, 'Session has been revoked'));
|
|
95
|
+
}
|
|
96
|
+
this.sink.accept(request, claims);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Cache first; database only when the cache could not answer.
|
|
101
|
+
*
|
|
102
|
+
* The happy path is one Redis `EXISTS` and no SQL, which is what makes this
|
|
103
|
+
* affordable on every request (Doc 03 §6).
|
|
104
|
+
*/
|
|
105
|
+
async isRevoked(claims) {
|
|
106
|
+
try {
|
|
107
|
+
return await this.revocations.isRevoked(claims.sid);
|
|
108
|
+
}
|
|
109
|
+
catch (cacheError) {
|
|
110
|
+
this.logger.warn(`Revocation cache unavailable (${messageOf(cacheError)}); ` +
|
|
111
|
+
(this.fallback ? 'falling back to the database' : 'no database fallback'));
|
|
112
|
+
if (this.fallback) {
|
|
113
|
+
try {
|
|
114
|
+
return await this.fallback.isRevoked(claims);
|
|
115
|
+
}
|
|
116
|
+
catch (dbError) {
|
|
117
|
+
this.logger.error(`Revocation fallback also failed (${messageOf(dbError)})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Both sources are down. Deny unless the deployment said otherwise.
|
|
121
|
+
return (this.options.onRevocationUnavailable ?? 'deny') === 'deny';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** One shape of refusal, with the reason kept out of the response. */
|
|
125
|
+
refuse(error) {
|
|
126
|
+
if (error instanceof claims_1.TokenVerificationError) {
|
|
127
|
+
this.logger.debug(`Token refused: ${error.reason}`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
this.logger.warn(`Token verification failed: ${messageOf(error)}`);
|
|
131
|
+
}
|
|
132
|
+
return new common_1.UnauthorizedException('Authentication is required');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
exports.AuthGuard = AuthGuard;
|
|
136
|
+
exports.AuthGuard = AuthGuard = AuthGuard_1 = tslib_1.__decorate([
|
|
137
|
+
(0, common_1.Injectable)(),
|
|
138
|
+
tslib_1.__param(1, (0, common_1.Inject)(exports.TOKEN_VERIFIER)),
|
|
139
|
+
tslib_1.__param(2, (0, common_1.Inject)(exports.REVOCATION_CHECKER)),
|
|
140
|
+
tslib_1.__param(3, (0, common_1.Inject)(exports.VERIFIED_CLAIMS_SINK)),
|
|
141
|
+
tslib_1.__param(4, (0, common_1.Optional)()),
|
|
142
|
+
tslib_1.__param(4, (0, common_1.Inject)(exports.REVOCATION_FALLBACK)),
|
|
143
|
+
tslib_1.__param(5, (0, common_1.Optional)()),
|
|
144
|
+
tslib_1.__param(5, (0, common_1.Inject)(exports.AUTH_GUARD_OPTIONS)),
|
|
145
|
+
tslib_1.__metadata("design:paramtypes", [core_1.Reflector, Object, Object, Object, Object, Object])
|
|
146
|
+
], AuthGuard);
|
|
147
|
+
/**
|
|
148
|
+
* The token from an `Authorization` header, or `undefined`.
|
|
149
|
+
*
|
|
150
|
+
* The scheme is matched case-insensitively (RFC 7235 makes it so) but the token
|
|
151
|
+
* is taken verbatim: trimming or re-casing it would change the bytes the
|
|
152
|
+
* signature covers. A repeated header arrives as an array — ambiguous, and
|
|
153
|
+
* therefore refused rather than resolved by picking one.
|
|
154
|
+
*/
|
|
155
|
+
function bearerTokenOf(header) {
|
|
156
|
+
if (typeof header !== 'string')
|
|
157
|
+
return undefined;
|
|
158
|
+
const [scheme, ...rest] = header.split(' ');
|
|
159
|
+
if (scheme?.toLowerCase() !== 'bearer' || rest.length !== 1)
|
|
160
|
+
return undefined;
|
|
161
|
+
return rest[0] === '' ? undefined : rest[0];
|
|
162
|
+
}
|
|
163
|
+
function messageOf(error) {
|
|
164
|
+
return error instanceof Error ? error.message : String(error);
|
|
165
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The NestJS adapter: guards, decorators and the injectable resolver,
|
|
3
|
+
* re-exported exactly as they were before Session 50 split them out of a flat
|
|
4
|
+
* directory. Consumers import from the package barrel and see no difference.
|
|
5
|
+
*/
|
|
6
|
+
export * from './auth.guard';
|
|
7
|
+
export * from './permission.guard';
|
|
8
|
+
export * from './require-permission.decorator';
|
|
9
|
+
export * from './scope-resolver';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/adapters/nestjs/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,cAAc,CAAC;AAC7B,cAAc,oBAAoB,CAAC;AACnC,cAAc,gCAAgC,CAAC;AAC/C,cAAc,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The NestJS adapter: guards, decorators and the injectable resolver,
|
|
4
|
+
* re-exported exactly as they were before Session 50 split them out of a flat
|
|
5
|
+
* directory. Consumers import from the package barrel and see no difference.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
const tslib_1 = require("tslib");
|
|
9
|
+
tslib_1.__exportStar(require("./auth.guard"), exports);
|
|
10
|
+
tslib_1.__exportStar(require("./permission.guard"), exports);
|
|
11
|
+
tslib_1.__exportStar(require("./require-permission.decorator"), exports);
|
|
12
|
+
tslib_1.__exportStar(require("./scope-resolver"), exports);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard that enforces `@RequirePermission` (Doc 04 §8, Doc 08 §4).
|
|
3
|
+
*
|
|
4
|
+
* `AuthGuard` establishes **who** is calling. This one establishes **whether
|
|
5
|
+
* they may** — the WHO × WHAT × WHERE question of Doc 04 §1, asked once per
|
|
6
|
+
* request, before the handler and before the transaction that would carry out
|
|
7
|
+
* whatever it was going to do.
|
|
8
|
+
*
|
|
9
|
+
* Four steps, and each one is a line of Doc 04 §8's contract:
|
|
10
|
+
*
|
|
11
|
+
* 1. Read the route's requirement. No requirement and no explicit opt-out is a
|
|
12
|
+
* refusal — see {@link RequirePermission} for why the direction is that way
|
|
13
|
+
* round.
|
|
14
|
+
* 2. Load the subject's resolved grants, through {@link GrantsSource}.
|
|
15
|
+
* 3. Confirm the permission is held, and — where the route names a scope node —
|
|
16
|
+
* that the subject's grants cover it.
|
|
17
|
+
* 4. Refuse with `PERMISSION_DENIED` or `SCOPE_DENIED`, and audit the attempt.
|
|
18
|
+
*
|
|
19
|
+
* Steps 2 and 3 are {@link ScopeResolver.decide}, which is where the rule lives
|
|
20
|
+
* and where it is unit-tested. What is left here is the framework adapter: read
|
|
21
|
+
* metadata, read the request, translate an outcome into an exception. That
|
|
22
|
+
* split is deliberate — a guard is the one place a test has to build an
|
|
23
|
+
* `ExecutionContext` to reach, and authorization logic should not be behind
|
|
24
|
+
* that.
|
|
25
|
+
*
|
|
26
|
+
* ## Which connection step 2 runs on
|
|
27
|
+
*
|
|
28
|
+
* Not this file's decision, and deliberately not this file's problem.
|
|
29
|
+
* `docs/adr/0001-permission-guard-connection-strategy.md` settles it: a guard
|
|
30
|
+
* runs *before* the per-request transaction exists (Nest runs guards ahead of
|
|
31
|
+
* interceptors), so on a grants-cache miss the IAM's {@link GrantsSource} opens
|
|
32
|
+
* its own `QueryRunner`, applies the RLS context from the verified claims,
|
|
33
|
+
* resolves on it, and commits and releases in a `finally`. It does **not** open,
|
|
34
|
+
* reuse or leave open the request transaction: a guard has no "after" phase in
|
|
35
|
+
* which to close one, so `TenantContextInterceptor` stays its sole owner.
|
|
36
|
+
*
|
|
37
|
+
* `auth-kit` may depend on `@plantops/contracts` and nothing else (Doc 08 §2),
|
|
38
|
+
* so it could not name a `DataSource` here in any case — which is why the port
|
|
39
|
+
* exists and why a future module can satisfy it with a cached HTTP call instead.
|
|
40
|
+
*
|
|
41
|
+
* ## Every denial is audited, and a failure to audit is not a failure to deny
|
|
42
|
+
*
|
|
43
|
+
* Doc 04 §8 step 5 and Doc 10 §3: the attempt is recorded with the permission
|
|
44
|
+
* that was wanted and the target that was named. The IAM binds
|
|
45
|
+
* {@link DENIAL_AUDITOR} to `AuditService.recordDenial`, which commits on its
|
|
46
|
+
* own connection precisely because the request it accompanies is about to be
|
|
47
|
+
* rolled back by its own 403 — and which never throws, because turning a lost
|
|
48
|
+
* audit row into a 500 would tell a caller which requests were refused for which
|
|
49
|
+
* reason.
|
|
50
|
+
*
|
|
51
|
+
* The auditor is optional: a downstream module has no `audit_trail` table and
|
|
52
|
+
* records nothing. Binding it is the IAM's business.
|
|
53
|
+
*
|
|
54
|
+
* ## The two refusals say different things, and neither says whether the target
|
|
55
|
+
* exists
|
|
56
|
+
*
|
|
57
|
+
* `PERMISSION_DENIED` means the subject does not hold it anywhere;
|
|
58
|
+
* `SCOPE_DENIED` means they hold it, but not over the node they named. A node
|
|
59
|
+
* belonging to another tenant produces `SCOPE_DENIED` too — indistinguishable
|
|
60
|
+
* from one they simply do not cover, because RLS makes it so and Doc 06 §2
|
|
61
|
+
* requires that a denial never reveal cross-tenant existence.
|
|
62
|
+
*/
|
|
63
|
+
import { type CanActivate, type ExecutionContext, ForbiddenException } from '@nestjs/common';
|
|
64
|
+
import { Reflector } from '@nestjs/core';
|
|
65
|
+
import { IamErrorCode, type PermissionKey } from '@plantops/contracts';
|
|
66
|
+
import { ScopeResolver } from './scope-resolver';
|
|
67
|
+
import { AuthorizationOutcome, type SubjectClaims } from '../../core/scope-resolver';
|
|
68
|
+
/** The two 403 codes of Doc 06 §2, and no others. */
|
|
69
|
+
export type DenialErrorCode = typeof IamErrorCode.PERMISSION_DENIED | typeof IamErrorCode.SCOPE_DENIED;
|
|
70
|
+
/**
|
|
71
|
+
* A 403 that knows which of Doc 06 §2's two codes it is.
|
|
72
|
+
*
|
|
73
|
+
* `ForbiddenException` alone would come back as `PERMISSION_DENIED`, because
|
|
74
|
+
* that is the less specific of the two and the only one a bare status can imply
|
|
75
|
+
* (`http-exception.filter.ts`). The distinction is worth carrying: a client that
|
|
76
|
+
* sees `SCOPE_DENIED` can tell its user *where* they lack access, which is the
|
|
77
|
+
* one actionable thing about a refusal.
|
|
78
|
+
*
|
|
79
|
+
* `IamErrorCode` is a `@plantops/contracts` export, so naming it here crosses no
|
|
80
|
+
* boundary — the code table is part of the published contract, not of the IAM.
|
|
81
|
+
*/
|
|
82
|
+
export declare class AuthorizationDeniedException extends ForbiddenException {
|
|
83
|
+
readonly code: DenialErrorCode;
|
|
84
|
+
constructor(code: DenialErrorCode, message: string);
|
|
85
|
+
/** The subject does not hold the permission at all. */
|
|
86
|
+
static permission(): AuthorizationDeniedException;
|
|
87
|
+
/** They hold it, but not over the node they named. */
|
|
88
|
+
static scope(): AuthorizationDeniedException;
|
|
89
|
+
}
|
|
90
|
+
/** How the guard reads back the claims `AuthGuard` verified. */
|
|
91
|
+
export interface VerifiedClaimsSource {
|
|
92
|
+
claimsOf(request: unknown): SubjectClaims | undefined;
|
|
93
|
+
}
|
|
94
|
+
/** Records a refused attempt (Doc 10 §3). Must not throw. */
|
|
95
|
+
export interface DenialAuditor {
|
|
96
|
+
recordDenial(claims: SubjectClaims, outcome: Exclude<AuthorizationOutcome, 'allowed'>,
|
|
97
|
+
/**
|
|
98
|
+
* The keys the refusal was about — one on all but the routes that admit
|
|
99
|
+
* either tier's key, and never empty. `AuthorizationDecision` in
|
|
100
|
+
* `scope-resolver.ts` says which keys a given outcome names.
|
|
101
|
+
*/
|
|
102
|
+
permissions: readonly PermissionKey[], scopeNodeId?: string): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
export declare const VERIFIED_CLAIMS_SOURCE: unique symbol;
|
|
105
|
+
export declare const DENIAL_AUDITOR: unique symbol;
|
|
106
|
+
export declare class PermissionGuard implements CanActivate {
|
|
107
|
+
private readonly reflector;
|
|
108
|
+
private readonly resolver;
|
|
109
|
+
private readonly claims;
|
|
110
|
+
private readonly auditor;
|
|
111
|
+
private readonly logger;
|
|
112
|
+
constructor(reflector: Reflector, resolver: ScopeResolver, claims: VerifiedClaimsSource, auditor?: DenialAuditor | null);
|
|
113
|
+
canActivate(context: ExecutionContext): Promise<boolean>;
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=permission.guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission.guard.d.ts","sourceRoot":"","sources":["../../../src/adapters/nestjs/permission.guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAEH,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,kBAAkB,EAKnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAQvE,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EAAE,oBAAoB,EAAE,KAAK,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAErF,qDAAqD;AACrD,MAAM,MAAM,eAAe,GACvB,OAAO,YAAY,CAAC,iBAAiB,GACrC,OAAO,YAAY,CAAC,YAAY,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,qBAAa,4BAA6B,SAAQ,kBAAkB;IAEhE,QAAQ,CAAC,IAAI,EAAE,eAAe;gBAArB,IAAI,EAAE,eAAe,EAC9B,OAAO,EAAE,MAAM;IAMjB,uDAAuD;IACvD,MAAM,CAAC,UAAU,IAAI,4BAA4B;IAOjD,sDAAsD;IACtD,MAAM,CAAC,KAAK,IAAI,4BAA4B;CAM7C;AAED,gEAAgE;AAChE,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,GAAG,SAAS,CAAC;CACvD;AAED,6DAA6D;AAC7D,MAAM,WAAW,aAAa;IAC5B,YAAY,CACV,MAAM,EAAE,aAAa,EACrB,OAAO,EAAE,OAAO,CAAC,oBAAoB,EAAE,SAAS,CAAC;IACjD;;;;OAIG;IACH,WAAW,EAAE,SAAS,aAAa,EAAE,EACrC,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAAC;CAClB;AAED,eAAO,MAAM,sBAAsB,eAA0C,CAAC;AAC9E,eAAO,MAAM,cAAc,eAAmC,CAAC;AAE/D,qBACa,eAAgB,YAAW,WAAW;IAI/C,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACO,OAAO,CAAC,QAAQ,CAAC,MAAM;IAGvD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAR1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;gBAGxC,SAAS,EAAE,SAAS,EACpB,QAAQ,EAAE,aAAa,EACS,MAAM,EAAE,oBAAoB,EAG5D,OAAO,GAAE,aAAa,GAAG,IAAW;IAGjD,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;CAiE/D"}
|