@c9up/warden 0.1.21 → 0.1.23

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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/AuthManager.d.ts +14 -3
  3. package/dist/AuthManager.d.ts.map +1 -1
  4. package/dist/AuthManager.js +32 -4
  5. package/dist/AuthManager.js.map +1 -1
  6. package/dist/Authenticator.d.ts +48 -4
  7. package/dist/Authenticator.d.ts.map +1 -1
  8. package/dist/Authenticator.js +234 -25
  9. package/dist/Authenticator.js.map +1 -1
  10. package/dist/config.d.ts +8 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js.map +1 -1
  13. package/dist/index.d.ts +3 -7
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/middleware.d.ts +13 -0
  18. package/dist/middleware.d.ts.map +1 -1
  19. package/dist/middleware.js.map +1 -1
  20. package/dist/native/generated.d.ts +8 -0
  21. package/dist/native/generated.d.ts.map +1 -0
  22. package/dist/native/generated.js +7 -0
  23. package/dist/native/generated.js.map +1 -0
  24. package/dist/native.d.ts +8 -9
  25. package/dist/native.d.ts.map +1 -1
  26. package/dist/native.js.map +1 -1
  27. package/dist/strategies/SessionStrategy.d.ts +37 -25
  28. package/dist/strategies/SessionStrategy.d.ts.map +1 -1
  29. package/dist/strategies/SessionStrategy.js +43 -44
  30. package/dist/strategies/SessionStrategy.js.map +1 -1
  31. package/index.win32-x64-msvc.node +0 -0
  32. package/package.json +3 -2
  33. package/scripts/build-napi-types.mjs +68 -0
  34. package/scripts/generate-napi-types.mjs +153 -0
  35. package/src/AuthManager.ts +68 -6
  36. package/src/Authenticator.ts +298 -27
  37. package/src/config.ts +8 -1
  38. package/src/index.ts +9 -11
  39. package/src/middleware.ts +17 -0
  40. package/src/native/generated.ts +23 -0
  41. package/src/native.ts +8 -9
  42. package/src/strategies/SessionStrategy.ts +73 -46
  43. package/dist/firstcontact/FirstContactManager.d.ts +0 -25
  44. package/dist/firstcontact/FirstContactManager.d.ts.map +0 -1
  45. package/dist/firstcontact/FirstContactManager.js +0 -38
  46. package/dist/firstcontact/FirstContactManager.js.map +0 -1
  47. package/dist/firstcontact/drivers/GitHubDriver.d.ts +0 -14
  48. package/dist/firstcontact/drivers/GitHubDriver.d.ts.map +0 -1
  49. package/dist/firstcontact/drivers/GitHubDriver.js +0 -60
  50. package/dist/firstcontact/drivers/GitHubDriver.js.map +0 -1
  51. package/dist/firstcontact/drivers/GoogleDriver.d.ts +0 -14
  52. package/dist/firstcontact/drivers/GoogleDriver.d.ts.map +0 -1
  53. package/dist/firstcontact/drivers/GoogleDriver.js +0 -63
  54. package/dist/firstcontact/drivers/GoogleDriver.js.map +0 -1
  55. package/dist/firstcontact/types.d.ts +0 -44
  56. package/dist/firstcontact/types.d.ts.map +0 -1
  57. package/dist/firstcontact/types.js +0 -22
  58. package/dist/firstcontact/types.js.map +0 -1
  59. package/src/firstcontact/FirstContactManager.ts +0 -54
  60. package/src/firstcontact/drivers/GitHubDriver.ts +0 -77
  61. package/src/firstcontact/drivers/GoogleDriver.ts +0 -80
  62. package/src/firstcontact/types.ts +0 -62
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate the TypeScript surface of the native module FROM the Rust.
4
+ *
5
+ * `napi-derive`'s `type-def` feature emits one JSON line per exported item
6
+ * while cargo compiles. Those lines are the source of truth: they are derived
7
+ * from the `#[napi]` items themselves, so a signature cannot drift from the
8
+ * Rust without this file changing.
9
+ *
10
+ * Hand-written interfaces are what this replaces, and they do drift — nothing
11
+ * on the TypeScript side notices when a `pub fn` gains a parameter or stops
12
+ * being `async`.
13
+ *
14
+ * One thing napi-rs cannot infer: the shape of a `JsFunction` callback. It
15
+ * emits `(...args: any[]) => any`. Any such parameter is refined below, by
16
+ * name, and the script fails if a refinement no longer matches anything — so
17
+ * the table cannot rot either.
18
+ *
19
+ * TYPE_DEF_TMP_PATH=<file> cargo build -p <crate>
20
+ * node scripts/generate-napi-types.mjs <file> <out.d.ts>
21
+ */
22
+
23
+ import { readFileSync, writeFileSync } from 'node:fs'
24
+
25
+ /**
26
+ * Callback signatures napi-rs erases to `any`, restored here.
27
+ *
28
+ * Keyed by `<owner>.<method>`; the value replaces the whole parameter. Every
29
+ * entry must match, or the script fails: a stale refinement is how a hand
30
+ * annotation quietly stops describing the Rust.
31
+ *
32
+ * Empty while this crate exposes no `JsFunction` parameter — add an entry the
33
+ * day one appears, rather than letting an `any` through.
34
+ */
35
+ const CALLBACK_REFINEMENTS = {}
36
+
37
+ const [input, output] = process.argv.slice(2)
38
+ if (!input || !output) {
39
+ console.error('usage: generate-napi-types.mjs <type-def file> <out.d.ts>')
40
+ process.exit(2)
41
+ }
42
+
43
+ const entries = readFileSync(input, 'utf8')
44
+ .split('\n')
45
+ .map((l) => l.trim())
46
+ .filter(Boolean)
47
+ .map((l) => JSON.parse(l))
48
+
49
+ const used = new Set()
50
+
51
+ /** Apply the refinements that belong to `owner`, tracking which ones matched. */
52
+ function refine(owner, body) {
53
+ let out = body
54
+ for (const [key, { from, to }] of Object.entries(CALLBACK_REFINEMENTS)) {
55
+ const [entryOwner] = key.split('.')
56
+ if (entryOwner !== owner) continue
57
+ if (out.includes(from)) {
58
+ out = out.replace(from, to)
59
+ used.add(key)
60
+ }
61
+ }
62
+ return out
63
+ }
64
+
65
+ /**
66
+ * JSDoc as emitted by napi-derive, indented to sit above its member.
67
+ *
68
+ * `*` followed by `/` inside the text closes the comment early — a Rust doc
69
+ * example holding a cron expression (`0 *​/5 * * *`) is enough to do it, and
70
+ * the generated file then fails to parse. Escaped rather than stripped, so the
71
+ * example still reads correctly.
72
+ */
73
+ function docBlock(doc, indent = '') {
74
+ if (!doc) return ''
75
+ // Escape every `*/` EXCEPT the one that closes the block. A Rust doc example
76
+ // holding a cron expression (`0 */5 * * *`) closes the comment early
77
+ // otherwise, and the generated file stops parsing — but escaping the closer
78
+ // too breaks it just as thoroughly, whether the block spans one line or many.
79
+ const closer = doc.lastIndexOf('*/')
80
+ const escaped =
81
+ closer === -1
82
+ ? doc
83
+ : doc.slice(0, closer).replaceAll('*/', '*\\/') + doc.slice(closer)
84
+ return (
85
+ escaped
86
+ .split('\n')
87
+ .filter((l) => l.length > 0)
88
+ .map((l) => `${indent}${l}`)
89
+ .join('\n') + '\n'
90
+ )
91
+ }
92
+
93
+ const interfaces = entries.filter((e) => e.kind === 'interface')
94
+ const structs = entries.filter((e) => e.kind === 'struct')
95
+ const impls = new Map(entries.filter((e) => e.kind === 'impl').map((e) => [e.name, e]))
96
+ const fns = entries.filter((e) => e.kind === 'fn')
97
+
98
+ const out = [
99
+ '// GENERATED FROM THE RUST — do not edit.',
100
+ '//',
101
+ "// Produced by scripts/generate-napi-types.mjs from napi-derive's type-def",
102
+ '// output. Editing this file by hand puts it back where it started: a',
103
+ '// description that can disagree with the code it describes.',
104
+ '',
105
+ ]
106
+
107
+ for (const iface of interfaces) {
108
+ out.push(docBlock(iface.js_doc))
109
+ out.push(`export interface ${iface.name} {`)
110
+ for (const line of iface.def.split('\n')) {
111
+ out.push(line ? ` ${line.trim()}` : '')
112
+ }
113
+ out.push('}', '')
114
+ }
115
+
116
+ for (const struct of structs) {
117
+ const impl = impls.get(struct.name)
118
+ out.push(docBlock(struct.js_doc))
119
+ out.push(`export declare class ${struct.name} {`)
120
+ if (impl) {
121
+ for (const line of refine(struct.name, impl.def).split('\n')) {
122
+ out.push(line ? ` ${line.trim()}` : '')
123
+ }
124
+ }
125
+ out.push('}', '')
126
+ }
127
+
128
+ for (const fn of fns) {
129
+ out.push(docBlock(fn.js_doc))
130
+ // napi-derive emits the whole declaration for a function, unlike a struct
131
+ // where `def` holds only the members.
132
+ const declaration = fn.def.trim()
133
+ out.push(
134
+ declaration.startsWith('export declare function')
135
+ ? `${declaration};`
136
+ : `export declare function ${fn.name}${declaration};`,
137
+ '',
138
+ )
139
+ }
140
+
141
+ const stale = Object.keys(CALLBACK_REFINEMENTS).filter((k) => !used.has(k))
142
+ if (stale.length > 0) {
143
+ console.error(
144
+ `[napi-types] refinement(s) that matched nothing: ${stale.join(', ')}\n` +
145
+ '[napi-types] the Rust changed under them — update or remove the entry.',
146
+ )
147
+ process.exit(1)
148
+ }
149
+
150
+ writeFileSync(output, out.join('\n'))
151
+ console.log(
152
+ `[napi-types] ${output} — ${interfaces.length} interface(s), ${structs.length} class(es), ${fns.length} function(s)`,
153
+ )
@@ -14,7 +14,10 @@ export { sanitizePayload };
14
14
  import { MemoryRightsStore } from "./rights/MemoryRightsStore.js";
