@c9up/warden 0.1.17 → 0.1.19

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 (124) hide show
  1. package/dist/AuthManager.d.ts +86 -1
  2. package/dist/AuthManager.d.ts.map +1 -1
  3. package/dist/AuthManager.js +113 -10
  4. package/dist/AuthManager.js.map +1 -1
  5. package/dist/Authenticator.d.ts +13 -1
  6. package/dist/Authenticator.d.ts.map +1 -1
  7. package/dist/Authenticator.js +37 -3
  8. package/dist/Authenticator.js.map +1 -1
  9. package/dist/RememberMeToken.d.ts +169 -0
  10. package/dist/RememberMeToken.d.ts.map +1 -0
  11. package/dist/RememberMeToken.js +269 -0
  12. package/dist/RememberMeToken.js.map +1 -0
  13. package/dist/ResilientBlacklistDriver.d.ts +15 -9
  14. package/dist/ResilientBlacklistDriver.d.ts.map +1 -1
  15. package/dist/ResilientBlacklistDriver.js +53 -15
  16. package/dist/ResilientBlacklistDriver.js.map +1 -1
  17. package/dist/Secret.d.ts +11 -0
  18. package/dist/Secret.d.ts.map +1 -0
  19. package/dist/Secret.js +36 -0
  20. package/dist/Secret.js.map +1 -0
  21. package/dist/WardenProvider.d.ts +1 -0
  22. package/dist/WardenProvider.d.ts.map +1 -1
  23. package/dist/WardenProvider.js +64 -0
  24. package/dist/WardenProvider.js.map +1 -1
  25. package/dist/bouncer/Bouncer.d.ts +46 -1
  26. package/dist/bouncer/Bouncer.d.ts.map +1 -1
  27. package/dist/bouncer/Bouncer.js +77 -4
  28. package/dist/bouncer/Bouncer.js.map +1 -1
  29. package/dist/bouncer/PolicyAuthorizer.d.ts +9 -1
  30. package/dist/bouncer/PolicyAuthorizer.d.ts.map +1 -1
  31. package/dist/bouncer/PolicyAuthorizer.js +17 -1
  32. package/dist/bouncer/PolicyAuthorizer.js.map +1 -1
  33. package/dist/bouncer/evaluate.d.ts +14 -2
  34. package/dist/bouncer/evaluate.d.ts.map +1 -1
  35. package/dist/bouncer/evaluate.js +16 -5
  36. package/dist/bouncer/evaluate.js.map +1 -1
  37. package/dist/bouncer/types.d.ts +4 -2
  38. package/dist/bouncer/types.d.ts.map +1 -1
  39. package/dist/config.d.ts +3 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/config.js +5 -0
  42. package/dist/config.js.map +1 -1
  43. package/dist/firstcontact/drivers/GitHubDriver.d.ts.map +1 -1
  44. package/dist/firstcontact/drivers/GitHubDriver.js +2 -3
  45. package/dist/firstcontact/drivers/GitHubDriver.js.map +1 -1
  46. package/dist/firstcontact/drivers/GoogleDriver.d.ts.map +1 -1
  47. package/dist/firstcontact/drivers/GoogleDriver.js +2 -3
  48. package/dist/firstcontact/drivers/GoogleDriver.js.map +1 -1
  49. package/dist/firstcontact/types.d.ts +10 -0
  50. package/dist/firstcontact/types.d.ts.map +1 -1
  51. package/dist/firstcontact/types.js +18 -1
  52. package/dist/firstcontact/types.js.map +1 -1
  53. package/dist/index.d.ts +8 -4
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +6 -3
  56. package/dist/index.js.map +1 -1
  57. package/dist/mfa/MfaManager.d.ts.map +1 -1
  58. package/dist/mfa/MfaManager.js +14 -5
  59. package/dist/mfa/MfaManager.js.map +1 -1
  60. package/dist/mfa/TotpProvider.d.ts +34 -1
  61. package/dist/mfa/TotpProvider.d.ts.map +1 -1
  62. package/dist/mfa/TotpProvider.js +60 -4
  63. package/dist/mfa/TotpProvider.js.map +1 -1
  64. package/dist/mfa/webauthn-codec.d.ts +6 -1
  65. package/dist/mfa/webauthn-codec.d.ts.map +1 -1
  66. package/dist/mfa/webauthn-codec.js +50 -6
  67. package/dist/mfa/webauthn-codec.js.map +1 -1
  68. package/dist/middleware.d.ts.map +1 -1
  69. package/dist/middleware.js +46 -1
  70. package/dist/middleware.js.map +1 -1
  71. package/dist/quasar.d.ts.map +1 -1
  72. package/dist/quasar.js +6 -14
  73. package/dist/quasar.js.map +1 -1
  74. package/dist/sanitize.d.ts +10 -0
  75. package/dist/sanitize.d.ts.map +1 -0
  76. package/dist/sanitize.js +15 -0
  77. package/dist/sanitize.js.map +1 -0
  78. package/dist/strategies/ApiKeyStrategy.d.ts +10 -1
  79. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
  80. package/dist/strategies/ApiKeyStrategy.js +16 -0
  81. package/dist/strategies/ApiKeyStrategy.js.map +1 -1
  82. package/dist/strategies/BasicAuthStrategy.d.ts +59 -0
  83. package/dist/strategies/BasicAuthStrategy.d.ts.map +1 -0
  84. package/dist/strategies/BasicAuthStrategy.js +97 -0
  85. package/dist/strategies/BasicAuthStrategy.js.map +1 -0
  86. package/dist/strategies/JwtStrategy.d.ts +10 -1
  87. package/dist/strategies/JwtStrategy.d.ts.map +1 -1
  88. package/dist/strategies/JwtStrategy.js +11 -0
  89. package/dist/strategies/JwtStrategy.js.map +1 -1
  90. package/dist/strategies/SessionStrategy.d.ts +84 -2
  91. package/dist/strategies/SessionStrategy.d.ts.map +1 -1
  92. package/dist/strategies/SessionStrategy.js +124 -1
  93. package/dist/strategies/SessionStrategy.js.map +1 -1
  94. package/index.darwin-arm64.node +0 -0
  95. package/index.darwin-x64.node +0 -0
  96. package/index.linux-arm64-gnu.node +0 -0
  97. package/index.linux-x64-gnu.node +0 -0
  98. package/index.win32-x64-msvc.node +0 -0
  99. package/package.json +4 -2
  100. package/src/AuthManager.ts +163 -7
  101. package/src/Authenticator.ts +58 -10
  102. package/src/RememberMeToken.ts +357 -0
  103. package/src/ResilientBlacklistDriver.ts +58 -18
  104. package/src/Secret.ts +41 -0
  105. package/src/WardenProvider.ts +90 -0
  106. package/src/bouncer/Bouncer.ts +93 -4
  107. package/src/bouncer/PolicyAuthorizer.ts +20 -1
  108. package/src/bouncer/evaluate.ts +26 -6
  109. package/src/bouncer/types.ts +4 -2
  110. package/src/config.ts +9 -0
  111. package/src/firstcontact/drivers/GitHubDriver.ts +2 -3
  112. package/src/firstcontact/drivers/GoogleDriver.ts +2 -3
  113. package/src/firstcontact/types.ts +24 -0
  114. package/src/index.ts +30 -2
  115. package/src/mfa/MfaManager.ts +15 -8
  116. package/src/mfa/TotpProvider.ts +90 -6
  117. package/src/mfa/webauthn-codec.ts +48 -6
  118. package/src/middleware.ts +55 -2
  119. package/src/quasar.ts +6 -14
  120. package/src/sanitize.ts +17 -0
  121. package/src/strategies/ApiKeyStrategy.ts +23 -1
  122. package/src/strategies/BasicAuthStrategy.ts +127 -0
  123. package/src/strategies/JwtStrategy.ts +18 -1
  124. package/src/strategies/SessionStrategy.ts +167 -2
