@c9up/warden 0.1.3 → 0.1.5

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 (185) hide show
  1. package/README.md +57 -0
  2. package/dist/AuthManager.d.ts +98 -0
  3. package/dist/AuthManager.d.ts.map +1 -0
  4. package/dist/AuthManager.js +147 -0
  5. package/dist/AuthManager.js.map +1 -0
  6. package/dist/AuthRateLimiter.d.ts +29 -0
  7. package/dist/AuthRateLimiter.d.ts.map +1 -0
  8. package/dist/AuthRateLimiter.js +88 -0
  9. package/dist/AuthRateLimiter.js.map +1 -0
  10. package/dist/Guard.d.ts +35 -0
  11. package/dist/Guard.d.ts.map +1 -0
  12. package/dist/Guard.js +68 -0
  13. package/dist/Guard.js.map +1 -0
  14. package/dist/RedisBlacklistDriver.d.ts +32 -0
  15. package/dist/RedisBlacklistDriver.d.ts.map +1 -0
  16. package/dist/RedisBlacklistDriver.js +37 -0
  17. package/dist/RedisBlacklistDriver.js.map +1 -0
  18. package/dist/RefreshTokenStore.d.ts +34 -0
  19. package/dist/RefreshTokenStore.d.ts.map +1 -0
  20. package/dist/RefreshTokenStore.js +48 -0
  21. package/dist/RefreshTokenStore.js.map +1 -0
  22. package/dist/ResilientBlacklistDriver.d.ts +35 -0
  23. package/dist/ResilientBlacklistDriver.d.ts.map +1 -0
  24. package/dist/ResilientBlacklistDriver.js +61 -0
  25. package/dist/ResilientBlacklistDriver.js.map +1 -0
  26. package/dist/TokenBlacklist.d.ts +30 -0
  27. package/dist/TokenBlacklist.d.ts.map +1 -0
  28. package/dist/TokenBlacklist.js +50 -0
  29. package/dist/TokenBlacklist.js.map +1 -0
  30. package/dist/WardenProvider.d.ts +19 -0
  31. package/dist/WardenProvider.d.ts.map +1 -0
  32. package/dist/WardenProvider.js +71 -0
  33. package/dist/WardenProvider.js.map +1 -0
  34. package/dist/bouncer/AuthorizationResponse.d.ts +25 -0
  35. package/dist/bouncer/AuthorizationResponse.d.ts.map +1 -0
  36. package/dist/bouncer/AuthorizationResponse.js +31 -0
  37. package/dist/bouncer/AuthorizationResponse.js.map +1 -0
  38. package/dist/bouncer/BasePolicy.d.ts +47 -0
  39. package/dist/bouncer/BasePolicy.d.ts.map +1 -0
  40. package/dist/bouncer/BasePolicy.js +36 -0
  41. package/dist/bouncer/BasePolicy.js.map +1 -0
  42. package/dist/bouncer/Bouncer.d.ts +47 -0
  43. package/dist/bouncer/Bouncer.d.ts.map +1 -0
  44. package/dist/bouncer/Bouncer.js +134 -0
  45. package/dist/bouncer/Bouncer.js.map +1 -0
  46. package/dist/bouncer/PolicyAuthorizer.d.ts +24 -0
  47. package/dist/bouncer/PolicyAuthorizer.d.ts.map +1 -0
  48. package/dist/bouncer/PolicyAuthorizer.js +92 -0
  49. package/dist/bouncer/PolicyAuthorizer.js.map +1 -0
  50. package/dist/bouncer/decorators.d.ts +25 -0
  51. package/dist/bouncer/decorators.d.ts.map +1 -0
  52. package/dist/bouncer/decorators.js +36 -0
  53. package/dist/bouncer/decorators.js.map +1 -0
  54. package/dist/bouncer/evaluate.d.ts +36 -0
  55. package/dist/bouncer/evaluate.d.ts.map +1 -0
  56. package/dist/bouncer/evaluate.js +64 -0
  57. package/dist/bouncer/evaluate.js.map +1 -0
  58. package/dist/bouncer/policyContext.d.ts +29 -0
  59. package/dist/bouncer/policyContext.d.ts.map +1 -0
  60. package/dist/bouncer/policyContext.js +36 -0
  61. package/dist/bouncer/policyContext.js.map +1 -0
  62. package/dist/bouncer/types.d.ts +52 -0
  63. package/dist/bouncer/types.d.ts.map +1 -0
  64. package/dist/bouncer/types.js +9 -0
  65. package/dist/bouncer/types.js.map +1 -0
  66. package/dist/config.d.ts +51 -0
  67. package/dist/config.d.ts.map +1 -0
  68. package/dist/config.js +21 -0
  69. package/dist/config.js.map +1 -0
  70. package/dist/configure.d.ts +10 -0
  71. package/dist/configure.d.ts.map +1 -0
  72. package/dist/configure.js +44 -0
  73. package/dist/configure.js.map +1 -0
  74. package/dist/errors.d.ts +18 -0
  75. package/dist/errors.d.ts.map +1 -0
  76. package/dist/errors.js +21 -0
  77. package/dist/errors.js.map +1 -0
  78. package/dist/firstcontact/FirstContactManager.d.ts +25 -0
  79. package/dist/firstcontact/FirstContactManager.d.ts.map +1 -0
  80. package/dist/firstcontact/FirstContactManager.js +38 -0
  81. package/dist/firstcontact/FirstContactManager.js.map +1 -0
  82. package/dist/firstcontact/drivers/GitHubDriver.d.ts +14 -0
  83. package/dist/firstcontact/drivers/GitHubDriver.d.ts.map +1 -0
  84. package/dist/firstcontact/drivers/GitHubDriver.js +61 -0
  85. package/dist/firstcontact/drivers/GitHubDriver.js.map +1 -0
  86. package/dist/firstcontact/drivers/GoogleDriver.d.ts +14 -0
  87. package/dist/firstcontact/drivers/GoogleDriver.d.ts.map +1 -0
  88. package/dist/firstcontact/drivers/GoogleDriver.js +64 -0
  89. package/dist/firstcontact/drivers/GoogleDriver.js.map +1 -0
  90. package/dist/firstcontact/types.d.ts +34 -0
  91. package/dist/firstcontact/types.d.ts.map +1 -0
  92. package/dist/firstcontact/types.js +5 -0
  93. package/dist/firstcontact/types.js.map +1 -0
  94. package/dist/index.d.ts +52 -0
  95. package/dist/index.d.ts.map +1 -0
  96. package/dist/index.js +34 -0
  97. package/dist/index.js.map +1 -0
  98. package/dist/mfa/BackupCodesProvider.d.ts +39 -0
  99. package/dist/mfa/BackupCodesProvider.d.ts.map +1 -0
  100. package/dist/mfa/BackupCodesProvider.js +87 -0
  101. package/dist/mfa/BackupCodesProvider.js.map +1 -0
  102. package/dist/mfa/MfaManager.d.ts +107 -0
  103. package/dist/mfa/MfaManager.d.ts.map +1 -0
  104. package/dist/mfa/MfaManager.js +219 -0
  105. package/dist/mfa/MfaManager.js.map +1 -0
  106. package/dist/mfa/OtpProvider.d.ts +67 -0
  107. package/dist/mfa/OtpProvider.d.ts.map +1 -0
  108. package/dist/mfa/OtpProvider.js +104 -0
  109. package/dist/mfa/OtpProvider.js.map +1 -0
  110. package/dist/mfa/TotpProvider.d.ts +52 -0
  111. package/dist/mfa/TotpProvider.d.ts.map +1 -0
  112. package/dist/mfa/TotpProvider.js +103 -0
  113. package/dist/mfa/TotpProvider.js.map +1 -0
  114. package/dist/mfa/WebauthnProvider.d.ts +175 -0
  115. package/dist/mfa/WebauthnProvider.d.ts.map +1 -0
  116. package/dist/mfa/WebauthnProvider.js +239 -0
  117. package/dist/mfa/WebauthnProvider.js.map +1 -0
  118. package/dist/mfa/base32.d.ts +13 -0
  119. package/dist/mfa/base32.d.ts.map +1 -0
  120. package/dist/mfa/base32.js +52 -0
  121. package/dist/mfa/base32.js.map +1 -0
  122. package/dist/mfa/webauthn-codec.d.ts +58 -0
  123. package/dist/mfa/webauthn-codec.d.ts.map +1 -0
  124. package/dist/mfa/webauthn-codec.js +221 -0
  125. package/dist/mfa/webauthn-codec.js.map +1 -0
  126. package/dist/middleware.d.ts +51 -0
  127. package/dist/middleware.d.ts.map +1 -0
  128. package/dist/middleware.js +213 -0
  129. package/dist/middleware.js.map +1 -0
  130. package/dist/native.d.ts +14 -0
  131. package/dist/native.d.ts.map +1 -0
  132. package/dist/native.js +39 -0
  133. package/dist/native.js.map +1 -0
  134. package/dist/rights/MemoryRightsStore.d.ts +29 -0
  135. package/dist/rights/MemoryRightsStore.d.ts.map +1 -0
  136. package/dist/rights/MemoryRightsStore.js +70 -0
  137. package/dist/rights/MemoryRightsStore.js.map +1 -0
  138. package/dist/rights/RightsResolver.d.ts +29 -0
  139. package/dist/rights/RightsResolver.d.ts.map +1 -0
  140. package/dist/rights/RightsResolver.js +73 -0
  141. package/dist/rights/RightsResolver.js.map +1 -0
  142. package/dist/rights/types.d.ts +55 -0
  143. package/dist/rights/types.d.ts.map +1 -0
  144. package/dist/rights/types.js +18 -0
  145. package/dist/rights/types.js.map +1 -0
  146. package/dist/services/main.d.ts +19 -0
  147. package/dist/services/main.d.ts.map +1 -0
  148. package/dist/services/main.js +32 -0
  149. package/dist/services/main.js.map +1 -0
  150. package/dist/standalone.d.ts +37 -0
  151. package/dist/standalone.d.ts.map +1 -0
  152. package/dist/standalone.js +92 -0
  153. package/dist/standalone.js.map +1 -0
  154. package/dist/strategies/ApiKeyStrategy.d.ts +28 -0
  155. package/dist/strategies/ApiKeyStrategy.d.ts.map +1 -0
  156. package/dist/strategies/ApiKeyStrategy.js +36 -0
  157. package/dist/strategies/ApiKeyStrategy.js.map +1 -0
  158. package/dist/strategies/JwtStrategy.d.ts +51 -0
  159. package/dist/strategies/JwtStrategy.d.ts.map +1 -0
  160. package/dist/strategies/JwtStrategy.js +150 -0
  161. package/dist/strategies/JwtStrategy.js.map +1 -0
  162. package/dist/strategies/SessionStrategy.d.ts +58 -0
  163. package/dist/strategies/SessionStrategy.d.ts.map +1 -0
  164. package/dist/strategies/SessionStrategy.js +70 -0
  165. package/dist/strategies/SessionStrategy.js.map +1 -0
  166. package/index.darwin-arm64.node +0 -0
  167. package/index.darwin-x64.node +0 -0
  168. package/index.linux-arm64-gnu.node +0 -0
  169. package/index.linux-x64-gnu.node +0 -0
  170. package/index.win32-x64-msvc.node +0 -0
  171. package/package.json +1 -1
  172. package/src/Guard.ts +21 -0
  173. package/src/RedisBlacklistDriver.ts +60 -0
  174. package/src/ResilientBlacklistDriver.ts +84 -0
  175. package/src/WardenProvider.ts +11 -0
  176. package/src/config.ts +7 -0
  177. package/src/index.ts +58 -0
  178. package/src/mfa/BackupCodesProvider.ts +125 -0
  179. package/src/mfa/MfaManager.ts +307 -0
  180. package/src/mfa/OtpProvider.ts +177 -0
  181. package/src/mfa/TotpProvider.ts +140 -0
  182. package/src/mfa/WebauthnProvider.ts +416 -0
  183. package/src/mfa/base32.ts +54 -0
  184. package/src/mfa/webauthn-codec.ts +264 -0
  185. package/src/middleware.ts +14 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * TotpProvider — RFC 6238 time-based one-time passwords (HOTP/RFC 4226 under
3
+ * the hood). Pure TypeScript on Node's `crypto.createHmac` — no native binding
4
+ * and no third-party dependency: a TOTP is a single HMAC, not CPU-bound work.
5
+ *
6
+ * Compatible with Google Authenticator / 1Password / Authy by default
7
+ * (SHA1, 6 digits, 30s period).
8
+ */
9
+
10
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
11
+ import { WardenError } from "../errors.js";
12
+ import { base32Decode, base32Encode } from "./base32.js";
13
+
14
+ export type TotpAlgorithm = "SHA1" | "SHA256" | "SHA512";
15
+
16
+ export interface TotpConfig {
17
+ /** HMAC algorithm. Default `SHA1` (authenticator-app compatible). */
18
+ algorithm?: TotpAlgorithm;
19
+ /** Number of digits in the generated code. Default `6`. */
20
+ digits?: number;
21
+ /** Time step in seconds. Default `30`. */
22
+ period?: number;
23
+ /**
24
+ * How many time steps before/after `now` are accepted on `verify`, to
25
+ * tolerate clock skew. Default `1` (i.e. ±30s with the default period).
26
+ */
27
+ window?: number;
28
+ }
29
+
30
+ export interface TotpEnrollment {
31
+ /** Base32 secret to persist against the user. */
32
+ secret: string;
33
+ /** `otpauth://` URI to render as a QR code during enrollment. */
34
+ uri: string;
35
+ }
36
+
37
+ const DEFAULTS = {
38
+ algorithm: "SHA1" as TotpAlgorithm,
39
+ digits: 6,
40
+ period: 30,
41
+ window: 1,
42
+ };
43
+
44
+ export class TotpProvider {
45
+ readonly kind = "totp" as const;
46
+ readonly #cfg: Required<TotpConfig>;
47
+
48
+ constructor(config: TotpConfig = {}) {
49
+ this.#cfg = { ...DEFAULTS, ...config };
50
+ if (this.#cfg.digits < 6 || this.#cfg.digits > 8) {
51
+ throw new WardenError(
52
+ "INVALID_CONFIG",
53
+ `TOTP digits must be 6-8, got ${this.#cfg.digits}`,
54
+ );
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Generate a fresh secret and the `otpauth://` provisioning URI. Persist
60
+ * `secret`; render `uri` as a QR code for the user to scan.
61
+ *
62
+ * @param account Identifies the account in the authenticator (usually the
63
+ * user's email or username).
64
+ * @param issuer Your app/brand name, shown as the entry label.
65
+ */
66
+ enroll(account: string, issuer: string): TotpEnrollment {
67
+ const secret = base32Encode(randomBytes(20));
68
+ return { secret, uri: this.uri(secret, account, issuer) };
69
+ }
70
+
71
+ /** Build the `otpauth://totp/...` provisioning URI for an existing secret. */
72
+ uri(secret: string, account: string, issuer: string): string {
73
+ const label = encodeURIComponent(`${issuer}:${account}`);
74
+ const params = new URLSearchParams({
75
+ secret,
76
+ issuer,
77
+ algorithm: this.#cfg.algorithm,
78
+ digits: String(this.#cfg.digits),
79
+ period: String(this.#cfg.period),
80
+ });
81
+ return `otpauth://totp/${label}?${params.toString()}`;
82
+ }
83
+
84
+ /** Generate the code for a given secret at a given time (ms since epoch). */
85
+ generate(secret: string, atMs: number = Date.now()): string {
86
+ const counter = Math.floor(atMs / 1000 / this.#cfg.period);
87
+ return this.#hotp(base32Decode(secret), counter);
88
+ }
89
+
90
+ /**
91
+ * Verify a user-supplied code against the secret, accepting codes from the
92
+ * surrounding `window` time steps to tolerate clock skew. Constant-time.
93
+ */
94
+ verify(secret: string, code: string, atMs: number = Date.now()): boolean {
95
+ const normalized = code.replace(/\s/g, "");
96
+ if (normalized.length !== this.#cfg.digits) {
97
+ return false;
98
+ }
99
+ const key = base32Decode(secret);
100
+ const current = Math.floor(atMs / 1000 / this.#cfg.period);
101
+ for (let offset = -this.#cfg.window; offset <= this.#cfg.window; offset++) {
102
+ if (constantTimeEqual(this.#hotp(key, current + offset), normalized)) {
103
+ return true;
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /** RFC 4226 HOTP: HMAC over the 8-byte counter, dynamically truncated. */
110
+ #hotp(key: Uint8Array, counter: number): string {
111
+ const buf = Buffer.alloc(8);
112
+ // 64-bit big-endian counter. Codes stay valid well past year 2^53,
113
+ // so splitting hi/lo at 2^32 is safe and avoids BigInt overhead.
114
+ buf.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
115
+ buf.writeUInt32BE(counter >>> 0, 4);
116
+
117
+ const hmac = createHmac(this.#cfg.algorithm.toLowerCase(), Buffer.from(key))
118
+ .update(buf)
119
+ .digest();
120
+ const off = hmac[hmac.length - 1] & 0xf;
121
+ const bin =
122
+ ((hmac[off] & 0x7f) << 24) |
123
+ ((hmac[off + 1] & 0xff) << 16) |
124
+ ((hmac[off + 2] & 0xff) << 8) |
125
+ (hmac[off + 3] & 0xff);
126
+ return (bin % 10 ** this.#cfg.digits)
127
+ .toString()
128
+ .padStart(this.#cfg.digits, "0");
129
+ }
130
+ }
131
+
132
+ /** Length-safe constant-time string compare (avoids early-exit timing leaks). */
133
+ function constantTimeEqual(a: string, b: string): boolean {
134
+ const ba = Buffer.from(a);
135
+ const bb = Buffer.from(b);
136
+ if (ba.length !== bb.length) {
137
+ return false;
138
+ }
139
+ return timingSafeEqual(ba, bb);
140
+ }
@@ -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
+ }