@c9up/warden 0.1.13 → 0.1.15

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 (97) 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 +1 -1
  10. package/dist/WardenProvider.d.ts.map +1 -1
  11. package/dist/WardenProvider.js +54 -24
  12. package/dist/WardenProvider.js.map +1 -1
  13. package/dist/bouncer/AbilitiesBuilder.d.ts +20 -0
  14. package/dist/bouncer/AbilitiesBuilder.d.ts.map +1 -0
  15. package/dist/bouncer/AbilitiesBuilder.js +32 -0
  16. package/dist/bouncer/AbilitiesBuilder.js.map +1 -0
  17. package/dist/bouncer/AuthorizationResponse.d.ts +18 -8
  18. package/dist/bouncer/AuthorizationResponse.d.ts.map +1 -1
  19. package/dist/bouncer/AuthorizationResponse.js +20 -8
  20. package/dist/bouncer/AuthorizationResponse.js.map +1 -1
  21. package/dist/bouncer/BasePolicy.d.ts +4 -3
  22. package/dist/bouncer/BasePolicy.d.ts.map +1 -1
  23. package/dist/bouncer/BasePolicy.js.map +1 -1
  24. package/dist/bouncer/Bouncer.d.ts +9 -2
  25. package/dist/bouncer/Bouncer.d.ts.map +1 -1
  26. package/dist/bouncer/Bouncer.js +59 -10
  27. package/dist/bouncer/Bouncer.js.map +1 -1
  28. package/dist/bouncer/PolicyAuthorizer.d.ts +2 -1
  29. package/dist/bouncer/PolicyAuthorizer.d.ts.map +1 -1
  30. package/dist/bouncer/PolicyAuthorizer.js +11 -3
  31. package/dist/bouncer/PolicyAuthorizer.js.map +1 -1
  32. package/dist/bouncer/evaluate.d.ts +1 -1
  33. package/dist/bouncer/evaluate.d.ts.map +1 -1
  34. package/dist/bouncer/evaluate.js +3 -2
  35. package/dist/bouncer/evaluate.js.map +1 -1
  36. package/dist/bouncer/types.d.ts +22 -1
  37. package/dist/bouncer/types.d.ts.map +1 -1
  38. package/dist/config.d.ts +34 -3
  39. package/dist/config.d.ts.map +1 -1
  40. package/dist/config.js +15 -0
  41. package/dist/config.js.map +1 -1
  42. package/dist/configure.d.ts.map +1 -1
  43. package/dist/configure.js +15 -13
  44. package/dist/configure.js.map +1 -1
  45. package/dist/errors.d.ts +27 -0
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/errors.js +37 -1
  48. package/dist/errors.js.map +1 -1
  49. package/dist/index.d.ts +7 -3
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +4 -2
  52. package/dist/index.js.map +1 -1
  53. package/dist/mfa/OtpProvider.d.ts.map +1 -1
  54. package/dist/mfa/OtpProvider.js +21 -3
  55. package/dist/mfa/OtpProvider.js.map +1 -1
  56. package/dist/mfa/WebauthnProvider.d.ts +24 -2
  57. package/dist/mfa/WebauthnProvider.d.ts.map +1 -1
  58. package/dist/mfa/WebauthnProvider.js +28 -8
  59. package/dist/mfa/WebauthnProvider.js.map +1 -1
  60. package/dist/middleware.d.ts +29 -6
  61. package/dist/middleware.d.ts.map +1 -1
  62. package/dist/middleware.js +85 -166
  63. package/dist/middleware.js.map +1 -1
  64. package/dist/standalone.d.ts.map +1 -1
  65. package/dist/standalone.js +21 -13
  66. package/dist/standalone.js.map +1 -1
  67. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
  68. package/dist/strategies/ApiKeyStrategy.js +4 -1
  69. package/dist/strategies/ApiKeyStrategy.js.map +1 -1
  70. package/dist/strategies/SessionStrategy.js +2 -2
  71. package/dist/strategies/SessionStrategy.js.map +1 -1
  72. package/index.darwin-arm64.node +0 -0
  73. package/index.darwin-x64.node +0 -0
  74. package/index.linux-arm64-gnu.node +0 -0
  75. package/index.linux-x64-gnu.node +0 -0
  76. package/index.win32-x64-msvc.node +0 -0
  77. package/package.json +2 -1
  78. package/src/AuthManager.ts +125 -28
  79. package/src/Authenticator.ts +385 -0
  80. package/src/WardenProvider.ts +54 -27
  81. package/src/bouncer/AbilitiesBuilder.ts +41 -0
  82. package/src/bouncer/AuthorizationResponse.ts +24 -16
  83. package/src/bouncer/BasePolicy.ts +4 -2
  84. package/src/bouncer/Bouncer.ts +77 -14
  85. package/src/bouncer/PolicyAuthorizer.ts +14 -4
  86. package/src/bouncer/evaluate.ts +4 -2
  87. package/src/bouncer/types.ts +24 -1
  88. package/src/config.ts +47 -3
  89. package/src/configure.ts +15 -13
  90. package/src/errors.ts +43 -1
  91. package/src/index.ts +22 -2
  92. package/src/mfa/OtpProvider.ts +31 -3
  93. package/src/mfa/WebauthnProvider.ts +58 -12
  94. package/src/middleware.ts +112 -207
  95. package/src/standalone.ts +22 -14
  96. package/src/strategies/ApiKeyStrategy.ts +4 -1
  97. package/src/strategies/SessionStrategy.ts +2 -2
