@c9up/warden 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/AuthManager.d.ts +45 -9
  2. package/dist/AuthManager.d.ts.map +1 -1
  3. package/dist/AuthManager.js +75 -23
  4. package/dist/AuthManager.js.map +1 -1
  5. package/dist/Authenticator.d.ts +128 -0
  6. package/dist/Authenticator.d.ts.map +1 -0
  7. package/dist/Authenticator.js +296 -0
  8. package/dist/Authenticator.js.map +1 -0
  9. package/dist/WardenProvider.d.ts.map +1 -1
  10. package/dist/WardenProvider.js +51 -23
  11. package/dist/WardenProvider.js.map +1 -1
  12. package/dist/bouncer/AbilitiesBuilder.d.ts +20 -0
  13. package/dist/bouncer/AbilitiesBuilder.d.ts.map +1 -0
  14. package/dist/bouncer/AbilitiesBuilder.js +32 -0
  15. package/dist/bouncer/AbilitiesBuilder.js.map +1 -0
  16. package/dist/bouncer/AuthorizationResponse.d.ts +18 -8
  17. package/dist/bouncer/AuthorizationResponse.d.ts.map +1 -1
  18. package/dist/bouncer/AuthorizationResponse.js +20 -8
  19. package/dist/bouncer/AuthorizationResponse.js.map +1 -1
  20. package/dist/bouncer/BasePolicy.d.ts +4 -3
  21. package/dist/bouncer/BasePolicy.d.ts.map +1 -1
  22. package/dist/bouncer/BasePolicy.js.map +1 -1
  23. package/dist/bouncer/Bouncer.d.ts +9 -2
  24. package/dist/bouncer/Bouncer.d.ts.map +1 -1
  25. package/dist/bouncer/Bouncer.js +59 -10
  26. package/dist/bouncer/Bouncer.js.map +1 -1
  27. package/dist/bouncer/PolicyAuthorizer.d.ts +2 -1
  28. package/dist/bouncer/PolicyAuthorizer.d.ts.map +1 -1
  29. package/dist/bouncer/PolicyAuthorizer.js +11 -3
  30. package/dist/bouncer/PolicyAuthorizer.js.map +1 -1
  31. package/dist/bouncer/evaluate.d.ts +1 -1
  32. package/dist/bouncer/evaluate.d.ts.map +1 -1
  33. package/dist/bouncer/evaluate.js +3 -2
  34. package/dist/bouncer/evaluate.js.map +1 -1
  35. package/dist/bouncer/types.d.ts +22 -1
  36. package/dist/bouncer/types.d.ts.map +1 -1
  37. package/dist/config.d.ts +34 -3
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +15 -0
  40. package/dist/config.js.map +1 -1
  41. package/dist/configure.d.ts.map +1 -1
  42. package/dist/configure.js +15 -13
  43. package/dist/configure.js.map +1 -1
  44. package/dist/errors.d.ts +27 -0
  45. package/dist/errors.d.ts.map +1 -1
  46. package/dist/errors.js +37 -1
  47. package/dist/errors.js.map +1 -1
  48. package/dist/index.d.ts +7 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/mfa/OtpProvider.d.ts.map +1 -1
  53. package/dist/mfa/OtpProvider.js +21 -3
  54. package/dist/mfa/OtpProvider.js.map +1 -1
  55. package/dist/mfa/WebauthnProvider.d.ts +24 -2
  56. package/dist/mfa/WebauthnProvider.d.ts.map +1 -1
  57. package/dist/mfa/WebauthnProvider.js +28 -8
  58. package/dist/mfa/WebauthnProvider.js.map +1 -1
  59. package/dist/middleware.d.ts +29 -6
  60. package/dist/middleware.d.ts.map +1 -1
  61. package/dist/middleware.js +85 -166
  62. package/dist/middleware.js.map +1 -1
  63. package/dist/standalone.d.ts.map +1 -1
  64. package/dist/standalone.js +21 -13
  65. package/dist/standalone.js.map +1 -1
  66. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
  67. package/dist/strategies/ApiKeyStrategy.js +4 -1
  68. package/dist/strategies/ApiKeyStrategy.js.map +1 -1
  69. package/dist/strategies/SessionStrategy.js +2 -2
  70. package/dist/strategies/SessionStrategy.js.map +1 -1
  71. package/index.darwin-arm64.node +0 -0
  72. package/index.darwin-x64.node +0 -0
  73. package/index.linux-arm64-gnu.node +0 -0
  74. package/index.linux-x64-gnu.node +0 -0
  75. package/index.win32-x64-msvc.node +0 -0
  76. package/package.json +2 -1
  77. package/src/AuthManager.ts +125 -28
  78. package/src/Authenticator.ts +385 -0
  79. package/src/WardenProvider.ts +51 -25
  80. package/src/bouncer/AbilitiesBuilder.ts +41 -0
  81. package/src/bouncer/AuthorizationResponse.ts +24 -16
  82. package/src/bouncer/BasePolicy.ts +4 -2
  83. package/src/bouncer/Bouncer.ts +77 -14
  84. package/src/bouncer/PolicyAuthorizer.ts +14 -4
  85. package/src/bouncer/evaluate.ts +4 -2
  86. package/src/bouncer/types.ts +24 -1
  87. package/src/config.ts +47 -3
  88. package/src/configure.ts +15 -13
  89. package/src/errors.ts +43 -1
  90. package/src/index.ts +22 -2
  91. package/src/mfa/OtpProvider.ts +31 -3
  92. package/src/mfa/WebauthnProvider.ts +58 -12
  93. package/src/middleware.ts +112 -207
  94. package/src/standalone.ts +22 -14
  95. package/src/strategies/ApiKeyStrategy.ts +4 -1
  96. package/src/strategies/SessionStrategy.ts +2 -2