@@ -25,6 +25,57 @@ export interface TotpConfig {
25
25
  * tolerate clock skew. Default `1` (i.e. ±30s with the default period).
26
26
  */
27
27
  window?: number;
28
+ /**
29
+ * Remembers codes already accepted, so one cannot be used twice
30
+ * (RFC 6238 §5.2). Defaults to an in-process guard, which is correct for a
31
+ * single instance; inject a shared store (Redis, the session table) when the
32
+ * app runs on several.
33
+ *
34
+ * Omit deliberately — `replayGuard: null` — to accept replays, e.g. when an
35
+ * outer layer already enforces single use.
36
+ */
37
+ replayGuard?: TotpReplayGuard | null;
38
+ }
39
+
40
+ /**
41
+ * Remembers accepted (secret, time-step) pairs for as long as they could still
42
+ * be replayed.
43
+ *
44
+ * Keys are opaque digests, never the secret itself: a store may be shared, and
45
+ * a guard that leaks the seed is worse than the replay it prevents.
46
+ */
47
+ export interface TotpReplayGuard {
48
+ /** True when this exact code has already been accepted. */
49
+ used(key: string): boolean | Promise<boolean>;
50
+ /** Record it as used; `ttlMs` is how long it could still be replayed. */
51
+ remember(key: string, ttlMs: number): void | Promise<void>;
52
+ }
53
+
54
+ /**
55
+ * In-process replay guard. Entries expire on their own, and a sweep runs
56
+ * whenever one is added, so the set stays bounded by the accept window rather
57
+ * than by how many codes have been tried.
58
+ */
59
+ export class MemoryTotpReplayGuard implements TotpReplayGuard {
60
+ readonly #seen = new Map<string, number>();
61
+
62
+ used(key: string): boolean {
63
+ const expiresAt = this.#seen.get(key);
64
+ if (expiresAt === undefined) return false;
65
+ if (expiresAt <= Date.now()) {
66
+ this.#seen.delete(key);
67
+ return false;
68
+ }
69
+ return true;
70
+ }
71
+
72
+ remember(key: string, ttlMs: number): void {
73
+ const now = Date.now();
74
+ for (const [seenKey, expiresAt] of this.#seen) {
75
+ if (expiresAt <= now) this.#seen.delete(seenKey);
76
+ }
77
+ this.#seen.set(key, now + ttlMs);
78
+ }
28
79
  }
