@c9up/warden 0.1.12 → 0.1.14

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 (96) hide show
  1. package/dist/AuthManager.d.ts +45 -9
  2. package/dist/AuthManager.d.ts.map +1 -1
  3. package/dist/AuthManager.js +75 -23
  4. package/dist/AuthManager.js.map +1 -1
  5. package/dist/Authenticator.d.ts +128 -0
  6. package/dist/Authenticator.d.ts.map +1 -0
  7. package/dist/Authenticator.js +296 -0
  8. package/dist/Authenticator.js.map +1 -0
  9. package/dist/WardenProvider.d.ts.map +1 -1
  10. package/dist/WardenProvider.js +51 -23
  11. package/dist/WardenProvider.js.map +1 -1
  12. package/dist/bouncer/AbilitiesBuilder.d.ts +20 -0
  13. package/dist/bouncer/AbilitiesBuilder.d.ts.map +1 -0
  14. package/dist/bouncer/AbilitiesBuilder.js +32 -0
  15. package/dist/bouncer/AbilitiesBuilder.js.map +1 -0
  16. package/dist/bouncer/AuthorizationResponse.d.ts +18 -8
  17. package/dist/bouncer/AuthorizationResponse.d.ts.map +1 -1
  18. package/dist/bouncer/AuthorizationResponse.js +20 -8
  19. package/dist/bouncer/AuthorizationResponse.js.map +1 -1
  20. package/dist/bouncer/BasePolicy.d.ts +4 -3
  21. package/dist/bouncer/BasePolicy.d.ts.map +1 -1
  22. package/dist/bouncer/BasePolicy.js.map +1 -1
  23. package/dist/bouncer/Bouncer.d.ts +9 -2
  24. package/dist/bouncer/Bouncer.d.ts.map +1 -1
  25. package/dist/bouncer/Bouncer.js +59 -10
  26. package/dist/bouncer/Bouncer.js.map +1 -1
  27. package/dist/bouncer/PolicyAuthorizer.d.ts +2 -1
  28. package/dist/bouncer/PolicyAuthorizer.d.ts.map +1 -1
  29. package/dist/bouncer/PolicyAuthorizer.js +11 -3
  30. package/dist/bouncer/PolicyAuthorizer.js.map +1 -1
  31. package/dist/bouncer/evaluate.d.ts +1 -1
  32. package/dist/bouncer/evaluate.d.ts.map +1 -1
  33. package/dist/bouncer/evaluate.js +3 -2
  34. package/dist/bouncer/evaluate.js.map +1 -1
  35. package/dist/bouncer/types.d.ts +22 -1
  36. package/dist/bouncer/types.d.ts.map +1 -1
  37. package/dist/config.d.ts +34 -3
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +15 -0
  40. package/dist/config.js.map +1 -1
  41. package/dist/configure.d.ts.map +1 -1
  42. package/dist/configure.js +15 -13
  43. package/dist/configure.js.map +1 -1
  44. package/dist/errors.d.ts +27 -0
  45. package/dist/errors.d.ts.map +1 -1
  46. package/dist/errors.js +37 -1
  47. package/dist/errors.js.map +1 -1
  48. package/dist/index.d.ts +7 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/mfa/OtpProvider.d.ts.map +1 -1
  53. package/dist/mfa/OtpProvider.js +21 -3
  54. package/dist/mfa/OtpProvider.js.map +1 -1
  55. package/dist/mfa/WebauthnProvider.d.ts +24 -2
  56. package/dist/mfa/WebauthnProvider.d.ts.map +1 -1
  57. package/dist/mfa/WebauthnProvider.js +28 -8
  58. package/dist/mfa/WebauthnProvider.js.map +1 -1
  59. package/dist/middleware.d.ts +29 -6
  60. package/dist/middleware.d.ts.map +1 -1
  61. package/dist/middleware.js +85 -166
  62. package/dist/middleware.js.map +1 -1
  63. package/dist/standalone.d.ts.map +1 -1
  64. package/dist/standalone.js +21 -13
  65. package/dist/standalone.js.map +1 -1
  66. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
  67. package/dist/strategies/ApiKeyStrategy.js +4 -1
  68. package/dist/strategies/ApiKeyStrategy.js.map +1 -1
  69. package/dist/strategies/SessionStrategy.js +2 -2
  70. package/dist/strategies/SessionStrategy.js.map +1 -1
  71. package/index.darwin-arm64.node +0 -0
  72. package/index.darwin-x64.node +0 -0
  73. package/index.linux-arm64-gnu.node +0 -0
  74. package/index.linux-x64-gnu.node +0 -0
  75. package/index.win32-x64-msvc.node +0 -0
  76. package/package.json +2 -1
  77. package/src/AuthManager.ts +125 -28
  78. package/src/Authenticator.ts +385 -0
  79. package/src/WardenProvider.ts +51 -25
  80. package/src/bouncer/AbilitiesBuilder.ts +41 -0
  81. package/src/bouncer/AuthorizationResponse.ts +24 -16
  82. package/src/bouncer/BasePolicy.ts +4 -2
  83. package/src/bouncer/Bouncer.ts +77 -14
  84. package/src/bouncer/PolicyAuthorizer.ts +14 -4
  85. package/src/bouncer/evaluate.ts +4 -2
  86. package/src/bouncer/types.ts +24 -1
  87. package/src/config.ts +47 -3
  88. package/src/configure.ts +15 -13
  89. package/src/errors.ts +43 -1
  90. package/src/index.ts +22 -2
  91. package/src/mfa/OtpProvider.ts +31 -3
  92. package/src/mfa/WebauthnProvider.ts +58 -12
  93. package/src/middleware.ts +112 -207
  94. package/src/standalone.ts +22 -14
  95. package/src/strategies/ApiKeyStrategy.ts +4 -1
  96. package/src/strategies/SessionStrategy.ts +2 -2