package/src/errors.ts CHANGED
@@ -18,8 +18,50 @@ export class WardenError extends Error {
18
18
  ) {
19
19
  super(message);
20
20
  this.name = "WardenError";
21
- this.code = `WARDEN_${code}`;
21
+ // Warden's own codes carry the `WARDEN_` namespace. The AdonisJS auth
22
+ // identifiers (`E_UNAUTHORIZED_ACCESS`, `E_INVALID_CREDENTIALS`) are
23
+ // PRESERVED verbatim so the exposed `.code` matches Adonis exactly — a
24
+ // consumer branching on `error.code === 'E_UNAUTHORIZED_ACCESS'` (or a
25
+ // framework mapping the Adonis id) sees the same string it would under
26
+ // `@adonisjs/auth`. Documented deviation from the blanket prefix.
27
+ this.code = code.startsWith("E_") ? code : `WARDEN_${code}`;
22
28
  this.hint = options?.hint;
23
29
  this.status = options?.status;
24
30
  }
25
31
  }
32
+
33
+ /**
34
+ * `E_UNAUTHORIZED_ACCESS` — raised when an incoming request cannot be
35
+ * authenticated (AdonisJS `@adonisjs/auth` parity). Carries `guardDriverName`
36
+ * (the guard that rejected) and, for session-guard flows, an optional
37
+ * `redirectTo` (the login route an HTML client is redirected to). Status 401.
38
+ *
39
+ * Content negotiation (redirect for `Accept: text/html` + session guard, else
40
+ * 401 JSON) is performed by Warden itself — see `renderAuthError` in
41
+ * `middleware.ts` — so the package stays agnostic of the host's exception
42
+ * handler.
43
+ */
44
+ export class E_UNAUTHORIZED_ACCESS extends WardenError {
45
+ readonly redirectTo?: string;
46
+ readonly guardDriverName: string;
47
+
48
+ constructor(
49
+ message: string,
50
+ options: { guardDriverName: string; redirectTo?: string },
51
+ ) {
52
+ super("E_UNAUTHORIZED_ACCESS", message, { status: 401 });
53
+ this.guardDriverName = options.guardDriverName;
54
+ this.redirectTo = options.redirectTo;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * `E_INVALID_CREDENTIALS` — raised when a credential check (email/password)
60
+ * fails (AdonisJS parity). Status 400. Distinct from `E_UNAUTHORIZED_ACCESS`
61
+ * (a missing/invalid session or token on a protected route → 401).
62
+ */
63
+ export class E_INVALID_CREDENTIALS extends WardenError {
64
+ constructor(message = "Invalid user credentials") {
65
+ super("E_INVALID_CREDENTIALS", message, { status: 400 });
66
+ }
67
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,13 @@
4
4
  * @implements FR48, FR49, FR50, FR51, FR52, FR53
5
5
  */
6
6
 
7
+ export type { AuthAttempt, ExtractedCredentials } from "./Authenticator.js";
8
+ export {
9
+ API_KEY_GUARD_NAMES,
10
+ Authenticator,
11
+ extractCredentials,
12
+ GuardAccessor,
13
+ } from "./Authenticator.js";
7
14
  export type {
8
15
  AuthConfig,
9
16
  AuthResult,
@@ -13,6 +20,7 @@ export type {
13
20
  export { AuthManager } from "./AuthManager.js";
14
21
  export type { AuthRateLimiterConfig } from "./AuthRateLimiter.js";
15
22
  export { AuthRateLimiter } from "./AuthRateLimiter.js";
23
+ export { AbilitiesBuilder } from "./bouncer/AbilitiesBuilder.js";
16
24
  export { AuthorizationResponse } from "./bouncer/AuthorizationResponse.js";
17
25
  export { BasePolicy } from "./bouncer/BasePolicy.js";
18
26
  export { Bouncer } from "./bouncer/Bouncer.js";
@@ -27,10 +35,22 @@ export type {
27
35
  AbilityOptions,
28
36
  AuthorizerResponse,
29
37
  BouncerContext,
38
+ BouncerEmitter,
39
+ PolicyContainerResolver,
30
40
  } from "./bouncer/types.js";
31
- export { defineConfig } from "./config.js";
41
+ export type { GuardFactory, WardenConfig } from "./config.js";
42
+ export {
43
+ apiKeyGuard,
44
+ defineConfig,
45
+ jwtGuard,
46
+ sessionGuard,
47
+ } from "./config.js";
32
48
  export { configure } from "./configure.js";
33
- export { WardenError } from "./errors.js";
49
+ export {
50
+ E_INVALID_CREDENTIALS,
51
+ E_UNAUTHORIZED_ACCESS,
52
+ WardenError,
53
+ } from "./errors.js";
34
54
  export { GitHubDriver } from "./firstcontact/drivers/GitHubDriver.js";
35
55
  export { GoogleDriver } from "./firstcontact/drivers/GoogleDriver.js";
36
56
  export { FirstContactManager } from "./firstcontact/FirstContactManager.js";
@@ -96,9 +96,37 @@ export class OtpProvider {
96
96
  }
97
97
  this.#channel = config.channel;
98
98
  this.#store = config.store ?? new MemoryOtpChallengeStore();
99
- this.#digits = config.digits ?? 6;
100
- this.#ttlMs = (config.ttlSeconds ?? 300) * 1000;
101
- this.#maxAttempts = config.maxAttempts ?? 5;
99
+
100
+ // Validate the numeric config up-front (mirrors TotpProvider's digit
101
+ // guard) so a misconfig fails loudly at construction instead of minting
102
+ // unusable codes: `digits` bounds the code length (also keeps `10**digits`
103
+ // within `randomInt`'s safe range); a non-positive `ttlSeconds` would make
104
+ // every code born-expired; a non-positive `maxAttempts` would burn the
105
+ // challenge on the first guess.
106
+ const digits = config.digits ?? 6;
107
+ if (!Number.isInteger(digits) || digits < 4 || digits > 10) {
108
+ throw new WardenError(
109
+ "INVALID_CONFIG",
110
+ `OTP digits must be an integer 4-10, got ${digits}`,
111
+ );
112
+ }
113
+ const ttlSeconds = config.ttlSeconds ?? 300;
114
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
115
+ throw new WardenError(
116
+ "INVALID_CONFIG",
117
+ `OTP ttlSeconds must be a positive integer, got ${ttlSeconds}`,
118
+ );
119
+ }
120
+ const maxAttempts = config.maxAttempts ?? 5;
121
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
122
+ throw new WardenError(
123
+ "INVALID_CONFIG",
124
+ `OTP maxAttempts must be a positive integer, got ${maxAttempts}`,
125
+ );
126
+ }
127
+ this.#digits = digits;
128
+ this.#ttlMs = ttlSeconds * 1000;
129
+ this.#maxAttempts = maxAttempts;
102
130
  }
103
131
 
104
132
  /** Mint a code, persist the challenge, and deliver it to `recipient`. */
@@ -81,10 +81,21 @@ export interface AuthenticationOptionsJSON {
81
81
  }>;
82
82
  }
83
83
 
84
- /** Transient per-ceremony challenge storage. */
84
+ /**
85
+ * Transient per-ceremony challenge storage.
86
+ *
87
+ * Implementations MUST be **time-bound**: a challenge that is never consumed has
88
+ * to expire server-side (single-use alone is not enough — an unconsumed
89
+ * challenge left live indefinitely widens the replay/relay window). A DB/Redis
90
+ * store should use a TTL column / `EXPIRE`; the in-memory default stamps an
91
+ * `expiresAt` and refuses an expired challenge in `take`.
92
+ */
85
93
  export interface WebauthnChallengeStore {
86
94
  save(state: string, challenge: string): Promise<void>;
87
- /** Return the challenge for `state` and remove it (single-use). */
95
+ /**
96
+ * Return the challenge for `state` and remove it (single-use). MUST return
97
+ * `null` if the stored challenge has passed its TTL.
98
+ */
88
99
  take(state: string): Promise<string | null>;
89
100
  }
90
101
 
@@ -109,14 +120,24 @@ export interface WebauthnCredentialStore {
109
120
  }
110
121
 
111
122
  export class MemoryWebauthnChallengeStore implements WebauthnChallengeStore {
112
- #store = new Map<string, string>();
123
+ #store = new Map<string, { challenge: string; expiresAt: number }>();
124
+ readonly #ttlMs: number;
125
+
126
+ /** @param ttlMs Server-side challenge lifetime. Default 5 min. */
127
+ constructor(ttlMs = 300_000) {
128
+ this.#ttlMs = ttlMs;
129
+ }
130
+
113
131
  async save(state: string, challenge: string): Promise<void> {
114
- this.#store.set(state, challenge);
132
+ this.#store.set(state, { challenge, expiresAt: Date.now() + this.#ttlMs });
115
133
  }
116
134
  async take(state: string): Promise<string | null> {
117
- const c = this.#store.get(state) ?? null;
118
- this.#store.delete(state);
119
- return c;
135
+ const entry = this.#store.get(state);
136
+ this.#store.delete(state); // single-use regardless of freshness
137
+ if (!entry || entry.expiresAt < Date.now()) {
138
+ return null;
139
+ }
140
+ return entry.challenge;
120
141
  }
121
142
  }
122
143
 
@@ -139,6 +160,12 @@ export class MemoryWebauthnCredentialStore implements WebauthnCredentialStore {
139
160
  }
140
161
  }
