@c9up/warden 0.1.17 → 0.1.18

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 (100) hide show
  1. package/dist/AuthManager.d.ts +40 -0
  2. package/dist/AuthManager.d.ts.map +1 -1
  3. package/dist/AuthManager.js +45 -3
  4. package/dist/AuthManager.js.map +1 -1
  5. package/dist/Authenticator.d.ts.map +1 -1
  6. package/dist/Authenticator.js +18 -2
  7. package/dist/Authenticator.js.map +1 -1
  8. package/dist/RememberMeToken.d.ts +99 -0
  9. package/dist/RememberMeToken.d.ts.map +1 -0
  10. package/dist/RememberMeToken.js +167 -0
  11. package/dist/RememberMeToken.js.map +1 -0
  12. package/dist/ResilientBlacklistDriver.d.ts +15 -9
  13. package/dist/ResilientBlacklistDriver.d.ts.map +1 -1
  14. package/dist/ResilientBlacklistDriver.js +53 -15
  15. package/dist/ResilientBlacklistDriver.js.map +1 -1
  16. package/dist/WardenProvider.d.ts +1 -0
  17. package/dist/WardenProvider.d.ts.map +1 -1
  18. package/dist/WardenProvider.js +64 -0
  19. package/dist/WardenProvider.js.map +1 -1
  20. package/dist/bouncer/Bouncer.d.ts +32 -1
  21. package/dist/bouncer/Bouncer.d.ts.map +1 -1
  22. package/dist/bouncer/Bouncer.js +54 -0
  23. package/dist/bouncer/Bouncer.js.map +1 -1
  24. package/dist/config.d.ts +3 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +5 -0
  27. package/dist/config.js.map +1 -1
  28. package/dist/firstcontact/drivers/GitHubDriver.d.ts.map +1 -1
  29. package/dist/firstcontact/drivers/GitHubDriver.js +2 -3
  30. package/dist/firstcontact/drivers/GitHubDriver.js.map +1 -1
  31. package/dist/firstcontact/drivers/GoogleDriver.d.ts.map +1 -1
  32. package/dist/firstcontact/drivers/GoogleDriver.js +2 -3
  33. package/dist/firstcontact/drivers/GoogleDriver.js.map +1 -1
  34. package/dist/firstcontact/types.d.ts +10 -0
  35. package/dist/firstcontact/types.d.ts.map +1 -1
  36. package/dist/firstcontact/types.js +18 -1
  37. package/dist/firstcontact/types.js.map +1 -1
  38. package/dist/index.d.ts +7 -4
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/mfa/MfaManager.d.ts.map +1 -1
  43. package/dist/mfa/MfaManager.js +14 -5
  44. package/dist/mfa/MfaManager.js.map +1 -1
  45. package/dist/mfa/TotpProvider.d.ts +34 -1
  46. package/dist/mfa/TotpProvider.d.ts.map +1 -1
  47. package/dist/mfa/TotpProvider.js +60 -4
  48. package/dist/mfa/TotpProvider.js.map +1 -1
  49. package/dist/mfa/webauthn-codec.d.ts +6 -1
  50. package/dist/mfa/webauthn-codec.d.ts.map +1 -1
  51. package/dist/mfa/webauthn-codec.js +50 -6
  52. package/dist/mfa/webauthn-codec.js.map +1 -1
  53. package/dist/middleware.d.ts.map +1 -1
  54. package/dist/middleware.js +46 -1
  55. package/dist/middleware.js.map +1 -1
  56. package/dist/quasar.d.ts.map +1 -1
  57. package/dist/quasar.js +6 -14
  58. package/dist/quasar.js.map +1 -1
  59. package/dist/strategies/ApiKeyStrategy.d.ts +10 -1
  60. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
  61. package/dist/strategies/ApiKeyStrategy.js +16 -0
  62. package/dist/strategies/ApiKeyStrategy.js.map +1 -1
  63. package/dist/strategies/BasicAuthStrategy.d.ts +59 -0
  64. package/dist/strategies/BasicAuthStrategy.d.ts.map +1 -0
  65. package/dist/strategies/BasicAuthStrategy.js +97 -0
  66. package/dist/strategies/BasicAuthStrategy.js.map +1 -0
  67. package/dist/strategies/JwtStrategy.d.ts +10 -1
  68. package/dist/strategies/JwtStrategy.d.ts.map +1 -1
  69. package/dist/strategies/JwtStrategy.js +11 -0
  70. package/dist/strategies/JwtStrategy.js.map +1 -1
  71. package/dist/strategies/SessionStrategy.d.ts +58 -2
  72. package/dist/strategies/SessionStrategy.d.ts.map +1 -1
  73. package/dist/strategies/SessionStrategy.js +75 -1
  74. package/dist/strategies/SessionStrategy.js.map +1 -1
  75. package/index.darwin-arm64.node +0 -0
  76. package/index.darwin-x64.node +0 -0
  77. package/index.linux-arm64-gnu.node +0 -0
  78. package/index.linux-x64-gnu.node +0 -0
  79. package/index.win32-x64-msvc.node +0 -0
  80. package/package.json +4 -2
  81. package/src/AuthManager.ts +80 -0
  82. package/src/Authenticator.ts +22 -3
  83. package/src/RememberMeToken.ts +223 -0
  84. package/src/ResilientBlacklistDriver.ts +58 -18
  85. package/src/WardenProvider.ts +90 -0
  86. package/src/bouncer/Bouncer.ts +61 -1
  87. package/src/config.ts +9 -0
  88. package/src/firstcontact/drivers/GitHubDriver.ts +2 -3
  89. package/src/firstcontact/drivers/GoogleDriver.ts +2 -3
  90. package/src/firstcontact/types.ts +24 -0
  91. package/src/index.ts +24 -2
  92. package/src/mfa/MfaManager.ts +15 -8
  93. package/src/mfa/TotpProvider.ts +90 -6
  94. package/src/mfa/webauthn-codec.ts +48 -6
  95. package/src/middleware.ts +55 -2
  96. package/src/quasar.ts +6 -14
  97. package/src/strategies/ApiKeyStrategy.ts +23 -1
  98. package/src/strategies/BasicAuthStrategy.ts +127 -0
  99. package/src/strategies/JwtStrategy.ts +18 -1
  100. package/src/strategies/SessionStrategy.ts +113 -2
