@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
@@ -153,5 +153,95 @@ export default class WardenProvider {
153
153
  async boot() {
154
154
  const manager = await this.app.container.resolve(AuthManager);
155
155
  if (manager instanceof AuthManager) setAuth(manager);
156
+ await this.#registerTemplateTags();
156
157
  }
158
+
159
+ /**
160
+ * Publish `@can` / `@cannot` to the template engine, as AdonisJS's bouncer
161
+ * does. A migrated template keeps `@can('PostPolicy.edit', post) … @endcan`
162
+ * unchanged.
163
+ *
164
+ * Both are BLOCK tags and both AWAIT — the check returns a promise — so they
165
+ * render on the async path and raise on a synchronous one, matching upstream
166
+ * (which emits `await bouncer.can(...)`). The checks themselves come from
167
+ * `ctx.bouncer.templateHelpers`, shared into the view per request.
168
+ *
169
+ * Resolved through the container: warden must not depend on the engine, and
170
+ * an app with no template layer simply skips this.
171
+ */
172
+ async #registerTemplateTags(): Promise<void> {
173
+ const engine = await this.#resolveTemplateEngine();
174
+ if (engine === undefined) return;
175
+ for (const tagName of ["can", "cannot"] as const) {
176
+ engine.registerTag({
177
+ tagName,
178
+ block: true,
179
+ seekable: true,
180
+ async compile(_parser, buffer, token) {
181
+ // The shared bag lives in the render scope under `bouncer`.
182
+ const bouncer = token.evaluate(
183
+ "typeof bouncer === 'undefined' ? undefined : bouncer",
184
+ );
185
+ const check = Reflect.get(Object(bouncer), tagName);
186
+ if (typeof check !== "function") {
187
+ // No bouncer on this request: deny rather than render an
188
+ // unguarded body.
189
+ return;
190
+ }
191
+ const args = token.evaluate(`[${token.properties.jsArg}]`);
192
+ const allowed = await check.apply(
193
+ bouncer,
194
+ Array.isArray(args) ? args : [],
195
+ );
196
+ if (allowed === true) buffer.writeRaw(await token.renderBody());
197
+ },
198
+ });
199
+ }
200
+ }
201
+
202
+ /** The template engine, if the host installed one. */
203
+ async #resolveTemplateEngine(): Promise<TemplateEngineLike | undefined> {
204
+ for (const token of ["inker", "view"]) {
205
+ try {
206
+ const binding: unknown = await this.app.container.resolve(token);
207
+ for (const candidate of [
208
+ binding,
209
+ Reflect.get(Object(binding), "_templates"),
210
+ ]) {
211
+ if (
212
+ typeof candidate === "object" &&
213
+ candidate !== null &&
214
+ typeof Reflect.get(candidate, "registerTag") === "function"
215
+ ) {
216
+ return {
217
+ registerTag: Reflect.get(candidate, "registerTag").bind(
218
+ candidate,
219
+ ),
220
+ };
221
+ }
222
+ }
223
+ } catch {
224
+ // A host without a template layer is not an error.
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ /** The only surface warden needs of a template engine. */
232
+ interface TemplateEngineLike {
233
+ registerTag(tag: {
234
+ tagName: string;
235
+ block: boolean;
236
+ seekable: boolean;
237
+ compile(
238
+ parser: unknown,
239
+ buffer: { writeRaw(text: string): void },
240
+ token: {
241
+ properties: { jsArg: string };
242
+ renderBody(): string | Promise<string>;
243
+ evaluate(expression: string): unknown;
244
+ },
245
+ ): void | Promise<void>;
246
+ }): void;
157
247
  }
@@ -18,7 +18,13 @@ import type { EffectivePermissions, Scope } from "../rights/types.js";
18
18
  import { AbilitiesBuilder } from "./AbilitiesBuilder.js";
19
19
  import { AuthorizationResponse } from "./AuthorizationResponse.js";
20
20
  import type { BasePolicy } from "./BasePolicy.js";
21
- import { evaluate, isAction, throwAuthorizationFailure } from "./evaluate.js";
21
+ import {
22
+ defaultResponseBuilder,
23
+ evaluate,
24
+ isAction,
25
+ type ResponseBuilder,
26
+ throwAuthorizationFailure,
27
+ } from "./evaluate.js";
22
28
  import { PolicyAuthorizer } from "./PolicyAuthorizer.js";
23
29
  import { emptyPermissions } from "./policyContext.js";
24
30
  import type {
@@ -31,6 +37,20 @@ import type {
31
37
  } from "./types.js";
32
38
 
33
39
  export class Bouncer {
40
+ /**
41
+ * How a bare boolean from an ability or a policy becomes an
42
+ * {@link AuthorizationResponse} (AdonisJS `Bouncer.responseBuilder`).
43
+ *
44
+ * Replace it once at boot to give every `return false` a house message and
45
+ * status instead of a naked 403:
46
+ *
47
+ * ```ts
48
+ * Bouncer.responseBuilder = (value) =>
49
+ * value === false ? AuthorizationResponse.deny('Nope', 404) : normalizeResponse(value)
50
+ * ```
51
+ */
52
+ static responseBuilder: ResponseBuilder = defaultResponseBuilder;
53
+
34
54
  readonly #userOrResolver: UserPayload | (() => UserPayload | null) | null;
35
55
  /** Lazily-resolved user cache (`undefined` until `#getUser` runs). */
36
56
  #user: UserPayload | null | undefined;
@@ -38,7 +58,7 @@ export class Bouncer {
38
58
  readonly #policies: Record<string, new () => BasePolicy>;
39
59
  readonly #scope: Scope;
40
60
  readonly #resolver: RightsResolver | undefined;
41
- readonly #containerResolver: PolicyContainerResolver | undefined;
61
+ #containerResolver: PolicyContainerResolver | undefined;
42
62
  readonly #emitter: BouncerEmitter | undefined;
43
63
  /** Memoized resolution — a Bouncer is fixed per `(user, scope)`, so resolve once (D3). */
44
64
  #resolved: Promise<EffectivePermissions> | undefined;
@@ -58,6 +78,19 @@ export class Bouncer {
58
78
  this.#emitter = context?.emitter;
59
79
  }
60
80
 
81
+ /**
82
+ * Point the bouncer at an IoC resolver, so a policy's constructor
83
+ * dependencies are injected (AdonisJS `setContainerResolver`).
84
+ *
85
+ * The constructor takes one too; this is the setter AdonisJS exposes so the
86
+ * HTTP layer can hand over the REQUEST's resolver after the bouncer exists.
87
+ * Passing `undefined` clears it, and policies fall back to `new Policy()`.
88
+ */
89
+ setContainerResolver(containerResolver?: PolicyContainerResolver): this {
90
+ this.#containerResolver = containerResolver;
91
+ return this;
92
+ }
93
+
61
94
  /**
62
95
  * Resolve the user, lazily invoking a resolver callback once and memoizing
63
96
  * the result (Adonis `#getUser`). A plain `UserPayload | null` passes through.
@@ -157,6 +190,53 @@ export class Bouncer {
157
190
  return AuthorizationResponse.deny(message, status);
158
191
  }
159
192
 
193
+ /**
194
+ * The checks a TEMPLATE performs, shared into the view per request so
195
+ * `@can('post.edit', post)` resolves them (AdonisJS shares the same pair
196
+ * under `bouncer`; it names the bag `edgeHelpers`, we do not borrow the
197
+ * engine's name).
198
+ *
199
+ * AdonisJS reads a dotted action as `policy.method` —
200
+ * `'PostPolicy.edit'` is `with('PostPolicy').allows('edit', …)`. Warden,
201
+ * however, keys its ABILITIES with dots (`'post.edit'`), so applying that
202
+ * split blindly would break every warden app.
203
+ *
204
+ * NAMED DEVIATION, strictly more permissive: a registered ability wins, and
205
+ * only an action with no matching ability falls through to the policy
206
+ * split. A migrated AdonisJS template resolves its policies exactly as
207
+ * before, and a warden app keeps its dotted abilities.
208
+ */
209
+ readonly templateHelpers: {
210
+ bouncer: {
211
+ can(action: string, ...args: unknown[]): Promise<boolean>;
212
+ cannot(action: string, ...args: unknown[]): Promise<boolean>;
213
+ };
214
+ } = {
215
+ bouncer: {
216
+ can: (action: string, ...args: unknown[]): Promise<boolean> => {
217
+ const split = this.#splitAction(action);
218
+ return split === undefined
219
+ ? this.allows(action, ...args)
220
+ : this.with(split.policy).allows(split.method, ...args);
221
+ },
222
+ cannot: (action: string, ...args: unknown[]): Promise<boolean> => {
223
+ const split = this.#splitAction(action);
224
+ return split === undefined
225
+ ? this.denies(action, ...args)
226
+ : this.with(split.policy).denies(split.method, ...args);
227
+ },
228
+ },
229
+ };
230
+
231
+ /** `policy.method` for a dotted action that names no registered ability;
232
+ * `undefined` when the action IS an ability (warden keys its own with dots). */
233
+ #splitAction(action: string): { policy: string; method: string } | undefined {
234
+ if (Object.hasOwn(this.#abilities, action)) return undefined;
235
+ const dot = action.indexOf(".");
236
+ if (dot <= 0 || dot === action.length - 1) return undefined;
237
+ return { policy: action.slice(0, dot), method: action.slice(dot + 1) };
238
+ }
239
+
160
240
  /** Open a policy for checks (D8 — fresh policy instance per check). */
161
241
  with(policy: (new () => BasePolicy) | string): PolicyAuthorizer {
162
242
  const factory =
@@ -171,6 +251,7 @@ export class Bouncer {
171
251
  this.#scope,
172
252
  () => this.#resolvePermissions(),
173
253
  this.#emitter,
254
+ Bouncer.responseBuilder,
174
255
  );
175
256
  }
176
257
 
@@ -185,10 +266,17 @@ export class Bouncer {
185
266
  }
186
267
 
187
268
  /** Emit `authorization:finished` when an emitter is wired (no-op otherwise). */
188
- #emit(action: string, response: AuthorizationResponse): void {
269
+ #emit(
270
+ action: string,
271
+ response: AuthorizationResponse,
272
+ parameters: unknown[],
273
+ ): void {
189
274
  this.#emitter?.emit("authorization:finished", {
190
275
  user: this.#getUser(),
191
276
  action,
277
+ // What the check was ABOUT. AdonisJS carries it, and without it an
278
+ // audit log can say "Ada was denied editPost" but never which post.
279
+ parameters,
192
280
  response,
193
281
  });
194
282
  }
@@ -261,8 +349,9 @@ export class Bouncer {
261
349
  allowGuest: resolved.allowGuest,
262
350
  run: (user) => resolved.execute(user, ...args),
263
351
  args,
352
+ responseBuilder: Bouncer.responseBuilder,
264
353
  });
265
- this.#emit(action, response);
354
+ this.#emit(action, response, args);
266
355
  return response;
267
356
  }
268
357
 
@@ -16,6 +16,7 @@ import {
16
16
  type Action,
17
17
  evaluate,
18
18
  isAction,
19
+ type ResponseBuilder,
19
20
  throwAuthorizationFailure,
20
21
  } from "./evaluate.js";
21
22
  import { emptyPermissions, setPolicyContext } from "./policyContext.js";
@@ -69,7 +70,8 @@ export class PolicyAuthorizer {
69
70
  readonly #factory: () => Promise<BasePolicy>;
70
71
  readonly #scope: Scope;
71
72
  readonly #resolvePermissions: () => Promise<EffectivePermissions>;
72
- readonly #emitter: BouncerEmitter | undefined;
73
+ #emitter: BouncerEmitter | undefined;
74
+ readonly #responseBuilder: ResponseBuilder | undefined;
73
75
 
74
76
  constructor(
75
77
  user: UserPayload | null,
@@ -78,12 +80,14 @@ export class PolicyAuthorizer {
78
80
  resolvePermissions: () => Promise<EffectivePermissions> = () =>
79
81
  Promise.resolve(emptyPermissions(scope)),
80
82
  emitter?: BouncerEmitter,
83
+ responseBuilder?: ResponseBuilder,
81
84
  ) {
82
85
  this.#user = user;
83
86
  this.#factory = factory;
84
87
  this.#scope = scope;
85
88
  this.#resolvePermissions = resolvePermissions;
86
89
  this.#emitter = emitter;
90
+ this.#responseBuilder = responseBuilder;
87
91
  }
88
92
 
89
93
  /** Run a check and resolve to the full response (D8 — fresh policy per check). */
@@ -106,15 +110,30 @@ export class PolicyAuthorizer {
106
110
  args,
107
111
  before: policy.before?.bind(policy),
108
112
  after: policy.after?.bind(policy),
113
+ responseBuilder: this.#responseBuilder,
109
114
  });
110
115
  this.#emitter?.emit("authorization:finished", {
111
116
  user: this.#user,
112
117
  action,
118
+ // Same payload as an ability check: which resource the decision was
119
+ // about, not just that a decision happened.
120
+ parameters: args,
113
121
  response,
114
122
  });
