@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.
Files changed (88) hide show
  1. package/README.md +58 -0
  2. package/dist/Guard.d.ts +9 -0
  3. package/dist/Guard.d.ts.map +1 -1
  4. package/dist/Guard.js +16 -0
  5. package/dist/Guard.js.map +1 -1
  6. package/dist/RedisBlacklistDriver.d.ts +32 -0
  7. package/dist/RedisBlacklistDriver.d.ts.map +1 -0
  8. package/dist/RedisBlacklistDriver.js +37 -0
  9. package/dist/RedisBlacklistDriver.js.map +1 -0
  10. package/dist/ResilientBlacklistDriver.d.ts +35 -0
  11. package/dist/ResilientBlacklistDriver.d.ts.map +1 -0
  12. package/dist/ResilientBlacklistDriver.js +61 -0
  13. package/dist/ResilientBlacklistDriver.js.map +1 -0
  14. package/dist/TokenBlacklist.d.ts +6 -2
  15. package/dist/TokenBlacklist.d.ts.map +1 -1
  16. package/dist/TokenBlacklist.js +2 -2
  17. package/dist/TokenBlacklist.js.map +1 -1
  18. package/dist/WardenProvider.d.ts.map +1 -1
  19. package/dist/WardenProvider.js +47 -6
  20. package/dist/WardenProvider.js.map +1 -1
  21. package/dist/config.d.ts +65 -1
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js.map +1 -1
  24. package/dist/index.d.ts +15 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/mfa/BackupCodesProvider.d.ts +39 -0
  29. package/dist/mfa/BackupCodesProvider.d.ts.map +1 -0
  30. package/dist/mfa/BackupCodesProvider.js +87 -0
  31. package/dist/mfa/BackupCodesProvider.js.map +1 -0
  32. package/dist/mfa/MfaManager.d.ts +107 -0
  33. package/dist/mfa/MfaManager.d.ts.map +1 -0
  34. package/dist/mfa/MfaManager.js +219 -0
  35. package/dist/mfa/MfaManager.js.map +1 -0
  36. package/dist/mfa/OtpProvider.d.ts +67 -0
  37. package/dist/mfa/OtpProvider.d.ts.map +1 -0
  38. package/dist/mfa/OtpProvider.js +104 -0
  39. package/dist/mfa/OtpProvider.js.map +1 -0
  40. package/dist/mfa/TotpProvider.d.ts +52 -0
  41. package/dist/mfa/TotpProvider.d.ts.map +1 -0
  42. package/dist/mfa/TotpProvider.js +103 -0
  43. package/dist/mfa/TotpProvider.js.map +1 -0
  44. package/dist/mfa/WebauthnProvider.d.ts +175 -0
  45. package/dist/mfa/WebauthnProvider.d.ts.map +1 -0
  46. package/dist/mfa/WebauthnProvider.js +239 -0
  47. package/dist/mfa/WebauthnProvider.js.map +1 -0
  48. package/dist/mfa/base32.d.ts +13 -0
  49. package/dist/mfa/base32.d.ts.map +1 -0
  50. package/dist/mfa/base32.js +52 -0
  51. package/dist/mfa/base32.js.map +1 -0
  52. package/dist/mfa/webauthn-codec.d.ts +58 -0
  53. package/dist/mfa/webauthn-codec.d.ts.map +1 -0
  54. package/dist/mfa/webauthn-codec.js +221 -0
  55. package/dist/mfa/webauthn-codec.js.map +1 -0
  56. package/dist/middleware.d.ts +31 -0
  57. package/dist/middleware.d.ts.map +1 -1
  58. package/dist/middleware.js +77 -4
  59. package/dist/middleware.js.map +1 -1
  60. package/dist/standalone.d.ts.map +1 -1
  61. package/dist/standalone.js +12 -0
  62. package/dist/standalone.js.map +1 -1
  63. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -1
  64. package/dist/strategies/ApiKeyStrategy.js +14 -1
  65. package/dist/strategies/ApiKeyStrategy.js.map +1 -1
  66. package/index.darwin-arm64.node +0 -0
  67. package/index.darwin-x64.node +0 -0
  68. package/index.linux-arm64-gnu.node +0 -0
  69. package/index.linux-x64-gnu.node +0 -0
  70. package/index.win32-x64-msvc.node +0 -0
  71. package/package.json +2 -2
  72. package/src/Guard.ts +21 -0
  73. package/src/RedisBlacklistDriver.ts +60 -0
  74. package/src/ResilientBlacklistDriver.ts +84 -0
  75. package/src/TokenBlacklist.ts +7 -3
  76. package/src/WardenProvider.ts +53 -7
  77. package/src/config.ts +60 -1
  78. package/src/index.ts +58 -0
  79. package/src/mfa/BackupCodesProvider.ts +125 -0
  80. package/src/mfa/MfaManager.ts +307 -0
  81. package/src/mfa/OtpProvider.ts +177 -0
  82. package/src/mfa/TotpProvider.ts +140 -0
  83. package/src/mfa/WebauthnProvider.ts +416 -0
  84. package/src/mfa/base32.ts +54 -0
  85. package/src/mfa/webauthn-codec.ts +264 -0
  86. package/src/middleware.ts +111 -2
  87. package/src/standalone.ts +14 -1
  88. package/src/strategies/ApiKeyStrategy.ts +14 -1
