@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.
Files changed (88) hide show
  1. package/README.md +58 -0
  2. package/dist/Guard.d.ts +9 -0
  3. package/dist/Guard.d.ts.map +1 -1
  4. package/dist/Guard.js +16 -0
  5. package/dist/Guard.js.map +1 -1
  6. package/dist/RedisBlacklistDriver.d.ts +32 -0
  7. package/dist/RedisBlacklistDriver.d.ts.map +1 -0
  8. package/dist/RedisBlacklistDriver.js +37 -0
  9. package/dist/RedisBlacklistDriver.js.map +1 -0
  10. package/dist/ResilientBlacklistDriver.d.ts +35 -0
  11. package/dist/ResilientBlacklistDriver.d.ts.map +1 -0
  12. package/dist/ResilientBlacklistDriver.js +61 -0
  13. package/dist/ResilientBlacklistDriver.js.map +1 -0
  14. package/dist/TokenBlacklist.d.ts +6 -2
  15. package/dist/TokenBlacklist.d.ts.map +1 -1
  16. package/dist/TokenBlacklist.js +2 -2
  17. package/dist/TokenBlacklist.js.map +1 -1
  18. package/dist/WardenProvider.d.ts.map +1 -1
  19. package/dist/WardenProvider.js +47 -6
  20. package/dist/WardenProvider.js.map +1 -1
  21. package/dist/config.d.ts +65 -1
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js.map +1 -1
  24. package/dist/index.d.ts +15 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/mfa/BackupCodesProvider.d.ts +39 -0
  29. package/dist/mfa/BackupCodesProvider.d.ts.map +1 -0
  30. package/dist/mfa/BackupCodesProvider.js +87 -0
  31. package/dist/mfa/BackupCodesProvider.js.map +1 -0
  32. package/dist/mfa/MfaManager.d.ts +107 -0
  33. package/dist/mfa/MfaManager.d.ts.map +1 -0
  34. package/dist/mfa/MfaManager.js +219 -0
  35. package/dist/mfa/MfaManager.js.map +1 -0
  36. package/dist/mfa/OtpProvider.d.ts +67 -0
  37. package/dist/mfa/OtpProvider.d.ts.map +1 -0
  38. package/dist/mfa/OtpProvider.js +104 -0
  39. package/dist/mfa/OtpProvider.js.map +1 -0
  40. package/dist/mfa/TotpProvider.d.ts +52 -0
  41. package/dist/mfa/TotpProvider.d.ts.map +1 -0
  42. package/dist/mfa/TotpProvider.js +103 -0
  43. package/dist/mfa/TotpProvider.js.map +1 -0
  44. package/dist/mfa/WebauthnProvider.d.ts +175 -0
  45. package/dist/mfa/WebauthnProvider.d.ts.map +1 -0
  46. package/dist/mfa/WebauthnProvider.js +239 -0
  47. package/dist/mfa/WebauthnProvider.js.map +1 -0
  48. package/dist/mfa/base32.d.ts +13 -0
  49. package/dist/mfa/base32.d.ts.map +1 -0
  50. package/dist/mfa/base32.js +52 -0
  51. package/dist/mfa/base32.js.map +1 -0
  52. package/dist/mfa/webauthn-codec.d.ts +58 -0
  53. package/dist/mfa/webauthn-codec.d.ts.map +1 -0
  54. package/dist/mfa/webauthn-codec.js +221 -0
  55. package/dist/mfa/webauthn-codec.js.map +1 -0
  56. package/dist/middleware.d.ts +31 -0
  57. package/dist/middleware.d.ts.map +1 -1
  58. package/dist/middleware.js +77 -4
  59. package/dist/middleware.js.map +1 -1
  60. package/dist/standalone.d.ts.map +1 -1
  61. package/dist/standalone.js +12 -0
  62. package/dist/standalone.js.map +1 -1
  63. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
  64. package/dist/strategies/ApiKeyStrategy.js +14 -1
  65. package/dist/strategies/ApiKeyStrategy.js.map +1 -1
  66. package/index.darwin-arm64.node +0 -0
  67. package/index.darwin-x64.node +0 -0
  68. package/index.linux-arm64-gnu.node +0 -0
  69. package/index.linux-x64-gnu.node +0 -0
  70. package/index.win32-x64-msvc.node +0 -0
  71. package/package.json +2 -2
  72. package/src/Guard.ts +21 -0
  73. package/src/RedisBlacklistDriver.ts +60 -0
  74. package/src/ResilientBlacklistDriver.ts +84 -0
  75. package/src/TokenBlacklist.ts +7 -3
  76. package/src/WardenProvider.ts +53 -7
  77. package/src/config.ts +60 -1
  78. package/src/index.ts +58 -0
  79. package/src/mfa/BackupCodesProvider.ts +125 -0
  80. package/src/mfa/MfaManager.ts +307 -0
  81. package/src/mfa/OtpProvider.ts +177 -0
  82. package/src/mfa/TotpProvider.ts +140 -0
  83. package/src/mfa/WebauthnProvider.ts +416 -0
  84. package/src/mfa/base32.ts +54 -0
  85. package/src/mfa/webauthn-codec.ts +264 -0
  86. package/src/middleware.ts +111 -2
  87. package/src/standalone.ts +14 -1
  88. 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 denied = await checkAuthorization(auth, user, controller, action);
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, "global");
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
- const strategies: Record<string, JwtStrategy> = {};
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
- // Merge scopes into permissions without mutating source object.
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
  }