package/src/middleware.ts CHANGED
@@ -16,17 +16,13 @@
16
16
  * }
17
17
  */
18
18
 
19
- import {
20
- AuthManager,
21
- type AuthResult,
22
- type AuthStrategy,
23
- sanitizePayload,
24
- } from "./AuthManager.js";
19
+ import { Authenticator } from "./Authenticator.js";
20
+ import { AuthManager, type UserPayload } from "./AuthManager.js";
25
21
  import type { BasePolicy } from "./bouncer/BasePolicy.js";
26
22
  import { Bouncer } from "./bouncer/Bouncer.js";
27
23
  import type { Ability } from "./bouncer/types.js";
28
24
  import type { ScopeRequestContext } from "./config.js";
29
- import { WardenError } from "./errors.js";
25
+ import { E_UNAUTHORIZED_ACCESS, WardenError } from "./errors.js";
30
26
  import {
31
27
  getGuardMetadata,
32
28
  getPermissionMetadata,
@@ -37,18 +33,6 @@ import { RightsResolver } from "./rights/RightsResolver.js";
37
33
  import type { Scope } from "./rights/types.js";
38
34
  import type { SessionStore } from "./strategies/SessionStrategy.js";
39
35
 
40
- interface StrategyWithContext extends AuthStrategy {
41
- verifyWithContext(token: string, ctx: unknown): Promise<AuthResult>;
42
- }
43
-
44
- function hasVerifyWithContext(
45
- strategy: AuthStrategy,
46
- ): strategy is StrategyWithContext {
47
- return (
48
- typeof (strategy as StrategyWithContext).verifyWithContext === "function"
49
- );
50
- }
51
-
52
36
  /**
53
37
  * Tokens warden resolves from the container: a service class, or a string/symbol
54
38
  * alias (`"bouncer:registry"`). Mirrors Ream's `ServiceToken` without importing
@@ -69,7 +53,7 @@ type ResolvableToken =
69
53
  * misconfigured kernel) yields no resolution: guarded routes fail CLOSED.
70
54
  */
71
55
  interface ContainerResolver {
72
- make(token: ResolvableToken): unknown;
56
+ make(token: ResolvableToken): Promise<unknown>;
73
57
  }
74
58
 
75
59
  /**
@@ -100,6 +84,13 @@ export interface WardenContext {
100
84
  response: {
101
85
  status: (code: number) => unknown;
102
86
  json: (data: unknown) => void;
87
+ /**
88
+ * Redirect the response (Ream's `ctx.response.redirect`). Optional so a
89
+ * minimal host without it still gets the 401 JSON fallback — Warden does
90
+ * its own content negotiation (see `renderAuthError`) rather than leaning
91
+ * on the host's exception handler.
92
+ */
93
+ redirect?: (url: string) => unknown;
103
94
  };