15
15
  import { RightsResolver } from "./rights/RightsResolver.js";
16
16
  import type { EffectivePermissions, Scope } from "./rights/types.js";
17
- import type { SessionStore } from "./strategies/SessionStrategy.js";
17
+ import type {
18
+ SessionGuardState,
19
+ SessionStore,
20
+ } from "./strategies/SessionStrategy.js";
18
21
 
19
22
  export interface UserPayload {
20
23
  id: string;
@@ -453,6 +456,7 @@ export class AuthManager {
453
456
  user: UserPayload,
454
457
  session: SessionStore,
455
458
  strategyName?: string,
459
+ state?: SessionGuardState,
456
460
  ): Promise<void> {
457
461
  const strategy = this.getStrategy(strategyName);
458
462
  if (!isLoginCapable(strategy)) {
@@ -469,7 +473,7 @@ export class AuthManager {
469
473
  guardName: strategyName ?? this.#default,
470
474
  user,
471
475
  });
472
- await strategy.login(user, session);
476
+ await strategy.login(user, session, state);
473
477
  this.#emit(`${prefix}:login_succeeded`, {
474
478
  guardName: strategyName ?? this.#default,
475
479
  user,
@@ -477,12 +481,41 @@ export class AuthManager {
477
481
  });
478
482
  }