@@ -8,6 +8,7 @@ import { WardenError } from "./errors.js";
8
8
  import { MemoryRightsStore } from "./rights/MemoryRightsStore.js";
9
9
  import { RightsResolver } from "./rights/RightsResolver.js";
10
10
  import type { EffectivePermissions, Scope } from "./rights/types.js";
11
+ import type { SessionStore } from "./strategies/SessionStrategy.js";
11
12
 
12
13
  export interface UserPayload {
13
14
  id: string;
@@ -36,15 +37,32 @@ export interface AuthStrategy {
36
37
  verify(token: string, context?: Record<string, unknown>): Promise<AuthResult>;
37
38
  }
38
39
 
40
+ /**
41
+ * AuthManager configuration. Two equivalent forms are accepted:
42
+ *
43
+ * - AdonisJS form (preferred): `{ default, guards }` — `default` names the
44
+ * guard used when none is specified, `guards` maps guard name → strategy.
45
+ * - Legacy form: `{ defaultStrategy, strategies }` — kept working so existing
46
+ * call sites and tests need no change.
47
+ *
48
+ * Exactly one of each pair must be supplied. Internally normalised to the
49
+ * AdonisJS names (`default` / `guards`).
50
+ */
39
51
  export interface AuthConfig {
40
- defaultStrategy: string;
41
- strategies: Record<string, AuthStrategy>;
52
+ /** AdonisJS name for the default guard. */
53
+ default?: string;
54
+ /** AdonisJS name for the guard map (name → strategy). */
55
+ guards?: Record<string, AuthStrategy>;
56
+ /** Legacy alias for {@link AuthConfig.default}. */
57
+ defaultStrategy?: string;
58
+ /** Legacy alias for {@link AuthConfig.guards}. */
59
+ strategies?: Record<string, AuthStrategy>;
42
60
  /**
43
61
  * The rights resolver backing the coarse RBAC helpers (Epic 56). When
44
62
  * absent, a default `RightsResolver(new MemoryRightsStore())` is used so
45
- * `new AuthManager({ defaultStrategy, strategies })` keeps working — with
46
- * an empty store, payload roles still fold in (D2) and permissions are
47
- * empty (token `user.permissions` is not an input — D1).
63
+ * `new AuthManager({ default, guards })` keeps working — with an empty
64
+ * store, payload roles still fold in (D2) and permissions are empty (token
65
+ * `user.permissions` is not an input — D1).
48
66
  */
49
67
  rights?: RightsResolver;
50
68
  }
@@ -69,32 +87,44 @@ function isTokenIssuer(
69
87
  * Manages authentication strategies and provides guard/permission checks.
70
88
  */
71
89
  export class AuthManager {
72
- private strategies: Map<string, AuthStrategy> = new Map();
73
- private defaultStrategy: string;
90
+ // AdonisJS names: `guards` (name strategy) and `default` (the guard used
91
+ // when none is named). Renamed from the previous `strategies`/`defaultStrategy`.
92
+ private guards: Map<string, AuthStrategy> = new Map();
93
+ private default: string;
74
94
  private readonly rights: RightsResolver;
75
95
 
76
96
  constructor(config: AuthConfig) {
77
- this.defaultStrategy = config.defaultStrategy;
97
+ // Normalise the two accepted forms — AdonisJS `{ default, guards }` and
98
+ // the legacy `{ defaultStrategy, strategies }` — to the AdonisJS names.
99
+ const guards = config.guards ?? config.strategies;
100
+ const defaultGuard = config.default ?? config.defaultStrategy;
101
+ if (!guards || defaultGuard === undefined) {
102
+ throw new WardenError(
103
+ "INVALID_CONFIG",
104
+ "AuthManager: config must supply `default` + `guards` (or the legacy `defaultStrategy` + `strategies`).",
105
+ );
106
+ }
107
+ this.default = defaultGuard;
78
108
  this.rights = config.rights ?? new RightsResolver(new MemoryRightsStore());
79
- for (const [name, strategy] of Object.entries(config.strategies)) {
80
- this.strategies.set(name, strategy);
109
+ for (const [name, strategy] of Object.entries(guards)) {
110
+ this.guards.set(name, strategy);
81
111
  }
82
- // Fail-fast at construction: an AuthManager with zero strategies
83
- // is a configuration bug. Previously, an empty `strategies: {}`
84
- // passed the constructor cleanly and the first protected request
85
- // crashed at runtime when `getStrategy('jwt')` threw — opaque 401
86
- // or 500 instead of a boot-time INVALID_CONFIG that points the
87
- // operator at the missing `config.auth.jwt` (or other strategy).
88
- if (Object.keys(config.strategies).length === 0) {
112
+ // Fail-fast at construction: an AuthManager with zero guards is a
113
+ // configuration bug. Previously, an empty `guards: {}` passed the
114
+ // constructor cleanly and the first protected request crashed at
115
+ // runtime when `getStrategy('jwt')` threw — opaque 401 or 500 instead
116
+ // of a boot-time INVALID_CONFIG that points the operator at the missing
117
+ // `config.auth.jwt` (or other guard).
118
+ if (this.guards.size === 0) {
89
119
  throw new WardenError(
90
120
  "INVALID_CONFIG",
91
- `AuthManager: no authentication strategies registered. Configure at least one strategy (e.g. config.warden.auth.jwt) before booting WardenProvider.`,
121
+ `AuthManager: no authentication guards registered. Configure at least one guard (e.g. config.warden.auth.jwt) before booting WardenProvider.`,
92
122
  );
93
123
  }
94
- if (!this.strategies.has(config.defaultStrategy)) {
124
+ if (!this.guards.has(defaultGuard)) {
95
125
  throw new WardenError(
96
126
  "INVALID_CONFIG",
97
- `defaultStrategy '${config.defaultStrategy}' is not present in strategies`,
127
+ `default guard '${defaultGuard}' is not present in guards`,
98
128
  );
99
129
  }
100
130
  }
@@ -164,7 +194,7 @@ export class AuthManager {
164
194
  if (!isTokenIssuer(strategy)) {
165
195
  throw new WardenError(
166
196
  "STRATEGY_CANNOT_ISSUE",
167
- `Auth strategy '${strategyName ?? this.defaultStrategy}' cannot issue tokens.`,
197
+ `Auth strategy '${strategyName ?? this.default}' cannot issue tokens.`,
168
198
  {
169
199
  hint: "issueFor() needs a token-minting strategy (e.g. JwtStrategy with signToken). Session / API-key strategies don't mint tokens.",
170
200
  },
@@ -228,8 +258,8 @@ export class AuthManager {
228
258
 
229
259
  /** Get a registered strategy by name. */
230
260
  getStrategy(name?: string): AuthStrategy {
231
- const strategyName = name ?? this.defaultStrategy;
232
- const strategy = this.strategies.get(strategyName);
261
+ const strategyName = name ?? this.default;
262
+ const strategy = this.guards.get(strategyName);
233
263
  if (!strategy) {
234
264
  throw new WardenError(
235
265
  "STRATEGY_NOT_FOUND",
@@ -243,24 +273,91 @@ export class AuthManager {
243
273
  }
244
274
 
245
275
  /**
246
- * Name of the default strategy (Adonis's "default guard"). Used by
247
- * `silentAuth` to pick which strategy to attempt when a route declares none.
276
+ * Name of the default guard (Adonis's "default guard"). Used by `silentAuth`
277
+ * and the {@link Authenticator} to pick which guard to attempt when a route
278
+ * declares none.
248
279
  */
249
280
  get defaultStrategyName(): string {
250
- return this.defaultStrategy;
281
+ return this.default;
251
282
  }
252
283
 
253
284
  /** Register a new strategy at runtime. */
254
285
  registerStrategy(name: string, strategy: AuthStrategy): void {
255
- this.strategies.set(name, strategy);
286
+ this.guards.set(name, strategy);
256
287
  }
257
288
 
258
289
  /** Get all registered strategy names. */
259
290
  getStrategyNames(): string[] {
260
- return [...this.strategies.keys()];
291
+ return [...this.guards.keys()];
292
+ }
293
+
294
+ /**
295
+ * Log a user in through a session-capable guard (AdonisJS
296
+ * `auth.use('web').login(user)` parity). Delegates to the guard's own
297
+ * `login()` — for `SessionStrategy` this rotates the session id (session
298
+ * fixation defence) and stores the user id. Throws if the resolved guard
299
+ * cannot log in (e.g. JWT / API-key guards, which are stateless).
300
+ *
301
+ * Fixes the misleading `SessionStrategy.authenticate()` sentinel that
302
+ * pointed at a then-nonexistent `authManager.login()` (bug-3261).
303
+ */
304
+ async login(
305
+ user: UserPayload,
306
+ session: SessionStore,
307
+ strategyName?: string,
308
+ ): Promise<void> {
309
+ const strategy = this.getStrategy(strategyName);
310
+ if (!isLoginCapable(strategy)) {
311
+ throw new WardenError(
312
+ "STRATEGY_CANNOT_LOGIN",
313
+ `Auth strategy '${strategyName ?? this.default}' does not support session login.`,
314
+ {
315
+ hint: "login()/logout() need a stateful guard (e.g. SessionStrategy). JWT / API-key guards are stateless — mint a token with issueFor() instead.",
316
+ },
317
+ );
318
+ }
319
+ await strategy.login(user, session);
320
+ }
321
+
322
+ /**
323
+ * Log a user out of a session-capable guard (AdonisJS `auth.use('web').logout()`
324
+ * parity). Delegates to the guard's `logout()`. Throws if the resolved guard
325
+ * cannot log out.
326
+ */
327
+ async logout(session: SessionStore, strategyName?: string): Promise<void> {
328
+ const strategy = this.getStrategy(strategyName);
329
+ if (!isLoginCapable(strategy)) {
330
+ throw new WardenError(
331
+ "STRATEGY_CANNOT_LOGIN",
332
+ `Auth strategy '${strategyName ?? this.default}' does not support session logout.`,
333
+ );
334
+ }
335
+ await strategy.logout(session);
261
336
  }
262
337
  }
263
338
 
339
+ /** A guard that can start/stop a session for a resolved user (e.g. SessionStrategy). */
340
+ interface LoginCapable {
341
+ login(user: UserPayload, session: SessionStore): Promise<void>;
342
+ logout(session: SessionStore): Promise<void>;
343
+ }
344
+
345
+ /**
346
+ * Capability check for {@link AuthManager.login}/{@link AuthManager.logout}.
347
+ * `login`/`logout` are not on the base `AuthStrategy` contract (only stateful
348
+ * guards implement them), so narrow via `in` + `typeof` — no cast.
349
+ */
350
+ function isLoginCapable(
351
+ strategy: AuthStrategy,
352
+ ): strategy is AuthStrategy & LoginCapable {
353
+ return (
354
+ "login" in strategy &&
355
+ typeof strategy.login === "function" &&
356
+ "logout" in strategy &&
357
+ typeof strategy.logout === "function"
358
+ );
359
+ }
360
+
264
361
  /** Strip dangerous prototype-pollution keys from user payload. */
265
362
  /**
266
363
  * Strip prototype-pollution keys from a user payload before it's
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Authenticator — a PER-REQUEST authentication contract (AdonisJS
3
+ * `@adonisjs/auth` `Authenticator` parity), attached as `ctx.auth`.
4
+ *
5
+ * Why it exists: before this, `ctx.auth` was a plain data object with no
6
+ * methods, so an INLINE route handler (`router.post('/login', handler)`) had no
7
+ * way to authenticate on demand — only the `@Guard` decorator path worked, and
8
+ * that reads `ctx.route.controller/action`, which inline/functional routes don't
9
+ * expose. This contract makes `await ctx.auth.authenticate()` /
10
+ * `ctx.auth.use('session').login(user)` work anywhere, exactly like Adonis.
11
+ *
12
+ * Warden stays agnostic: the Authenticator is built from the {@link AuthManager}
13
+ * (resolved via `ctx.containerResolver`) + the request `ctx`; it never imports
14
+ * `@c9up/ream`.
15
+ */
16
+
17
+ import {
18
+ type AuthManager,
19
+ type AuthResult,
20
+ type AuthStrategy,
21
+ sanitizePayload,
22
+ type UserPayload,
23
+ } from "./AuthManager.js";
24
+ import { E_UNAUTHORIZED_ACCESS, WardenError } from "./errors.js";
25
+ import type { WardenContext } from "./middleware.js";
26
+ import type { SessionStore } from "./strategies/SessionStrategy.js";
27
+
28
+ /**
29
+ * Guard names Warden accepts for the API-key / access-tokens driver. AdonisJS
30
+ * names the driver `access_tokens`; the legacy `api-key` spelling stays valid so
31
+ * existing `@Guard('api-key')` routes and configs keep working.
32
+ */
33
+ export const API_KEY_GUARD_NAMES: readonly string[] = [
34
+ "access_tokens",
35
+ "api-key",
36
+ ];
37
+
38
+ interface StrategyWithContext extends AuthStrategy {
39
+ verifyWithContext(token: string, ctx: unknown): Promise<AuthResult>;
40
+ }
41
+
42
+ function hasVerifyWithContext(
43
+ strategy: AuthStrategy,
44
+ ): strategy is StrategyWithContext {
45
+ return (
46
+ typeof (strategy as StrategyWithContext).verifyWithContext === "function"
47
+ );
48
+ }
49
+
50
+ /** The credentials Warden extracts from a request, independent of guard list. */
51
+ export interface ExtractedCredentials {
52
+ bearerToken: string;
53
+ apiKey: string;
54
+ session: SessionStore | undefined;
55
+ }
56
+
57
+ /**
58
+ * Resolve the header the API-key guard reads its key from. Prefers the
59
+ * registered guard's `headerName` (under either accepted guard name), else the
60
+ * `x-api-key` default. Never throws.
61
+ */
62
+ function resolveApiKeyHeader(auth: AuthManager): string {
63
+ for (const name of API_KEY_GUARD_NAMES) {
64
+ try {
65
+ const s = auth.getStrategy(name);
66
+ const header = "headerName" in s ? s.headerName : undefined;
67
+ if (typeof header === "string" && header.length > 0) return header;
68
+ } catch {
69
+ // Guard not registered under this name — try the next.
70
+ }
71
+ }
72
+ return "x-api-key";
73
+ }
74
+
75
+ /**
76
+ * Extract the request's credentials once — a Bearer token, an API key (read from
77
+ * the configured header, matched case-insensitively), and the session store.
78
+ * Shared by `wardenMiddleware`, `silentAuth`, and the Authenticator so all three
79
+ * read credentials identically.
80
+ */
81
+ export function extractCredentials(
82
+ ctx: WardenContext,
83
+ auth: AuthManager,
84
+ ): ExtractedCredentials {
85
+ const headers = ctx.request.headers();
86
+ const authHeader = headers.authorization ?? "";
87
+ const bearerToken = authHeader.startsWith("Bearer ")
88
+ ? authHeader.slice(7)
89
+ : "";
90
+ // HTTP header names are case-insensitive; runtimes lowercase incoming keys,
91
+ // so normalise the configured header name before the lookup.
92
+ const apiKey = headers[resolveApiKeyHeader(auth).toLowerCase()] ?? "";
93
+ return { bearerToken, apiKey, session: ctx.session };
94
+ }
95
+
96
+ /** Outcome of {@link tryAuthenticate}. */
97
+ export interface AuthAttempt {
98
+ result: AuthResult | null;
99
+ /** Name of the guard that authenticated (only when `result.authenticated`). */
100
+ viaGuard?: string;
101
+ attemptCount: number;
102
+ crashCount: number;
103
+ }
104
+
105
+ /**
106
+ * Try each declared guard in order — session guards via `verifyWithContext()`,
107
+ * others via `verify(token)` with native-first credential fallback.
108
+ * Distinguishes crashes (strategy threw / `strategyCrash`) from credential
109
+ * rejections so the caller can return 500 vs 401.
110
+ */
111
+ export async function tryAuthenticate(
112
+ auth: AuthManager,
113
+ strategies: string[],
114
+ creds: {
115
+ bearerToken: string;
116
+ apiKey: string;
117
+ session: SessionStore | undefined;
118
+ hasSessionStrategy: boolean;
119
+ },
120
+ ): Promise<AuthAttempt> {
121
+ const { bearerToken, apiKey, session } = creds;
122
+ let result: AuthResult | null = null;
123
+ let viaGuard: string | undefined;
124
+ let attemptCount = 0;
125
+ let crashCount = 0;
126
+ for (const strategyName of strategies) {
127
+ try {
128
+ let r: AuthResult;
129
+ if (strategyName === "session") {
130
+ const strategy = auth.getStrategy(strategyName);
131
+ const verifyWithContext =
132
+ strategy && hasVerifyWithContext(strategy)
133
+ ? strategy.verifyWithContext
134
+ : undefined;
135
+ if (verifyWithContext) {
136
+ attemptCount++;
137
+ r = await verifyWithContext.call(strategy, "", { session });
138
+ // The session path bypasses AuthManager.verify(), so apply the
139
+ // same prototype-pollution guard JWT / api-key users get there.
140
+ if (r.user) sanitizePayload(r.user);
141
+ } else {
142
+ continue;
143
+ }
144
+ } else {
145
+ // Native-first credential, other transport as fallback so a
146
+ // single-credential client still authenticates (and an invalid
147
+ // Bearer no longer masks a valid API key for the api-key guard).
148
+ const credential = API_KEY_GUARD_NAMES.includes(strategyName)
149
+ ? apiKey || bearerToken
150
+ : bearerToken || apiKey;
151
+ if (!credential) continue;
152
+ attemptCount++;
153
+ r = await auth.verify(credential, strategyName);
154
+ }
155
+ if (r.authenticated) {
156
+ result = r;
157
+ viaGuard = strategyName;
158
+ break;
159
+ }
160
+ if (r.strategyCrash === true) {
161
+ crashCount++;
162
+ console.error(
163
+ `[warden] strategy '${strategyName}' threw during verify(): ${r.error ?? "unknown error"}`,
164
+ );
165
+ }
166
+ } catch (err) {
167
+ // AuthManager rethrows structured WardenError sentinels — treat these
168
+ // as crashes too (config errors, SessionStrategy.USE_LOGIN, etc.).
169
+ crashCount++;
170
+ console.error(
171
+ `[warden] strategy '${strategyName}' threw during verify():`,
172
+ err,
173
+ );
174
+ }
175
+ }
176
+ return { result, viaGuard, attemptCount, crashCount };
177
+ }
178
+
179
+ /**
180
+ * A named-guard accessor (AdonisJS `auth.use('web')`). Exposes the stateful
181
+ * `login()`/`logout()` for session guards plus per-guard `authenticate()`.
182
+ */
183
+ export class GuardAccessor {
184
+ readonly #ctx: WardenContext;
185
+ readonly #auth: AuthManager;
186
+ readonly #name: string;
187
+ readonly #parent: Authenticator;
188
+
189
+ constructor(
190
+ ctx: WardenContext,
191
+ auth: AuthManager,
192
+ name: string,
193
+ parent: Authenticator,
194
+ ) {
195
+ this.#ctx = ctx;
196
+ this.#auth = auth;
197
+ this.#name = name;
198
+ this.#parent = parent;
199
+ }
200
+
201
+ /** The user, only if the request authenticated via THIS guard. */
202
+ get user(): UserPayload | undefined {
203
+ return this.#parent.authenticatedViaGuard === this.#name
204
+ ? this.#parent.user
205
+ : undefined;
206
+ }
207
+
208
+ get isAuthenticated(): boolean {
209
+ return this.user !== undefined;
210
+ }
211
+
212
+ /** Authenticate the request using only this guard (throws on failure). */
213
+ authenticate(): Promise<void> {
214
+ return this.#parent.authenticateUsing([this.#name]);
215
+ }
216
+
217
+ /** Log a user in through this guard (session guards). */
218
+ login(user: UserPayload): Promise<void> {
219
+ return this.#auth.login(user, this.#requireSession(), this.#name);
220
+ }
221
+
222
+ /** Log the current user out of this guard (session guards). */
223
+ logout(): Promise<void> {
224
+ return this.#auth.logout(this.#requireSession(), this.#name);
225
+ }
226
+
227
+ #requireSession(): SessionStore {
228
+ if (!this.#ctx.session) {
229
+ throw new WardenError(
230
+ "NO_SESSION",
231
+ `Guard '${this.#name}'.login()/logout() requires a session, but ctx.session is unset. Register the session middleware upstream.`,
232
+ );
233
+ }
234
+ return this.#ctx.session;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Per-request Authenticator attached as `ctx.auth`. Mirrors AdonisJS's
240
+ * `Authenticator` surface: `authenticate` / `check` / `authenticateUsing` /
241
+ * `getUserOrFail` / `use`, plus the `user` / `isAuthenticated` /
242
+ * `authenticationAttempted` / `authenticatedViaGuard` getters.
243
+ */
244
+ export class Authenticator {
245
+ readonly #ctx: WardenContext;
246
+ readonly #auth: AuthManager;
247
+ #user?: UserPayload;
248
+ #viaGuard?: string;
249
+ #attempted = false;
250
+ readonly #guardCache = new Map<string, GuardAccessor>();
251
+
252
+ constructor(ctx: WardenContext, auth: AuthManager) {
253
+ this.#ctx = ctx;
254
+ this.#auth = auth;
255
+ }
256
+
257
+ /** The authenticated user, or `undefined` for a guest. */
258
+ get user(): UserPayload | undefined {
259
+ return this.#user;
260
+ }
261
+
262
+ /** Whether the request has an authenticated user (AdonisJS name). */
263
+ get isAuthenticated(): boolean {
264
+ return this.#user !== undefined;
265
+ }
266
+
267
+ /** Whether `authenticate`/`check`/`authenticateUsing` ran this request. */
268
+ get authenticationAttempted(): boolean {
269
+ return this.#attempted;
270
+ }
271
+
272
+ /** Name of the guard that authenticated the request, if any. */
273
+ get authenticatedViaGuard(): string | undefined {
274
+ return this.#viaGuard;
275
+ }
276
+
277
+ /**
278
+ * Roles of the authenticated user (Ream reads `ctx.auth.roles ??
279
+ * ctx.auth.user.roles`). Warden nests them under `user`; this getter mirrors
280
+ * them at the top level for the host's guard readers.
281
+ */
282
+ get roles(): string[] | undefined {
283
+ return this.#user?.roles;
284
+ }
285
+
286
+ /** Permissions of the authenticated user (see {@link Authenticator.roles}). */
287
+ get permissions(): string[] | undefined {
288
+ return this.#user?.permissions;
289
+ }
290
+
291
+ /** Authenticate via the default guard. Throws `E_UNAUTHORIZED_ACCESS` on failure. */
292
+ authenticate(): Promise<void> {
293
+ return this.authenticateUsing([this.#auth.defaultStrategyName]);
294
+ }
295
+
296
+ /**
297
+ * Like {@link Authenticator.authenticate} but returns a boolean instead of
298
+ * throwing on a credential rejection. A strategy CRASH / config error still
299
+ * propagates (a server incident must not be silently swallowed as "guest").
300
+ */
301
+ async check(): Promise<boolean> {
302
+ try {
303
+ await this.authenticate();
304
+ return this.isAuthenticated;
305
+ } catch (err) {
306
+ if (err instanceof E_UNAUTHORIZED_ACCESS) return false;
307
+ throw err;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Authenticate by trying the given guards in order (default: the default
313
+ * guard). Sets the user on the first success; throws `E_UNAUTHORIZED_ACCESS`
314
+ * (401, carrying `redirectTo` for session guards) when all reject, or a
315
+ * `WARDEN_AUTH_STRATEGY_ERROR` (500) when every attempted guard crashed.
316
+ */
317
+ async authenticateUsing(
318
+ guards?: string[],
319
+ options?: { loginRoute?: string },
320
+ ): Promise<void> {
321
+ this.#attempted = true;
322
+ const names =
323
+ guards && guards.length > 0 ? guards : [this.#auth.defaultStrategyName];
324
+ const creds = extractCredentials(this.#ctx, this.#auth);
325
+ const hasSessionStrategy = names.includes("session");
326
+ const { result, viaGuard, attemptCount, crashCount } =
327
+ await tryAuthenticate(this.#auth, names, {
328
+ ...creds,
329
+ hasSessionStrategy,
330
+ });
331
+
332
+ if (result?.authenticated && result.user) {
333
+ this.#user = result.user;
334
+ this.#viaGuard = viaGuard;
335
+ return;
336
+ }
337
+ // Every attempted guard crashed → a server-side incident, not a
338
+ // credential rejection. Surface distinctly (mapped to 500 by the caller).
339
+ if (attemptCount > 0 && crashCount === attemptCount) {
340
+ throw new WardenError(
341
+ "AUTH_STRATEGY_ERROR",
342
+ "Authentication unavailable — one or more strategies failed. Check server logs.",
343
+ { status: 500 },
344
+ );
345
+ }
346
+ throw new E_UNAUTHORIZED_ACCESS(result?.error ?? "Unauthorized access", {
347
+ guardDriverName: names[0] ?? this.#auth.defaultStrategyName,
348
+ redirectTo: hasSessionStrategy ? options?.loginRoute : undefined,
349
+ });
350
+ }
351
+
352
+ /** Return the authenticated user or throw `E_UNAUTHORIZED_ACCESS`. */
353
+ getUserOrFail(): UserPayload {
354
+ if (!this.#user) {
355
+ throw new E_UNAUTHORIZED_ACCESS(
356
+ 'Cannot access authenticated user. Call "ctx.auth.authenticate()" first.',
357
+ { guardDriverName: this.#viaGuard ?? this.#auth.defaultStrategyName },
358
+ );
359
+ }
360
+ return this.#user;
361
+ }
362
+
363
+ /** Access a named guard (AdonisJS `auth.use('web')`). Instances are cached per request. */
364
+ use(name: string): GuardAccessor {
365
+ let accessor = this.#guardCache.get(name);
366
+ if (!accessor) {
367
+ accessor = new GuardAccessor(this.#ctx, this.#auth, name, this);
368
+ this.#guardCache.set(name, accessor);
369
+ }
370
+ return accessor;
371
+ }
372
+
373
+ /**
374
+ * @internal Seed the Authenticator from a result the enforcing middleware
375
+ * already computed, so `ctx.auth` reflects the authenticated user without a
376
+ * second verify pass.
377
+ */
378
+ adopt(result: AuthResult | null, viaGuard: string | undefined): void {
379
+ this.#attempted = true;
380
+ if (result?.authenticated && result.user) {
381
+ this.#user = result.user;
382
+ this.#viaGuard = viaGuard;
383
+ }
384
+ }
385
+ }