@c9up/warden 0.1.4 → 0.1.6
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 +58 -0
- package/dist/Guard.d.ts +9 -0
- package/dist/Guard.d.ts.map +1 -1
- package/dist/Guard.js +16 -0
- package/dist/Guard.js.map +1 -1
- package/dist/RedisBlacklistDriver.d.ts +32 -0
- package/dist/RedisBlacklistDriver.d.ts.map +1 -0
- package/dist/RedisBlacklistDriver.js +37 -0
- package/dist/RedisBlacklistDriver.js.map +1 -0
- package/dist/ResilientBlacklistDriver.d.ts +35 -0
- package/dist/ResilientBlacklistDriver.d.ts.map +1 -0
- package/dist/ResilientBlacklistDriver.js +61 -0
- package/dist/ResilientBlacklistDriver.js.map +1 -0
- package/dist/TokenBlacklist.d.ts +6 -2
- package/dist/TokenBlacklist.d.ts.map +1 -1
- package/dist/TokenBlacklist.js +2 -2
- package/dist/TokenBlacklist.js.map +1 -1
- package/dist/WardenProvider.d.ts.map +1 -1
- package/dist/WardenProvider.js +47 -6
- package/dist/WardenProvider.js.map +1 -1
- package/dist/config.d.ts +65 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/mfa/BackupCodesProvider.d.ts +39 -0
- package/dist/mfa/BackupCodesProvider.d.ts.map +1 -0
- package/dist/mfa/BackupCodesProvider.js +87 -0
- package/dist/mfa/BackupCodesProvider.js.map +1 -0
- package/dist/mfa/MfaManager.d.ts +107 -0
- package/dist/mfa/MfaManager.d.ts.map +1 -0
- package/dist/mfa/MfaManager.js +219 -0
- package/dist/mfa/MfaManager.js.map +1 -0
- package/dist/mfa/OtpProvider.d.ts +67 -0
- package/dist/mfa/OtpProvider.d.ts.map +1 -0
- package/dist/mfa/OtpProvider.js +104 -0
- package/dist/mfa/OtpProvider.js.map +1 -0
- package/dist/mfa/TotpProvider.d.ts +52 -0
- package/dist/mfa/TotpProvider.d.ts.map +1 -0
- package/dist/mfa/TotpProvider.js +103 -0
- package/dist/mfa/TotpProvider.js.map +1 -0
- package/dist/mfa/WebauthnProvider.d.ts +175 -0
- package/dist/mfa/WebauthnProvider.d.ts.map +1 -0
- package/dist/mfa/WebauthnProvider.js +239 -0
- package/dist/mfa/WebauthnProvider.js.map +1 -0
- package/dist/mfa/base32.d.ts +13 -0
- package/dist/mfa/base32.d.ts.map +1 -0
- package/dist/mfa/base32.js +52 -0
- package/dist/mfa/base32.js.map +1 -0
- package/dist/mfa/webauthn-codec.d.ts +58 -0
- package/dist/mfa/webauthn-codec.d.ts.map +1 -0
- package/dist/mfa/webauthn-codec.js +221 -0
- package/dist/mfa/webauthn-codec.js.map +1 -0
- package/dist/middleware.d.ts +31 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +77 -4
- package/dist/middleware.js.map +1 -1
- package/dist/standalone.d.ts.map +1 -1
- package/dist/standalone.js +12 -0
- package/dist/standalone.js.map +1 -1
- package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
- package/dist/strategies/ApiKeyStrategy.js +14 -1
- package/dist/strategies/ApiKeyStrategy.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +2 -2
- package/src/Guard.ts +21 -0
- package/src/RedisBlacklistDriver.ts +60 -0
- package/src/ResilientBlacklistDriver.ts +84 -0
- package/src/TokenBlacklist.ts +7 -3
- package/src/WardenProvider.ts +53 -7
- package/src/config.ts +60 -1
- package/src/index.ts +58 -0
- package/src/mfa/BackupCodesProvider.ts +125 -0
- package/src/mfa/MfaManager.ts +307 -0
- package/src/mfa/OtpProvider.ts +177 -0
- package/src/mfa/TotpProvider.ts +140 -0
- package/src/mfa/WebauthnProvider.ts +416 -0
- package/src/mfa/base32.ts +54 -0
- package/src/mfa/webauthn-codec.ts +264 -0
- package/src/middleware.ts +111 -2
- package/src/standalone.ts +14 -1
- package/src/strategies/ApiKeyStrategy.ts +14 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn wire-format codec — the parsing and crypto primitives behind
|
|
3
|
+
* `WebauthnProvider`, implemented from scratch on `node:crypto` so warden
|
|
4
|
+
* carries no third-party dependency.
|
|
5
|
+
*
|
|
6
|
+
* Scope: the subset of CBOR / COSE / authenticator-data that FIDO2 ceremonies
|
|
7
|
+
* actually use, with public-key algorithms Node can import via JWK:
|
|
8
|
+
* - ES256 / ES384 / ES512 (ECDSA, COSE alg -7 / -35 / -36)
|
|
9
|
+
* - RS256 / RS384 / RS512 (RSASSA-PKCS1-v1_5, alg -257 / -258 / -259)
|
|
10
|
+
* - EdDSA / Ed25519 (alg -8)
|
|
11
|
+
*
|
|
12
|
+
* Attestation *statements* are intentionally not verified (see WebauthnProvider).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createHash,
|
|
17
|
+
createPublicKey,
|
|
18
|
+
verify as cryptoVerify,
|
|
19
|
+
type JsonWebKey,
|
|
20
|
+
type KeyObject,
|
|
21
|
+
} from "node:crypto";
|
|
22
|
+
|
|
23
|
+
export type CborValue =
|
|
24
|
+
| number
|
|
25
|
+
| string
|
|
26
|
+
| Buffer
|
|
27
|
+
| CborValue[]
|
|
28
|
+
| CborMap
|
|
29
|
+
| boolean
|
|
30
|
+
| null;
|
|
31
|
+
export type CborMap = Map<number | string, CborValue>;
|
|
32
|
+
|
|
33
|
+
export function base64urlToBuffer(s: string): Buffer {
|
|
34
|
+
return Buffer.from(s, "base64url");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function bufferToBase64url(b: Buffer | Uint8Array): string {
|
|
38
|
+
return Buffer.from(b).toString("base64url");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sha256(data: Buffer): Buffer {
|
|
42
|
+
return createHash("sha256").update(data).digest();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Decoded {
|
|
46
|
+
value: CborValue;
|
|
47
|
+
/** Byte offset immediately after the decoded item. */
|
|
48
|
+
next: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decode a single CBOR item starting at `start`. Definite-length only —
|
|
53
|
+
* indefinite-length items and floats are rejected (WebAuthn uses neither).
|
|
54
|
+
*/
|
|
55
|
+
export function decodeCbor(buf: Buffer, start = 0): Decoded {
|
|
56
|
+
const major = buf[start] >> 5;
|
|
57
|
+
const info = buf[start] & 0x1f;
|
|
58
|
+
let len: number;
|
|
59
|
+
let p = start + 1;
|
|
60
|
+
if (info < 24) {
|
|
61
|
+
len = info;
|
|
62
|
+
} else if (info === 24) {
|
|
63
|
+
len = buf[p];
|
|
64
|
+
p += 1;
|
|
65
|
+
} else if (info === 25) {
|
|
66
|
+
len = buf.readUInt16BE(p);
|
|
67
|
+
p += 2;
|
|
68
|
+
} else if (info === 26) {
|
|
69
|
+
len = buf.readUInt32BE(p);
|
|
70
|
+
p += 4;
|
|
71
|
+
} else if (info === 27) {
|
|
72
|
+
len = Number(buf.readBigUInt64BE(p));
|
|
73
|
+
p += 8;
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error("CBOR: indefinite or reserved length is not supported");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
switch (major) {
|
|
79
|
+
case 0: // unsigned int
|
|
80
|
+
return { value: len, next: p };
|
|
81
|
+
case 1: // negative int
|
|
82
|
+
return { value: -1 - len, next: p };
|
|
83
|
+
case 2: // byte string
|
|
84
|
+
return { value: buf.subarray(p, p + len), next: p + len };
|
|
85
|
+
case 3: // text string
|
|
86
|
+
return { value: buf.toString("utf8", p, p + len), next: p + len };
|
|
87
|
+
case 4: {
|
|
88
|
+
// array
|
|
89
|
+
const arr: CborValue[] = [];
|
|
90
|
+
let cur = p;
|
|
91
|
+
for (let i = 0; i < len; i++) {
|
|
92
|
+
const d = decodeCbor(buf, cur);
|
|
93
|
+
arr.push(d.value);
|
|
94
|
+
cur = d.next;
|
|
95
|
+
}
|
|
96
|
+
return { value: arr, next: cur };
|
|
97
|
+
}
|
|
98
|
+
case 5: {
|
|
99
|
+
// map
|
|
100
|
+
const map: CborMap = new Map();
|
|
101
|
+
let cur = p;
|
|
102
|
+
for (let i = 0; i < len; i++) {
|
|
103
|
+
const k = decodeCbor(buf, cur);
|
|
104
|
+
cur = k.next;
|
|
105
|
+
const v = decodeCbor(buf, cur);
|
|
106
|
+
cur = v.next;
|
|
107
|
+
if (typeof k.value !== "number" && typeof k.value !== "string") {
|
|
108
|
+
throw new Error("CBOR: only integer/text map keys are supported");
|
|
109
|
+
}
|
|
110
|
+
map.set(k.value, v.value);
|
|
111
|
+
}
|
|
112
|
+
return { value: map, next: cur };
|
|
113
|
+
}
|
|
114
|
+
case 6: {
|
|
115
|
+
// tag — decode and surface the tagged content
|
|
116
|
+
const d = decodeCbor(buf, p);
|
|
117
|
+
return { value: d.value, next: d.next };
|
|
118
|
+
}
|
|
119
|
+
case 7:
|
|
120
|
+
if (info === 20) return { value: false, next: p };
|
|
121
|
+
if (info === 21) return { value: true, next: p };
|
|
122
|
+
if (info === 22) return { value: null, next: p };
|
|
123
|
+
throw new Error("CBOR: simple/float values are not supported");
|
|
124
|
+
default:
|
|
125
|
+
throw new Error(`CBOR: unknown major type ${major}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface AuthenticatorData {
|
|
130
|
+
rpIdHash: Buffer;
|
|
131
|
+
flags: { up: boolean; uv: boolean; at: boolean; ed: boolean };
|
|
132
|
+
signCount: number;
|
|
133
|
+
credentialId?: Buffer;
|
|
134
|
+
cosePublicKey?: CborMap;
|
|
135
|
+
/** Raw COSE_Key bytes, ready to persist and re-decode at sign-in time. */
|
|
136
|
+
cosePublicKeyBytes?: Buffer;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Parse the raw authenticator-data structure (§6.1 of the WebAuthn spec). */
|
|
140
|
+
export function parseAuthenticatorData(authData: Buffer): AuthenticatorData {
|
|
141
|
+
if (authData.length < 37) {
|
|
142
|
+
throw new Error("authenticatorData is too short");
|
|
143
|
+
}
|
|
144
|
+
const flagsByte = authData[32];
|
|
145
|
+
const flags = {
|
|
146
|
+
up: (flagsByte & 0x01) !== 0,
|
|
147
|
+
uv: (flagsByte & 0x04) !== 0,
|
|
148
|
+
at: (flagsByte & 0x40) !== 0,
|
|
149
|
+
ed: (flagsByte & 0x80) !== 0,
|
|
150
|
+
};
|
|
151
|
+
const result: AuthenticatorData = {
|
|
152
|
+
rpIdHash: authData.subarray(0, 32),
|
|
153
|
+
flags,
|
|
154
|
+
signCount: authData.readUInt32BE(33),
|
|
155
|
+
};
|
|
156
|
+
if (flags.at) {
|
|
157
|
+
if (authData.length < 55) {
|
|
158
|
+
throw new Error("attested credential data is truncated");
|
|
159
|
+
}
|
|
160
|
+
const credIdLen = authData.readUInt16BE(53);
|
|
161
|
+
result.credentialId = authData.subarray(55, 55 + credIdLen);
|
|
162
|
+
const coseStart = 55 + credIdLen;
|
|
163
|
+
const decoded = decodeCbor(authData, coseStart);
|
|
164
|
+
if (!(decoded.value instanceof Map)) {
|
|
165
|
+
throw new Error("COSE public key is not a CBOR map");
|
|
166
|
+
}
|
|
167
|
+
result.cosePublicKey = decoded.value;
|
|
168
|
+
result.cosePublicKeyBytes = authData.subarray(coseStart, decoded.next);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// COSE algorithm id → the hash Node should use ( `null` = pre-hashed / Ed25519 ).
|
|
174
|
+
const ALG_HASH: Record<number, string | null> = {
|
|
175
|
+
[-7]: "sha256",
|
|
176
|
+
[-35]: "sha384",
|
|
177
|
+
[-36]: "sha512",
|
|
178
|
+
[-257]: "sha256",
|
|
179
|
+
[-258]: "sha384",
|
|
180
|
+
[-259]: "sha512",
|
|
181
|
+
[-8]: null,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const EC_CURVES: Record<number, string> = {
|
|
185
|
+
1: "P-256",
|
|
186
|
+
2: "P-384",
|
|
187
|
+
3: "P-521",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Convert a COSE_Key map into a Node public key plus its COSE algorithm id. */
|
|
191
|
+
export function coseToKeyObject(cose: CborMap): {
|
|
192
|
+
key: KeyObject;
|
|
193
|
+
alg: number;
|
|
194
|
+
} {
|
|
195
|
+
const kty = expectNumber(cose.get(1), "COSE kty");
|
|
196
|
+
const alg = expectNumber(cose.get(3), "COSE alg");
|
|
197
|
+
let jwk: JsonWebKey;
|
|
198
|
+
|
|
199
|
+
if (kty === 2) {
|
|
200
|
+
// EC2
|
|
201
|
+
const curve = EC_CURVES[expectNumber(cose.get(-1), "EC curve")];
|
|
202
|
+
if (!curve) {
|
|
203
|
+
throw new Error("unsupported COSE EC curve");
|
|
204
|
+
}
|
|
205
|
+
jwk = {
|
|
206
|
+
kty: "EC",
|
|
207
|
+
crv: curve,
|
|
208
|
+
x: bufferToBase64url(expectBytes(cose.get(-2), "EC x")),
|
|
209
|
+
y: bufferToBase64url(expectBytes(cose.get(-3), "EC y")),
|
|
210
|
+
};
|
|
211
|
+
} else if (kty === 3) {
|
|
212
|
+
// RSA
|
|
213
|
+
jwk = {
|
|
214
|
+
kty: "RSA",
|
|
215
|
+
n: bufferToBase64url(expectBytes(cose.get(-1), "RSA n")),
|
|
216
|
+
e: bufferToBase64url(expectBytes(cose.get(-2), "RSA e")),
|
|
217
|
+
};
|
|
218
|
+
} else if (kty === 1) {
|
|
219
|
+
// OKP / Ed25519
|
|
220
|
+
if (expectNumber(cose.get(-1), "OKP curve") !== 6) {
|
|
221
|
+
throw new Error("unsupported COSE OKP curve (only Ed25519)");
|
|
222
|
+
}
|
|
223
|
+
jwk = {
|
|
224
|
+
kty: "OKP",
|
|
225
|
+
crv: "Ed25519",
|
|
226
|
+
x: bufferToBase64url(expectBytes(cose.get(-2), "OKP x")),
|
|
227
|
+
};
|
|
228
|
+
} else {
|
|
229
|
+
throw new Error(`unsupported COSE key type ${kty}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { key: createPublicKey({ key: jwk, format: "jwk" }), alg };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Verify a WebAuthn assertion signature over `data` (= authenticatorData ‖
|
|
237
|
+
* SHA-256(clientDataJSON)). ECDSA signatures are DER-encoded, matching what
|
|
238
|
+
* authenticators emit.
|
|
239
|
+
*/
|
|
240
|
+
export function verifyWebauthnSignature(
|
|
241
|
+
alg: number,
|
|
242
|
+
key: KeyObject,
|
|
243
|
+
data: Buffer,
|
|
244
|
+
signature: Buffer,
|
|
245
|
+
): boolean {
|
|
246
|
+
if (!(alg in ALG_HASH)) {
|
|
247
|
+
throw new Error(`unsupported COSE algorithm ${alg}`);
|
|
248
|
+
}
|
|
249
|
+
return cryptoVerify(ALG_HASH[alg], data, key, signature);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function expectNumber(v: CborValue | undefined, what: string): number {
|
|
253
|
+
if (typeof v !== "number") {
|
|
254
|
+
throw new Error(`${what}: expected an integer`);
|
|
255
|
+
}
|
|
256
|
+
return v;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function expectBytes(v: CborValue | undefined, what: string): Buffer {
|
|
260
|
+
if (!Buffer.isBuffer(v)) {
|
|
261
|
+
throw new Error(`${what}: expected a byte string`);
|
|
262
|
+
}
|
|
263
|
+
return v;
|
|
264
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -22,11 +22,18 @@ import {
|
|
|
22
22
|
type AuthStrategy,
|
|
23
23
|
sanitizePayload,
|
|
24
24
|
} from "./AuthManager.js";
|
|
25
|
+
import type { BasePolicy } from "./bouncer/BasePolicy.js";
|
|
26
|
+
import { Bouncer } from "./bouncer/Bouncer.js";
|
|
27
|
+
import type { Ability } from "./bouncer/types.js";
|
|
28
|
+
import type { ScopeRequestContext } from "./config.js";
|
|
25
29
|
import {
|
|
26
30
|
getGuardMetadata,
|
|
27
31
|
getPermissionMetadata,
|
|
32
|
+
getRequireMfaMetadata,
|
|
28
33
|
getRoleMetadata,
|
|
29
34
|
} from "./Guard.js";
|
|
35
|
+
import { RightsResolver } from "./rights/RightsResolver.js";
|
|
36
|
+
import type { Scope } from "./rights/types.js";
|
|
30
37
|
import type { SessionStore } from "./strategies/SessionStrategy.js";
|
|
31
38
|
|
|
32
39
|
interface StrategyWithContext extends AuthStrategy {
|
|
@@ -60,6 +67,8 @@ export interface WardenContext {
|
|
|
60
67
|
session?: SessionStore;
|
|
61
68
|
/** Set by the middleware after successful auth. */
|
|
62
69
|
auth?: AuthResult;
|
|
70
|
+
/** Set by `initializeBouncer` — the per-request authorization entry point. */
|
|
71
|
+
bouncer?: Bouncer;
|
|
63
72
|
/** The route handler metadata (decorators). */
|
|
64
73
|
route?: {
|
|
65
74
|
controller?: object;
|
|
@@ -163,12 +172,32 @@ export async function wardenMiddleware(ctx: WardenContext, next: WardenNext) {
|
|
|
163
172
|
// bypass permission checks. This is the strictest model — callers who want
|
|
164
173
|
// role-OR-permission semantics should use a custom guard instead.
|
|
165
174
|
if (controller && action) {
|
|
166
|
-
const
|
|
175
|
+
const scope = await resolveRequestScope(ctx);
|
|
176
|
+
const denied = await checkAuthorization(
|
|
177
|
+
auth,
|
|
178
|
+
user,
|
|
179
|
+
controller,
|
|
180
|
+
action,
|
|
181
|
+
scope,
|
|
182
|
+
);
|
|
167
183
|
if (denied) {
|
|
168
184
|
ctx.response.status(403);
|
|
169
185
|
ctx.response.json({ error: { code: "FORBIDDEN", message: denied } });
|
|
170
186
|
return;
|
|
171
187
|
}
|
|
188
|
+
|
|
189
|
+
// @RequireMfa gate: the user must carry a truthy `mfa` claim, set by the
|
|
190
|
+
// app's step-up flow once MfaManager.verify() succeeds.
|
|
191
|
+
if (getRequireMfaMetadata(controller, action) && user.mfa !== true) {
|
|
192
|
+
ctx.response.status(403);
|
|
193
|
+
ctx.response.json({
|
|
194
|
+
error: {
|
|
195
|
+
code: "MFA_REQUIRED",
|
|
196
|
+
message: "Multi-factor authentication is required for this action.",
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
172
201
|
}
|
|
173
202
|
|
|
174
203
|
// Auth successful — attach to context and continue.
|
|
@@ -176,6 +205,85 @@ export async function wardenMiddleware(ctx: WardenContext, next: WardenNext) {
|
|
|
176
205
|
await next();
|
|
177
206
|
}
|
|
178
207
|
|
|
208
|
+
/**
|
|
209
|
+
* The abilities/policies/scope-resolver a per-request Bouncer is built from.
|
|
210
|
+
* Registered once by `WardenProvider` (from `config.auth`) under the container
|
|
211
|
+
* token `"bouncer:registry"`; read fresh by `initializeBouncer` per request.
|
|
212
|
+
*/
|
|
213
|
+
export interface BouncerRegistry {
|
|
214
|
+
abilities: Record<string, Ability<never[]>>;
|
|
215
|
+
policies: Record<string, new () => BasePolicy>;
|
|
216
|
+
resolveScope?: (ctx: ScopeRequestContext) => Scope | Promise<Scope>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Bouncer initializer (Epic 56.6) — a GLOBAL middleware (register it for every
|
|
221
|
+
* route, after `wardenMiddleware`). It builds the per-request {@link Bouncer}
|
|
222
|
+
* from the authenticated user (`ctx.auth?.user`, or `null` for a guest), the
|
|
223
|
+
* shared `RightsResolver`, and the registered abilities/policies, then attaches
|
|
224
|
+
* it as `ctx.bouncer`. Handlers authorize via `await ctx.bouncer.authorize(...)`
|
|
225
|
+
* (throws `WARDEN_AUTHORIZATION_FAILURE` carrying `status: 403`, which the host's
|
|
226
|
+
* ExceptionHandler maps to a 403 response) or branch on `ctx.bouncer.allows(...)`.
|
|
227
|
+
*
|
|
228
|
+
* Unlike `wardenMiddleware` (a per-route GUARD that short-circuits public
|
|
229
|
+
* routes), this runs on EVERY request so `ctx.bouncer` is always available —
|
|
230
|
+
* including for guests — matching AdonisJS's `initialize_bouncer_middleware`.
|
|
231
|
+
*/
|
|
232
|
+
export async function initializeBouncer(ctx: WardenContext, next: WardenNext) {
|
|
233
|
+
// Both dependencies are registered by WardenProvider. Resolve defensively:
|
|
234
|
+
// a host that wired the middleware but not the provider gets an empty Bouncer
|
|
235
|
+
// (no abilities/resolver) rather than a crash on every request.
|
|
236
|
+
const resolved = tryResolve(ctx, RightsResolver);
|
|
237
|
+
const resolver = resolved instanceof RightsResolver ? resolved : undefined;
|
|
238
|
+
const candidate = tryResolve(ctx, "bouncer:registry");
|
|
239
|
+
const registry = isBouncerRegistry(candidate) ? candidate : undefined;
|
|
240
|
+
|
|
241
|
+
const user = ctx.auth?.user ?? null;
|
|
242
|
+
const scope: Scope = registry?.resolveScope
|
|
243
|
+
? await registry.resolveScope(ctx)
|
|
244
|
+
: "global";
|
|
245
|
+
|
|
246
|
+
ctx.bouncer = new Bouncer(user, registry?.abilities, registry?.policies, {
|
|
247
|
+
scope,
|
|
248
|
+
resolver,
|
|
249
|
+
});
|
|
250
|
+
await next();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Resolve a container token, returning undefined instead of throwing when absent. */
|
|
254
|
+
function tryResolve(
|
|
255
|
+
ctx: WardenContext,
|
|
256
|
+
token: string | (abstract new (...args: never[]) => unknown),
|
|
257
|
+
): unknown {
|
|
258
|
+
try {
|
|
259
|
+
return ctx.container.resolve(token);
|
|
260
|
+
} catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Structural guard — the registry is a plain object carrying ability/policy tables. */
|
|
266
|
+
function isBouncerRegistry(value: unknown): value is BouncerRegistry {
|
|
267
|
+
return (
|
|
268
|
+
typeof value === "object" &&
|
|
269
|
+
value !== null &&
|
|
270
|
+
"abilities" in value &&
|
|
271
|
+
"policies" in value
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Resolve the request's authorization scope from the registry's `resolveScope`
|
|
277
|
+
* hook (default `"global"`). Shared so the @Role/@Permission decorator gate and
|
|
278
|
+
* the Bouncer path evaluate in the SAME scope — without this the decorator path
|
|
279
|
+
* hardcoded "global" and tenant-scoped roles/grants never satisfied @Role/@Permission.
|
|
280
|
+
*/
|
|
281
|
+
async function resolveRequestScope(ctx: WardenContext): Promise<Scope> {
|
|
282
|
+
const candidate = tryResolve(ctx, "bouncer:registry");
|
|
283
|
+
const registry = isBouncerRegistry(candidate) ? candidate : undefined;
|
|
284
|
+
return registry?.resolveScope ? await registry.resolveScope(ctx) : "global";
|
|
285
|
+
}
|
|
286
|
+
|
|
179
287
|
/**
|
|
180
288
|
* Try each declared strategy in order — session strategies via
|
|
181
289
|
* `verifyWithContext()`, others via `verify(token)` with native-first credential
|
|
@@ -264,13 +372,14 @@ async function checkAuthorization(
|
|
|
264
372
|
user: NonNullable<AuthResult["user"]>,
|
|
265
373
|
controller: object,
|
|
266
374
|
action: string | symbol,
|
|
375
|
+
scope: Scope,
|
|
267
376
|
): Promise<string | null> {
|
|
268
377
|
const requiredPermissions = getPermissionMetadata(controller, action);
|
|
269
378
|
const requiredRoles = getRoleMetadata(controller, action);
|
|
270
379
|
if (requiredPermissions.length === 0 && requiredRoles.length === 0) {
|
|
271
380
|
return null;
|
|
272
381
|
}
|
|
273
|
-
const effective = await auth.resolvePermissions(user,
|
|
382
|
+
const effective = await auth.resolvePermissions(user, scope);
|
|
274
383
|
if (requiredPermissions.length > 0) {
|
|
275
384
|
const missing = requiredPermissions.filter((p) => !effective.has(p));
|
|
276
385
|
if (missing.length > 0) return `Missing permissions: ${missing.join(", ")}`;
|
package/src/standalone.ts
CHANGED
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
import {
|
|
21
21
|
AuthManager,
|
|
22
22
|
type AuthResult,
|
|
23
|
+
type AuthStrategy,
|
|
23
24
|
type UserPayload,
|
|
24
25
|
} from "./AuthManager.js";
|
|
25
26
|
import type { WardenConfig } from "./config.js";
|
|
27
|
+
import { ApiKeyStrategy } from "./strategies/ApiKeyStrategy.js";
|
|
26
28
|
import { JwtStrategy } from "./strategies/JwtStrategy.js";
|
|
29
|
+
import { SessionStrategy } from "./strategies/SessionStrategy.js";
|
|
27
30
|
|
|
28
31
|
export interface Warden {
|
|
29
32
|
/** Verify a bearer token. Returns auth result with user payload. */
|
|
@@ -49,11 +52,21 @@ export interface Warden {
|
|
|
49
52
|
* Create a standalone Warden instance — no Ream container needed.
|
|
50
53
|
*/
|
|
51
54
|
export function createWarden(config: WardenConfig): Warden {
|
|
52
|
-
|
|
55
|
+
// Mirror WardenProvider: build EVERY configured strategy, not just jwt —
|
|
56
|
+
// otherwise config.session / config.apiKey were silently dropped and a
|
|
57
|
+
// jwt-less config (e.g. { defaultStrategy: 'api-key', apiKey }) threw
|
|
58
|
+
// INVALID_CONFIG at boot (audit 2026-06-13). Keys match the @Guard names.
|
|
59
|
+
const strategies: Record<string, AuthStrategy> = {};
|
|
53
60
|
|
|
54
61
|
if (config.jwt) {
|
|
55
62
|
strategies.jwt = new JwtStrategy(config.jwt);
|
|
56
63
|
}
|
|
64
|
+
if (config.session) {
|
|
65
|
+
strategies.session = new SessionStrategy(config.session);
|
|
66
|
+
}
|
|
67
|
+
if (config.apiKey) {
|
|
68
|
+
strategies["api-key"] = new ApiKeyStrategy(config.apiKey);
|
|
69
|
+
}
|
|
57
70
|
|
|
58
71
|
const manager = new AuthManager({
|
|
59
72
|
defaultStrategy: config.defaultStrategy ?? "jwt",
|
|
@@ -45,7 +45,20 @@ export class ApiKeyStrategy implements AuthStrategy {
|
|
|
45
45
|
|
|
46
46
|
const user: UserPayload = { ...result.user };
|
|
47
47
|
if (result.scopes && result.scopes.length > 0) {
|
|
48
|
-
//
|
|
48
|
+
// Expose the API key's scopes on `user.permissions` (without mutating
|
|
49
|
+
// the source object) so APP code can read them via
|
|
50
|
+
// `ctx.auth.user.permissions`.
|
|
51
|
+
//
|
|
52
|
+
// IMPORTANT — these scopes are a SEPARATE axis from the `@Permission`
|
|
53
|
+
// route gate, by design (Adonis Bouncer parity): the gate flows through
|
|
54
|
+
// RightsResolver, which resolves grants from the rights STORE and
|
|
55
|
+
// deliberately ignores payload-carried permissions (see RightsResolver
|
|
56
|
+
// D1). This mirrors AdonisJS, where token abilities are checked via
|
|
57
|
+
// `currentAccessToken.allows(scope)` and are NOT consulted by Bouncer
|
|
58
|
+
// ability/policy checks. So an API key carrying `orders.create` is NOT
|
|
59
|
+
// auto-granted by `@Permission('orders.create')` — that's intentional,
|
|
60
|
+
// not a bug. Gate API-key routes on scopes with an explicit app-level
|
|
61
|
+
// check against `ctx.auth.user.permissions`.
|
|
49
62
|
const merged = new Set([...(user.permissions ?? []), ...result.scopes]);
|
|
50
63
|
user.permissions = [...merged];
|
|
51
64
|
}
|