104
95
  /**
105
96
  * Matched-route info — Ream (like Adonis) exposes the controller class +
@@ -120,8 +111,12 @@ export interface WardenContext {
120
111
  action?: string | symbol;
121
112
  /** Session store — set by a session middleware upstream. */
122
113
  session?: SessionStore;
123
- /** Set by the middleware after successful auth (Ream's `auth` slot). */
124
- auth?: AuthResult;
114
+ /**
115
+ * The per-request {@link Authenticator} (Ream's `auth` slot). `silentAuth`
116
+ * (global) or `wardenMiddleware` (per guarded route) sets it; inline handlers
117
+ * then call `ctx.auth.authenticate()` / `ctx.auth.use('session').login(user)`.
118
+ */
119
+ auth?: Authenticator;
125
120
  /**
126
121
  * Per-request authorization entry point — set by `initializeBouncer`. Typed
127
122
  * as the agnostic {@link Authorizer} contract (Ream's `ctx.bouncer` slot),
@@ -137,9 +132,12 @@ export interface WardenContext {
137
132
  * AuthManager gate in `wardenMiddleware`). No `@c9up/ream` import: warden reads
138
133
  * only from the context Ream hands it.
139
134
  */
140
- function resolveFromCtx(ctx: WardenContext, token: ResolvableToken): unknown {
135
+ async function resolveFromCtx(
136
+ ctx: WardenContext,
137
+ token: ResolvableToken,
138
+ ): Promise<unknown> {
141
139
  try {
142
- return ctx.containerResolver?.make(token);
140
+ return await ctx.containerResolver?.make(token);
143
141
  } catch {
144
142
  return undefined;
145
143
  }
@@ -162,7 +160,7 @@ type WardenNext = () => Promise<void> | void;
162
160
  * and authenticates + authorizes the request.
163
161
  */
164
162
  export async function wardenMiddleware(ctx: WardenContext, next: WardenNext) {
165
- const resolvedAuth = resolveFromCtx(ctx, AuthManager);
163
+ const resolvedAuth = await resolveFromCtx(ctx, AuthManager);
166
164
  if (!(resolvedAuth instanceof AuthManager)) {
167
165
  // Fail CLOSED — a missing AuthManager (WardenProvider not registered, or
168
166
  // no `ctx.containerResolver` on a non-Ream host) must never silently let
@@ -190,75 +188,33 @@ export async function wardenMiddleware(ctx: WardenContext, next: WardenNext) {
190
188
  return;
191
189
  }
192
190
 
193
- // Extract credentials from multiple sources:
194
- // 1. Authorization: Bearer <jwt> (JWT strategy)
195
- // 2. API key header reads the header name from the ApiKeyStrategy config
196
- // (defaults to 'x-api-key' if no ApiKeyStrategy is registered)
197
- // 3. Cookie/session (Session strategy handled by the strategy itself via context)
198
- const headers = ctx.request.headers();
199
- const authHeader = headers.authorization ?? "";
200
- const bearerToken = authHeader.startsWith("Bearer ")
201
- ? authHeader.slice(7)
202
- : "";
203
- const apiKeyHeader = (() => {
204
- try {
205
- const s = auth.getStrategy("api-key");
206
- return (s as { headerName?: string }).headerName ?? "x-api-key";
207
- } catch {
208
- return "x-api-key";
191
+ // Delegate authentication to the per-request Authenticator (the SAME contract
192
+ // inline handlers use via `ctx.auth.authenticate()`), so the decorator path
193
+ // and the functional path share one code path. It sets `ctx.auth` and throws
194
+ // on failure E_UNAUTHORIZED_ACCESS (401, credential rejection) or a
195
+ // WARDEN_AUTH_STRATEGY_ERROR (500, every attempted guard crashed).
196
+ const authenticator = ensureAuthenticator(ctx, auth);
197
+ const loginRoute = await resolveLoginRoute(ctx);
198
+ try {
199
+ await authenticator.authenticateUsing(strategies, { loginRoute });
200
+ } catch (err) {
201
+ if (err instanceof E_UNAUTHORIZED_ACCESS) {
202
+ renderAuthError(ctx, err);
203
+ return;
209
204
  }
210
- })();
211
- // HTTP header names are case-insensitive. Node + every Ream-side
212
- // runtime lowercases incoming header keys, so an `ApiKeyStrategy`
213
- // declared with `headerName: "X-Custom-Key"` would never match the
214
- // incoming `x-custom-key` key without this normalisation — auth
215
- // would silently fail even with the right header on the wire.
216
- const apiKey = headers[apiKeyHeader.toLowerCase()] ?? "";
217
-
218
- const hasSessionStrategy = strategies.some((s) => s === "session");
219
- if (!bearerToken && !apiKey && !hasSessionStrategy) {
220
- ctx.response.status(401);
221
- ctx.response.json({
222
- error: {
223
- code: "UNAUTHORIZED",
224
- message: "Missing authentication token (Bearer or x-api-key)",
225
- },
226
- });
227
- return;
228
- }
229
-
230
- // Try each declared strategy. If every attempted strategy crashed AND none
231
- // succeeded, the caller returns 500 instead of 401 — a server-side incident
232
- // must stay observably distinct from a credential rejection.
233
- const { result, attemptCount, crashCount } = await tryAuthenticate(
234
- auth,
235
- strategies,
236
- { bearerToken, apiKey, session: ctx.session, hasSessionStrategy },
237
- );
238
-
239
- if (!result?.authenticated || !result.user) {
240
- if (attemptCount > 0 && crashCount === attemptCount) {
205
+ // Every attempted guard crashed → server-side incident, mapped to 500 so
206
+ // it stays observably distinct from a credential rejection.
207
+ if (err instanceof WardenError && err.status === 500) {
241
208
  ctx.response.status(500);
242
209
  ctx.response.json({
243
- error: {
244
- code: "AUTH_STRATEGY_ERROR",
245
- message:
246
- "Authentication unavailable — one or more strategies failed. Check server logs.",
247
- },
210
+ error: { code: "AUTH_STRATEGY_ERROR", message: err.message },
248
211
  });
249
212
  return;
250
213
  }
251
- ctx.response.status(401);
252
- ctx.response.json({
253
- error: {
254
- code: "UNAUTHORIZED",
255
- message: result?.error ?? "Authentication failed",
256
- },
257
- });
258
- return;
214
+ throw err;
259
215
  }
260
216
 
261
- const user = result.user;
217
+ const user = authenticator.getUserOrFail();
262
218
 
263
219
  // Permission + role checks are independent AND gates: the user must satisfy
264
220
  // ALL required permissions AND ALL required roles. Having a role does NOT
@@ -293,8 +249,7 @@ export async function wardenMiddleware(ctx: WardenContext, next: WardenNext) {
293
249
  }
294
250
  }
295
251
 
296
- // Auth successful — attach to context and continue.
297
- ctx.auth = result;
252
+ // Auth successful — `ctx.auth` (the Authenticator) already carries the user.
298
253
  await next();
299
254
  }
300
255
 
@@ -316,56 +271,80 @@ export async function wardenMiddleware(ctx: WardenContext, next: WardenNext) {
316
271
  * middleware / route guard enforces).
317
272
  *
318
273
  * Uses the AuthManager's DEFAULT strategy (Adonis's "default guard"). A missing
319
- * AuthManager, absent credentials, or a failed/crashed verify all leave the
320
- * request as a guest (`ctx.auth` unset) silent auth is non-enforcing.
274
+ * AuthManager leaves the request a guest with `ctx.auth` unset; otherwise
275
+ * `ctx.auth` is ALWAYS set to an {@link Authenticator} (AdonisJS
276
+ * `initialize_auth_middleware` parity) — populated when credentials verify,
277
+ * left as a guest (`isAuthenticated === false`) when they're absent, invalid, or
278
+ * a guard crashes. Silent auth NEVER surfaces a 401/500.
321
279
  */
322
280
  export async function silentAuth(ctx: WardenContext, next: WardenNext) {
323
- const resolvedAuth = resolveFromCtx(ctx, AuthManager);
281
+ const resolvedAuth = await resolveFromCtx(ctx, AuthManager);
324
282
  // Non-enforcing: a host without warden wired runs as guest. (Enforcement
325
283
  // middlewares fail CLOSED on a missing AuthManager; silent auth does not.)
326
284
  if (!(resolvedAuth instanceof AuthManager)) {
327
285
  await next();
328
286
  return;
329
287
  }
330
- const auth = resolvedAuth;
288
+ const authenticator = ensureAuthenticator(ctx, resolvedAuth);
289
+ try {
290
+ // `check()` runs the default guard and returns a boolean instead of
291
+ // throwing on a credential rejection; the catch swallows the residual
292
+ // crash/config path so silent auth stays strictly non-enforcing.
293
+ await authenticator.check();
294
+ } catch {
295
+ // Guard crash / config error → stay a guest, never surface here.
296
+ }
297
+ await next();
298
+ }
331
299
 
332
- const headers = ctx.request.headers();
333
- const authHeader = headers.authorization ?? "";
334
- const bearerToken = authHeader.startsWith("Bearer ")
335
- ? authHeader.slice(7)
336
- : "";
337
- const apiKeyHeader = (() => {
338
- try {
339
- const s = auth.getStrategy("api-key");
340
- const name = "headerName" in s ? s.headerName : undefined;
341
- return typeof name === "string" ? name : "x-api-key";
342
- } catch {
343
- return "x-api-key";
344
- }
345
- })();
346
- const apiKey = headers[apiKeyHeader.toLowerCase()] ?? "";
300
+ /**
301
+ * Reuse the request's Authenticator if one is already attached (e.g. `silentAuth`
302
+ * ran first), else build and attach a fresh one. Keeps `ctx.auth` a single
303
+ * per-request instance across the middleware chain.
304
+ */
305
+ function ensureAuthenticator(
306
+ ctx: WardenContext,
307
+ auth: AuthManager,
308
+ ): Authenticator {
309
+ if (ctx.auth instanceof Authenticator) return ctx.auth;
310
+ const authenticator = new Authenticator(ctx, auth);
311
+ ctx.auth = authenticator;
312
+ return authenticator;
313
+ }
347
314
 
348
- const strategy = auth.defaultStrategyName;
349
- const hasSessionStrategy = strategy === "session";
350
- // No credentials on the wire (and not a session strategy) → guest, no-op.
351
- if (!bearerToken && !apiKey && !hasSessionStrategy) {
352
- await next();
315
+ /**
316
+ * Optional login route (`ctx.response.redirect` target for session-guard HTML
317
+ * flows). Registered by `WardenProvider` from `config.auth.loginRoute` under the
318
+ * `"warden:loginRoute"` token; absent ⇒ no redirect (401 JSON).
319
+ */
320
+ async function resolveLoginRoute(
321
+ ctx: WardenContext,
322
+ ): Promise<string | undefined> {
323
+ const value = await resolveFromCtx(ctx, "warden:loginRoute");
324
+ return typeof value === "string" ? value : undefined;
325
+ }
326
+
327
+ /**
328
+ * Render an `E_UNAUTHORIZED_ACCESS` to the response — Warden does its OWN content
329
+ * negotiation (AdonisJS session renderer parity) instead of leaning on the
330
+ * host's exception handler, so it stays agnostic. An `Accept: text/html` request
331
+ * against a session guard carrying a `redirectTo` is redirected to the login
332
+ * route; every other case gets a 401 JSON body. Always fail-closed.
333
+ */
334
+ export function renderAuthError(
335
+ ctx: WardenContext,
336
+ error: E_UNAUTHORIZED_ACCESS,
337
+ ): void {
338
+ const accept = ctx.request.headers().accept ?? "";
339
+ const wantsHtml = accept.includes("text/html");
340
+ if (wantsHtml && error.redirectTo && ctx.response.redirect) {
341
+ ctx.response.redirect(error.redirectTo);
353
342
  return;
354
343
  }
355
-
356
- // Reuse the same per-strategy verification `wardenMiddleware` uses, but only
357
- // for the default strategy, and swallow the outcome: a failed/crashed attempt
358
- // must NOT surface as 401/500 here — silent auth only ever populates or skips.
359
- const { result } = await tryAuthenticate(auth, [strategy], {
360
- bearerToken,
361
- apiKey,
362
- session: ctx.session,
363
- hasSessionStrategy,
344
+ ctx.response.status(error.status ?? 401);
345
+ ctx.response.json({
346
+ error: { code: error.code, message: error.message },
364
347
  });
365
- if (result?.authenticated && result.user) {
366
- ctx.auth = result;
367
- }
368
- await next();
369
348
  }
370
349
 
371
350
  /**
@@ -396,9 +375,9 @@ export async function initializeBouncer(ctx: WardenContext, next: WardenNext) {
396
375
  // Both dependencies are registered by WardenProvider. Resolve defensively:
397
376
  // a host that wired the middleware but not the provider gets an empty Bouncer
398
377
  // (no abilities/resolver) rather than a crash on every request.
399
- const resolved = tryResolve(ctx, RightsResolver);
378
+ const resolved = await tryResolve(ctx, RightsResolver);
400
379
  const resolver = resolved instanceof RightsResolver ? resolved : undefined;
401
- const candidate = tryResolve(ctx, "bouncer:registry");
380
+ const candidate = await tryResolve(ctx, "bouncer:registry");
402
381
  const registry = isBouncerRegistry(candidate) ? candidate : undefined;
403
382
 
404
383
  const user = ctx.auth?.user ?? null;
@@ -414,7 +393,10 @@ export async function initializeBouncer(ctx: WardenContext, next: WardenNext) {
414
393
  }
415
394
 
416
395
  /** Resolve a container token from the request resolver, returning undefined instead of throwing when absent. */
417
- function tryResolve(ctx: WardenContext, token: ResolvableToken): unknown {
396
+ async function tryResolve(
397
+ ctx: WardenContext,
398
+ token: ResolvableToken,
399
+ ): Promise<unknown> {
418
400
  return resolveFromCtx(ctx, token);
419
401
  }
420
402
 
@@ -435,90 +417,13 @@ function isBouncerRegistry(value: unknown): value is BouncerRegistry {
435
417
  * hardcoded "global" and tenant-scoped roles/grants never satisfied @Role/@Permission.
436
418
  */
437
419
  async function resolveRequestScope(ctx: WardenContext): Promise<Scope> {
438
- const candidate = tryResolve(ctx, "bouncer:registry");
420
+ const candidate = await tryResolve(ctx, "bouncer:registry");
439
421
  const registry = isBouncerRegistry(candidate) ? candidate : undefined;
440
422
  return registry?.resolveScope
441
423
  ? await registry.resolveScope(toScopeContext(ctx))
442
424
  : "global";
443
425
  }
444
426
 
445
- /**
446
- * Try each declared strategy in order — session strategies via
447
- * `verifyWithContext()`, others via `verify(token)` with native-first credential
448
- * fallback. Distinguishes crashes (strategy threw / `strategyCrash`) from
449
- * credential rejections so the caller can return 500 vs 401.
450
- */
451
- async function tryAuthenticate(
452
- auth: AuthManager,
453
- strategies: string[],
454
- creds: {
455
- bearerToken: string;
456
- apiKey: string;
457
- session: SessionStore | undefined;
458
- hasSessionStrategy: boolean;
459
- },
460
- ): Promise<{
461
- result: AuthResult | null;
462
- attemptCount: number;
463
- crashCount: number;
464
- }> {
465
- const { bearerToken, apiKey, session } = creds;
466
- let result: AuthResult | null = null;
467
- let attemptCount = 0;
468
- let crashCount = 0;
469
- for (const strategyName of strategies) {
470
- try {
471
- let r: AuthResult;
472
- if (strategyName === "session") {
473
- const strategy = auth.getStrategy(strategyName);
474
- const verifyWithContext =
475
- strategy && hasVerifyWithContext(strategy)
476
- ? strategy.verifyWithContext
477
- : undefined;
478
- if (verifyWithContext) {
479
- attemptCount++;
480
- r = await verifyWithContext.call(strategy, "", { session });
481
- // The session path bypasses AuthManager.verify(), so apply the
482
- // same prototype-pollution guard JWT / api-key users get there.
483
- if (r.user) sanitizePayload(r.user);
484
- } else {
485
- continue;
486
- }
487
- } else {
488
- // Native-first credential, other transport as fallback so a
489
- // single-credential client still authenticates (and an invalid
490
- // Bearer no longer masks a valid API key for the api-key strategy).
491
- const credential =
492
- strategyName === "api-key"
493
- ? apiKey || bearerToken
494
- : bearerToken || apiKey;
495
- if (!credential) continue;
496
- attemptCount++;
497
- r = await auth.verify(credential, strategyName);
498
- }
499
- if (r.authenticated) {
500
- result = r;
501
- break;
502
- }
503
- if (r.strategyCrash === true) {
504
- crashCount++;
505
- console.error(
506
- `[warden] strategy '${strategyName}' threw during verify(): ${r.error ?? "unknown error"}`,
507
- );
508
- }
509
- } catch (err) {
510
- // AuthManager rethrows structured WardenError sentinels — treat these
511
- // as crashes too (config errors, SessionStrategy.USE_LOGIN, etc.).
512
- crashCount++;
513
- console.error(
514
- `[warden] strategy '${strategyName}' threw during verify():`,
515
- err,
516
- );
517
- }
518
- }
519
- return { result, attemptCount, crashCount };
520
- }
521
-
522
427
  /**
523
428
  * Gate the authenticated user against the route's @Permission / @Role decorators.
524
429
  * Both are independent AND gates resolved ONCE against the same unified
@@ -527,7 +432,7 @@ async function tryAuthenticate(
527
432
  */
528
433
  async function checkAuthorization(
529
434
  auth: AuthManager,
530
- user: NonNullable<AuthResult["user"]>,
435
+ user: UserPayload,
531
436
  controller: object,
532
437
  action: string | symbol,
533
438
  scope: Scope,
package/src/standalone.ts CHANGED
@@ -52,25 +52,33 @@ export interface Warden {
52
52
  * Create a standalone Warden instance — no Ream container needed.
53
53
  */
54
54
  export function createWarden(config: WardenConfig): Warden {
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.
55
+ // Mirror WardenProvider: accept the AdonisJS `guards` form OR the legacy
56
+ // driver-keyed fields, building EVERY configured strategy otherwise
57
+ // config.session / config.apiKey were silently dropped and a jwt-less config
58
+ // (e.g. { defaultStrategy: 'api-key', apiKey }) threw INVALID_CONFIG at boot
59
+ // (audit 2026-06-13). Keys match the @Guard names.
59
60
  const strategies: Record<string, AuthStrategy> = {};
60
61
 
61
- if (config.jwt) {
62
- strategies.jwt = new JwtStrategy(config.jwt);
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);
62
+ if (config.guards && Object.keys(config.guards).length > 0) {
63
+ Object.assign(strategies, config.guards);
64
+ } else {
65
+ if (config.jwt) {
66
+ strategies.jwt = new JwtStrategy(config.jwt);
67
+ }
68
+ if (config.session) {
69
+ strategies.session = new SessionStrategy(config.session);
70
+ }
71
+ if (config.apiKey) {
72
+ const apiKey = new ApiKeyStrategy(config.apiKey);
73
+ strategies.access_tokens = apiKey;
74
+ strategies["api-key"] = apiKey;
75
+ }
69
76
  }
70
77
 
71
78
  const manager = new AuthManager({
72
- defaultStrategy: config.defaultStrategy ?? "jwt",
73
- strategies,
79
+ default:
80
+ config.default ?? config.defaultStrategy ?? Object.keys(strategies)[0],
81
+ guards: strategies,
74
82
  });
75
83
 
76
84
  return {
@@ -16,7 +16,10 @@ export interface ApiKeyConfig {
16
16
  }
17
17
 
18
18
  export class ApiKeyStrategy implements AuthStrategy {
19
- name = "api-key";
19
+ // AdonisJS names this guard driver "access_tokens". The legacy "api-key"
20
+ // spelling is still accepted as a guard name / credential trigger by the
21
+ // middleware and providers (see `API_KEY_GUARD_NAMES`) for back-compat.
22
+ name = "access_tokens";
20
23
  #config: ApiKeyConfig;
21
24
  #headerName: string;
22
25
 
@@ -58,9 +58,9 @@ export class SessionStrategy implements AuthStrategy {
58
58
  // `strategy.authenticate()` directly.
59
59
  throw new WardenError(
60
60
  "USE_LOGIN",
61
- "SessionStrategy.authenticate() requires login() instead. Use authManager.login(user, session).",
61
+ "SessionStrategy has no credential step. Verify the password yourself, then start the session via authManager.login(user, session) (or ctx.auth.use('session').login(user)).",
62
62
  {
63
- hint: "Verify the password yourself (e.g. via @c9up/sigil Hash.verify), then call SessionStrategy.login(user, session).",
63
+ hint: "Verify the password yourself (e.g. via @c9up/sigil Hash.verify), then call authManager.login(user, session) — SessionStrategy.login(user, session) also works directly.",
64
64
  },
65
65
  );
66
66
  }