@@ -7,6 +7,9 @@
7
7
  * @implements MISS-7
8
8
  */
9
9
  import { WardenError } from "../errors.js";
10
+ import { decodeTokenValue, mintRememberMeToken, verifyAndRecycleRememberMeToken, } from "../RememberMeToken.js";
11
+ /** Two years, Adonis' `rememberMeTokensAge` default. */
12
+ const DEFAULT_REMEMBER_AGE_SECONDS = 63_072_000;
10
13
  export class SessionStrategy {
11
14
  name = "session";
12
15
  #config;
@@ -15,6 +18,62 @@ export class SessionStrategy {
15
18
  this.#config = config;
16
19
  this.#sessionKey = config.sessionKey ?? "auth_user_id";
17
20
  }
21
+ /** The cookie the remember-me token travels in. */
22
+ get rememberMeCookieName() {
23
+ return this.#config.rememberMeCookieName ?? "remember_web";
24
+ }
25
+ /** Whether "keep me signed in" is wired at all. */
26
+ get usesRememberMeTokens() {
27
+ return this.#config.rememberMeTokens !== undefined;
28
+ }
29
+ #rememberMeAge() {
30
+ return this.#config.rememberMeAge ?? DEFAULT_REMEMBER_AGE_SECONDS;
31
+ }
32
+ /**
33
+ * Mint a remember-me token for `user` and return the cookie value the caller
34
+ * must set. `null` when the feature is not wired.
35
+ */
36
+ async issueRememberMeToken(user) {
37
+ const driver = this.#config.rememberMeTokens;
38
+ if (!driver)
39
+ return null;
40
+ const minted = mintRememberMeToken(user.id, this.#rememberMeAge());
41
+ await driver.create(minted.stored);
42
+ return minted.value;
43
+ }
44
+ /**
45
+ * Authenticate from a remember-me cookie, and RECYCLE it: the returned
46
+ * `cookieValue` must replace the one the browser holds.
47
+ *
48
+ * The user is re-read through `findUser`, never trusted from the token — a
49
+ * token outlives the row it points at, and a deleted or disabled account
50
+ * must not walk back in through a cookie.
51
+ */
52
+ async authenticateViaRememberMeToken(cookieValue) {
53
+ const driver = this.#config.rememberMeTokens;
54
+ if (!driver)
55
+ return null;
56
+ const recycled = await verifyAndRecycleRememberMeToken(driver, cookieValue, this.#rememberMeAge());
57
+ if (!recycled)
58
+ return null;
59
+ const user = await this.#config.findUser(recycled.userId);
60
+ if (!user)
61
+ return null;
62
+ return { user, cookieValue: recycled.value };
63
+ }
64
+ /**
65
+ * Drop the token behind `cookieValue`. Called on logout so the persistent
66
+ * credential dies with the session — Adonis deletes the row too.
67
+ */
68
+ async revokeRememberMeToken(cookieValue) {
69
+ const driver = this.#config.rememberMeTokens;
70
+ if (!driver)
71
+ return;
72
+ const decoded = decodeTokenValue(cookieValue);
73
+ if (!decoded)
74
+ return;
75
+ await driver.delete(decoded.identifier);
76
+ }
18
77
  /** Authenticate via email/password — stores user ID in session. */
19
78
  async authenticate(_credentials, _context) {
20
79
  // Session strategy doesn't handle password verification — that's done
@@ -62,7 +121,22 @@ export class SessionStrategy {
62
121
  session.regenerate();
63
122
  session.put(this.#sessionKey, user.id);
64
123
  }
65
- /** Logout — remove user ID from session. */
124
+ /**
125
+ * The session entry a test client needs to be `user` (AdonisJS
126
+ * `authenticateAsClient`).
127
+ *
128
+ * Returns the same key `login()` writes, so the request is authenticated by
129
+ * the guard's own logic rather than by the test knowing where the id lives.
130
+ */
131
+ authenticateAsClient(user) {
132
+ return { session: { [this.#sessionKey]: user.id } };
133
+ }
134
+ /**
135
+ * Logout — remove the user id from the session.
136
+ *
137
+ * The remember-me token is revoked separately through
138
+ * {@link revokeRememberMeToken}, because only the caller holds the cookie.
139
+ */
66
140
  async logout(session) {
67
141
  session.forget(this.#sessionKey);
68
142
  }
@@ -1 +1 @@
1
- {"version":3,"file":"SessionStrategy.js","sourceRoot":"","sources":["../../src/strategies/SessionStrategy.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AA2B3C,MAAM,OAAO,eAAe;IAC3B,IAAI,GAAG,SAAS,CAAC;IACjB,OAAO,CAAwB;IAC/B,WAAW,CAAS;IAEpB,YAAY,MAA6B;QACxC,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QACtB,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,UAAU,IAAI,cAAc,CAAC;IACxD,CAAC;IAED,mEAAmE;IACnE,KAAK,CAAC,YAAY,CACjB,YAAiD,EACjD,QAAqC;QAErC,sEAAsE;QACtE,wEAAwE;QACxE,mEAAmE;QACnE,sEAAsE;QACtE,kEAAkE;QAClE,sCAAsC;QACtC,MAAM,IAAI,WAAW,CACpB,WAAW,EACX,6KAA6K,EAC7K;YACC,IAAI,EAAE,yKAAyK;SAC/K,CACD,CAAC;IACH,CAAC;IAED,8DAA8D;IAC9D,KAAK,CAAC,MAAM,CAAC,MAAc;QAC1B,OAAO;YACN,aAAa,EAAE,KAAK;YACpB,KAAK,EACJ,qEAAqE;SACtE,CAAC;IACH,CAAC;IAED,2EAA2E;IAC3E,KAAK,CAAC,iBAAiB,CACtB,MAAc,EACd,OAAoC;QAEpC,IAAI,CAAC,OAAO,EAAE,OAAO;YAAE,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,KAAK,EAAE,YAAY,EAAE,CAAC;QAC5E,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QACrD,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC;QACvE,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,OAAO,MAAM,KAAK,QAAQ;YAC3D,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,KAAK,EAAE,sBAAsB,EAAE,CAAC;QAChE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACjD,IAAI,CAAC,IAAI;YAAE,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QACpE,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IACtC,CAAC;IAED;;;;;;;;;OASG;IACH,KAAK,CAAC,KAAK,CAAC,IAAiB,EAAE,OAAqB;QACnD,OAAO,CAAC,UAAU,EAAE,CAAC;QACrB,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC;IACxC,CAAC;IAED,4CAA4C;IAC5C,KAAK,CAAC,MAAM,CAAC,OAAqB;QACjC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IAClC,CAAC;CACD"}
1
+ {"version":3,"file":"SessionStrategy.js","sourceRoot":"","sources":["../../src/strategies/SessionStrategy.ts"],"names":[],"mappings":"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"}
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/warden",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Warden — Authentication & authorization for the Ream framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -62,6 +62,7 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@biomejs/biome": "^2.4.10",
65
+ "@c9up/quasar": "^0.1.0",
65
66
  "@types/node": "^22.19.15",
66
67
  "reflect-metadata": "^0.2.2",
67
68
  "typescript": "^6.0.2",
@@ -85,6 +86,7 @@
85
86
  "test:rust": "cargo test -p warden-engine",
86
87
  "lint": "biome check src/",
87
88
  "test:coverage": "vitest run --coverage",
88
- "typecheck": "tsc --noEmit"
89
+ "typecheck": "tsc --noEmit",
90
+ "test:integration": "vitest run -c vitest.integration.config.ts"
89
91
  }
90
92
  }
@@ -31,10 +31,34 @@ export interface AuthResult {
31
31
  strategyCrash?: true;
32
32
  }
33
33
 
34
+ /**
35
+ * What a test client must send to act as a user (AdonisJS `AuthClientResponse`).
36
+ *
37
+ * Each guard answers in its own currency — a header for a token guard, a
38
+ * session entry for a session guard — so the test harness applies whichever
39
+ * fields come back instead of knowing how each guard authenticates.
40
+ */
41
+ export interface AuthClientResponse {
42
+ /** Headers to set on the request. */
43
+ headers?: Record<string, string>;
44
+ /** Session values to seed before the request. */
45
+ session?: Record<string, unknown>;
46
+ /** Cookies to send. */
47
+ cookies?: Record<string, string>;
48
+ }
49
+
34
50
  export interface AuthStrategy {
35
51
  name: string;
36
52
  authenticate(credentials: Record<string, unknown>): Promise<AuthResult>;
37
53
  verify(token: string, context?: Record<string, unknown>): Promise<AuthResult>;
54
+ /**
55
+ * Build what a test client sends to be this user (AdonisJS
56
+ * `authenticateAsClient`). Optional: a guard with no test seam simply has
57
+ * none, and the harness says so instead of forging one.
58
+ */
59
+ authenticateAsClient?(
60
+ ...args: never[]
61
+ ): Promise<AuthClientResponse> | AuthClientResponse;
38
62
  }
39
63
 
40
64
  /**
@@ -86,6 +110,14 @@ function isTokenIssuer(
86
110
  /**
87
111
  * Manages authentication strategies and provides guard/permission checks.
88
112
  */
113
+ /**
114
+ * The slice of an event emitter warden publishes through — `emit(name, data)`,
115
+ * which is what both `@c9up/ream`'s Emitter and Adonis' expose.
116
+ */
117
+ export interface WardenEmitter {
118
+ emit(event: string, data: unknown): unknown;
119
+ }
120
+
89
121
  export class AuthManager {
90
122
  // AdonisJS names: `guards` (name → strategy) and `default` (the guard used
91
123
  // when none is named). Renamed from the previous `strategies`/`defaultStrategy`.
@@ -93,6 +125,40 @@ export class AuthManager {
93
125
  private default: string;
94
126
  private readonly rights: RightsResolver;
95
127
 
128
+ /**
129
+ * Where auth events go. Structural and optional: warden is a leaf and must
130
+ * not import an emitter — a host wires its own through `setEmitter`, and one
131
+ * that wires none simply gets no events.
132
+ */
133
+ #emitter: WardenEmitter | undefined;
134
+
135
+ /**
136
+ * Publish auth events on `emitter`. AdonisJS emits six of them from its
137
+ * session guard, and an app that audits logins reads exactly those.
138
+ */
139
+ setEmitter(emitter: WardenEmitter): this {
140
+ this.#emitter = emitter;
141
+ return this;
142
+ }
143
+
144
+ /**
145
+ * Publish one auth event. Public so the per-request `Authenticator` can
146
+ * report what it observed — it holds the guard chain, the manager holds the
147
+ * emitter.
148
+ */
149
+ emitAuthEvent(event: string, payload: Record<string, unknown>): void {
150
+ this.#emit(event, payload);
151
+ }
152
+
153
+ /** Fire-and-forget: an event listener must never break authentication. */
154
+ #emit(event: string, payload: Record<string, unknown>): void {
155
+ try {
156
+ void this.#emitter?.emit(event, payload);
157
+ } catch {
158
+ // A throwing listener is the listener's problem, not the login's.
159
+ }
160
+ }
161
+
96
162
  constructor(config: AuthConfig) {
97
163
  // Normalise the two accepted forms — AdonisJS `{ default, guards }` and
98
164
  // the legacy `{ defaultStrategy, strategies }` — to the AdonisJS names.
@@ -316,7 +382,16 @@ export class AuthManager {
316
382
  },
317
383
  );
318
384
  }
385
+ this.#emit("session_auth:login_attempted", {
386
+ guardName: strategyName ?? this.default,
387
+ user,
388
+ });
319
389
  await strategy.login(user, session);
390
+ this.#emit("session_auth:login_succeeded", {
391
+ guardName: strategyName ?? this.default,
392
+ user,
393
+ sessionId: undefined,
394
+ });
320
395
  }
321
396
 
322
397
  /**
@@ -333,6 +408,11 @@ export class AuthManager {
333
408
  );
334
409
  }
335
410
  await strategy.logout(session);
411
+ this.#emit("session_auth:logged_out", {
412
+ guardName: strategyName ?? this.default,
413
+ user: null,
414
+ error: null,
415
+ });
336
416
  }
337
417
  }
338
418
 
@@ -321,6 +321,12 @@ export class Authenticator {
321
321
  this.#attempted = true;
322
322
  const names =
323
323
  guards && guards.length > 0 ? guards : [this.#auth.defaultStrategyName];
324
+ const guardName = names[0] ?? this.#auth.defaultStrategyName;
325
+ // The four authentication events AdonisJS' session guard emits. An app
326
+ // auditing logins subscribes to exactly these names.
327
+ this.#auth.emitAuthEvent("session_auth:authentication_attempted", {
328
+ guardName,
329
+ });
324
330
  const creds = extractCredentials(this.#ctx, this.#auth);
325
331
  const hasSessionStrategy = names.includes("session");
326
332
  const { result, viaGuard, attemptCount, crashCount } =
@@ -332,6 +338,11 @@ export class Authenticator {
332
338
  if (result?.authenticated && result.user) {
333
339
  this.#user = result.user;
334
340
  this.#viaGuard = viaGuard;
341
+ this.#auth.emitAuthEvent("session_auth:authentication_succeeded", {
342
+ guardName: viaGuard ?? guardName,
343
+ user: result.user,
344
+ sessionId: undefined,
345
+ });
335
346
  return;
336
347
  }
337
348
  // Every attempted guard crashed → a server-side incident, not a
@@ -343,10 +354,18 @@ export class Authenticator {
343
354
  { status: 500 },
344
355
  );
345
356
  }
346
- throw new E_UNAUTHORIZED_ACCESS(result?.error ?? "Unauthorized access", {
347
- guardDriverName: names[0] ?? this.#auth.defaultStrategyName,
348
- redirectTo: hasSessionStrategy ? options?.loginRoute : undefined,
357
+ const failure = new E_UNAUTHORIZED_ACCESS(
358
+ result?.error ?? "Unauthorized access",
359
+ {
360
+ guardDriverName: guardName,
361
+ redirectTo: hasSessionStrategy ? options?.loginRoute : undefined,
362
+ },
363
+ );
364
+ this.#auth.emitAuthEvent("session_auth:authentication_failed", {
365
+ guardName,
366
+ error: failure,
349
367
  });
368
+ throw failure;
350
369
  }
351
370
 
352
371
  /** Return the authenticated user or throw `E_UNAUTHORIZED_ACCESS`. */
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Remember-me tokens — "keep me signed in" that survives the session cookie.
3
+ *
4
+ * Modelled on `@adonisjs/auth`'s `RememberMeToken`
5
+ * (modules/session_guard/remember_me_token.ts), because an app moving over
6
+ * carries the same table and the same cookie:
7
+ *
8
+ * value = base64url(identifier) + "." + base64url(secret)
9
+ * stored = sha256(secret), never the secret itself
10
+ *
11
+ * The three properties that make this safe, all taken from upstream:
12
+ *
13
+ * 1. **Only a hash is stored.** A dump of the tokens table does not let anyone
14
+ * log in — the secret exists solely in the user's cookie.
15
+ * 2. **The comparison is constant-time.** Comparing hashes with `===` leaks
16
+ * how many leading bytes matched, which is enough to forge one byte at a
17
+ * time.
18
+ * 3. **A used token is recycled.** Authenticating rotates the secret and
19
+ * invalidates the old one, so a stolen cookie stops working the moment the
20
+ * legitimate user comes back.
21
+ */
22
+
23
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
24
+
25
+ /** Bytes of entropy in a token secret. Adonis defaults to 40. */
26
+ export const DEFAULT_SECRET_LENGTH = 40;
27
+
28
+ /** A token as persisted. The secret is NOT here — only its hash. */
29
+ export interface StoredRememberMeToken {
30
+ /** Unique id of the row; the public half of the token value. */
31
+ identifier: string;
32
+ /** Whose token this is. */
33
+ tokenableId: string | number;
34
+ /** `sha256(secret)`, hex. */
35
+ hash: string;
36
+ createdAt: number;
37
+ updatedAt: number;
38
+ expiresAt: number;
39
+ }
40
+
41
+ /** What a store must implement. Structural: warden owns no database. */
42
+ export interface RememberMeTokenDriver {
43
+ create(token: StoredRememberMeToken): Promise<void>;
44
+ find(identifier: string): Promise<StoredRememberMeToken | null>;
45
+ /** Replace the hash + expiry of an existing row (recycling). */
46
+ update(identifier: string, hash: string, expiresAt: number): Promise<void>;
47
+ delete(identifier: string): Promise<void>;
48
+ deleteAllForUser(userId: string | number): Promise<void>;
49
+ }
50
+
51
+ /** Base64url without padding — the encoding Adonis uses for the token halves. */
52
+ function base64UrlEncode(value: string): string {
53
+ return Buffer.from(value, "utf8").toString("base64url");
54
+ }
55
+
56
+ function base64UrlDecode(value: string): string | null {
57
+ try {
58
+ const decoded = Buffer.from(value, "base64url").toString("utf8");
59
+ // A non-base64url character decodes to something that does not round-trip;
60
+ // reject rather than authenticate against a mangled identifier.
61
+ return Buffer.from(decoded, "utf8").toString("base64url") === value
62
+ ? decoded
63
+ : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** `sha256(secret)` as hex — what gets stored. */
70
+ export function hashSecret(secret: string): string {
71
+ return createHash("sha256").update(secret).digest("hex");
72
+ }
73
+
74
+ /**
75
+ * Compare two hex hashes without leaking where they diverge. Length is checked
76
+ * first because `timingSafeEqual` throws on a mismatch — and the length of a
77
+ * sha256 hex digest is public anyway.
78
+ */
79
+ export function safeCompareHashes(a: string, b: string): boolean {
80
+ const left = Buffer.from(a, "utf8");
81
+ const right = Buffer.from(b, "utf8");
82
+ if (left.length !== right.length) return false;
83
+ return timingSafeEqual(left, right);
84
+ }
85
+
86
+ /** Split a cookie value into its identifier and secret, or `null` if malformed. */
87
+ export function decodeTokenValue(
88
+ value: unknown,
89
+ ): { identifier: string; secret: string } | null {
90
+ if (typeof value !== "string" || value.length === 0) return null;
91
+ const [encodedIdentifier, ...rest] = value.split(".");
92
+ if (!encodedIdentifier || rest.length === 0) return null;
93
+ const identifier = base64UrlDecode(encodedIdentifier);
94
+ const secret = base64UrlDecode(rest.join("."));
95
+ if (identifier === null || secret === null) return null;
96
+ if (identifier === "" || secret === "") return null;
97
+ return { identifier, secret };
98
+ }
99
+
100
+ /** Build the cookie value from its two halves. */
101
+ export function encodeTokenValue(identifier: string, secret: string): string {
102
+ return `${base64UrlEncode(identifier)}.${base64UrlEncode(secret)}`;
103
+ }
104
+
105
+ /** A freshly minted token: what to store, and what to hand the browser. */
106
+ export interface MintedRememberMeToken {
107
+ stored: StoredRememberMeToken;
108
+ /** Goes in the cookie. Never persisted. */
109
+ value: string;
110
+ }
111
+
112
+ /**
113
+ * Mint a token for `userId`, valid for `ageSeconds`.
114
+ *
115
+ * `identifier` is random too, not a counter: a guessable identifier would let
116
+ * an attacker enumerate rows and target a specific user's token.
117
+ */
118
+ export function mintRememberMeToken(
119
+ userId: string | number,
120
+ ageSeconds: number,
121
+ secretLength = DEFAULT_SECRET_LENGTH,
122
+ ): MintedRememberMeToken {
123
+ const identifier = randomBytes(16).toString("hex");
124
+ const secret = randomBytes(secretLength).toString("base64url");
125
+ const now = Date.now();
126
+ return {
127
+ stored: {
128
+ identifier,
129
+ tokenableId: userId,
130
+ hash: hashSecret(secret),
131
+ createdAt: now,
132
+ updatedAt: now,
133
+ expiresAt: now + ageSeconds * 1000,
134
+ },
135
+ value: encodeTokenValue(identifier, secret),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Verify a cookie value against the store, and RECYCLE the token on success:
141
+ * the row keeps its identifier but gets a fresh secret and expiry, and the
142
+ * caller must send the returned value back as the new cookie.
143
+ *
144
+ * Recycling is what limits the damage of a stolen cookie — the thief's copy
145
+ * dies as soon as the real user authenticates again.
146
+ *
147
+ * Returns `null` for anything wrong: malformed value, unknown identifier,
148
+ * expired row, or a secret that does not match. The caller cannot tell these
149
+ * apart, which is deliberate — a distinguishable "unknown identifier" tells an
150
+ * attacker which identifiers exist.
151
+ */
152
+ export async function verifyAndRecycleRememberMeToken(
153
+ driver: RememberMeTokenDriver,
154
+ cookieValue: unknown,
155
+ ageSeconds: number,
156
+ secretLength = DEFAULT_SECRET_LENGTH,
157
+ ): Promise<{ userId: string | number; value: string } | null> {
158
+ const decoded = decodeTokenValue(cookieValue);
159
+ if (!decoded) return null;
160
+
161
+ const stored = await driver.find(decoded.identifier);
162
+ if (!stored) return null;
163
+
164
+ if (stored.expiresAt <= Date.now()) {
165
+ // Expired rows are removed on sight rather than left to a cleanup job.
166
+ await driver.delete(stored.identifier);
167
+ return null;
168
+ }
169
+
170
+ if (!safeCompareHashes(stored.hash, hashSecret(decoded.secret))) {
171
+ return null;
172
+ }
173
+
174
+ const secret = randomBytes(secretLength).toString("base64url");
175
+ const expiresAt = Date.now() + ageSeconds * 1000;
176
+ await driver.update(stored.identifier, hashSecret(secret), expiresAt);
177
+
178
+ return {
179
+ userId: stored.tokenableId,
180
+ value: encodeTokenValue(stored.identifier, secret),
181
+ };
182
+ }
183
+
184
+ /** In-memory store — for tests and single-process apps. Not for a cluster. */
185
+ export class MemoryRememberMeTokenDriver implements RememberMeTokenDriver {
186
+ readonly #rows = new Map<string, StoredRememberMeToken>();
187
+
188
+ async create(token: StoredRememberMeToken): Promise<void> {
189
+ this.#rows.set(token.identifier, { ...token });
190
+ }
191
+
192
+ async find(identifier: string): Promise<StoredRememberMeToken | null> {
193
+ const row = this.#rows.get(identifier);
194
+ return row ? { ...row } : null;
195
+ }
196
+
197
+ async update(
198
+ identifier: string,
199
+ hash: string,
200
+ expiresAt: number,
201
+ ): Promise<void> {
202
+ const row = this.#rows.get(identifier);
203
+ if (!row) return;
204
+ this.#rows.set(identifier, {
205
+ ...row,
206
+ hash,
207
+ expiresAt,
208
+ updatedAt: Date.now(),
209
+ });
210
+ }
211
+
212
+ async delete(identifier: string): Promise<void> {
213
+ this.#rows.delete(identifier);
214
+ }
215
+
216
+ async deleteAllForUser(userId: string | number): Promise<void> {
217
+ for (const [identifier, row] of this.#rows) {
218
+ if (String(row.tokenableId) === String(userId)) {
219
+ this.#rows.delete(identifier);
220
+ }
221
+ }
222
+ }
223
+ }
@@ -4,17 +4,23 @@
4
4
  * takes the whole app down.
5
5
  *
6
6
  * Policy (fail-open on reads):
7
- * - `add()` — if the primary throws, the revocation is recorded in the
8
- * fallback so logout stays effective on this instance.
9
- * - `has()` — if the primary throws, consult the fallback: a token revoked
10
- * during the outage is still caught here; anything else is
11
- * allowed through (fail-open). Access tokens are short-lived, so
12
- * the window a missed revocation survives is bounded by the token
13
- * TTL — the blacklist is defense-in-depth above expiry, not the
7
+ * - `add()` — records the revocation in BOTH, always. The fallback is this
8
+ * instance's own record of what it has revoked, not a log of its
9
+ * outages: writing only on failure meant a token revoked while
10
+ * the primary was HEALTHY was absent from the fallback, so the
11
+ * first check during a later outage let that logged-out token
12
+ * through. A primary failure still does not fail the logout.
13
+ * - `has()` asks the fallback FIRST: what this instance revoked is known
14
+ * locally and needs no round trip. Otherwise it asks the
15
+ * primary, and if that throws, allows the token through
16
+ * (fail-open). Access tokens are short-lived, so the window a
17
+ * revocation missed by BOTH survives is bounded by the token TTL
18
+ * — the blacklist is defense-in-depth above expiry, not the
14
19
  * primary boundary.
15
20
  *
16
- * Every degradation invokes `onDegrade` (defaults to a stderr warning) so the
17
- * outage is observable.
21
+ * Every degradation invokes `onDegrade`. The default reports the first failure
22
+ * per operation and then a periodic count: an outage that logs on every request
23
+ * buries itself, and the reader stops seeing what the flood is about.
18
24
  */
19
25
 
20
26
  import {
@@ -32,11 +38,36 @@ export interface ResilientBlacklistConfig {
32
38
  onDegrade?: (event: BlacklistDegradeEvent) => void;
33
39
  }
34
40
 
35
- function defaultOnDegrade({ op, error }: BlacklistDegradeEvent): void {
36
- console.warn(
37
- `[warden] blacklist primary failed on ${op}; using in-memory fallback:`,
38
- error,
39
- );
41
+ /** How long a repeat of the same failing operation stays folded into a count. */
42
+ const DEGRADE_LOG_WINDOW_MS = 60_000;
43
+
44
+ /**
45
+ * Report the first failure of each operation, then one line per window with
46
+ * how many were suppressed.
47
+ *
48
+ * Keyed by operation — a fixed, three-value set — never by the error text,
49
+ * which an attacker could influence and which would make the key set unbounded.
50
+ */
51
+ function makeDefaultOnDegrade(): (event: BlacklistDegradeEvent) => void {
52
+ const windows = new Map<string, { until: number; suppressed: number }>();
53
+ return ({ op, error }: BlacklistDegradeEvent): void => {
54
+ const now = Date.now();
55
+ const open = windows.get(op);
56
+ if (open === undefined || open.until <= now) {
57
+ if (open !== undefined && open.suppressed > 0) {
58
+ console.warn(
59
+ `[warden] blacklist primary failed on ${op} ${open.suppressed} more time(s) in the last minute.`,
60
+ );
61
+ }
62
+ windows.set(op, { until: now + DEGRADE_LOG_WINDOW_MS, suppressed: 0 });
63
+ console.warn(
64
+ `[warden] blacklist primary failed on ${op}; using in-memory fallback:`,
65
+ error,
66
+ );
67
+ return;
68
+ }
69
+ open.suppressed++;
70
+ };
40
71
  }
41
72
 
42
73
  export class ResilientBlacklistDriver implements BlacklistDriver {
@@ -51,25 +82,34 @@ export class ResilientBlacklistDriver implements BlacklistDriver {
51
82
  ) {
52
83
  this.#primary = primary;
53
84
  this.#fallback = fallback;
54
- this.#onDegrade = config.onDegrade ?? defaultOnDegrade;
85
+ this.#onDegrade = config.onDegrade ?? makeDefaultOnDegrade();
55
86
  }
56
87
 
57
88
  async add(jti: string, expiresAt: number): Promise<void> {
89
+ // The fallback first, and unconditionally: it is what this instance
90
+ // knows it revoked. Recording only on failure left every revocation made
91
+ // while the primary was healthy invisible to the fallback — and those are
92
+ // most of them.
93
+ await this.#fallback.add(jti, expiresAt);
58
94
  try {
59
95
  await this.#primary.add(jti, expiresAt);
60
96
  } catch (error) {
97
+ // The revocation is already durable enough for this instance, so a
98
+ // primary failure must not fail the logout.
61
99
  this.#onDegrade({ op: "add", error });
62
- await this.#fallback.add(jti, expiresAt);
63
100
  }
64
101
  }
65
102
 
66
103
  async has(jti: string): Promise<boolean> {
104
+ // What this instance revoked is known locally — no round trip needed, and
105
+ // it stays correct through an outage that starts a moment later.
106
+ if (await this.#fallback.has(jti)) return true;
67
107
  try {
68
108
  return await this.#primary.has(jti);
69
109
  } catch (error) {
70
110
  this.#onDegrade({ op: "has", error });
71
- // Fail-open: honour in-outage revocations on this instance, allow rest.
72
- return this.#fallback.has(jti);
111
+ // Fail-open on what neither knows: access tokens are short-lived.
112
+ return false;
73
113
  }
74
114
  }
75
115