@@ -0,0 +1,416 @@
1
+ /**
2
+ * WebauthnProvider — passkeys / FIDO2 via WebAuthn, implemented in-house on
3
+ * `node:crypto` (see `webauthn-codec.ts`). No third-party dependency: warden
4
+ * keeps its zero-runtime-dependency footprint.
5
+ *
6
+ * The browser side produces the standard `PublicKeyCredential` JSON; this
7
+ * provider builds the ceremony options and verifies the responses server-side.
8
+ *
9
+ * Attestation statements are NOT verified — this is a deliberate
10
+ * trust-on-registration posture (request `attestation: "none"` on the client),
11
+ * which is what the vast majority of relying parties use for 2FA / passkeys.
12
+ * Full attestation (packed/tpm/apple/MDS cert-chain validation) can be layered
13
+ * on later without changing this API.
14
+ *
15
+ * Both the per-ceremony challenge and the persisted passkeys go through
16
+ * pluggable stores (in-memory defaults), mirroring the rest of warden.
17
+ */
18
+
19
+ import { randomBytes } from "node:crypto";
20
+ import { WardenError } from "../errors.js";
21
+ import {
22
+ base64urlToBuffer,
23
+ bufferToBase64url,
24
+ type CborMap,
25
+ coseToKeyObject,
26
+ decodeCbor,
27
+ parseAuthenticatorData,
28
+ sha256,
29
+ verifyWebauthnSignature,
30
+ } from "./webauthn-codec.js";
31
+
32
+ /** Browser → server payload from `navigator.credentials.create()`. */
33
+ export interface RegistrationResponseJSON {
34
+ id: string;
35
+ rawId: string;
36
+ type: "public-key";
37
+ response: {
38
+ clientDataJSON: string;
39
+ attestationObject: string;
40
+ transports?: string[];
41
+ };
42
+ }
43
+
44
+ /** Browser → server payload from `navigator.credentials.get()`. */
45
+ export interface AuthenticationResponseJSON {
46
+ id: string;
47
+ rawId: string;
48
+ type: "public-key";
49
+ response: {
50
+ clientDataJSON: string;
51
+ authenticatorData: string;
52
+ signature: string;
53
+ userHandle?: string;
54
+ };
55
+ }
56
+
57
+ export interface RegistrationOptionsJSON {
58
+ challenge: string;
59
+ rp: { name: string; id: string };
60
+ user: { id: string; name: string; displayName: string };
61
+ pubKeyCredParams: Array<{ type: "public-key"; alg: number }>;
62
+ timeout: number;
63
+ attestation: "none";
64
+ excludeCredentials: Array<{
65
+ id: string;
66
+ type: "public-key";
67
+ transports?: string[];
68
+ }>;
69
+ authenticatorSelection: { residentKey: string; userVerification: string };
70
+ }
71
+
72
+ export interface AuthenticationOptionsJSON {
73
+ challenge: string;
74
+ timeout: number;
75
+ rpId: string;
76
+ userVerification: string;
77
+ allowCredentials?: Array<{
78
+ id: string;
79
+ type: "public-key";
80
+ transports?: string[];
81
+ }>;
82
+ }
83
+
84
+ /** Transient per-ceremony challenge storage. */
85
+ export interface WebauthnChallengeStore {
86
+ save(state: string, challenge: string): Promise<void>;
87
+ /** Return the challenge for `state` and remove it (single-use). */
88
+ take(state: string): Promise<string | null>;
89
+ }
90
+
91
+ /** A registered passkey, persisted between ceremonies. */
92
+ export interface StoredPasskey {
93
+ /** Base64URL credential id. */
94
+ id: string;
95
+ userId: string;
96
+ /** Base64URL of the raw COSE public-key bytes. */
97
+ publicKey: string;
98
+ /** COSE algorithm id (e.g. -7 for ES256). */
99
+ alg: number;
100
+ counter: number;
101
+ transports?: string[];
102
+ }
103
+
104
+ export interface WebauthnCredentialStore {
105
+ save(passkey: StoredPasskey): Promise<void>;
106
+ findById(id: string): Promise<StoredPasskey | null>;
107
+ findByUser(userId: string): Promise<StoredPasskey[]>;
108
+ updateCounter(id: string, counter: number): Promise<void>;
109
+ }
110
+
111
+ export class MemoryWebauthnChallengeStore implements WebauthnChallengeStore {
112
+ #store = new Map<string, string>();
113
+ async save(state: string, challenge: string): Promise<void> {
114
+ this.#store.set(state, challenge);
115
+ }
116
+ async take(state: string): Promise<string | null> {
117
+ const c = this.#store.get(state) ?? null;
118
+ this.#store.delete(state);
119
+ return c;
120
+ }
121
+ }
122
+
123
+ export class MemoryWebauthnCredentialStore implements WebauthnCredentialStore {
124
+ #store = new Map<string, StoredPasskey>();
125
+ async save(passkey: StoredPasskey): Promise<void> {
126
+ this.#store.set(passkey.id, passkey);
127
+ }
128
+ async findById(id: string): Promise<StoredPasskey | null> {
129
+ return this.#store.get(id) ?? null;
130
+ }
131
+ async findByUser(userId: string): Promise<StoredPasskey[]> {
132
+ return [...this.#store.values()].filter((p) => p.userId === userId);
133
+ }
134
+ async updateCounter(id: string, counter: number): Promise<void> {
135
+ const p = this.#store.get(id);
136
+ if (p) {
137
+ this.#store.set(id, { ...p, counter });
138
+ }
139
+ }
140
+ }
141
+
142
+ export interface WebauthnConfig {
143
+ /** Human-readable relying-party name (your app). */
144
+ rpName: string;
145
+ /** Relying-party ID — your registrable domain (e.g. `fluveo.ch`). */
146
+ rpID: string;
147
+ /** Expected origin(s) of the ceremony (e.g. `https://fluveo.ch`). */
148
+ origin: string | string[];
149
+ challengeStore?: WebauthnChallengeStore;
150
+ credentialStore?: WebauthnCredentialStore;
151
+ /** Ceremony timeout in ms. Default `60000`. */
152
+ timeout?: number;
153
+ /**
154
+ * COSE algorithms offered to the authenticator, in preference order.
155
+ * Default ES256, RS256, EdDSA.
156
+ */
157
+ supportedAlgorithms?: number[];
158
+ }
159
+
160
+ export interface WebauthnUser {
161
+ id: string;
162
+ name: string;
163
+ displayName?: string;
164
+ }
165
+
166
+ const DEFAULT_ALGS = [-7, -257, -8];
167
+
168
+ export class WebauthnProvider {
169
+ readonly kind = "webauthn" as const;
170
+ readonly #rpName: string;
171
+ readonly #rpID: string;
172
+ readonly #origins: string[];
173
+ readonly #timeout: number;
174
+ readonly #algorithms: number[];
175
+ readonly #challenges: WebauthnChallengeStore;
176
+ readonly #credentials: WebauthnCredentialStore;
177
+
178
+ constructor(config: WebauthnConfig) {
179
+ if (!config?.rpID || !config?.rpName || !config?.origin) {
180
+ throw new WardenError(
181
+ "INVALID_CONFIG",
182
+ "WebauthnProvider requires rpName, rpID and origin",
183
+ );
184
+ }
185
+ this.#rpName = config.rpName;
186
+ this.#rpID = config.rpID;
187
+ this.#origins = Array.isArray(config.origin)
188
+ ? config.origin
189
+ : [config.origin];
190
+ this.#timeout = config.timeout ?? 60_000;
191
+ this.#algorithms = config.supportedAlgorithms ?? DEFAULT_ALGS;
192
+ this.#challenges =
193
+ config.challengeStore ?? new MemoryWebauthnChallengeStore();
194
+ this.#credentials =
195
+ config.credentialStore ?? new MemoryWebauthnCredentialStore();
196
+ }
197
+
198
+ /**
199
+ * Begin passkey registration. Returns the options to hand to the browser,
200
+ * plus an opaque `state` token the caller stashes in the session and passes
201
+ * back to `finishRegistration()`.
202
+ */
203
+ async startRegistration(
204
+ user: WebauthnUser,
205
+ ): Promise<{ options: RegistrationOptionsJSON; state: string }> {
206
+ const existing = await this.#credentials.findByUser(user.id);
207
+ const challenge = bufferToBase64url(randomBytes(32));
208
+ const options: RegistrationOptionsJSON = {
209
+ challenge,
210
+ rp: { name: this.#rpName, id: this.#rpID },
211
+ user: {
212
+ id: bufferToBase64url(Buffer.from(user.id, "utf8")),
213
+ name: user.name,
214
+ displayName: user.displayName ?? user.name,
215
+ },
216
+ pubKeyCredParams: this.#algorithms.map((alg) => ({
217
+ type: "public-key",
218
+ alg,
219
+ })),
220
+ timeout: this.#timeout,
221
+ attestation: "none",
222
+ excludeCredentials: existing.map((c) => ({
223
+ id: c.id,
224
+ type: "public-key",
225
+ transports: c.transports,
226
+ })),
227
+ authenticatorSelection: {
228
+ residentKey: "preferred",
229
+ userVerification: "preferred",
230
+ },
231
+ };
232
+ const state = randomBytes(16).toString("hex");
233
+ await this.#challenges.save(state, challenge);
234
+ return { options, state };
235
+ }
236
+
237
+ /**
238
+ * Complete registration: validate client data + authenticator data against
239
+ * the stored challenge and persist the new passkey on success.
240
+ */
241
+ async finishRegistration(
242
+ state: string,
243
+ userId: string,
244
+ response: RegistrationResponseJSON,
245
+ ): Promise<{ verified: boolean }> {
246
+ const expectedChallenge = await this.#challenges.take(state);
247
+ if (!expectedChallenge) {
248
+ return { verified: false };
249
+ }
250
+ if (
251
+ !this.#validClientData(
252
+ response.response.clientDataJSON,
253
+ "webauthn.create",
254
+ expectedChallenge,
255
+ )
256
+ ) {
257
+ return { verified: false };
258
+ }
259
+
260
+ const attestation = decodeCbor(
261
+ base64urlToBuffer(response.response.attestationObject),
262
+ ).value;
263
+ if (!(attestation instanceof Map)) {
264
+ return { verified: false };
265
+ }
266
+ const authDataRaw = attestation.get("authData");
267
+ if (!Buffer.isBuffer(authDataRaw)) {
268
+ return { verified: false };
269
+ }
270
+ const authData = parseAuthenticatorData(authDataRaw);
271
+ if (
272
+ !this.#validAuthenticator(authData) ||
273
+ !authData.cosePublicKey ||
274
+ !authData.cosePublicKeyBytes ||
275
+ !authData.credentialId
276
+ ) {
277
+ return { verified: false };
278
+ }
279
+
280
+ const { alg } = coseToKeyObject(authData.cosePublicKey);
281
+ await this.#credentials.save({
282
+ id: bufferToBase64url(authData.credentialId),
283
+ userId,
284
+ publicKey: bufferToBase64url(authData.cosePublicKeyBytes),
285
+ alg,
286
+ counter: authData.signCount,
287
+ transports: response.response.transports,
288
+ });
289
+ return { verified: true };
290
+ }
291
+
292
+ /**
293
+ * Begin authentication. Pass `userId` to restrict to that user's passkeys
294
+ * (2FA step); omit it for a usernameless / discoverable-credential sign-in.
295
+ */
296
+ async startAuthentication(
297
+ userId?: string,
298
+ ): Promise<{ options: AuthenticationOptionsJSON; state: string }> {
299
+ const challenge = bufferToBase64url(randomBytes(32));
300
+ const allow = userId ? await this.#credentials.findByUser(userId) : [];
301
+ const options: AuthenticationOptionsJSON = {
302
+ challenge,
303
+ timeout: this.#timeout,
304
+ rpId: this.#rpID,
305
+ userVerification: "preferred",
306
+ allowCredentials: userId
307
+ ? allow.map((c) => ({
308
+ id: c.id,
309
+ type: "public-key",
310
+ transports: c.transports,
311
+ }))
312
+ : undefined,
313
+ };
314
+ const state = randomBytes(16).toString("hex");
315
+ await this.#challenges.save(state, challenge);
316
+ return { options, state };
317
+ }
318
+
319
+ /**
320
+ * Complete authentication: look up the asserted credential, verify the
321
+ * signature against the stored challenge, and advance the replay counter.
322
+ * Returns the owning `userId` on success.
323
+ */
324
+ async finishAuthentication(
325
+ state: string,
326
+ response: AuthenticationResponseJSON,
327
+ ): Promise<{ verified: boolean; userId?: string }> {
328
+ const expectedChallenge = await this.#challenges.take(state);
329
+ if (!expectedChallenge) {
330
+ return { verified: false };
331
+ }
332
+ if (
333
+ !this.#validClientData(
334
+ response.response.clientDataJSON,
335
+ "webauthn.get",
336
+ expectedChallenge,
337
+ )
338
+ ) {
339
+ return { verified: false };
340
+ }
341
+
342
+ const stored = await this.#credentials.findById(response.id);
343
+ if (!stored) {
344
+ return { verified: false };
345
+ }
346
+
347
+ const authDataRaw = base64urlToBuffer(response.response.authenticatorData);
348
+ const authData = parseAuthenticatorData(authDataRaw);
349
+ if (!this.#validAuthenticator(authData)) {
350
+ return { verified: false };
351
+ }
352
+
353
+ // Signed payload = authenticatorData ‖ SHA-256(clientDataJSON).
354
+ const clientHash = sha256(
355
+ base64urlToBuffer(response.response.clientDataJSON),
356
+ );
357
+ const signedData = Buffer.concat([authDataRaw, clientHash]);
358
+ const { key } = coseToKeyObject(
359
+ expectCoseMap(base64urlToBuffer(stored.publicKey)),
360
+ );
361
+ const ok = verifyWebauthnSignature(
362
+ stored.alg,
363
+ key,
364
+ signedData,
365
+ base64urlToBuffer(response.response.signature),
366
+ );
367
+ if (!ok) {
368
+ return { verified: false };
369
+ }
370
+
371
+ // Replay guard: a non-zero counter must strictly advance. Authenticators
372
+ // that always report 0 (e.g. many platform passkeys) are exempt.
373
+ if (authData.signCount !== 0 && authData.signCount <= stored.counter) {
374
+ return { verified: false };
375
+ }
376
+ await this.#credentials.updateCounter(stored.id, authData.signCount);
377
+ return { verified: true, userId: stored.userId };
378
+ }
379
+
380
+ /** Validate clientDataJSON: type, challenge match, and origin allow-list. */
381
+ #validClientData(
382
+ clientDataB64: string,
383
+ expectedType: "webauthn.create" | "webauthn.get",
384
+ expectedChallenge: string,
385
+ ): boolean {
386
+ let parsed: { type?: string; challenge?: string; origin?: string };
387
+ try {
388
+ parsed = JSON.parse(base64urlToBuffer(clientDataB64).toString("utf8"));
389
+ } catch {
390
+ return false;
391
+ }
392
+ return (
393
+ parsed.type === expectedType &&
394
+ parsed.challenge === expectedChallenge &&
395
+ typeof parsed.origin === "string" &&
396
+ this.#origins.includes(parsed.origin)
397
+ );
398
+ }
399
+
400
+ /** Validate authenticator data: rpIdHash match and user-presence flag. */
401
+ #validAuthenticator(authData: {
402
+ rpIdHash: Buffer;
403
+ flags: { up: boolean };
404
+ }): boolean {
405
+ const expectedRpIdHash = sha256(Buffer.from(this.#rpID, "utf8"));
406
+ return authData.flags.up && expectedRpIdHash.equals(authData.rpIdHash);
407
+ }
408
+ }
409
+
410
+ function expectCoseMap(bytes: Buffer): CborMap {
411
+ const decoded = decodeCbor(bytes).value;
412
+ if (!(decoded instanceof Map)) {
413
+ throw new WardenError("INVALID_CREDENTIAL", "stored COSE key is malformed");
414
+ }
415
+ return decoded;
416
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * RFC 4648 base32 (no padding) — the encoding authenticator apps expect for
3
+ * TOTP secrets. Kept dependency-free and local to the MFA subsystem.
4
+ */
5
+
6
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
7
+ const CHAR_TO_VAL: Record<string, number> = {};
8
+ for (let i = 0; i < ALPHABET.length; i++) {
9
+ CHAR_TO_VAL[ALPHABET[i]] = i;
10
+ }
11
+
12
+ /** Encode bytes to an unpadded, uppercase base32 string. */
13
+ export function base32Encode(bytes: Uint8Array): string {
14
+ let bits = 0;
15
+ let value = 0;
16
+ let out = "";
17
+ for (const byte of bytes) {
18
+ value = (value << 8) | byte;
19
+ bits += 8;
20
+ while (bits >= 5) {
21
+ out += ALPHABET[(value >>> (bits - 5)) & 31];
22
+ bits -= 5;
23
+ }
24
+ }
25
+ if (bits > 0) {
26
+ out += ALPHABET[(value << (5 - bits)) & 31];
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Decode a base32 string to bytes. Tolerant of lowercase, spaces, and `=`
33
+ * padding (authenticator apps and manual entry produce all three). Throws on
34
+ * any character outside the alphabet.
35
+ */
36
+ export function base32Decode(input: string): Uint8Array {
37
+ const clean = input.replace(/[\s=]/g, "").toUpperCase();
38
+ let bits = 0;
39
+ let value = 0;
40
+ const out: number[] = [];
41
+ for (const ch of clean) {
42
+ const v = CHAR_TO_VAL[ch];
43
+ if (v === undefined) {
44
+ throw new Error(`Invalid base32 character: ${ch}`);
45
+ }
46
+ value = (value << 5) | v;
47
+ bits += 5;
48
+ if (bits >= 8) {
49
+ out.push((value >>> (bits - 8)) & 0xff);
50
+ bits -= 8;
51
+ }
52
+ }
53
+ return Uint8Array.from(out);
54
+ }