29
80
 
30
81
  export interface TotpEnrollment {
@@ -43,10 +94,18 @@ const DEFAULTS = {
43
94
 
44
95
  export class TotpProvider {
45
96
  readonly kind = "totp" as const;
46
- readonly #cfg: Required<TotpConfig>;
97
+ readonly #cfg: Required<Omit<TotpConfig, "replayGuard">>;
98
+ readonly #replayGuard: TotpReplayGuard | null;
47
99
 
48
100
  constructor(config: TotpConfig = {}) {
49
- this.#cfg = { ...DEFAULTS, ...config };
101
+ const { replayGuard, ...rest } = config;
102
+ this.#cfg = { ...DEFAULTS, ...rest };
103
+ // Defaults ON: a code that works twice is the failure this exists to
104
+ // prevent, so opting out has to be deliberate.
105
+ this.#replayGuard =
106
+ replayGuard === null
107
+ ? null
108
+ : (replayGuard ?? new MemoryTotpReplayGuard());
50
109
  if (this.#cfg.digits < 6 || this.#cfg.digits > 8) {
51
110
  throw new WardenError(
52
111
  "INVALID_CONFIG",
@@ -91,7 +150,11 @@ export class TotpProvider {
91
150
  * Verify a user-supplied code against the secret, accepting codes from the
92
151
  * surrounding `window` time steps to tolerate clock skew. Constant-time.
93
152
  */
94
- verify(secret: string, code: string, atMs: number = Date.now()): boolean {
153
+ async verify(
154
+ secret: string,
155
+ code: string,
156
+ atMs: number = Date.now(),
157
+ ): Promise<boolean> {
95
158
  const normalized = code.replace(/\s/g, "");
96
159
  if (normalized.length !== this.#cfg.digits) {
97
160
  return false;
@@ -99,13 +162,34 @@ export class TotpProvider {
99
162
  const key = base32Decode(secret);
100
163
  const current = Math.floor(atMs / 1000 / this.#cfg.period);
101
164
  for (let offset = -this.#cfg.window; offset <= this.#cfg.window; offset++) {
102
- if (constantTimeEqual(this.#hotp(key, current + offset), normalized)) {
103
- return true;
104
- }
165
+ const counter = current + offset;
166
+ if (!constantTimeEqual(this.#hotp(key, counter), normalized)) continue;
167
+ if (this.#replayGuard === null) return true;
168
+ // A matching code is only accepted ONCE. Without this it stays valid
169
+ // for the whole window — with the default settings, ~90 seconds in
170
+ // which a shoulder-surfed or intercepted code still works.
171
+ const seenKey = this.#replayKey(secret, counter);
172
+ if (await this.#replayGuard.used(seenKey)) return false;
173
+ // Remember it for as long as it could still be replayed: until the
174
+ // last step that would accept it has passed.
175
+ const ttlMs =
176
+ (counter + this.#cfg.window + 1) * this.#cfg.period * 1000 - atMs;
177
+ await this.#replayGuard.remember(seenKey, Math.max(ttlMs, 0));
178
+ return true;
105
179
  }
106
180
  return false;
107
181
  }
108
182
 
183
+ /**
184
+ * An opaque key for one (secret, step) pair. Hashed, so a shared store never
185
+ * holds the seed, and bounded in length whatever the secret looks like.
186
+ */
187
+ #replayKey(secret: string, counter: number): string {
188
+ return createHmac("sha256", secret)
189
+ .update(`totp:${counter}`)
190
+ .digest("base64url");
191
+ }
192
+
109
193
  /** RFC 4226 HOTP: HMAC over the 8-byte counter, dynamically truncated. */
110
194
  #hotp(key: Uint8Array, counter: number): string {
111
195
  const buf = Buffer.alloc(8);
@@ -48,11 +48,31 @@ interface Decoded {
48
48
  next: number;
49
49
  }
50
50
 
51
+ /**
52
+ * How deep a nested item may go.
53
+ *
54
+ * Every item here is attacker-supplied: a registration payload is whatever the
55
+ * browser posted. Without a limit, a run of tag bytes recurses once per byte
56
+ * and overflows the stack — 200 KB was enough to kill the request.
57
+ */
58
+ const MAX_DEPTH = 32;
59
+
51
60
  /**
52
61
  * Decode a single CBOR item starting at `start`. Definite-length only —
53
62
  * indefinite-length items and floats are rejected (WebAuthn uses neither).
63
+ *
64
+ * Every declared length is checked against the bytes actually left. A CBOR
65
+ * header can claim up to 2^64 items in five bytes; believing it meant looping
66
+ * billions of times over a buffer that ended long ago, which cost seconds of
67
+ * blocked event loop per request.
54
68
  */
55
- export function decodeCbor(buf: Buffer, start = 0): Decoded {
69
+ export function decodeCbor(buf: Buffer, start = 0, depth = 0): Decoded {
70
+ if (depth > MAX_DEPTH) {
71
+ throw new Error(`CBOR: nesting deeper than ${MAX_DEPTH} is not supported`);
72
+ }
73
+ if (start < 0 || start >= buf.length) {
74
+ throw new Error("CBOR: item starts past the end of the buffer");
75
+ }
56
76
  const major = buf[start] >> 5;
57
77
  const info = buf[start] & 0x1f;
58
78
  let len: number;
@@ -60,36 +80,56 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
60
80
  if (info < 24) {
61
81
  len = info;
62
82
  } else if (info === 24) {
83
+ if (p >= buf.length) throw new Error("CBOR: truncated length");
63
84
  len = buf[p];
64
85
  p += 1;
65
86
  } else if (info === 25) {
87
+ if (p + 2 > buf.length) throw new Error("CBOR: truncated length");
66
88
  len = buf.readUInt16BE(p);
67
89
  p += 2;
68
90
  } else if (info === 26) {
91
+ if (p + 4 > buf.length) throw new Error("CBOR: truncated length");
69
92
  len = buf.readUInt32BE(p);
70
93
  p += 4;
71
94
  } else if (info === 27) {
72
- len = Number(buf.readBigUInt64BE(p));
95
+ if (p + 8 > buf.length) throw new Error("CBOR: truncated length");
96
+ const wide = buf.readBigUInt64BE(p);
97
+ if (wide > BigInt(Number.MAX_SAFE_INTEGER)) {
98
+ throw new Error("CBOR: length exceeds the safe integer range");
99
+ }
100
+ len = Number(wide);
73
101
  p += 8;
74
102
  } else {
75
103
  throw new Error("CBOR: indefinite or reserved length is not supported");
76
104
  }
77
105
 
106
+ /** Reject a declared count the remaining bytes cannot possibly hold. */
107
+ const fits = (perItem: number): void => {
108
+ if (len > (buf.length - p) / perItem) {
109
+ throw new Error("CBOR: declared length exceeds the remaining bytes");
110
+ }
111
+ };
112
+
78
113
  switch (major) {
79
114
  case 0: // unsigned int
80
115
  return { value: len, next: p };
81
116
  case 1: // negative int
82
117
  return { value: -1 - len, next: p };
83
118
  case 2: // byte string
119
+ fits(1);
84
120
  return { value: buf.subarray(p, p + len), next: p + len };
85
121
  case 3: // text string
122
+ fits(1);
86
123
  return { value: buf.toString("utf8", p, p + len), next: p + len };
87
124
  case 4: {
88
125
  // array
126
+ // One item is one byte at minimum, so a count above the bytes left
127
+ // cannot be honest.
128
+ fits(1);
89
129
  const arr: CborValue[] = [];
90
130
  let cur = p;
91
131
  for (let i = 0; i < len; i++) {
92
- const d = decodeCbor(buf, cur);
132
+ const d = decodeCbor(buf, cur, depth + 1);
93
133
  arr.push(d.value);
94
134
  cur = d.next;
95
135
  }
@@ -97,12 +137,14 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
97
137
  }
98
138
  case 5: {
99
139
  // map
140
+ // A pair is two bytes at minimum.
141
+ fits(2);
100
142
  const map: CborMap = new Map();
101
143
  let cur = p;
102
144
  for (let i = 0; i < len; i++) {
103
- const k = decodeCbor(buf, cur);
145
+ const k = decodeCbor(buf, cur, depth + 1);
104
146
  cur = k.next;
105
- const v = decodeCbor(buf, cur);
147
+ const v = decodeCbor(buf, cur, depth + 1);
106
148
  cur = v.next;
107
149
  if (typeof k.value !== "number" && typeof k.value !== "string") {
108
150
  throw new Error("CBOR: only integer/text map keys are supported");
@@ -113,7 +155,7 @@ export function decodeCbor(buf: Buffer, start = 0): Decoded {
113
155
  }
114
156
  case 6: {
115
157
  // tag — decode and surface the tagged content
116
- const d = decodeCbor(buf, p);
158
+ const d = decodeCbor(buf, p, depth + 1);
117
159
  return { value: d.value, next: d.next };
118
160
  }
119
161
  case 7:
package/src/middleware.ts CHANGED
@@ -20,7 +20,7 @@ import { Authenticator } from "./Authenticator.js";
20
20
  import { AuthManager, type UserPayload } from "./AuthManager.js";
21
21
  import type { BasePolicy } from "./bouncer/BasePolicy.js";
22
22
  import { Bouncer } from "./bouncer/Bouncer.js";
23
- import type { Ability } from "./bouncer/types.js";
23
+ import type { Ability, PolicyContainerResolver } from "./bouncer/types.js";
24
24
  import type { ScopeRequestContext } from "./config.js";
25
25
  import { E_UNAUTHORIZED_ACCESS, WardenError } from "./errors.js";
26
26
  import {
@@ -56,6 +56,33 @@ interface ContainerResolver {
56
56
  make(token: ResolvableToken): Promise<unknown>;
57
57
  }
58
58
 
59
+ /**
60
+ * Adapt the request resolver to the shape the Bouncer needs for policy DI.
61
+ *
62
+ * The context resolver is deliberately untyped (`Promise<unknown>`) — it
63
+ * resolves string tokens too. `instanceof` is what proves the instance is the
64
+ * policy that was asked for: a real check rather than an assertion, and it
65
+ * catches a host resolver that hands back something else instead of letting a
66
+ * wrong object reach the policy's methods.
67
+ */
68
+ function policyResolver(
69
+ resolver: ContainerResolver | undefined,
70
+ ): PolicyContainerResolver | undefined {
71
+ if (resolver === undefined) return undefined;
72
+ return {
73
+ async make<T>(ctor: new (...args: never[]) => T): Promise<T> {
74
+ const instance = await resolver.make(ctor);
75
+ if (!(instance instanceof ctor)) {
76
+ throw new WardenError(
77
+ "POLICY_RESOLUTION_FAILED",
78
+ `The container resolved "${ctor.name}" to something else — a policy must be an instance of the class that was requested.`,
79
+ );
80
+ }
81
+ return instance;
82
+ },
83
+ };
84
+ }
85
+
59
86
  /**
60
87
  * Agnostic authorization slot — structurally Ream's `Authorizer` interface (and
61
88
  * Adonis's bouncer contract): `allows` / `denies` / `authorize`. Typed as the
@@ -317,6 +344,19 @@ export async function silentAuth(ctx: WardenContext, next: WardenNext) {
317
344
  await next();
318
345
  }
319
346
 
347
+ /**
348
+ * Merge values into the request's view state, when the app has a template
349
+ * layer. A no-op outside an HTTP render, or in an app with no views.
350
+ */
351
+ function shareWithView(
352
+ ctx: WardenContext,
353
+ values: Record<string, unknown>,
354
+ ): void {
355
+ const view = Reflect.get(Object(ctx), "view");
356
+ const share = Reflect.get(Object(view), "share");
357
+ if (typeof share === "function") share.call(view, values);
358
+ }
359
+
320
360
  /**
321
361
  * Reuse the request's Authenticator if one is already attached (e.g. `silentAuth`
322
362
  * ran first), else build and attach a fresh one. Keeps `ctx.auth` a single
@@ -329,6 +369,10 @@ function ensureAuthenticator(
329
369
  if (ctx.auth instanceof Authenticator) return ctx.auth;
330
370
  const authenticator = new Authenticator(ctx, auth);
331
371
  ctx.auth = authenticator;
372
+ // Share it with the request's view, as AdonisJS's auth does, so a migrated
373
+ // template reads `{{ auth.user.email }}` / `@if(auth.isAuthenticated)`
374
+ // unchanged. Both middlewares funnel through here, so one share covers them.
375
+ shareWithView(ctx, { auth: authenticator });
332
376
  return authenticator;
333
377
  }
334
378
 
@@ -405,10 +449,19 @@ export async function initializeBouncer(ctx: WardenContext, next: WardenNext) {
405
449
  ? await registry.resolveScope(toScopeContext(ctx))
406
450
  : "global";
407
451
 
408
- ctx.bouncer = new Bouncer(user, registry?.abilities, registry?.policies, {
452
+ const bouncer = new Bouncer(user, registry?.abilities, registry?.policies, {
409
453
  scope,
410
454
  resolver,
455
+ // The REQUEST's resolver, so a policy taking constructor dependencies
456
+ // gets them — and gets this request's, not the application container's.
457
+ // It was never passed, so `@inject()` on a policy silently resolved to a
458
+ // plain `new Policy()` with no dependencies at all.
459
+ containerResolver: policyResolver(ctx.containerResolver),
411
460
  });
461
+ ctx.bouncer = bouncer;
462
+ // Share the checks with the request's view so `@can(...)` resolves them,
463
+ // as AdonisJS's own middleware does.
464
+ shareWithView(ctx, bouncer.templateHelpers);
412
465
  await next();
413
466
  }
414
467
 
package/src/quasar.ts CHANGED
@@ -27,20 +27,12 @@ function isConnectionSource(value: unknown): value is ConnectionSource {
27
27
 
28
28
  function isRedisLikeClient(value: unknown): value is RedisLikeClient {
29
29
  if (typeof value !== "object" || value === null) return false;
30
- // The commands this driver actually issues. A connection missing one of
31
- // them would fail on the first revocation, far from the cause.
32
- const required = [
33
- "get",
34
- "set",
35
- "del",
36
- "exists",
37
- "keys",
38
- "sadd",
39
- "srem",
40
- "smembers",
41
- "expire",
42
- "ttl",
43
- ];
30
+ // The commands this driver actually issues the whole of `RedisLikeClient`.
31
+ // A connection missing one would fail on the first revocation, far from the
32
+ // cause. Nothing more: the driver documents itself as needing only `set`
33
+ // with an "EX" ttl and `exists`, so demanding sets or key scans here would
34
+ // reject a client that works perfectly well.
35
+ const required = ["set", "exists"];
44
36
  return required.every(
45
37
  (name) => typeof Reflect.get(value, name) === "function",
46
38
  );
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Strip the prototype-pollution keys from a user payload.
3
+ *
4
+ * Lives in its own module so `AuthManager` and `Authenticator` do not have to
5
+ * import each other's values: the manager builds an authenticator, and the
6
+ * authenticator sanitises — putting both in one file made a runtime cycle.
7
+ */
8
+
9
+ import type { UserPayload } from "./AuthManager.js";
10
+
11
+ export function sanitizePayload(user: UserPayload): void {
12
+ for (const key of ["__proto__", "constructor", "prototype"]) {
13
+ if (key in user) {
14
+ delete (user as Record<string, unknown>)[key];
15
+ }
16
+ }
17
+ }
@@ -6,7 +6,12 @@
6
6
  * @implements MISS-8
7
7
  */
8
8
 
9
- import type { AuthResult, AuthStrategy, UserPayload } from "../AuthManager.js";
9
+ import type {
10
+ AuthClientResponse,
11
+ AuthResult,
12
+ AuthStrategy,
13
+ UserPayload,
14
+ } from "../AuthManager.js";
10
15
 
11
16
  export interface ApiKeyConfig {
12
17
  headerName?: string;
@@ -42,6 +47,23 @@ export class ApiKeyStrategy implements AuthStrategy {
42
47
  );
43
48
  }
44
49
 
50
+ /**
51
+ * The header a test client sends to present `key` (AdonisJS
52
+ * `authenticateAsClient`).
53
+ *
54
+ * Takes the KEY, not a user: an API key is issued out of band, so a test
55
+ * seeds one through `findByKey` and hands that same key here — inventing a
56
+ * key the resolver has never seen would prove nothing.
57
+ */
58
+ authenticateAsClient(key: string): AuthClientResponse {
59
+ return {
60
+ headers: {
61
+ authorization: `Bearer ${key}`,
62
+ [this.#headerName]: key,
63
+ },
64
+ };
65
+ }
66
+
45
67
  async verify(token: string): Promise<AuthResult> {
46
68
  const result = await this.#config.findByKey(token);
47
69
  if (!result) return { authenticated: false, error: "Invalid API key" };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * HTTP Basic authentication (AdonisJS `basic_auth` guard).
3
+ *
4
+ * The browser prompts for credentials and resends them on every request; there
5
+ * is no session and nothing to log out of. That makes it the right fit for a
6
+ * machine-to-machine endpoint or an internal tool behind TLS, and the wrong fit
7
+ * for a user-facing login — the credentials travel on EVERY request, so without
8
+ * TLS they are exposed on every one of them.
9
+ */
10
+
11
+ import { timingSafeEqual } from "node:crypto";
12
+ import type {
13
+ AuthClientResponse,
14
+ AuthResult,
15
+ AuthStrategy,
16
+ UserPayload,
17
+ } from "../AuthManager.js";
18
+
19
+ export interface BasicAuthConfig {
20
+ /**
21
+ * Resolve a user by the username half of the header, and say whether the
22
+ * password matches. Returning null is an unknown user.
23
+ */
24
+ verifyCredentials: (
25
+ uid: string,
26
+ password: string,
27
+ ) => Promise<UserPayload | null>;
28
+ /**
29
+ * The realm announced in `WWW-Authenticate`. The browser shows it in its
30
+ * prompt, so it should name what is being unlocked.
31
+ */
32
+ realm?: string;
33
+ }
34
+
35
+ /** The `WWW-Authenticate` value a 401 must carry for the browser to prompt. */
36
+ export function basicAuthChallenge(realm = "Restricted"): string {
37
+ // The realm is quoted, so a quote inside it would end the field early.
38
+ return `Basic realm="${realm.replace(/["\\]/g, "")}", charset="UTF-8"`;
39
+ }
40
+
41
+ /**
42
+ * Split an `Authorization: Basic` header into its two halves.
43
+ *
44
+ * RFC 7617 splits on the FIRST colon: a password may contain colons, a username
45
+ * may not. Splitting on the last one would silently accept the wrong pair.
46
+ */
47
+ export function decodeBasicAuth(
48
+ header: string | undefined,
49
+ ): { uid: string; password: string } | null {
50
+ if (!header) return null;
51
+ const [scheme, encoded] = header.split(" ");
52
+ if ((scheme ?? "").toLowerCase() !== "basic" || !encoded) return null;
53
+ let decoded: string;
54
+ try {
55
+ decoded = Buffer.from(encoded, "base64").toString("utf8");
56
+ } catch {
57
+ return null;
58
+ }
59
+ const separator = decoded.indexOf(":");
60
+ if (separator === -1) return null;
61
+ return {
62
+ uid: decoded.slice(0, separator),
63
+ password: decoded.slice(separator + 1),
64
+ };
65
+ }
66
+
67
+ export class BasicAuthStrategy implements AuthStrategy {
68
+ name = "basic_auth";
69
+ readonly #config: BasicAuthConfig;
70
+
71
+ constructor(config: BasicAuthConfig) {
72
+ this.#config = config;
73
+ }
74
+
75
+ /** The challenge to send with a 401 so the browser prompts. */
76
+ get challenge(): string {
77
+ return basicAuthChallenge(this.#config.realm);
78
+ }
79
+
80
+ /** Authenticate a `{ uid, password }` pair directly. */
81
+ async authenticate(
82
+ credentials: Record<string, unknown>,
83
+ ): Promise<AuthResult> {
84
+ const uid = credentials.uid ?? credentials.username ?? credentials.email;
85
+ const password = credentials.password;
86
+ if (typeof uid !== "string" || typeof password !== "string") {
87
+ return { authenticated: false, error: "Invalid credentials" };
88
+ }
89
+ const user = await this.#config.verifyCredentials(uid, password);
90
+ // One message for an unknown user and for a wrong password: telling them
91
+ // apart turns the endpoint into a username oracle.
92
+ if (!user) return { authenticated: false, error: "Invalid credentials" };
93
+ return { authenticated: true, user };
94
+ }
95
+
96
+ /** Authenticate from the raw `Authorization` header value. */
97
+ async verify(header: string): Promise<AuthResult> {
98
+ const decoded = decodeBasicAuth(header);
99
+ if (!decoded) return { authenticated: false, error: "Invalid credentials" };
100
+ return this.authenticate(decoded);
101
+ }
102
+
103
+ /**
104
+ * Build the header a client sends (AdonisJS `authenticateAsClient`) — what a
105
+ * test uses to act as a user without reproducing the encoding by hand.
106
+ */
107
+ authenticateAsClient(uid: string, password: string): AuthClientResponse {
108
+ const encoded = Buffer.from(`${uid}:${password}`, "utf8").toString(
109
+ "base64",
110
+ );
111
+ return { headers: { authorization: `Basic ${encoded}` } };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Compare two secrets without leaking how far they matched.
117
+ *
118
+ * Exposed because an app implementing `verifyCredentials` against a plaintext
119
+ * shared secret needs it — a plain `===` returns sooner on an early mismatch,
120
+ * and that difference is measurable.
121
+ */
122
+ export function safeCompare(a: string, b: string): boolean {
123
+ const left = Buffer.from(a, "utf8");
124
+ const right = Buffer.from(b, "utf8");
125
+ if (left.length !== right.length) return false;
126
+ return timingSafeEqual(left, right);
127
+ }
@@ -4,7 +4,12 @@
4
4
  */
5
5
 
6
6
  import { randomBytes, randomUUID } from "node:crypto";
7
- import type { AuthResult, AuthStrategy, UserPayload } from "../AuthManager.js";
7
+ import type {
8
+ AuthClientResponse,
9
+ AuthResult,
10
+ AuthStrategy,
11
+ UserPayload,
12
+ } from "../AuthManager.js";
8
13
  import { nativeWarden } from "../native.js";
9
14
  import type { TokenBlacklist } from "../TokenBlacklist.js";
10
15
 
@@ -214,6 +219,18 @@ export class JwtStrategy implements AuthStrategy {
214
219
  * Returns `true` when the token was added to the blacklist, `false` when
215
220
  * the token is already expired (revocation is unnecessary) or unparseable.
216
221
  */
222
+ /**
223
+ * The header a test client sends to be `user` (AdonisJS
224
+ * `authenticateAsClient`).
225
+ *
226
+ * Signs a real token with the configured secret, so the request travels the
227
+ * SAME verification path as production — a test that forges its own header
228
+ * proves only that the forgery works.
229
+ */
230
+ authenticateAsClient(user: UserPayload): AuthClientResponse {
231
+ return { headers: { authorization: `Bearer ${this.signToken(user)}` } };
232
+ }
233
+
217
234
  async revoke(token: string): Promise<boolean> {
218
235
  if (!this.#blacklist) {
219
236
  throw new Error(