479
483
 
484
+ /**
485
+ * Revive a user from a remember-me cookie through a session guard, recording
486
+ * the attempt on the caller's per-request `state`.
487
+ *
488
+ * The returned `cookieValue` must replace the one the browser holds — the
489
+ * token is single-use and recycled on every successful revival.
490
+ */
491
+ async authenticateViaRememberMeToken(
492
+ cookieValue: unknown,
493
+ strategyName?: string,
494
+ state?: SessionGuardState,
495
+ ): Promise<{ user: UserPayload; cookieValue: string } | null> {
496
+ const strategy = this.getStrategy(strategyName);
497
+ if (!isRememberMeCapable(strategy)) {
498
+ throw new WardenError(
499
+ "STRATEGY_CANNOT_LOGIN",
500
+ `Auth strategy '${strategyName ?? this.#default}' has no remember-me tokens.`,
501
+ {
502
+ hint: "Remember-me is a session-guard feature. Configure `rememberMeTokens` on the guard, or implement authenticateViaRememberMeToken() on your own.",
503
+ },
504
+ );
505
+ }
506
+ return strategy.authenticateViaRememberMeToken(cookieValue, state);
507
+ }
508
+
480
509
  /**
481
510
  * Log a user out of a session-capable guard (AdonisJS `auth.use('web').logout()`
482
511
  * parity). Delegates to the guard's `logout()`. Throws if the resolved guard
483
512
  * cannot log out.
484
513
  */
