@c9up/warden 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AuthManager.d.ts +40 -0
- package/dist/AuthManager.d.ts.map +1 -1
- package/dist/AuthManager.js +45 -3
- package/dist/AuthManager.js.map +1 -1
- package/dist/Authenticator.d.ts.map +1 -1
- package/dist/Authenticator.js +18 -2
- package/dist/Authenticator.js.map +1 -1
- package/dist/RedisBlacklistDriver.d.ts +7 -1
- package/dist/RedisBlacklistDriver.d.ts.map +1 -1
- package/dist/RedisBlacklistDriver.js +30 -5
- package/dist/RedisBlacklistDriver.js.map +1 -1
- package/dist/RememberMeToken.d.ts +99 -0
- package/dist/RememberMeToken.d.ts.map +1 -0
- package/dist/RememberMeToken.js +167 -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/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 +32 -1
- package/dist/bouncer/Bouncer.d.ts.map +1 -1
- package/dist/bouncer/Bouncer.js +54 -0
- package/dist/bouncer/Bouncer.js.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 +5 -2
- 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 +18 -0
- package/dist/quasar.d.ts.map +1 -0
- package/dist/quasar.js +56 -0
- package/dist/quasar.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 +58 -2
- package/dist/strategies/SessionStrategy.d.ts.map +1 -1
- package/dist/strategies/SessionStrategy.js +75 -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 +9 -3
- package/src/AuthManager.ts +80 -0
- package/src/Authenticator.ts +22 -3
- package/src/RedisBlacklistDriver.ts +39 -5
- package/src/RememberMeToken.ts +223 -0
- package/src/ResilientBlacklistDriver.ts +58 -18
- package/src/WardenProvider.ts +90 -0
- package/src/bouncer/Bouncer.ts +61 -1
- 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 +25 -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 +78 -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 +113 -2
|
@@ -4,17 +4,23 @@
|
|
|
4
4
|
* takes the whole app down.
|
|
5
5
|
*
|
|
6
6
|
* Policy (fail-open on reads):
|
|
7
|
-
* - `add()` —
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* - `add()` — records the revocation in BOTH, always. The fallback is this
|
|
8
|
+
* instance's own record of what it has revoked, not a log of its
|
|
9
|
+
* outages: writing only on failure meant a token revoked while
|
|
10
|
+
* the primary was HEALTHY was absent from the fallback, so the
|
|
11
|
+
* first check during a later outage let that logged-out token
|
|
12
|
+
* through. A primary failure still does not fail the logout.
|
|
13
|
+
* - `has()` — asks the fallback FIRST: what this instance revoked is known
|
|
14
|
+
* locally and needs no round trip. Otherwise it asks the
|
|
15
|
+
* primary, and if that throws, allows the token through
|
|
16
|
+
* (fail-open). Access tokens are short-lived, so the window a
|
|
17
|
+
* revocation missed by BOTH survives is bounded by the token TTL
|
|
18
|
+
* — the blacklist is defense-in-depth above expiry, not the
|
|
14
19
|
* primary boundary.
|
|
15
20
|
*
|
|
16
|
-
* Every degradation invokes `onDegrade
|
|
17
|
-
* outage
|
|
21
|
+
* Every degradation invokes `onDegrade`. The default reports the first failure
|
|
22
|
+
* per operation and then a periodic count: an outage that logs on every request
|
|
23
|
+
* buries itself, and the reader stops seeing what the flood is about.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import {
|
|
@@ -32,11 +38,36 @@ export interface ResilientBlacklistConfig {
|
|
|
32
38
|
onDegrade?: (event: BlacklistDegradeEvent) => void;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
/** How long a repeat of the same failing operation stays folded into a count. */
|
|
42
|
+
const DEGRADE_LOG_WINDOW_MS = 60_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Report the first failure of each operation, then one line per window with
|
|
46
|
+
* how many were suppressed.
|
|
47
|
+
*
|
|
48
|
+
* Keyed by operation — a fixed, three-value set — never by the error text,
|
|
49
|
+
* which an attacker could influence and which would make the key set unbounded.
|
|
50
|
+
*/
|
|
51
|
+
function makeDefaultOnDegrade(): (event: BlacklistDegradeEvent) => void {
|
|
52
|
+
const windows = new Map<string, { until: number; suppressed: number }>();
|
|
53
|
+
return ({ op, error }: BlacklistDegradeEvent): void => {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const open = windows.get(op);
|
|
56
|
+
if (open === undefined || open.until <= now) {
|
|
57
|
+
if (open !== undefined && open.suppressed > 0) {
|
|
58
|
+
console.warn(
|
|
59
|
+
`[warden] blacklist primary failed on ${op} ${open.suppressed} more time(s) in the last minute.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
windows.set(op, { until: now + DEGRADE_LOG_WINDOW_MS, suppressed: 0 });
|
|
63
|
+
console.warn(
|
|
64
|
+
`[warden] blacklist primary failed on ${op}; using in-memory fallback:`,
|
|
65
|
+
error,
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
open.suppressed++;
|
|
70
|
+
};
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
export class ResilientBlacklistDriver implements BlacklistDriver {
|
|
@@ -51,25 +82,34 @@ export class ResilientBlacklistDriver implements BlacklistDriver {
|
|
|
51
82
|
) {
|
|
52
83
|
this.#primary = primary;
|
|
53
84
|
this.#fallback = fallback;
|
|
54
|
-
this.#onDegrade = config.onDegrade ??
|
|
85
|
+
this.#onDegrade = config.onDegrade ?? makeDefaultOnDegrade();
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
async add(jti: string, expiresAt: number): Promise<void> {
|
|
89
|
+
// The fallback first, and unconditionally: it is what this instance
|
|
90
|
+
// knows it revoked. Recording only on failure left every revocation made
|
|
91
|
+
// while the primary was healthy invisible to the fallback — and those are
|
|
92
|
+
// most of them.
|
|
93
|
+
await this.#fallback.add(jti, expiresAt);
|
|
58
94
|
try {
|
|
59
95
|
await this.#primary.add(jti, expiresAt);
|
|
60
96
|
} catch (error) {
|
|
97
|
+
// The revocation is already durable enough for this instance, so a
|
|
98
|
+
// primary failure must not fail the logout.
|
|
61
99
|
this.#onDegrade({ op: "add", error });
|
|
62
|
-
await this.#fallback.add(jti, expiresAt);
|
|
63
100
|
}
|
|
64
101
|
}
|
|
65
102
|
|
|
66
103
|
async has(jti: string): Promise<boolean> {
|
|
104
|
+
// What this instance revoked is known locally — no round trip needed, and
|
|
105
|
+
// it stays correct through an outage that starts a moment later.
|
|
106
|
+
if (await this.#fallback.has(jti)) return true;
|
|
67
107
|
try {
|
|
68
108
|
return await this.#primary.has(jti);
|
|
69
109
|
} catch (error) {
|
|
70
110
|
this.#onDegrade({ op: "has", error });
|
|
71
|
-
// Fail-open
|
|
72
|
-
return
|
|
111
|
+
// Fail-open on what neither knows: access tokens are short-lived.
|
|
112
|
+
return false;
|
|
73
113
|
}
|
|
74
114
|
}
|
|
75
115
|
|
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
|
@@ -38,7 +38,7 @@ export class Bouncer {
|
|
|
38
38
|
readonly #policies: Record<string, new () => BasePolicy>;
|
|
39
39
|
readonly #scope: Scope;
|
|
40
40
|
readonly #resolver: RightsResolver | undefined;
|
|
41
|
-
|
|
41
|
+
#containerResolver: PolicyContainerResolver | undefined;
|
|
42
42
|
readonly #emitter: BouncerEmitter | undefined;
|
|
43
43
|
/** Memoized resolution — a Bouncer is fixed per `(user, scope)`, so resolve once (D3). */
|
|
44
44
|
#resolved: Promise<EffectivePermissions> | undefined;
|
|
@@ -58,6 +58,19 @@ export class Bouncer {
|
|
|
58
58
|
this.#emitter = context?.emitter;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Point the bouncer at an IoC resolver, so a policy's constructor
|
|
63
|
+
* dependencies are injected (AdonisJS `setContainerResolver`).
|
|
64
|
+
*
|
|
65
|
+
* The constructor takes one too; this is the setter AdonisJS exposes so the
|
|
66
|
+
* HTTP layer can hand over the REQUEST's resolver after the bouncer exists.
|
|
67
|
+
* Passing `undefined` clears it, and policies fall back to `new Policy()`.
|
|
68
|
+
*/
|
|
69
|
+
setContainerResolver(containerResolver?: PolicyContainerResolver): this {
|
|
70
|
+
this.#containerResolver = containerResolver;
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
61
74
|
/**
|
|
62
75
|
* Resolve the user, lazily invoking a resolver callback once and memoizing
|
|
63
76
|
* the result (Adonis `#getUser`). A plain `UserPayload | null` passes through.
|
|
@@ -157,6 +170,53 @@ export class Bouncer {
|
|
|
157
170
|
return AuthorizationResponse.deny(message, status);
|
|
158
171
|
}
|
|
159
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The checks a TEMPLATE performs, shared into the view per request so
|
|
175
|
+
* `@can('post.edit', post)` resolves them (AdonisJS shares the same pair
|
|
176
|
+
* under `bouncer`; it names the bag `edgeHelpers`, we do not borrow the
|
|
177
|
+
* engine's name).
|
|
178
|
+
*
|
|
179
|
+
* AdonisJS reads a dotted action as `policy.method` —
|
|
180
|
+
* `'PostPolicy.edit'` is `with('PostPolicy').allows('edit', …)`. Warden,
|
|
181
|
+
* however, keys its ABILITIES with dots (`'post.edit'`), so applying that
|
|
182
|
+
* split blindly would break every warden app.
|
|
183
|
+
*
|
|
184
|
+
* NAMED DEVIATION, strictly more permissive: a registered ability wins, and
|
|
185
|
+
* only an action with no matching ability falls through to the policy
|
|
186
|
+
* split. A migrated AdonisJS template resolves its policies exactly as
|
|
187
|
+
* before, and a warden app keeps its dotted abilities.
|
|
188
|
+
*/
|
|
189
|
+
readonly templateHelpers: {
|
|
190
|
+
bouncer: {
|
|
191
|
+
can(action: string, ...args: unknown[]): Promise<boolean>;
|
|
192
|
+
cannot(action: string, ...args: unknown[]): Promise<boolean>;
|
|
193
|
+
};
|
|
194
|
+
} = {
|
|
195
|
+
bouncer: {
|
|
196
|
+
can: (action: string, ...args: unknown[]): Promise<boolean> => {
|
|
197
|
+
const split = this.#splitAction(action);
|
|
198
|
+
return split === undefined
|
|
199
|
+
? this.allows(action, ...args)
|
|
200
|
+
: this.with(split.policy).allows(split.method, ...args);
|
|
201
|
+
},
|
|
202
|
+
cannot: (action: string, ...args: unknown[]): Promise<boolean> => {
|
|
203
|
+
const split = this.#splitAction(action);
|
|
204
|
+
return split === undefined
|
|
205
|
+
? this.denies(action, ...args)
|
|
206
|
+
: this.with(split.policy).denies(split.method, ...args);
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/** `policy.method` for a dotted action that names no registered ability;
|
|
212
|
+
* `undefined` when the action IS an ability (warden keys its own with dots). */
|
|
213
|
+
#splitAction(action: string): { policy: string; method: string } | undefined {
|
|
214
|
+
if (Object.hasOwn(this.#abilities, action)) return undefined;
|
|
215
|
+
const dot = action.indexOf(".");
|
|
216
|
+
if (dot <= 0 || dot === action.length - 1) return undefined;
|
|
217
|
+
return { policy: action.slice(0, dot), method: action.slice(dot + 1) };
|
|
218
|
+
}
|
|
219
|
+
|
|
160
220
|
/** Open a policy for checks (D8 — fresh policy instance per check). */
|
|
161
221
|
with(policy: (new () => BasePolicy) | string): PolicyAuthorizer {
|
|
162
222
|
const factory =
|
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,7 @@ export type {
|
|
|
17
17
|
AuthStrategy,
|
|
18
18
|
UserPayload,
|
|
19
19
|
} from "./AuthManager.js";
|
|
20
|
-
export { AuthManager } from "./AuthManager.js";
|
|
20
|
+
export { AuthManager, type WardenEmitter } from "./AuthManager.js";
|
|
21
21
|
export type { AuthRateLimiterConfig } from "./AuthRateLimiter.js";
|
|
22
22
|
export { AuthRateLimiter } from "./AuthRateLimiter.js";
|
|
23
23
|
export { AbilitiesBuilder } from "./bouncer/AbilitiesBuilder.js";
|
|
@@ -41,6 +41,7 @@ export type {
|
|
|
41
41
|
export type { GuardFactory, WardenConfig } from "./config.js";
|
|
42
42
|
export {
|
|
43
43
|
apiKeyGuard,
|
|
44
|
+
basicAuthGuard,
|
|
44
45
|
defineConfig,
|
|
45
46
|
jwtGuard,
|
|
46
47
|
sessionGuard,
|
|
@@ -98,8 +99,9 @@ export type {
|
|
|
98
99
|
TotpAlgorithm,
|
|
99
100
|
TotpConfig,
|
|
100
101
|
TotpEnrollment,
|
|
102
|
+
TotpReplayGuard,
|
|
101
103
|
} from "./mfa/TotpProvider.js";
|
|
102
|
-
export { TotpProvider } from "./mfa/TotpProvider.js";
|
|
104
|
+
export { MemoryTotpReplayGuard, TotpProvider } from "./mfa/TotpProvider.js";
|
|
103
105
|
export type {
|
|
104
106
|
AuthenticationOptionsJSON,
|
|
105
107
|
AuthenticationResponseJSON,
|
|
@@ -116,6 +118,7 @@ export {
|
|
|
116
118
|
MemoryWebauthnCredentialStore,
|
|
117
119
|
WebauthnProvider,
|
|
118
120
|
} from "./mfa/WebauthnProvider.js";
|
|
121
|
+
export { quasarConnection } from "./quasar.js";
|
|
119
122
|
export type {
|
|
120
123
|
RedisBlacklistConfig,
|
|
121
124
|
RedisLikeClient,
|
|
@@ -129,6 +132,19 @@ export {
|
|
|
129
132
|
generateRefreshToken,
|
|
130
133
|
MemoryRefreshTokenDriver,
|
|
131
134
|
} from "./RefreshTokenStore.js";
|
|
135
|
+
export {
|
|
136
|
+
DEFAULT_SECRET_LENGTH,
|
|
137
|
+
decodeTokenValue,
|
|
138
|
+
encodeTokenValue,
|
|
139
|
+
hashSecret,
|
|
140
|
+
MemoryRememberMeTokenDriver,
|
|
141
|
+
type MintedRememberMeToken,
|
|
142
|
+
mintRememberMeToken,
|
|
143
|
+
type RememberMeTokenDriver,
|
|
144
|
+
type StoredRememberMeToken,
|
|
145
|
+
safeCompareHashes,
|
|
146
|
+
verifyAndRecycleRememberMeToken,
|
|
147
|
+
} from "./RememberMeToken.js";
|
|
132
148
|
export type {
|
|
133
149
|
BlacklistDegradeEvent,
|
|
134
150
|
ResilientBlacklistConfig,
|
|
@@ -144,6 +160,13 @@ export type {
|
|
|
144
160
|
export { scopeKey } from "./rights/types.js";
|
|
145
161
|
export type { ApiKeyConfig } from "./strategies/ApiKeyStrategy.js";
|
|
146
162
|
export { ApiKeyStrategy } from "./strategies/ApiKeyStrategy.js";
|
|
163
|
+
export type { BasicAuthConfig } from "./strategies/BasicAuthStrategy.js";
|
|
164
|
+
export {
|
|
165
|
+
BasicAuthStrategy,
|
|
166
|
+
basicAuthChallenge,
|
|
167
|
+
decodeBasicAuth,
|
|
168
|
+
safeCompare,
|
|
169
|
+
} from "./strategies/BasicAuthStrategy.js";
|
|
147
170
|
export type { JwtClaims, JwtStrategyConfig } from "./strategies/JwtStrategy.js";
|
|
148
171
|
export { generateJwtSecret, JwtStrategy } from "./strategies/JwtStrategy.js";
|
|
149
172
|
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 ─────────────────────────────────────────────────
|
package/src/mfa/TotpProvider.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
103
|
-
|
|
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);
|