@c9up/warden 0.1.4 → 0.1.6
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/README.md +58 -0
- package/dist/Guard.d.ts +9 -0
- package/dist/Guard.d.ts.map +1 -1
- package/dist/Guard.js +16 -0
- package/dist/Guard.js.map +1 -1
- package/dist/RedisBlacklistDriver.d.ts +32 -0
- package/dist/RedisBlacklistDriver.d.ts.map +1 -0
- package/dist/RedisBlacklistDriver.js +37 -0
- package/dist/RedisBlacklistDriver.js.map +1 -0
- package/dist/ResilientBlacklistDriver.d.ts +35 -0
- package/dist/ResilientBlacklistDriver.d.ts.map +1 -0
- package/dist/ResilientBlacklistDriver.js +61 -0
- package/dist/ResilientBlacklistDriver.js.map +1 -0
- package/dist/TokenBlacklist.d.ts +6 -2
- package/dist/TokenBlacklist.d.ts.map +1 -1
- package/dist/TokenBlacklist.js +2 -2
- package/dist/TokenBlacklist.js.map +1 -1
- package/dist/WardenProvider.d.ts.map +1 -1
- package/dist/WardenProvider.js +47 -6
- package/dist/WardenProvider.js.map +1 -1
- package/dist/config.d.ts +65 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/mfa/BackupCodesProvider.d.ts +39 -0
- package/dist/mfa/BackupCodesProvider.d.ts.map +1 -0
- package/dist/mfa/BackupCodesProvider.js +87 -0
- package/dist/mfa/BackupCodesProvider.js.map +1 -0
- package/dist/mfa/MfaManager.d.ts +107 -0
- package/dist/mfa/MfaManager.d.ts.map +1 -0
- package/dist/mfa/MfaManager.js +219 -0
- package/dist/mfa/MfaManager.js.map +1 -0
- package/dist/mfa/OtpProvider.d.ts +67 -0
- package/dist/mfa/OtpProvider.d.ts.map +1 -0
- package/dist/mfa/OtpProvider.js +104 -0
- package/dist/mfa/OtpProvider.js.map +1 -0
- package/dist/mfa/TotpProvider.d.ts +52 -0
- package/dist/mfa/TotpProvider.d.ts.map +1 -0
- package/dist/mfa/TotpProvider.js +103 -0
- package/dist/mfa/TotpProvider.js.map +1 -0
- package/dist/mfa/WebauthnProvider.d.ts +175 -0
- package/dist/mfa/WebauthnProvider.d.ts.map +1 -0
- package/dist/mfa/WebauthnProvider.js +239 -0
- package/dist/mfa/WebauthnProvider.js.map +1 -0
- package/dist/mfa/base32.d.ts +13 -0
- package/dist/mfa/base32.d.ts.map +1 -0
- package/dist/mfa/base32.js +52 -0
- package/dist/mfa/base32.js.map +1 -0
- package/dist/mfa/webauthn-codec.d.ts +58 -0
- package/dist/mfa/webauthn-codec.d.ts.map +1 -0
- package/dist/mfa/webauthn-codec.js +221 -0
- package/dist/mfa/webauthn-codec.js.map +1 -0
- package/dist/middleware.d.ts +31 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +77 -4
- package/dist/middleware.js.map +1 -1
- package/dist/standalone.d.ts.map +1 -1
- package/dist/standalone.js +12 -0
- package/dist/standalone.js.map +1 -1
- package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
- package/dist/strategies/ApiKeyStrategy.js +14 -1
- package/dist/strategies/ApiKeyStrategy.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 +2 -2
- package/src/Guard.ts +21 -0
- package/src/RedisBlacklistDriver.ts +60 -0
- package/src/ResilientBlacklistDriver.ts +84 -0
- package/src/TokenBlacklist.ts +7 -3
- package/src/WardenProvider.ts +53 -7
- package/src/config.ts +60 -1
- package/src/index.ts +58 -0
- package/src/mfa/BackupCodesProvider.ts +125 -0
- package/src/mfa/MfaManager.ts +307 -0
- package/src/mfa/OtpProvider.ts +177 -0
- package/src/mfa/TotpProvider.ts +140 -0
- package/src/mfa/WebauthnProvider.ts +416 -0
- package/src/mfa/base32.ts +54 -0
- package/src/mfa/webauthn-codec.ts +264 -0
- package/src/middleware.ts +111 -2
- package/src/standalone.ts +14 -1
- package/src/strategies/ApiKeyStrategy.ts +14 -1
package/src/config.ts
CHANGED
|
@@ -16,7 +16,23 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { UserPayload } from "./AuthManager.js";
|
|
19
|
-
import type {
|
|
19
|
+
import type { BasePolicy } from "./bouncer/BasePolicy.js";
|
|
20
|
+
import type { Ability } from "./bouncer/types.js";
|
|
21
|
+
import type { MfaManager } from "./mfa/MfaManager.js";
|
|
22
|
+
import type { RightsStore, Scope } from "./rights/types.js";
|
|
23
|
+
import type { ApiKeyConfig } from "./strategies/ApiKeyStrategy.js";
|
|
24
|
+
import type { SessionStrategyConfig } from "./strategies/SessionStrategy.js";
|
|
25
|
+
import type { TokenBlacklist } from "./TokenBlacklist.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Minimal request shape passed to `resolveScope` — enough to derive a tenant
|
|
29
|
+
* from a header or the authenticated user, without coupling config to the full
|
|
30
|
+
* HTTP context type (keeps Warden agnostic of the host framework).
|
|
31
|
+
*/
|
|
32
|
+
export interface ScopeRequestContext {
|
|
33
|
+
request: { headers: Record<string, string> };
|
|
34
|
+
auth?: { user?: UserPayload | null };
|
|
35
|
+
}
|
|
20
36
|
|
|
21
37
|
export interface JwtConfig {
|
|
22
38
|
secret: string;
|
|
@@ -26,6 +42,12 @@ export interface JwtConfig {
|
|
|
26
42
|
email: string,
|
|
27
43
|
password: string,
|
|
28
44
|
) => Promise<UserPayload | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Optional revocation list. Supply a `TokenBlacklist` (Memory/Redis-backed) to
|
|
47
|
+
* enable `revoke()`/early-rejection of JWTs before expiry. Without it, `revoke()`
|
|
48
|
+
* throws (the call would be a silent no-op).
|
|
49
|
+
*/
|
|
50
|
+
blacklist?: TokenBlacklist;
|
|
29
51
|
}
|
|
30
52
|
|
|
31
53
|
export interface WardenConfig {
|
|
@@ -33,6 +55,18 @@ export interface WardenConfig {
|
|
|
33
55
|
defaultStrategy?: string;
|
|
34
56
|
/** JWT strategy configuration. */
|
|
35
57
|
jwt?: JwtConfig;
|
|
58
|
+
/**
|
|
59
|
+
* Session strategy configuration. Supply `findUser` so `@Guard('session')`
|
|
60
|
+
* routes resolve the authenticated user from the session id. Without this,
|
|
61
|
+
* the `session` strategy is unregistered and `@Guard('session')` throws.
|
|
62
|
+
*/
|
|
63
|
+
session?: SessionStrategyConfig;
|
|
64
|
+
/**
|
|
65
|
+
* API-key strategy configuration. Supply `findByKey` so `@Guard('api-key')`
|
|
66
|
+
* routes resolve the user (and scopes) from the request header. Without this,
|
|
67
|
+
* the `api-key` strategy is unregistered and `@Guard('api-key')` throws.
|
|
68
|
+
*/
|
|
69
|
+
apiKey?: ApiKeyConfig;
|
|
36
70
|
/**
|
|
37
71
|
* Rights layer configuration (Epic 56). Supply a custom `store` to back the
|
|
38
72
|
* unified resolver with a DB-backed driver; when omitted, an in-memory
|
|
@@ -40,6 +74,31 @@ export interface WardenConfig {
|
|
|
40
74
|
* in-memory shipped).
|
|
41
75
|
*/
|
|
42
76
|
rights?: { store?: RightsStore };
|
|
77
|
+
/**
|
|
78
|
+
* Standalone abilities the per-request Bouncer knows by name (Epic 56.6).
|
|
79
|
+
* Keyed by the string passed to `ctx.bouncer.authorize('post.edit', …)`.
|
|
80
|
+
* Define with `Bouncer.ability((user, post) => …)`.
|
|
81
|
+
*/
|
|
82
|
+
abilities?: Record<string, Ability<never[]>>;
|
|
83
|
+
/**
|
|
84
|
+
* Class-based policies the per-request Bouncer knows by name (Epic 56.6).
|
|
85
|
+
* Reachable via `ctx.bouncer.with('PostPolicy')`. Each entry is the policy
|
|
86
|
+
* constructor; a fresh instance is created per check.
|
|
87
|
+
*/
|
|
88
|
+
policies?: Record<string, new () => BasePolicy>;
|
|
89
|
+
/**
|
|
90
|
+
* Derive the authorization scope for a request (AD4 — scope-first). Return
|
|
91
|
+
* `{ tenant }` to scope rights to a tenant (e.g. from a subdomain or header),
|
|
92
|
+
* or `"global"`. Omitted ⇒ every request runs in the implicit `global` scope
|
|
93
|
+
* (zero config for single-tenant apps).
|
|
94
|
+
*/
|
|
95
|
+
resolveScope?: (ctx: ScopeRequestContext) => Scope | Promise<Scope>;
|
|
96
|
+
/**
|
|
97
|
+
* Multi-factor authentication. Supply a configured `MfaManager` (built with
|
|
98
|
+
* your persistent stores + providers) to register it in the container as
|
|
99
|
+
* `MfaManager` / `"mfa"`. Required to use `@RequireMfa` step-up flows.
|
|
100
|
+
*/
|
|
101
|
+
mfa?: { manager: MfaManager };
|
|
43
102
|
}
|
|
44
103
|
|
|
45
104
|
/** Typed config helper — identity function for editor inference. */
|
package/src/index.ts
CHANGED
|
@@ -44,10 +44,63 @@ export {
|
|
|
44
44
|
Guard,
|
|
45
45
|
getGuardMetadata,
|
|
46
46
|
getPermissionMetadata,
|
|
47
|
+
getRequireMfaMetadata,
|
|
47
48
|
getRoleMetadata,
|
|
48
49
|
Permission,
|
|
50
|
+
RequireMfa,
|
|
49
51
|
Role,
|
|
50
52
|
} from "./Guard.js";
|
|
53
|
+
export type {
|
|
54
|
+
BackupCodesConfig,
|
|
55
|
+
BackupCodesResult,
|
|
56
|
+
BackupCodeVerification,
|
|
57
|
+
} from "./mfa/BackupCodesProvider.js";
|
|
58
|
+
export { BackupCodesProvider } from "./mfa/BackupCodesProvider.js";
|
|
59
|
+
export type {
|
|
60
|
+
MfaFactor,
|
|
61
|
+
MfaFactorKind,
|
|
62
|
+
MfaFactorStore,
|
|
63
|
+
MfaFactorSummary,
|
|
64
|
+
MfaManagerConfig,
|
|
65
|
+
} from "./mfa/MfaManager.js";
|
|
66
|
+
export { MemoryMfaFactorStore, MfaManager } from "./mfa/MfaManager.js";
|
|
67
|
+
export type {
|
|
68
|
+
OtpChallenge,
|
|
69
|
+
OtpChallengeStore,
|
|
70
|
+
OtpConfig,
|
|
71
|
+
OtpDeliveryChannel,
|
|
72
|
+
OtpFailureReason,
|
|
73
|
+
OtpStartResult,
|
|
74
|
+
OtpVerification,
|
|
75
|
+
} from "./mfa/OtpProvider.js";
|
|
76
|
+
export { MemoryOtpChallengeStore, OtpProvider } from "./mfa/OtpProvider.js";
|
|
77
|
+
export type {
|
|
78
|
+
TotpAlgorithm,
|
|
79
|
+
TotpConfig,
|
|
80
|
+
TotpEnrollment,
|
|
81
|
+
} from "./mfa/TotpProvider.js";
|
|
82
|
+
export { TotpProvider } from "./mfa/TotpProvider.js";
|
|
83
|
+
export type {
|
|
84
|
+
AuthenticationOptionsJSON,
|
|
85
|
+
AuthenticationResponseJSON,
|
|
86
|
+
RegistrationOptionsJSON,
|
|
87
|
+
RegistrationResponseJSON,
|
|
88
|
+
StoredPasskey,
|
|
89
|
+
WebauthnChallengeStore,
|
|
90
|
+
WebauthnConfig,
|
|
91
|
+
WebauthnCredentialStore,
|
|
92
|
+
WebauthnUser,
|
|
93
|
+
} from "./mfa/WebauthnProvider.js";
|
|
94
|
+
export {
|
|
95
|
+
MemoryWebauthnChallengeStore,
|
|
96
|
+
MemoryWebauthnCredentialStore,
|
|
97
|
+
WebauthnProvider,
|
|
98
|
+
} from "./mfa/WebauthnProvider.js";
|
|
99
|
+
export type {
|
|
100
|
+
RedisBlacklistConfig,
|
|
101
|
+
RedisLikeClient,
|
|
102
|
+
} from "./RedisBlacklistDriver.js";
|
|
103
|
+
export { RedisBlacklistDriver } from "./RedisBlacklistDriver.js";
|
|
51
104
|
export type {
|
|
52
105
|
RefreshTokenDriver,
|
|
53
106
|
StoredRefreshToken,
|
|
@@ -56,6 +109,11 @@ export {
|
|
|
56
109
|
generateRefreshToken,
|
|
57
110
|
MemoryRefreshTokenDriver,
|
|
58
111
|
} from "./RefreshTokenStore.js";
|
|
112
|
+
export type {
|
|
113
|
+
BlacklistDegradeEvent,
|
|
114
|
+
ResilientBlacklistConfig,
|
|
115
|
+
} from "./ResilientBlacklistDriver.js";
|
|
116
|
+
export { ResilientBlacklistDriver } from "./ResilientBlacklistDriver.js";
|
|
59
117
|
export { MemoryRightsStore } from "./rights/MemoryRightsStore.js";
|
|
60
118
|
export { RightsResolver } from "./rights/RightsResolver.js";
|
|
61
119
|
export type {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackupCodesProvider — single-use recovery codes for when a user loses their
|
|
3
|
+
* authenticator. Codes are shown to the user exactly once at generation time;
|
|
4
|
+
* only salted hashes are persisted. Verifying a code consumes it.
|
|
5
|
+
*
|
|
6
|
+
* Codes are high-entropy random tokens (default ~50 bits), so a single salted
|
|
7
|
+
* SHA-256 is the right primitive here — not an expensive password KDF.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
11
|
+
import { WardenError } from "../errors.js";
|
|
12
|
+
import { base32Encode } from "./base32.js";
|
|
13
|
+
|
|
14
|
+
export interface BackupCodesConfig {
|
|
15
|
+
/** How many codes to generate. Default `10`. */
|
|
16
|
+
count?: number;
|
|
17
|
+
/** Characters per code (base32, excludes the separator). Default `10`. */
|
|
18
|
+
length?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BackupCodesResult {
|
|
22
|
+
/** Plaintext codes — display ONCE, never persist. */
|
|
23
|
+
codes: string[];
|
|
24
|
+
/** Salted hashes (`salt$digest`) to persist against the user. */
|
|
25
|
+
hashes: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BackupCodeVerification {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
/** The hash list with the consumed code removed (persist this on success). */
|
|
31
|
+
remaining: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULTS = { count: 10, length: 10 };
|
|
35
|
+
|
|
36
|
+
export class BackupCodesProvider {
|
|
37
|
+
readonly kind = "backup_codes" as const;
|
|
38
|
+
readonly #cfg: Required<BackupCodesConfig>;
|
|
39
|
+
|
|
40
|
+
constructor(config: BackupCodesConfig = {}) {
|
|
41
|
+
this.#cfg = { ...DEFAULTS, ...config };
|
|
42
|
+
if (this.#cfg.count < 1) {
|
|
43
|
+
throw new WardenError(
|
|
44
|
+
"INVALID_CONFIG",
|
|
45
|
+
`backup code count must be >= 1, got ${this.#cfg.count}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (this.#cfg.length < 8) {
|
|
49
|
+
throw new WardenError(
|
|
50
|
+
"INVALID_CONFIG",
|
|
51
|
+
`backup code length must be >= 8 for adequate entropy, got ${this.#cfg.length}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Generate a fresh batch of codes plus the hashes to persist. */
|
|
57
|
+
generate(): BackupCodesResult {
|
|
58
|
+
const codes: string[] = [];
|
|
59
|
+
const hashes: string[] = [];
|
|
60
|
+
for (let i = 0; i < this.#cfg.count; i++) {
|
|
61
|
+
const raw = base32Encode(randomBytes(this.#cfg.length)).slice(
|
|
62
|
+
0,
|
|
63
|
+
this.#cfg.length,
|
|
64
|
+
);
|
|
65
|
+
// Split in the middle for readability: "ABCDE-FGHIJ".
|
|
66
|
+
const half = Math.floor(this.#cfg.length / 2);
|
|
67
|
+
codes.push(`${raw.slice(0, half)}-${raw.slice(half)}`);
|
|
68
|
+
hashes.push(saltedHash(raw));
|
|
69
|
+
}
|
|
70
|
+
return { codes, hashes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Verify a user-supplied code against the stored hashes. On a match the
|
|
75
|
+
* code is consumed: `remaining` excludes it and must be persisted. Input is
|
|
76
|
+
* normalized (separators stripped, upper-cased) before comparison.
|
|
77
|
+
*/
|
|
78
|
+
verify(hashes: string[], code: string): BackupCodeVerification {
|
|
79
|
+
const candidate = normalize(code);
|
|
80
|
+
let matchedIndex = -1;
|
|
81
|
+
// Scan every entry (no early exit) so timing doesn't leak position.
|
|
82
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
83
|
+
if (matchesStored(hashes[i], candidate)) {
|
|
84
|
+
matchedIndex = i;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (matchedIndex === -1) {
|
|
88
|
+
return { ok: false, remaining: hashes };
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
remaining: hashes.filter((_, i) => i !== matchedIndex),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalize(code: string): string {
|
|
98
|
+
return code.replace(/[\s-]/g, "").toUpperCase();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Hash a normalized code under a fresh random salt → `salt$digest` (hex). */
|
|
102
|
+
function saltedHash(normalized: string): string {
|
|
103
|
+
const salt = randomBytes(8);
|
|
104
|
+
const digest = digestWith(salt, normalized);
|
|
105
|
+
return `${salt.toString("hex")}$${digest.toString("hex")}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Re-hash the candidate under the stored entry's salt and compare. */
|
|
109
|
+
function matchesStored(stored: string, candidate: string): boolean {
|
|
110
|
+
const sep = stored.indexOf("$");
|
|
111
|
+
if (sep === -1) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const salt = Buffer.from(stored.slice(0, sep), "hex");
|
|
115
|
+
const expected = Buffer.from(stored.slice(sep + 1), "hex");
|
|
116
|
+
const actual = digestWith(salt, candidate);
|
|
117
|
+
if (actual.length !== expected.length) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return timingSafeEqual(actual, expected);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function digestWith(salt: Buffer, normalized: string): Buffer {
|
|
124
|
+
return createHash("sha256").update(salt).update(normalized).digest();
|
|
125
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MfaManager — orchestrates the stateful "enable 2FA" lifecycle for a user:
|
|
3
|
+
* TOTP enrollment (pending → confirmed) and single-use backup codes, over a
|
|
4
|
+
* pluggable factor store (in-memory default).
|
|
5
|
+
*
|
|
6
|
+
* OTP (email/SMS) and WebAuthn are NOT folded in here: OTP is ephemeral
|
|
7
|
+
* (challenge/response, no enrollment) and WebAuthn already persists its own
|
|
8
|
+
* passkeys in its credential store. Use `OtpProvider` / `WebauthnProvider`
|
|
9
|
+
* directly for those, alongside this manager.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { WardenError } from "../errors.js";
|
|
14
|
+
import type { BackupCodesProvider } from "./BackupCodesProvider.js";
|
|
15
|
+
import type { TotpProvider } from "./TotpProvider.js";
|
|
16
|
+
|
|
17
|
+
export type MfaFactorKind = "totp" | "backup_codes";
|
|
18
|
+
|
|
19
|
+
export interface MfaFactor {
|
|
20
|
+
id: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
kind: MfaFactorKind;
|
|
23
|
+
label?: string;
|
|
24
|
+
/** TOTP shared secret (base32), present for `totp` factors. */
|
|
25
|
+
secret?: string;
|
|
26
|
+
/** Salted hashes of the remaining backup codes, for `backup_codes` factors. */
|
|
27
|
+
backupHashes?: string[];
|
|
28
|
+
/** Epoch ms when the factor became usable; absent while pending. */
|
|
29
|
+
confirmedAt?: number;
|
|
30
|
+
createdAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Public, secret-free view of an enrolled factor. */
|
|
34
|
+
export interface MfaFactorSummary {
|
|
35
|
+
id: string;
|
|
36
|
+
kind: MfaFactorKind;
|
|
37
|
+
label?: string;
|
|
38
|
+
confirmed: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MfaFactorStore {
|
|
42
|
+
save(factor: MfaFactor): Promise<void>;
|
|
43
|
+
findById(id: string): Promise<MfaFactor | null>;
|
|
44
|
+
findByUser(userId: string): Promise<MfaFactor[]>;
|
|
45
|
+
delete(id: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class MemoryMfaFactorStore implements MfaFactorStore {
|
|
49
|
+
#store = new Map<string, MfaFactor>();
|
|
50
|
+
async save(factor: MfaFactor): Promise<void> {
|
|
51
|
+
this.#store.set(factor.id, factor);
|
|
52
|
+
}
|
|
53
|
+
async findById(id: string): Promise<MfaFactor | null> {
|
|
54
|
+
return this.#store.get(id) ?? null;
|
|
55
|
+
}
|
|
56
|
+
async findByUser(userId: string): Promise<MfaFactor[]> {
|
|
57
|
+
return [...this.#store.values()].filter((f) => f.userId === userId);
|
|
58
|
+
}
|
|
59
|
+
async delete(id: string): Promise<void> {
|
|
60
|
+
this.#store.delete(id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MfaRateLimitConfig {
|
|
65
|
+
/** Failed verifications allowed per user before lockout. Default `5`. */
|
|
66
|
+
maxAttempts?: number;
|
|
67
|
+
/** Lockout / counter window in seconds. Default `900` (15 min). */
|
|
68
|
+
windowSeconds?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MfaManagerConfig {
|
|
72
|
+
/** App/brand name shown in the authenticator (the TOTP issuer). */
|
|
73
|
+
issuer: string;
|
|
74
|
+
totp?: TotpProvider;
|
|
75
|
+
backupCodes?: BackupCodesProvider;
|
|
76
|
+
store?: MfaFactorStore;
|
|
77
|
+
/** Per-user brute-force protection on `verify()`. Defaults to 5 / 15 min. */
|
|
78
|
+
rateLimit?: MfaRateLimitConfig;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface AttemptEntry {
|
|
82
|
+
count: number;
|
|
83
|
+
resetAt: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class MfaManager {
|
|
87
|
+
readonly #issuer: string;
|
|
88
|
+
readonly #totp?: TotpProvider;
|
|
89
|
+
readonly #backupCodes?: BackupCodesProvider;
|
|
90
|
+
readonly #store: MfaFactorStore;
|
|
91
|
+
readonly #maxAttempts: number;
|
|
92
|
+
readonly #windowMs: number;
|
|
93
|
+
readonly #attempts = new Map<string, AttemptEntry>();
|
|
94
|
+
|
|
95
|
+
constructor(config: MfaManagerConfig) {
|
|
96
|
+
if (!config?.issuer) {
|
|
97
|
+
throw new WardenError("INVALID_CONFIG", "MfaManager requires an issuer");
|
|
98
|
+
}
|
|
99
|
+
this.#issuer = config.issuer;
|
|
100
|
+
this.#totp = config.totp;
|
|
101
|
+
this.#backupCodes = config.backupCodes;
|
|
102
|
+
this.#store = config.store ?? new MemoryMfaFactorStore();
|
|
103
|
+
this.#maxAttempts = config.rateLimit?.maxAttempts ?? 5;
|
|
104
|
+
this.#windowMs = (config.rateLimit?.windowSeconds ?? 900) * 1000;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── TOTP enrollment ──────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Begin TOTP enrollment: persist a pending factor and return the secret +
|
|
111
|
+
* `otpauth://` URI to show as a QR code. The factor is unusable until
|
|
112
|
+
* `confirmTotp()` succeeds.
|
|
113
|
+
*/
|
|
114
|
+
async enrollTotp(
|
|
115
|
+
user: { id: string; name: string },
|
|
116
|
+
label?: string,
|
|
117
|
+
): Promise<{ factorId: string; secret: string; uri: string }> {
|
|
118
|
+
const totp = this.#requireTotp();
|
|
119
|
+
const { secret, uri } = totp.enroll(user.name, this.#issuer);
|
|
120
|
+
const factor: MfaFactor = {
|
|
121
|
+
id: newId(),
|
|
122
|
+
userId: user.id,
|
|
123
|
+
kind: "totp",
|
|
124
|
+
label,
|
|
125
|
+
secret,
|
|
126
|
+
createdAt: Date.now(),
|
|
127
|
+
};
|
|
128
|
+
await this.#store.save(factor);
|
|
129
|
+
return { factorId: factor.id, secret, uri };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Confirm a pending TOTP factor by verifying a first code. */
|
|
133
|
+
async confirmTotp(factorId: string, code: string): Promise<boolean> {
|
|
134
|
+
const totp = this.#requireTotp();
|
|
135
|
+
const factor = await this.#store.findById(factorId);
|
|
136
|
+
if (!factor || factor.kind !== "totp" || !factor.secret) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (!totp.verify(factor.secret, code)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
await this.#store.save({ ...factor, confirmedAt: Date.now() });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Verify a TOTP code against the user's confirmed authenticator factors. */
|
|
147
|
+
async verifyTotp(userId: string, code: string): Promise<boolean> {
|
|
148
|
+
const totp = this.#requireTotp();
|
|
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
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Backup codes ─────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Generate (or regenerate) the user's backup codes. Returns the plaintext
|
|
163
|
+
* codes to display ONCE; only hashes are stored. Any prior backup-code
|
|
164
|
+
* factor is replaced.
|
|
165
|
+
*/
|
|
166
|
+
async createBackupCodes(userId: string, label?: string): Promise<string[]> {
|
|
167
|
+
const provider = this.#requireBackupCodes();
|
|
168
|
+
const existing = await this.#store.findByUser(userId);
|
|
169
|
+
for (const f of existing) {
|
|
170
|
+
if (f.kind === "backup_codes") {
|
|
171
|
+
await this.#store.delete(f.id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const { codes, hashes } = provider.generate();
|
|
175
|
+
await this.#store.save({
|
|
176
|
+
id: newId(),
|
|
177
|
+
userId,
|
|
178
|
+
kind: "backup_codes",
|
|
179
|
+
label,
|
|
180
|
+
backupHashes: hashes,
|
|
181
|
+
// Backup codes are usable as soon as they are shown.
|
|
182
|
+
confirmedAt: Date.now(),
|
|
183
|
+
createdAt: Date.now(),
|
|
184
|
+
});
|
|
185
|
+
return codes;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Verify and consume one backup code for the user. */
|
|
189
|
+
async verifyBackupCode(userId: string, code: string): Promise<boolean> {
|
|
190
|
+
const provider = this.#requireBackupCodes();
|
|
191
|
+
const factors = await this.#store.findByUser(userId);
|
|
192
|
+
for (const factor of factors) {
|
|
193
|
+
if (factor.kind !== "backup_codes" || !factor.backupHashes) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const result = provider.verify(factor.backupHashes, code);
|
|
197
|
+
if (result.ok) {
|
|
198
|
+
await this.#store.save({ ...factor, backupHashes: result.remaining });
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Unified verify + status ──────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Verify a code entered in a single "2FA code" field: tries the user's TOTP
|
|
209
|
+
* factors first, then falls back to consuming a backup code.
|
|
210
|
+
*/
|
|
211
|
+
async verify(userId: string, code: string): Promise<boolean> {
|
|
212
|
+
if (this.#isLocked(userId)) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
let ok = false;
|
|
216
|
+
if (this.#totp && (await this.verifyTotp(userId, code))) {
|
|
217
|
+
ok = true;
|
|
218
|
+
} else if (
|
|
219
|
+
this.#backupCodes &&
|
|
220
|
+
(await this.verifyBackupCode(userId, code))
|
|
221
|
+
) {
|
|
222
|
+
ok = true;
|
|
223
|
+
}
|
|
224
|
+
if (ok) {
|
|
225
|
+
this.#attempts.delete(userId);
|
|
226
|
+
} else {
|
|
227
|
+
this.#recordFailure(userId);
|
|
228
|
+
}
|
|
229
|
+
return ok;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Whether the user is currently locked out of `verify()` after too many
|
|
234
|
+
* failed attempts. Use it to surface a "try again later" message.
|
|
235
|
+
*/
|
|
236
|
+
isLocked(userId: string): boolean {
|
|
237
|
+
return this.#isLocked(userId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** List the user's factors without exposing any secret material. */
|
|
241
|
+
async listFactors(userId: string): Promise<MfaFactorSummary[]> {
|
|
242
|
+
const factors = await this.#store.findByUser(userId);
|
|
243
|
+
return factors.map((f) => ({
|
|
244
|
+
id: f.id,
|
|
245
|
+
kind: f.kind,
|
|
246
|
+
label: f.label,
|
|
247
|
+
confirmed: f.confirmedAt !== undefined,
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Whether the user has at least one confirmed factor. */
|
|
252
|
+
async isEnabled(userId: string): Promise<boolean> {
|
|
253
|
+
const factors = await this.#store.findByUser(userId);
|
|
254
|
+
return factors.some((f) => f.confirmedAt !== undefined);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Remove a factor (e.g. the user disables their authenticator). */
|
|
258
|
+
async disableFactor(factorId: string): Promise<void> {
|
|
259
|
+
await this.#store.delete(factorId);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#isLocked(userId: string): boolean {
|
|
263
|
+
const entry = this.#attempts.get(userId);
|
|
264
|
+
if (!entry) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
if (entry.resetAt < Date.now()) {
|
|
268
|
+
this.#attempts.delete(userId);
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return entry.count >= this.#maxAttempts;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#recordFailure(userId: string): void {
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
let entry = this.#attempts.get(userId);
|
|
277
|
+
if (!entry || entry.resetAt < now) {
|
|
278
|
+
entry = { count: 0, resetAt: now + this.#windowMs };
|
|
279
|
+
this.#attempts.set(userId, entry);
|
|
280
|
+
}
|
|
281
|
+
entry.count++;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#requireTotp(): TotpProvider {
|
|
285
|
+
if (!this.#totp) {
|
|
286
|
+
throw new WardenError(
|
|
287
|
+
"INVALID_CONFIG",
|
|
288
|
+
"MfaManager has no TotpProvider configured",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return this.#totp;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#requireBackupCodes(): BackupCodesProvider {
|
|
295
|
+
if (!this.#backupCodes) {
|
|
296
|
+
throw new WardenError(
|
|
297
|
+
"INVALID_CONFIG",
|
|
298
|
+
"MfaManager has no BackupCodesProvider configured",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return this.#backupCodes;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function newId(): string {
|
|
306
|
+
return randomBytes(16).toString("hex");
|
|
307
|
+
}
|