115
123
  return response;
116
124
  }
117
125
 
126
+ /**
127
+ * Swap the event sink after construction (AdonisJS `setEmitter`).
128
+ *
129
+ * The Bouncer passes its own down, but an authorizer built directly — a
130
+ * test, a console command — had no way to be given one.
131
+ */
132
+ setEmitter(emitter?: BouncerEmitter): this {
133
+ this.#emitter = emitter;
134
+ return this;
135
+ }
136
+
118
137
  /** True iff the action is authorized. Never throws on denial. */
119
138
  async allows(action: string, ...args: unknown[]): Promise<boolean> {
120
139
  return (await this.execute(action, ...args)).authorized;
@@ -19,14 +19,31 @@ export type Action = (
19
19
  ...args: unknown[]
20
20
  ) => AuthorizerResponse;
21
21
 
22
- /** Boolean sugar response; an explicit response passes through (D7). */
23
- export function normalizeResponse(
22
+ /** Turns whatever an ability returned into an {@link AuthorizationResponse}. */
23
+ export type ResponseBuilder = (
24
24
  value: boolean | AuthorizationResponse,
25
- ): AuthorizationResponse {
25
+ ) => AuthorizationResponse;
26
+
27
+ /** The default: boolean sugar → response; an explicit response passes through (D7). */
28
+ export const defaultResponseBuilder: ResponseBuilder = (value) => {
26
29
  if (value instanceof AuthorizationResponse) {
27
30
  return value;
28
31
  }
29
32
  return value ? AuthorizationResponse.allow() : AuthorizationResponse.deny();
33
+ };
34
+
35
+ /**
36
+ * Boolean sugar → response, through whatever builder is installed.
37
+ *
38
+ * `Bouncer.responseBuilder` replaces it app-wide, which is how you give every
39
+ * bare `return false` a house message and status instead of a naked 403
40
+ * (AdonisJS `Bouncer.responseBuilder`).
41
+ */
42
+ export function normalizeResponse(
43
+ value: boolean | AuthorizationResponse,
44
+ builder: ResponseBuilder = defaultResponseBuilder,
45
+ ): AuthorizationResponse {
46
+ return builder(value);
30
47
  }
31
48
 
32
49
  /**
@@ -71,8 +88,11 @@ export async function evaluate(params: {
71
88
  result: AuthorizationResponse,
72
89
  ...args: unknown[]
73
90
  ) => HookResponse;
91
+ /** Overrides how a bare boolean becomes a response. */
92
+ responseBuilder?: ResponseBuilder;
74
93
  }): Promise<AuthorizationResponse> {
75
94
  const { user, action, allowGuest, run, args, before, after } = params;
95
+ const builder = params.responseBuilder ?? defaultResponseBuilder;
76
96
 
77
97
  let response: AuthorizationResponse | undefined;
78
98
 
@@ -80,7 +100,7 @@ export async function evaluate(params: {
80
100
  if (before) {
81
101
  const early = await before(user, action, ...args);
82
102
  if (early !== undefined) {
83
- response = normalizeResponse(early);
103
+ response = normalizeResponse(early, builder);
84
104
  }
85
105
  }
86
106
 
@@ -89,7 +109,7 @@ export async function evaluate(params: {
89
109
  if (user === null && !allowGuest) {
90
110
  response = AuthorizationResponse.deny();
91
111
  } else {
92
- response = normalizeResponse(await run(user));
112
+ response = normalizeResponse(await run(user), builder);
93
113
  }
94
114
  }
95
115
 
@@ -98,7 +118,7 @@ export async function evaluate(params: {
98
118
  if (after) {
99
119
  const override = await after(user, action, response, ...args);
100
120
  if (override !== undefined) {
101
- response = normalizeResponse(override);
121
+ response = normalizeResponse(override, builder);
102
122
  }
103
123
  }
104
124
 
@@ -23,8 +23,10 @@ export interface PolicyContainerResolver {
23
23
 
24
24
  /**
25
25
  * Agnostic event sink (Adonis `Bouncer.emitter`). When present, an
26
- * `authorization:finished` event fires after every ability/policy evaluation with
27
- * `{ user, action, response }`. No-op when absent.
26
+ * `authorization:finished` event fires after every ability/policy evaluation
27
+ * with `{ user, action, parameters, response }` (AdonisJS `BouncerEvents`).
28
+ * `parameters` are the arguments the check ran against — an audit log needs
29
+ * them to say WHICH resource was refused. No-op when absent.
28
30
  */
29
31
  export interface BouncerEmitter {
30
32
  emit(event: string, payload: unknown): void;
package/src/config.ts CHANGED
@@ -22,6 +22,10 @@ import type { MfaManager } from "./mfa/MfaManager.js";
22
22
  import type { RightsStore, Scope } from "./rights/types.js";
23
23
  import type { ApiKeyConfig } from "./strategies/ApiKeyStrategy.js";
24
24
  import { ApiKeyStrategy } from "./strategies/ApiKeyStrategy.js";
25
+ import {
26
+ type BasicAuthConfig,
27
+ BasicAuthStrategy,
28
+ } from "./strategies/BasicAuthStrategy.js";
25
29
  import { JwtStrategy } from "./strategies/JwtStrategy.js";
26
30
  import type { SessionStrategyConfig } from "./strategies/SessionStrategy.js";
27
31
  import { SessionStrategy } from "./strategies/SessionStrategy.js";
@@ -76,6 +80,11 @@ export function apiKeyGuard(config: ApiKeyConfig): GuardFactory {
76
80
  return new ApiKeyStrategy(config);
77
81
  }
78
82
 
83
+ /** Build an HTTP Basic guard from its config (AdonisJS `basicAuthGuard()`). */
84
+ export function basicAuthGuard(config: BasicAuthConfig): GuardFactory {
85
+ return new BasicAuthStrategy(config);
86
+ }
87
+
79
88
  export interface WardenConfig {
80
89
  /**
81
90
  * AdonisJS-style default guard NAME — the key in {@link WardenConfig.guards}
@@ -8,6 +8,7 @@ import type {
8
8
  OAuthToken,
9
9
  OAuthUser,
10
10
  } from "../types.js";
11
+ import { assertOAuthState } from "../types.js";
11
12
 
12
13
  export class GitHubDriver implements FirstContactDriver {
13
14
  constructor(private config: OAuthConfig) {}
@@ -27,9 +28,7 @@ export class GitHubDriver implements FirstContactDriver {
27
28
  state?: string,
28
29
  expectedState?: string,
29
30
  ): Promise<{ user: OAuthUser; token: OAuthToken }> {
30
- if (expectedState && state !== expectedState) {
31
- throw new Error("OAuth state mismatch — possible CSRF attack");
32
- }
31
+ assertOAuthState(state, expectedState);
33
32
  const tokenRes = await fetch(
34
33
  "https://github.com/login/oauth/access_token",
35
34
  {
@@ -8,6 +8,7 @@ import type {
8
8
  OAuthToken,
9
9
  OAuthUser,
10
10
  } from "../types.js";
11
+ import { assertOAuthState } from "../types.js";
11
12
 
12
13
  export class GoogleDriver implements FirstContactDriver {
13
14
  constructor(private config: OAuthConfig) {}
@@ -30,9 +31,7 @@ export class GoogleDriver implements FirstContactDriver {
30
31
  expectedState?: string,
31
32
  ): Promise<{ user: OAuthUser; token: OAuthToken }> {
32
33
  // CSRF protection: validate state matches what we sent in redirectUrl().
33
- if (expectedState && state !== expectedState) {
34
- throw new Error("OAuth state mismatch — possible CSRF attack");
35
- }
34
+ assertOAuthState(state, expectedState);
36
35
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
37
36
  method: "POST",
38
37
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -36,3 +36,27 @@ export interface FirstContactDriver {
36
36
  expectedState?: string,
37
37
  ): Promise<{ user: OAuthUser; token: OAuthToken }>;
38
38
  }
39
+
40
+ /**
41
+ * Check the OAuth `state` round-trip, failing CLOSED.
42
+ *
43
+ * The check used to be `if (expectedState && state !== expectedState)`, so a
44
+ * caller that passed no expected state got NO CSRF protection and no sign that
45
+ * it was missing. A driver used directly — without the manager, which already
46
+ * refuses — was therefore open by default. Anything that cannot be verified is
47
+ * refused instead.
48
+ */
49
+ export function assertOAuthState(
50
+ state: string | undefined,
51
+ expectedState: string | undefined,
52
+ ): void {
53
+ if (!expectedState) {
54
+ throw new Error(
55
+ "[warden] OAuth callback requires expectedState for CSRF protection. " +
56
+ "Store the state given to redirectUrl() in the session and pass it here.",
57
+ );
58
+ }
59
+ if (state !== expectedState) {
60
+ throw new Error("OAuth state mismatch — possible CSRF attack");
61
+ }
62
+ }
package/src/index.ts CHANGED
@@ -17,7 +17,11 @@ export type {
17
17
  AuthStrategy,
18
18
  UserPayload,
19
19
  } from "./AuthManager.js";
20
- export { AuthManager } from "./AuthManager.js";
20
+ export {
21
+ AuthManager,
22
+ authEventPrefix,
23
+ type WardenEmitter,
24
+ } from "./AuthManager.js";
21
25
  export type { AuthRateLimiterConfig } from "./AuthRateLimiter.js";
22
26
  export { AuthRateLimiter } from "./AuthRateLimiter.js";
23
27
  export { AbilitiesBuilder } from "./bouncer/AbilitiesBuilder.js";
@@ -41,6 +45,7 @@ export type {
41
45
  export type { GuardFactory, WardenConfig } from "./config.js";
42
46
  export {
43
47
  apiKeyGuard,
48
+ basicAuthGuard,
44
49
  defineConfig,
45
50
  jwtGuard,
46
51
  sessionGuard,
@@ -98,8 +103,9 @@ export type {
98
103
  TotpAlgorithm,
99
104
  TotpConfig,
100
105
  TotpEnrollment,
106
+ TotpReplayGuard,
101
107
  } from "./mfa/TotpProvider.js";
102
- export { TotpProvider } from "./mfa/TotpProvider.js";
108
+ export { MemoryTotpReplayGuard, TotpProvider } from "./mfa/TotpProvider.js";
103
109
  export type {
104
110
  AuthenticationOptionsJSON,
105
111
  AuthenticationResponseJSON,
@@ -130,6 +136,20 @@ export {
130
136
  generateRefreshToken,
131
137
  MemoryRefreshTokenDriver,
132
138
  } from "./RefreshTokenStore.js";
139
+ export {
140
+ DEFAULT_SECRET_LENGTH,
141
+ decodeTokenValue,
142
+ encodeTokenValue,
143
+ hashSecret,
144
+ MemoryRememberMeTokenDriver,
145
+ type MintedRememberMeToken,
146
+ mintRememberMeToken,
147
+ RememberMeToken,
148
+ type RememberMeTokenDriver,
149
+ type StoredRememberMeToken,
150
+ safeCompareHashes,
151
+ verifyAndRecycleRememberMeToken,
152
+ } from "./RememberMeToken.js";
133
153
  export type {
134
154
  BlacklistDegradeEvent,
135
155
  ResilientBlacklistConfig,
@@ -143,8 +163,16 @@ export type {
143
163
  Scope,
144
164
  } from "./rights/types.js";
145
165
  export { scopeKey } from "./rights/types.js";
166
+ export { Secret } from "./Secret.js";
146
167
  export type { ApiKeyConfig } from "./strategies/ApiKeyStrategy.js";
147
168
  export { ApiKeyStrategy } from "./strategies/ApiKeyStrategy.js";
169
+ export type { BasicAuthConfig } from "./strategies/BasicAuthStrategy.js";
170
+ export {
171
+ BasicAuthStrategy,
172
+ basicAuthChallenge,
173
+ decodeBasicAuth,
174
+ safeCompare,
175
+ } from "./strategies/BasicAuthStrategy.js";
148
176
  export type { JwtClaims, JwtStrategyConfig } from "./strategies/JwtStrategy.js";
149
177
  export { generateJwtSecret, JwtStrategy } from "./strategies/JwtStrategy.js";
150
178
  export type {
@@ -136,7 +136,7 @@ export class MfaManager {
136
136
  if (!factor || factor.kind !== "totp" || !factor.secret) {
137
137
  return false;
138
138
  }
139
- if (!totp.verify(factor.secret, code)) {
139
+ if (!(await totp.verify(factor.secret, code))) {
140
140
  return false;
141
141
  }
142
142
  await this.#store.save({ ...factor, confirmedAt: Date.now() });
@@ -147,13 +147,20 @@ export class MfaManager {
147
147
  async verifyTotp(userId: string, code: string): Promise<boolean> {
148
148
  const totp = this.#requireTotp();
149
149
  const factors = await this.#store.findByUser(userId);
150
- return factors.some(
151
- (f) =>
152
- f.kind === "totp" &&
153
- f.confirmedAt !== undefined &&
154
- f.secret !== undefined &&
155
- totp.verify(f.secret, code),
156
- );
150
+ // Sequential, not Promise.all: the replay guard must see one accepted
151
+ // code at a time, and stopping at the first match avoids burning the
152
+ // code against a second factor that would then reject it as replayed.
153
+ for (const factor of factors) {
154
+ if (
155
+ factor.kind !== "totp" ||
156
+ factor.confirmedAt === undefined ||
157
+ factor.secret === undefined
158
+ ) {
159
+ continue;
160
+ }
161
+ if (await totp.verify(factor.secret, code)) return true;
162
+ }
163
+ return false;
157
164
  }
158
165
 
159
166
  // ── Backup codes ─────────────────────────────────────────────────