141
162
 
163
+ /** WebAuthn user-verification requirement (PIN / biometric). */
164
+ export type UserVerificationRequirement =
165
+ | "required"
166
+ | "preferred"
167
+ | "discouraged";
168
+
142
169
  export interface WebauthnConfig {
143
170
  /** Human-readable relying-party name (your app). */
144
171
  rpName: string;
@@ -155,6 +182,13 @@ export interface WebauthnConfig {
155
182
  * Default ES256, RS256, EdDSA.
156
183
  */
157
184
  supportedAlgorithms?: number[];
185
+ /**
186
+ * User-verification requirement. Default `"preferred"`. Set `"required"`
187
+ * for strong MFA / sensitive passkeys — the ceremony then requests UV AND
188
+ * the server rejects an assertion whose authenticator-data UV flag is unset
189
+ * (a mere user-presence touch no longer satisfies the check).
190
+ */
191
+ userVerification?: UserVerificationRequirement;
158
192
  }
159
193
 
160
194
  export interface WebauthnUser {
@@ -172,6 +206,7 @@ export class WebauthnProvider {
172
206
  readonly #origins: string[];
173
207
  readonly #timeout: number;
174
208
  readonly #algorithms: number[];
209
+ readonly #userVerification: UserVerificationRequirement;
175
210
  readonly #challenges: WebauthnChallengeStore;
176
211
  readonly #credentials: WebauthnCredentialStore;
177
212
 
@@ -189,6 +224,7 @@ export class WebauthnProvider {
189
224
  : [config.origin];
190
225
  this.#timeout = config.timeout ?? 60_000;
191
226
  this.#algorithms = config.supportedAlgorithms ?? DEFAULT_ALGS;
227
+ this.#userVerification = config.userVerification ?? "preferred";
192
228
  this.#challenges =
193
229
  config.challengeStore ?? new MemoryWebauthnChallengeStore();
194
230
  this.#credentials =
@@ -226,7 +262,7 @@ export class WebauthnProvider {
226
262
  })),
227
263
  authenticatorSelection: {
228
264
  residentKey: "preferred",
229
- userVerification: "preferred",
265
+ userVerification: this.#userVerification,
230
266
  },
231
267
  };
232
268
  const state = randomBytes(16).toString("hex");
@@ -302,7 +338,7 @@ export class WebauthnProvider {
302
338
  challenge,
303
339
  timeout: this.#timeout,
304
340
  rpId: this.#rpID,
305
- userVerification: "preferred",
341
+ userVerification: this.#userVerification,
306
342
  allowCredentials: userId
307
343
  ? allow.map((c) => ({
308
344
  id: c.id,
@@ -397,13 +433,23 @@ export class WebauthnProvider {
397
433
  );
398
434
  }
399
435
 
400
- /** Validate authenticator data: rpIdHash match and user-presence flag. */
436
+ /**
437
+ * Validate authenticator data: rpIdHash match, mandatory user-presence, and
438
+ * — when `userVerification: "required"` is configured — the user-verification
439
+ * flag (PIN / biometric actually performed, not just a presence touch).
440
+ */
401
441
  #validAuthenticator(authData: {
402
442
  rpIdHash: Buffer;
403
- flags: { up: boolean };
443
+ flags: { up: boolean; uv: boolean };
404
444
  }): boolean {
405
445
  const expectedRpIdHash = sha256(Buffer.from(this.#rpID, "utf8"));
406
- return authData.flags.up && expectedRpIdHash.equals(authData.rpIdHash);
446
+ if (!authData.flags.up || !expectedRpIdHash.equals(authData.rpIdHash)) {
447
+ return false;
448
+ }
449
+ if (this.#userVerification === "required" && !authData.flags.uv) {
450
+ return false;
451
+ }
452
+ return true;
407
453
  }
408
454
  }
409
455