485
- async logout(session: SessionStore, strategyName?: string): Promise<void> {
514
+ async logout(
515
+ session: SessionStore,
516
+ strategyName?: string,
517
+ state?: SessionGuardState,
518
+ ): Promise<void> {
486
519
  const strategy = this.getStrategy(strategyName);
487
520
  if (!isLoginCapable(strategy)) {
488
521
  throw new WardenError(
@@ -490,7 +523,7 @@ export class AuthManager {
490
523
  `Auth strategy '${strategyName ?? this.#default}' does not support session logout.`,
491
524
  );
492
525
  }
493
- await strategy.logout(session);
526
+ await strategy.logout(session, state);
494
527
  this.#emit(`${authEventPrefix(strategy.name)}:logged_out`, {
495
528
  guardName: strategyName ?? this.#default,
496
529
  user: null,
@@ -500,9 +533,38 @@ export class AuthManager {
500
533
  }
501
534
 
502
535
  /** A guard that can start/stop a session for a resolved user (e.g. SessionStrategy). */
536
+ /** A guard that can revive a user from a remember-me cookie. */
537
+ interface RememberMeCapable {
538
+ authenticateViaRememberMeToken(
539
+ cookieValue: unknown,
540
+ state?: SessionGuardState,
541
+ ): Promise<{ user: UserPayload; cookieValue: string } | null>;
542
+ }
543
+
544
+ /**
545
+ * Capability check for {@link AuthManager.authenticateViaRememberMeToken}.
546
+ *
547
+ * Structural, like {@link isLoginCapable} beside it, and NOT an `instanceof`:
548
+ * a guard an application wrote itself, carrying the same method, is as capable
549
+ * as the one shipped here — refusing it would make the built-in strategy the
550
+ * only one that can ever hold a remember-me token.
551
+ */
552
+ function isRememberMeCapable(
553
+ strategy: AuthStrategy,
554
+ ): strategy is AuthStrategy & RememberMeCapable {
555
+ return (
556
+ "authenticateViaRememberMeToken" in strategy &&
557
+ typeof strategy.authenticateViaRememberMeToken === "function"
558
+ );
559
+ }
560
+
503
561
  interface LoginCapable {
504
- login(user: UserPayload, session: SessionStore): Promise<void>;
505
- logout(session: SessionStore): Promise<void>;
562
+ login(
563
+ user: UserPayload,
564
+ session: SessionStore,
565
+ state?: SessionGuardState,
566
+ ): Promise<void>;
567
+ logout(session: SessionStore, state?: SessionGuardState): Promise<void>;
506
568
  }
507
569
 
508
570
  /**
@@ -23,7 +23,11 @@ import type {
23
23
  import { E_UNAUTHORIZED_ACCESS, WardenError } from "./errors.js";
24
24
  import type { WardenContext } from "./middleware.js";
25
25
  import { sanitizePayload } from "./sanitize.js";
26
- import type { SessionStore } from "./strategies/SessionStrategy.js";
26
+ import {
27
+ createSessionGuardState,
28
+ type SessionGuardState,
29
+ type SessionStore,
30
+ } from "./strategies/SessionStrategy.js";
27
31
 
28
32
  /**
29
33
  * Guard names Warden accepts for the API-key / access-tokens driver. AdonisJS
@@ -39,6 +43,66 @@ interface StrategyWithContext extends AuthStrategy {
39
43
  verifyWithContext(token: string, ctx: unknown): Promise<AuthResult>;
40
44
  }
41
45
 
46
+ /**
47
+ * The strategy behind a guard name, or `undefined` when nothing is registered
48
+ * under it.
49
+ *
50
+ * `getStrategy()` throws for an unknown name — correct when an application
51
+ * asks for a guard by name, wrong here: this is a capability probe across a
52
+ * list, and a name that resolves to nothing simply is not a session guard.
53
+ */
54
+ function strategyOrUndefined(
55
+ auth: AuthManager,
56
+ name: string,
57
+ ): AuthStrategy | undefined {
58
+ try {
59
+ return auth.getStrategy(name);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * A guard that can keep a user signed in: mint a token, name its cookie, read
67
+ * one back, and seat the session that follows.
68
+ */
69
+ interface RememberMeIssuer {
70
+ issueRememberMeToken(user: UserPayload): Promise<string | null>;
71
+ authenticateViaRememberMeToken(
72
+ cookieValue: unknown,
73
+ state?: SessionGuardState,
74
+ ): Promise<{ user: UserPayload; cookieValue: string } | null>;
75
+ seatSession(user: UserPayload, session: SessionStore): void;
76
+ revokeRememberMeToken(cookieValue: unknown): Promise<void>;
77
+ readonly rememberMeCookieName: string;
78
+ readonly rememberMeAgeSeconds: number;
79
+ }
80
+
81
+ /**
82
+ * Structural, like every other capability probe here: a guard an application
83
+ * wrote itself, carrying the same three members, keeps users signed in just as
84
+ * well as the one shipped with the package.
85
+ */
86
+ function isRememberMeIssuer(
87
+ strategy: AuthStrategy,
88
+ ): strategy is AuthStrategy & RememberMeIssuer {
89
+ return (
90
+ typeof Reflect.get(strategy, "issueRememberMeToken") === "function" &&
91
+ typeof Reflect.get(strategy, "authenticateViaRememberMeToken") ===
92
+ "function" &&
93
+ typeof Reflect.get(strategy, "seatSession") === "function" &&
94
+ typeof Reflect.get(strategy, "revokeRememberMeToken") === "function" &&
95
+ typeof Reflect.get(strategy, "rememberMeCookieName") === "string" &&
96
+ typeof Reflect.get(strategy, "rememberMeAgeSeconds") === "number"
97
+ );
98
+ }
99
+
100
+ /** Whether the guard behind `name` authenticates from the request context. */
101
+ function isSessionGuard(auth: AuthManager, name: string): boolean {
102
+ const strategy = strategyOrUndefined(auth, name);
103
+ return strategy !== undefined && hasVerifyWithContext(strategy);
104
+ }
105
+
42
106
  function hasVerifyWithContext(
43
107
  strategy: AuthStrategy,
44
108
  ): strategy is StrategyWithContext {
@@ -115,7 +179,6 @@ export async function tryAuthenticate(
115
179
  bearerToken: string;
116
180
  apiKey: string;
117
181
  session: SessionStore | undefined;
118
- hasSessionStrategy: boolean;
119
182
  },
120
183
  ): Promise<AuthAttempt> {
121
184
  const { bearerToken, apiKey, session } = creds;
@@ -126,21 +189,22 @@ export async function tryAuthenticate(
126
189
  for (const strategyName of strategies) {
127
190
  try {
128
191
  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
- }
192
+ // A session guard is one that can verify FROM THE REQUEST CONTEXT,
193
+ // not one registered under a particular name. `guards: { web:
194
+ // sessionGuard(...) }` is the documented config shape, and matching
195
+ // on the literal "session" sent it down the bearer-token path — so
196
+ // `auth.use('web').authenticate()` never read the session at all.
197
+ const strategy = strategyOrUndefined(auth, strategyName);
198
+ const verifyWithContext =
199
+ strategy && hasVerifyWithContext(strategy)
200
+ ? strategy.verifyWithContext
201
+ : undefined;
202
+ if (verifyWithContext !== undefined) {
203
+ attemptCount++;
204
+ r = await verifyWithContext.call(strategy, "", { session });
205
+ // The session path bypasses AuthManager.verify(), so apply the
206
+ // same prototype-pollution guard JWT / api-key users get there.
207
+ if (r.user) sanitizePayload(r.user);
144
208
  } else {
145
209
  // Native-first credential, other transport as fallback so a
146
210
  // single-credential client still authenticates (and an invalid
@@ -185,6 +249,12 @@ export class GuardAccessor {
185
249
  readonly #auth: AuthManager;
186
250
  readonly #name: string;
187
251
  readonly #parent: Authenticator;
252
+ /**
253
+ * The session-guard flags for THIS request. They live on the accessor —
254
+ * which the Authenticator builds and caches per request — and never on the
255
+ * strategy, which is built once from config and shared by every request.
256
+ */
257
+ readonly #state: SessionGuardState = createSessionGuardState();
188
258
 
189
259
  constructor(
190
260
  ctx: WardenContext,
@@ -209,19 +279,203 @@ export class GuardAccessor {
209
279
  return this.user !== undefined;
210
280
  }
211
281
 
282
+ /**
283
+ * Whether the user was revived from a remember-me cookie rather than
284
+ * signing in.
285
+ *
286
+ * This is the distinction that lets an app demand the password again before
287
+ * something sensitive — changing an email, spending money, deleting an
288
+ * account.
289
+ */
290
+ get viaRemember(): boolean {
291
+ return this.#state.viaRemember;
292
+ }
293
+
294
+ /** Whether a remember-me cookie was tried at all on this request. */
295
+ get attemptedViaRemember(): boolean {
296
+ return this.#state.attemptedViaRemember;
297
+ }
298
+
299
+ /**
300
+ * Whether `logout()` has run during this request.
301
+ *
302
+ * A handler that logs out and then keeps working — clearing a cart, writing
303
+ * an audit line — could not otherwise tell the session was already gone.
304
+ */
305
+ get isLoggedOut(): boolean {
306
+ return this.#state.isLoggedOut;
307
+ }
308
+
309
+ /**
310
+ * Revive the user from a remember-me cookie, recording on this request that
311
+ * one was tried and whether it worked.
312
+ *
313
+ * The returned `cookieValue` must replace the one the browser holds: the
314
+ * token is single-use and is recycled on every successful revival.
315
+ */
316
+ authenticateViaRememberMeToken(
317
+ cookieValue: unknown,
318
+ ): Promise<{ user: UserPayload; cookieValue: string } | null> {
319
+ return this.#auth.authenticateViaRememberMeToken(
320
+ cookieValue,
321
+ this.#name,
322
+ this.#state,
323
+ );
324
+ }
325
+
212
326
  /** Authenticate the request using only this guard (throws on failure). */
213
327
  authenticate(): Promise<void> {
214
328
  return this.#parent.authenticateUsing([this.#name]);
215
329
  }
216
330
 
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);
331
+ /**
332
+ * Revive this request from the remember-me cookie, if the browser holds one.
333
+ *
334
+ * The token is single-use: a success recycles the cookie and re-seats the
335
+ * session, so a stolen copy stops working the moment the real user comes
336
+ * back, and the rest of the request sees an ordinary signed-in user.
337
+ */
338
+ async tryRememberMeCookie(): Promise<UserPayload | undefined> {
339
+ const read = this.#ctx.request.encryptedCookie;
340
+ const write = this.#ctx.response.encryptedCookie;
341
+ const session = this.#ctx.session;
342
+ if (!read || !write || !session) return undefined;
343
+
344
+ const strategy = strategyOrUndefined(this.#auth, this.#name);
345
+ if (!strategy || !isRememberMeIssuer(strategy)) return undefined;
346
+
347
+ const cookie = read.call(this.#ctx.request, strategy.rememberMeCookieName);
348
+ if (!cookie) return undefined;
349
+
350
+ const revived = await strategy.authenticateViaRememberMeToken(
351
+ cookie,
352
+ this.#state,
353
+ );
354
+ if (!revived) return undefined;
355
+
356
+ write.call(
357
+ this.#ctx.response,
358
+ strategy.rememberMeCookieName,
359
+ revived.cookieValue,
360
+ { maxAge: strategy.rememberMeAgeSeconds, httpOnly: true },
361
+ );
362
+ // Seat the session WITHOUT `login()`: that method means a password was
363
+ // typed, and it clears `viaRemember` — the one thing this path exists to
364
+ // report, and what an app checks before letting someone change an email
365
+ // or spend money.
366
+ strategy.seatSession(revived.user, session);
367
+ return revived.user;
368
+ }
369
+
370
+ /**
371
+ * Log a user in through this guard (session guards).
372
+ *
373
+ * `remember` mints a remember-me token and writes it as an ENCRYPTED,
374
+ * httpOnly cookie — the cookie IS the credential, so anyone who can read it
375
+ * can present it. Without `remember`, any cookie the browser still holds is
376
+ * cleared: signing in without ticking the box has to REVOKE the standing
377
+ * permission, not leave it in place.
378
+ */
379
+ async login(user: UserPayload, remember = false): Promise<void> {
380
+ const session = this.#requireSession();
381
+ const issued = remember ? await this.#issueRememberMe(user) : undefined;
382
+ if (!remember) this.#clearRememberMe();
383
+ try {
384
+ await this.#auth.login(user, session, this.#name, this.#state);
385
+ } catch (err) {
386
+ // The token is minted and the cookie sent before the session is
387
+ // seated — upstream's order, and it has to be, because the cookie
388
+ // belongs on the same response.
389
+ //
390
+ // NAMED DEVIATION — upstream does not roll any of this back; it has
391
+ // no need to, because its session write is a map assignment that
392
+ // cannot fail. Here `login()` also fires listeners, any of which
393
+ // can throw, so the window is real.
394
+ //
395
+ // And clearing the cookie is not enough: the token was PERSISTED,
396
+ // and its value reached the wire, where it may have been captured
397
+ // — a proxy log, an already-flushed response. Revoking the row is
398
+ // what makes a failed sign-in leave nothing usable behind.
399
+ if (issued !== undefined) await this.#revokeRememberMe(issued);
400
+ throw err;
401
+ }
402
+ }
403
+
404
+ /** Undo an issued remember-me: the stored row first, then the cookie. */
405
+ async #revokeRememberMe(value: string): Promise<void> {
406
+ const strategy = strategyOrUndefined(this.#auth, this.#name);
407
+ if (strategy && isRememberMeIssuer(strategy)) {
408
+ // Best-effort: a store that is itself down must not replace the
409
+ // caller's error with its own.
410
+ await strategy.revokeRememberMeToken(value).catch(() => undefined);
411
+ }
412
+ this.#clearRememberMe();
413
+ }
414
+
415
+ /**
416
+ * Mint the token and put it in the browser, or say why it cannot.
417
+ *
418
+ * Returns the minted value so a failure further along can revoke it: a
419
+ * persisted token nobody can reach is still a credential.
420
+ */
421
+ async #issueRememberMe(user: UserPayload): Promise<string> {
422
+ const strategy = strategyOrUndefined(this.#auth, this.#name);
423
+ if (!strategy || !isRememberMeIssuer(strategy)) {
424
+ throw new WardenError(
425
+ "REMEMBER_ME_UNAVAILABLE",
426
+ `Guard '${this.#name}' cannot keep a user signed in: no remember-me tokens are configured.`,
427
+ {
428
+ hint: "Set `rememberMeTokens` on the session guard, or call login(user) without the remember flag.",
429
+ },
430
+ );
431
+ }
432
+ const value = await strategy.issueRememberMeToken(user);
433
+ if (value === null) {
434
+ throw new WardenError(
435
+ "REMEMBER_ME_UNAVAILABLE",
436
+ `Guard '${this.#name}' cannot keep a user signed in: no remember-me tokens are configured.`,
437
+ {
438
+ hint: "Set `rememberMeTokens` on the session guard, or call login(user) without the remember flag.",
439
+ },
440
+ );
441
+ }
442
+ const write = this.#ctx.response.encryptedCookie;
443
+ if (!write) {
444
+ throw new WardenError(
445
+ "REMEMBER_ME_UNAVAILABLE",
446
+ "This host cannot write an encrypted cookie, which is where the remember-me token lives.",
447
+ {
448
+ hint: "Use a host whose response exposes encryptedCookie(), or call login(user) without the remember flag.",
449
+ },
450
+ );
451
+ }
452
+ try {
453
+ write.call(this.#ctx.response, strategy.rememberMeCookieName, value, {
454
+ maxAge: strategy.rememberMeAgeSeconds,
455
+ httpOnly: true,
456
+ });
457
+ } catch (err) {
458
+ // The row exists and the browser will never hold it: a credential
459
+ // nobody can reach is still one, so it does not survive the failure.
460
+ await strategy.revokeRememberMeToken(value).catch(() => undefined);
461
+ throw err;
462
+ }
463
+ return value;
464
+ }
465
+
466
+ /** Drop whatever remember-me cookie the browser is still holding. */
467
+ #clearRememberMe(): void {
468
+ const strategy = strategyOrUndefined(this.#auth, this.#name);
469
+ if (!strategy || !isRememberMeIssuer(strategy)) return;
470
+ this.#ctx.response.clearCookie?.call(
471
+ this.#ctx.response,
472
+ strategy.rememberMeCookieName,
473
+ );
220
474
  }
221
475
 
222
476
  /** Log the current user out of this guard (session guards). */
223
477
  logout(): Promise<void> {
224
- return this.#auth.logout(this.#requireSession(), this.#name);
478
+ return this.#auth.logout(this.#requireSession(), this.#name, this.#state);
225
479
  }
226
480
 
227
481
  #requireSession(): SessionStore {
@@ -355,12 +609,29 @@ export class Authenticator {
355
609
  { guardName },
356
610
  );
357
611
  const creds = extractCredentials(this.#ctx, this.#auth);
358
- const hasSessionStrategy = names.includes("session");
359
- const { result, viaGuard, attemptCount, crashCount } =
360
- await tryAuthenticate(this.#auth, names, {
361
- ...creds,
362
- hasSessionStrategy,
363
- });
612
+ // Same rule as the loop above: a session guard is one that verifies from
613
+ // the request context. Matching the literal name meant a guard called
614
+ // `web` never got the login redirect a browser needs.
615
+ const hasSessionStrategy = names.some((name) =>
616
+ isSessionGuard(this.#auth, name),
617
+ );
618
+ const attempt = await tryAuthenticate(this.#auth, names, creds);
619
+ const { attemptCount, crashCount } = attempt;
620
+ let result = attempt.result;
621
+ let viaGuard = attempt.viaGuard;
622
+
623
+ // No credential answered, but the browser may still hold a remember-me
624
+ // cookie — that is what "keep me signed in" means, and nothing read it.
625
+ if (!result?.authenticated && hasSessionStrategy) {
626
+ for (const name of names) {
627
+ const user = await this.use(name).tryRememberMeCookie();
628
+ if (user) {
629
+ result = { authenticated: true, user };
630
+ viaGuard = name;
631
+ break;
632
+ }
633
+ }
634
+ }
364
635
 
365
636
  if (result?.authenticated && result.user) {
366
637
  this.#user = result.user;
package/src/config.ts CHANGED
@@ -61,7 +61,14 @@ export interface JwtConfig {
61
61
  * A guard entry in the AdonisJS-style config — an {@link AuthStrategy} instance,
62
62
  * built via {@link jwtGuard}/{@link sessionGuard}/{@link apiKeyGuard}. Named a
63
63
  * "factory" for AdonisJS symmetry (`sessionGuard({...})`), though Warden guards
64
- * are shared per-app instances (the per-request state lives on the Authenticator).
64
+ * are shared per-app instances rather than one built per request.
65
+ *
66
+ * Because the instance is shared, a guard must hold NO per-request state: the
67
+ * flags a request asks about — `viaRemember`, `attemptedViaRemember`,
68
+ * `isLoggedOut` — live on the per-request guard the Authenticator hands out
69
+ * (`auth.use(name)`), and a strategy method records into the state it is
70
+ * given. A flag stored on the strategy would answer the next request with the
71
+ * previous one's truth.
65
72
  */
66
73
  export type GuardFactory = AuthStrategy;
67
74