@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.
- package/dist/AuthManager.d.ts +86 -1
- package/dist/AuthManager.d.ts.map +1 -1
- package/dist/AuthManager.js +113 -10
- package/dist/AuthManager.js.map +1 -1
- package/dist/Authenticator.d.ts +13 -1
- package/dist/Authenticator.d.ts.map +1 -1
- package/dist/Authenticator.js +37 -3
- package/dist/Authenticator.js.map +1 -1
- package/dist/RememberMeToken.d.ts +169 -0
- package/dist/RememberMeToken.d.ts.map +1 -0
- package/dist/RememberMeToken.js +269 -0
- package/dist/RememberMeToken.js.map +1 -0
- package/dist/ResilientBlacklistDriver.d.ts +15 -9
- package/dist/ResilientBlacklistDriver.d.ts.map +1 -1
- package/dist/ResilientBlacklistDriver.js +53 -15
- package/dist/ResilientBlacklistDriver.js.map +1 -1
- package/dist/Secret.d.ts +11 -0
- package/dist/Secret.d.ts.map +1 -0
- package/dist/Secret.js +36 -0
- package/dist/Secret.js.map +1 -0
- package/dist/WardenProvider.d.ts +1 -0
- package/dist/WardenProvider.d.ts.map +1 -1
- package/dist/WardenProvider.js +64 -0
- package/dist/WardenProvider.js.map +1 -1
- package/dist/bouncer/Bouncer.d.ts +46 -1
- package/dist/bouncer/Bouncer.d.ts.map +1 -1
- package/dist/bouncer/Bouncer.js +77 -4
- package/dist/bouncer/Bouncer.js.map +1 -1
- package/dist/bouncer/PolicyAuthorizer.d.ts +9 -1
- package/dist/bouncer/PolicyAuthorizer.d.ts.map +1 -1
- package/dist/bouncer/PolicyAuthorizer.js +17 -1
- package/dist/bouncer/PolicyAuthorizer.js.map +1 -1
- package/dist/bouncer/evaluate.d.ts +14 -2
- package/dist/bouncer/evaluate.d.ts.map +1 -1
- package/dist/bouncer/evaluate.js +16 -5
- package/dist/bouncer/evaluate.js.map +1 -1
- package/dist/bouncer/types.d.ts +4 -2
- package/dist/bouncer/types.d.ts.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/firstcontact/drivers/GitHubDriver.d.ts.map +1 -1
- package/dist/firstcontact/drivers/GitHubDriver.js +2 -3
- package/dist/firstcontact/drivers/GitHubDriver.js.map +1 -1
- package/dist/firstcontact/drivers/GoogleDriver.d.ts.map +1 -1
- package/dist/firstcontact/drivers/GoogleDriver.js +2 -3
- package/dist/firstcontact/drivers/GoogleDriver.js.map +1 -1
- package/dist/firstcontact/types.d.ts +10 -0
- package/dist/firstcontact/types.d.ts.map +1 -1
- package/dist/firstcontact/types.js +18 -1
- package/dist/firstcontact/types.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/mfa/MfaManager.d.ts.map +1 -1
- package/dist/mfa/MfaManager.js +14 -5
- package/dist/mfa/MfaManager.js.map +1 -1
- package/dist/mfa/TotpProvider.d.ts +34 -1
- package/dist/mfa/TotpProvider.d.ts.map +1 -1
- package/dist/mfa/TotpProvider.js +60 -4
- package/dist/mfa/TotpProvider.js.map +1 -1
- package/dist/mfa/webauthn-codec.d.ts +6 -1
- package/dist/mfa/webauthn-codec.d.ts.map +1 -1
- package/dist/mfa/webauthn-codec.js +50 -6
- package/dist/mfa/webauthn-codec.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +46 -1
- package/dist/middleware.js.map +1 -1
- package/dist/quasar.d.ts.map +1 -1
- package/dist/quasar.js +6 -14
- package/dist/quasar.js.map +1 -1
- package/dist/sanitize.d.ts +10 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +15 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/strategies/ApiKeyStrategy.d.ts +10 -1
- package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
- package/dist/strategies/ApiKeyStrategy.js +16 -0
- package/dist/strategies/ApiKeyStrategy.js.map +1 -1
- package/dist/strategies/BasicAuthStrategy.d.ts +59 -0
- package/dist/strategies/BasicAuthStrategy.d.ts.map +1 -0
- package/dist/strategies/BasicAuthStrategy.js +97 -0
- package/dist/strategies/BasicAuthStrategy.js.map +1 -0
- package/dist/strategies/JwtStrategy.d.ts +10 -1
- package/dist/strategies/JwtStrategy.d.ts.map +1 -1
- package/dist/strategies/JwtStrategy.js +11 -0
- package/dist/strategies/JwtStrategy.js.map +1 -1
- package/dist/strategies/SessionStrategy.d.ts +84 -2
- package/dist/strategies/SessionStrategy.d.ts.map +1 -1
- package/dist/strategies/SessionStrategy.js +124 -1
- package/dist/strategies/SessionStrategy.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +4 -2
- package/src/AuthManager.ts +163 -7
- package/src/Authenticator.ts +58 -10
- package/src/RememberMeToken.ts +357 -0
- package/src/ResilientBlacklistDriver.ts +58 -18
- package/src/Secret.ts +41 -0
- package/src/WardenProvider.ts +90 -0
- package/src/bouncer/Bouncer.ts +93 -4
- package/src/bouncer/PolicyAuthorizer.ts +20 -1
- package/src/bouncer/evaluate.ts +26 -6
- package/src/bouncer/types.ts +4 -2
- package/src/config.ts +9 -0
- package/src/firstcontact/drivers/GitHubDriver.ts +2 -3
- package/src/firstcontact/drivers/GoogleDriver.ts +2 -3
- package/src/firstcontact/types.ts +24 -0
- package/src/index.ts +30 -2
- package/src/mfa/MfaManager.ts +15 -8
- package/src/mfa/TotpProvider.ts +90 -6
- package/src/mfa/webauthn-codec.ts +48 -6
- package/src/middleware.ts +55 -2
- package/src/quasar.ts +6 -14
- package/src/sanitize.ts +17 -0
- package/src/strategies/ApiKeyStrategy.ts +23 -1
- package/src/strategies/BasicAuthStrategy.ts +127 -0
- package/src/strategies/JwtStrategy.ts +18 -1
- package/src/strategies/SessionStrategy.ts +167 -2
package/src/WardenProvider.ts
CHANGED
|
@@ -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
|
}
|
package/src/bouncer/Bouncer.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
package/src/bouncer/evaluate.ts
CHANGED
|
@@ -19,14 +19,31 @@ export type Action = (
|
|
|
19
19
|
...args: unknown[]
|
|
20
20
|
) => AuthorizerResponse;
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
export
|
|
22
|
+
/** Turns whatever an ability returned into an {@link AuthorizationResponse}. */
|
|
23
|
+
export type ResponseBuilder = (
|
|
24
24
|
value: boolean | AuthorizationResponse,
|
|
25
|
-
)
|
|
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
|
|
package/src/bouncer/types.ts
CHANGED
|
@@ -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
|
|
27
|
-
* `{ user, action, response }
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
package/src/mfa/MfaManager.ts
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 ─────────────────────────────────────────────────
|