totp-auth-service 0.1.0
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/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/crypto.cjs +145 -0
- package/dist/crypto.cjs.map +1 -0
- package/dist/crypto.d.cts +45 -0
- package/dist/crypto.d.ts +45 -0
- package/dist/crypto.js +139 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.cjs +379 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +114 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +368 -0
- package/dist/index.js.map +1 -0
- package/dist/storage-xBzobyb-.d.cts +44 -0
- package/dist/storage-xBzobyb-.d.ts +44 -0
- package/dist/testing.cjs +70 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +25 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +68 -0
- package/dist/testing.js.map +1 -0
- package/dist/totp-algorithm-CUpdtI9d.d.cts +8 -0
- package/dist/totp-algorithm-CUpdtI9d.d.ts +8 -0
- package/package.json +81 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { E as EnrollmentStatus, S as StorageAdapter } from './storage-xBzobyb-.js';
|
|
2
|
+
export { R as RecoveryCode, T as TOTPEnrollment } from './storage-xBzobyb-.js';
|
|
3
|
+
import { T as TotpAlgorithm } from './totp-algorithm-CUpdtI9d.js';
|
|
4
|
+
|
|
5
|
+
/** Returned from enroll(); secret + URI for QR / manual setup. */
|
|
6
|
+
interface EnrollmentResult {
|
|
7
|
+
secret: string;
|
|
8
|
+
otpAuthUri: string;
|
|
9
|
+
}
|
|
10
|
+
/** Returned from confirm(); backup codes shown once, not stored in plaintext. */
|
|
11
|
+
interface ConfirmResult {
|
|
12
|
+
recoveryCodes: string[];
|
|
13
|
+
}
|
|
14
|
+
interface VerifyResult {
|
|
15
|
+
valid: boolean;
|
|
16
|
+
/** True when a backup code was consumed (single-use). */
|
|
17
|
+
usedRecoveryCode: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Public view of enrollment — intentionally excludes secret. */
|
|
20
|
+
interface EnrollmentStatusView {
|
|
21
|
+
userId: string;
|
|
22
|
+
status: EnrollmentStatus;
|
|
23
|
+
createdAt: Date;
|
|
24
|
+
confirmedAt: Date | null;
|
|
25
|
+
revokedAt: Date | null;
|
|
26
|
+
}
|
|
27
|
+
interface TOTPServiceConfig {
|
|
28
|
+
storage: StorageAdapter;
|
|
29
|
+
/** Display name in authenticator apps (otpauth issuer param). */
|
|
30
|
+
issuer: string;
|
|
31
|
+
digits?: 6 | 8;
|
|
32
|
+
period?: number;
|
|
33
|
+
algorithm?: TotpAlgorithm;
|
|
34
|
+
/** Clock drift tolerance in TOTP periods (default 1 → ±30s at 30s period). */
|
|
35
|
+
window?: number;
|
|
36
|
+
recoveryCodeCount?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
declare class TOTPService {
|
|
40
|
+
private readonly storage;
|
|
41
|
+
private readonly issuer;
|
|
42
|
+
private readonly digits;
|
|
43
|
+
private readonly period;
|
|
44
|
+
private readonly algorithm;
|
|
45
|
+
private readonly window;
|
|
46
|
+
private readonly recoveryCodeCount;
|
|
47
|
+
constructor(config: TOTPServiceConfig);
|
|
48
|
+
/**
|
|
49
|
+
* Start enrollment: new secret, status pending.
|
|
50
|
+
* Allowed when no record exists or previous enrollment was revoked.
|
|
51
|
+
*/
|
|
52
|
+
enroll(userId: string): Promise<EnrollmentResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Prove the authenticator is configured: first valid TOTP → active.
|
|
55
|
+
* Issues recovery codes (plaintext returned once).
|
|
56
|
+
*/
|
|
57
|
+
confirm(userId: string, code: string): Promise<ConfirmResult>;
|
|
58
|
+
/**
|
|
59
|
+
* Login MFA step: try TOTP first, then single-use recovery codes.
|
|
60
|
+
* Invalid TOTP returns { valid: false } rather than throwing.
|
|
61
|
+
*/
|
|
62
|
+
verify(userId: string, code: string): Promise<VerifyResult>;
|
|
63
|
+
/** Disable MFA; removes all recovery codes. Allowed from pending or active. */
|
|
64
|
+
revoke(userId: string): Promise<void>;
|
|
65
|
+
/** Status snapshot without the shared secret. */
|
|
66
|
+
getStatus(userId: string): Promise<EnrollmentStatusView | null>;
|
|
67
|
+
/** GDPR / account deletion — never throws, safe if user has no data. */
|
|
68
|
+
delete(userId: string): Promise<void>;
|
|
69
|
+
/** Replace all backup codes; previous codes are invalidated immediately. */
|
|
70
|
+
regenerateRecoveryCodes(userId: string): Promise<{
|
|
71
|
+
recoveryCodes: string[];
|
|
72
|
+
}>;
|
|
73
|
+
private requireEnrollment;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Programmatic `error.code` values on TOTPError subclasses. */
|
|
77
|
+
declare enum TOTPErrorCode {
|
|
78
|
+
EnrollmentNotFound = "ENROLLMENT_NOT_FOUND",
|
|
79
|
+
EnrollmentConflict = "ENROLLMENT_CONFLICT",
|
|
80
|
+
EnrollmentNotActive = "ENROLLMENT_NOT_ACTIVE",
|
|
81
|
+
EnrollmentPending = "ENROLLMENT_PENDING",
|
|
82
|
+
InvalidCode = "INVALID_CODE"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Typed errors for enrollment state violations and invalid codes.
|
|
87
|
+
* Catch by subclass or by `error.code` (TOTPErrorCode).
|
|
88
|
+
*/
|
|
89
|
+
declare class TOTPError extends Error {
|
|
90
|
+
readonly code: TOTPErrorCode;
|
|
91
|
+
constructor(message: string, code: TOTPErrorCode);
|
|
92
|
+
}
|
|
93
|
+
/** No enrollment row, or revoke() called when status is already revoked. */
|
|
94
|
+
declare class EnrollmentNotFoundError extends TOTPError {
|
|
95
|
+
constructor(message?: string);
|
|
96
|
+
}
|
|
97
|
+
/** enroll() while status is pending or active. */
|
|
98
|
+
declare class EnrollmentConflictError extends TOTPError {
|
|
99
|
+
constructor(message?: string);
|
|
100
|
+
}
|
|
101
|
+
/** verify() or regenerateRecoveryCodes() when not active (includes revoked). */
|
|
102
|
+
declare class EnrollmentNotActiveError extends TOTPError {
|
|
103
|
+
constructor(message?: string);
|
|
104
|
+
}
|
|
105
|
+
/** verify() before confirm() completes (status still pending). */
|
|
106
|
+
declare class EnrollmentPendingError extends TOTPError {
|
|
107
|
+
constructor(message?: string);
|
|
108
|
+
}
|
|
109
|
+
/** confirm() when the submitted TOTP does not match. */
|
|
110
|
+
declare class InvalidCodeError extends TOTPError {
|
|
111
|
+
constructor(message?: string);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { type ConfirmResult, EnrollmentConflictError, EnrollmentNotActiveError, EnrollmentNotFoundError, EnrollmentPendingError, type EnrollmentResult, EnrollmentStatus, type EnrollmentStatusView, InvalidCodeError, StorageAdapter, TOTPError, TOTPErrorCode, TOTPService, type TOTPServiceConfig, TotpAlgorithm, type VerifyResult };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { randomBytes, createHmac, createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/enums/totp-algorithm.ts
|
|
4
|
+
var TotpAlgorithm = /* @__PURE__ */ ((TotpAlgorithm2) => {
|
|
5
|
+
TotpAlgorithm2["SHA1"] = "SHA1";
|
|
6
|
+
TotpAlgorithm2["SHA256"] = "SHA256";
|
|
7
|
+
TotpAlgorithm2["SHA512"] = "SHA512";
|
|
8
|
+
return TotpAlgorithm2;
|
|
9
|
+
})(TotpAlgorithm || {});
|
|
10
|
+
|
|
11
|
+
// src/crypto/uri.ts
|
|
12
|
+
function buildOtpAuthUri(options) {
|
|
13
|
+
const digits = options.digits ?? 6;
|
|
14
|
+
const period = options.period ?? 30;
|
|
15
|
+
const algorithm = options.algorithm ?? "SHA1" /* SHA1 */;
|
|
16
|
+
const label = encodeURIComponent(`${options.issuer}:${options.accountName}`);
|
|
17
|
+
const params = new URLSearchParams({
|
|
18
|
+
secret: options.secret,
|
|
19
|
+
issuer: options.issuer,
|
|
20
|
+
algorithm,
|
|
21
|
+
digits: String(digits),
|
|
22
|
+
period: String(period)
|
|
23
|
+
});
|
|
24
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/crypto/base32.ts
|
|
28
|
+
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
29
|
+
function encodeBase32(buffer) {
|
|
30
|
+
let bits = 0;
|
|
31
|
+
let value = 0;
|
|
32
|
+
let output = "";
|
|
33
|
+
for (const byte of buffer) {
|
|
34
|
+
value = value << 8 | byte;
|
|
35
|
+
bits += 8;
|
|
36
|
+
while (bits >= 5) {
|
|
37
|
+
output += ALPHABET[value >>> bits - 5 & 31];
|
|
38
|
+
bits -= 5;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (bits > 0) {
|
|
42
|
+
output += ALPHABET[value << 5 - bits & 31];
|
|
43
|
+
}
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
function decodeBase32(encoded) {
|
|
47
|
+
const normalized = encoded.replace(/=+$/, "").toUpperCase();
|
|
48
|
+
let bits = 0;
|
|
49
|
+
let value = 0;
|
|
50
|
+
const bytes = [];
|
|
51
|
+
for (const char of normalized) {
|
|
52
|
+
const index = ALPHABET.indexOf(char);
|
|
53
|
+
if (index === -1) {
|
|
54
|
+
throw new Error(`Invalid base32 character: ${char}`);
|
|
55
|
+
}
|
|
56
|
+
value = value << 5 | index;
|
|
57
|
+
bits += 5;
|
|
58
|
+
if (bits >= 8) {
|
|
59
|
+
bytes.push(value >>> bits - 8 & 255);
|
|
60
|
+
bits -= 8;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return Buffer.from(bytes);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/crypto/secret.ts
|
|
67
|
+
var DEFAULT_BYTE_LENGTH = 20;
|
|
68
|
+
function generateSecret(options) {
|
|
69
|
+
const byteLength = DEFAULT_BYTE_LENGTH;
|
|
70
|
+
return encodeBase32(randomBytes(byteLength));
|
|
71
|
+
}
|
|
72
|
+
function algorithmToHashName(algorithm) {
|
|
73
|
+
switch (algorithm) {
|
|
74
|
+
case "SHA256" /* SHA256 */:
|
|
75
|
+
return "sha256";
|
|
76
|
+
case "SHA512" /* SHA512 */:
|
|
77
|
+
return "sha512";
|
|
78
|
+
default:
|
|
79
|
+
return "sha1";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function counterToBuffer(counter) {
|
|
83
|
+
const buf = Buffer.alloc(8);
|
|
84
|
+
let value = counter;
|
|
85
|
+
for (let i = 7; i >= 0; i--) {
|
|
86
|
+
buf[i] = value & 255;
|
|
87
|
+
value = Math.floor(value / 256);
|
|
88
|
+
}
|
|
89
|
+
return buf;
|
|
90
|
+
}
|
|
91
|
+
function hotp(secret, counter, digits, algorithm) {
|
|
92
|
+
const hmac = createHmac(algorithmToHashName(algorithm), secret);
|
|
93
|
+
hmac.update(counterToBuffer(counter));
|
|
94
|
+
const digest = hmac.digest();
|
|
95
|
+
const offset = digest[digest.length - 1] & 15;
|
|
96
|
+
const binary = (digest[offset] & 127) << 24 | (digest[offset + 1] & 255) << 16 | (digest[offset + 2] & 255) << 8 | digest[offset + 3] & 255;
|
|
97
|
+
const otp = binary % 10 ** digits;
|
|
98
|
+
return otp.toString().padStart(digits, "0");
|
|
99
|
+
}
|
|
100
|
+
function resolveTimestamp(timestamp) {
|
|
101
|
+
return timestamp ?? Date.now();
|
|
102
|
+
}
|
|
103
|
+
function verifyCode(secret, code, options) {
|
|
104
|
+
const period = options?.period ?? 30;
|
|
105
|
+
const digits = options?.digits ?? 6;
|
|
106
|
+
const window = options?.window ?? 0;
|
|
107
|
+
const algorithm = options?.algorithm ?? "SHA1" /* SHA1 */;
|
|
108
|
+
const timestamp = resolveTimestamp(options?.timestamp);
|
|
109
|
+
const counter = Math.floor(timestamp / 1e3 / period);
|
|
110
|
+
const key = decodeBase32(secret);
|
|
111
|
+
const normalized = code.replace(/\s/g, "");
|
|
112
|
+
if (!/^\d+$/.test(normalized) || normalized.length !== digits) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
for (let drift = -window; drift <= window; drift++) {
|
|
116
|
+
if (hotp(key, counter + drift, digits, algorithm) === normalized) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/enums/enrollment-status.ts
|
|
124
|
+
var EnrollmentStatus = /* @__PURE__ */ ((EnrollmentStatus2) => {
|
|
125
|
+
EnrollmentStatus2["Pending"] = "pending";
|
|
126
|
+
EnrollmentStatus2["Active"] = "active";
|
|
127
|
+
EnrollmentStatus2["Revoked"] = "revoked";
|
|
128
|
+
return EnrollmentStatus2;
|
|
129
|
+
})(EnrollmentStatus || {});
|
|
130
|
+
|
|
131
|
+
// src/enums/error-code.ts
|
|
132
|
+
var TOTPErrorCode = /* @__PURE__ */ ((TOTPErrorCode2) => {
|
|
133
|
+
TOTPErrorCode2["EnrollmentNotFound"] = "ENROLLMENT_NOT_FOUND";
|
|
134
|
+
TOTPErrorCode2["EnrollmentConflict"] = "ENROLLMENT_CONFLICT";
|
|
135
|
+
TOTPErrorCode2["EnrollmentNotActive"] = "ENROLLMENT_NOT_ACTIVE";
|
|
136
|
+
TOTPErrorCode2["EnrollmentPending"] = "ENROLLMENT_PENDING";
|
|
137
|
+
TOTPErrorCode2["InvalidCode"] = "INVALID_CODE";
|
|
138
|
+
return TOTPErrorCode2;
|
|
139
|
+
})(TOTPErrorCode || {});
|
|
140
|
+
|
|
141
|
+
// src/errors.ts
|
|
142
|
+
var TOTPError = class extends Error {
|
|
143
|
+
code;
|
|
144
|
+
constructor(message, code) {
|
|
145
|
+
super(message);
|
|
146
|
+
this.name = new.target.name;
|
|
147
|
+
this.code = code;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
var EnrollmentNotFoundError = class extends TOTPError {
|
|
151
|
+
constructor(message = "Enrollment not found") {
|
|
152
|
+
super(message, "ENROLLMENT_NOT_FOUND" /* EnrollmentNotFound */);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
var EnrollmentConflictError = class extends TOTPError {
|
|
156
|
+
constructor(message = "Enrollment already exists") {
|
|
157
|
+
super(message, "ENROLLMENT_CONFLICT" /* EnrollmentConflict */);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
var EnrollmentNotActiveError = class extends TOTPError {
|
|
161
|
+
constructor(message = "Enrollment is not active") {
|
|
162
|
+
super(message, "ENROLLMENT_NOT_ACTIVE" /* EnrollmentNotActive */);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
var EnrollmentPendingError = class extends TOTPError {
|
|
166
|
+
constructor(message = "Enrollment is pending confirmation") {
|
|
167
|
+
super(message, "ENROLLMENT_PENDING" /* EnrollmentPending */);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
var InvalidCodeError = class extends TOTPError {
|
|
171
|
+
constructor(message = "Invalid code") {
|
|
172
|
+
super(message, "INVALID_CODE" /* InvalidCode */);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var RECOVERY_CODE_BYTES = 5;
|
|
176
|
+
function hashRecoveryCode(code) {
|
|
177
|
+
return createHash("sha256").update(code.trim()).digest("hex");
|
|
178
|
+
}
|
|
179
|
+
function generateRecoveryCodes(userId, count) {
|
|
180
|
+
const plaintext = [];
|
|
181
|
+
const stored = [];
|
|
182
|
+
const seen = /* @__PURE__ */ new Set();
|
|
183
|
+
while (plaintext.length < count) {
|
|
184
|
+
const code = randomBytes(RECOVERY_CODE_BYTES).toString("hex").toUpperCase().match(/.{1,4}/g).join("-");
|
|
185
|
+
if (seen.has(code)) continue;
|
|
186
|
+
seen.add(code);
|
|
187
|
+
plaintext.push(code);
|
|
188
|
+
stored.push({
|
|
189
|
+
userId,
|
|
190
|
+
codeHash: hashRecoveryCode(code),
|
|
191
|
+
usedAt: null
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return { plaintext, stored };
|
|
195
|
+
}
|
|
196
|
+
function findMatchingRecoveryCode(codes, submittedCode) {
|
|
197
|
+
const hash = hashRecoveryCode(submittedCode);
|
|
198
|
+
return codes.find((c) => c.codeHash === hash && c.usedAt === null) ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/service/TOTPService.ts
|
|
202
|
+
var TOTPService = class {
|
|
203
|
+
storage;
|
|
204
|
+
issuer;
|
|
205
|
+
digits;
|
|
206
|
+
period;
|
|
207
|
+
algorithm;
|
|
208
|
+
window;
|
|
209
|
+
recoveryCodeCount;
|
|
210
|
+
constructor(config) {
|
|
211
|
+
this.storage = config.storage;
|
|
212
|
+
this.issuer = config.issuer;
|
|
213
|
+
this.digits = config.digits ?? 6;
|
|
214
|
+
this.period = config.period ?? 30;
|
|
215
|
+
this.algorithm = config.algorithm ?? "SHA1" /* SHA1 */;
|
|
216
|
+
this.window = config.window ?? 1;
|
|
217
|
+
this.recoveryCodeCount = config.recoveryCodeCount ?? 10;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Start enrollment: new secret, status pending.
|
|
221
|
+
* Allowed when no record exists or previous enrollment was revoked.
|
|
222
|
+
*/
|
|
223
|
+
async enroll(userId) {
|
|
224
|
+
const existing = await this.storage.getEnrollment(userId);
|
|
225
|
+
if (existing?.status === "pending" /* Pending */ || existing?.status === "active" /* Active */) {
|
|
226
|
+
throw new EnrollmentConflictError();
|
|
227
|
+
}
|
|
228
|
+
if (existing?.status === "revoked" /* Revoked */) {
|
|
229
|
+
await this.storage.deleteRecoveryCodes(userId);
|
|
230
|
+
}
|
|
231
|
+
const secret = generateSecret();
|
|
232
|
+
const now = /* @__PURE__ */ new Date();
|
|
233
|
+
const enrollment = {
|
|
234
|
+
userId,
|
|
235
|
+
secret,
|
|
236
|
+
status: "pending" /* Pending */,
|
|
237
|
+
createdAt: now,
|
|
238
|
+
confirmedAt: null,
|
|
239
|
+
revokedAt: null
|
|
240
|
+
};
|
|
241
|
+
await this.storage.saveEnrollment(enrollment);
|
|
242
|
+
const otpAuthUri = buildOtpAuthUri({
|
|
243
|
+
secret,
|
|
244
|
+
accountName: userId,
|
|
245
|
+
issuer: this.issuer,
|
|
246
|
+
digits: this.digits,
|
|
247
|
+
period: this.period,
|
|
248
|
+
algorithm: this.algorithm
|
|
249
|
+
});
|
|
250
|
+
return { secret, otpAuthUri };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Prove the authenticator is configured: first valid TOTP → active.
|
|
254
|
+
* Issues recovery codes (plaintext returned once).
|
|
255
|
+
*/
|
|
256
|
+
async confirm(userId, code) {
|
|
257
|
+
const enrollment = await this.requireEnrollment(userId);
|
|
258
|
+
if (enrollment.status !== "pending" /* Pending */) {
|
|
259
|
+
if (enrollment.status === "active" /* Active */) {
|
|
260
|
+
throw new EnrollmentConflictError("Enrollment is already active");
|
|
261
|
+
}
|
|
262
|
+
throw new EnrollmentNotActiveError();
|
|
263
|
+
}
|
|
264
|
+
if (!verifyCode(enrollment.secret, code, {
|
|
265
|
+
window: this.window,
|
|
266
|
+
period: this.period,
|
|
267
|
+
digits: this.digits,
|
|
268
|
+
algorithm: this.algorithm
|
|
269
|
+
})) {
|
|
270
|
+
throw new InvalidCodeError();
|
|
271
|
+
}
|
|
272
|
+
const now = /* @__PURE__ */ new Date();
|
|
273
|
+
await this.storage.updateEnrollment(userId, {
|
|
274
|
+
status: "active" /* Active */,
|
|
275
|
+
confirmedAt: now
|
|
276
|
+
});
|
|
277
|
+
await this.storage.deleteRecoveryCodes(userId);
|
|
278
|
+
const { plaintext, stored } = generateRecoveryCodes(
|
|
279
|
+
userId,
|
|
280
|
+
this.recoveryCodeCount
|
|
281
|
+
);
|
|
282
|
+
await this.storage.saveRecoveryCodes(stored);
|
|
283
|
+
return { recoveryCodes: plaintext };
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Login MFA step: try TOTP first, then single-use recovery codes.
|
|
287
|
+
* Invalid TOTP returns { valid: false } rather than throwing.
|
|
288
|
+
*/
|
|
289
|
+
async verify(userId, code) {
|
|
290
|
+
const enrollment = await this.requireEnrollment(userId);
|
|
291
|
+
if (enrollment.status === "pending" /* Pending */) {
|
|
292
|
+
throw new EnrollmentPendingError();
|
|
293
|
+
}
|
|
294
|
+
if (enrollment.status === "revoked" /* Revoked */) {
|
|
295
|
+
throw new EnrollmentNotActiveError();
|
|
296
|
+
}
|
|
297
|
+
const totpValid = verifyCode(enrollment.secret, code, {
|
|
298
|
+
window: this.window,
|
|
299
|
+
period: this.period,
|
|
300
|
+
digits: this.digits,
|
|
301
|
+
algorithm: this.algorithm
|
|
302
|
+
});
|
|
303
|
+
if (totpValid) {
|
|
304
|
+
return { valid: true, usedRecoveryCode: false };
|
|
305
|
+
}
|
|
306
|
+
const recoveryCodes = await this.storage.getRecoveryCodes(userId);
|
|
307
|
+
const match = findMatchingRecoveryCode(recoveryCodes, code);
|
|
308
|
+
if (!match) {
|
|
309
|
+
return { valid: false, usedRecoveryCode: false };
|
|
310
|
+
}
|
|
311
|
+
await this.storage.markRecoveryCodeUsed(userId, match.codeHash);
|
|
312
|
+
return { valid: true, usedRecoveryCode: true };
|
|
313
|
+
}
|
|
314
|
+
/** Disable MFA; removes all recovery codes. Allowed from pending or active. */
|
|
315
|
+
async revoke(userId) {
|
|
316
|
+
const enrollment = await this.requireEnrollment(userId);
|
|
317
|
+
if (enrollment.status !== "pending" /* Pending */ && enrollment.status !== "active" /* Active */) {
|
|
318
|
+
throw new EnrollmentNotFoundError();
|
|
319
|
+
}
|
|
320
|
+
await this.storage.updateEnrollment(userId, {
|
|
321
|
+
status: "revoked" /* Revoked */,
|
|
322
|
+
revokedAt: /* @__PURE__ */ new Date()
|
|
323
|
+
});
|
|
324
|
+
await this.storage.deleteRecoveryCodes(userId);
|
|
325
|
+
}
|
|
326
|
+
/** Status snapshot without the shared secret. */
|
|
327
|
+
async getStatus(userId) {
|
|
328
|
+
const enrollment = await this.storage.getEnrollment(userId);
|
|
329
|
+
if (!enrollment) return null;
|
|
330
|
+
return {
|
|
331
|
+
userId: enrollment.userId,
|
|
332
|
+
status: enrollment.status,
|
|
333
|
+
createdAt: enrollment.createdAt,
|
|
334
|
+
confirmedAt: enrollment.confirmedAt,
|
|
335
|
+
revokedAt: enrollment.revokedAt
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/** GDPR / account deletion — never throws, safe if user has no data. */
|
|
339
|
+
async delete(userId) {
|
|
340
|
+
await this.storage.deleteEnrollment(userId);
|
|
341
|
+
await this.storage.deleteRecoveryCodes(userId);
|
|
342
|
+
}
|
|
343
|
+
/** Replace all backup codes; previous codes are invalidated immediately. */
|
|
344
|
+
async regenerateRecoveryCodes(userId) {
|
|
345
|
+
const enrollment = await this.requireEnrollment(userId);
|
|
346
|
+
if (enrollment.status !== "active" /* Active */) {
|
|
347
|
+
throw new EnrollmentNotActiveError();
|
|
348
|
+
}
|
|
349
|
+
await this.storage.deleteRecoveryCodes(userId);
|
|
350
|
+
const { plaintext, stored } = generateRecoveryCodes(
|
|
351
|
+
userId,
|
|
352
|
+
this.recoveryCodeCount
|
|
353
|
+
);
|
|
354
|
+
await this.storage.saveRecoveryCodes(stored);
|
|
355
|
+
return { recoveryCodes: plaintext };
|
|
356
|
+
}
|
|
357
|
+
async requireEnrollment(userId) {
|
|
358
|
+
const enrollment = await this.storage.getEnrollment(userId);
|
|
359
|
+
if (!enrollment) {
|
|
360
|
+
throw new EnrollmentNotFoundError();
|
|
361
|
+
}
|
|
362
|
+
return enrollment;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
export { EnrollmentConflictError, EnrollmentNotActiveError, EnrollmentNotFoundError, EnrollmentPendingError, EnrollmentStatus, InvalidCodeError, TOTPError, TOTPErrorCode, TOTPService, TotpAlgorithm };
|
|
367
|
+
//# sourceMappingURL=index.js.map
|
|
368
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/enums/totp-algorithm.ts","../src/crypto/uri.ts","../src/crypto/base32.ts","../src/crypto/secret.ts","../src/crypto/verify.ts","../src/enums/enrollment-status.ts","../src/enums/error-code.ts","../src/errors.ts","../src/service/recovery.ts","../src/service/TOTPService.ts"],"names":["TotpAlgorithm","EnrollmentStatus","TOTPErrorCode","randomBytes"],"mappings":"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","file":"index.js","sourcesContent":["/** HMAC algorithm for TOTP (otpauth `algorithm` param and HMAC digest). */\nexport enum TotpAlgorithm {\n SHA1 = 'SHA1',\n SHA256 = 'SHA256',\n SHA512 = 'SHA512',\n}\n","import { TotpAlgorithm } from '../enums/totp-algorithm.js'\n\nexport interface BuildOtpAuthUriOptions {\n secret: string\n accountName: string\n issuer: string\n digits?: number\n period?: number\n algorithm?: TotpAlgorithm\n}\n\n/**\n * Build an otpauth://totp/ URI for QR codes and manual entry in authenticator apps.\n * Label format follows the de-facto `Issuer:account` convention (both URL-encoded).\n */\nexport function buildOtpAuthUri(options: BuildOtpAuthUriOptions): string {\n const digits = options.digits ?? 6\n const period = options.period ?? 30\n const algorithm = options.algorithm ?? TotpAlgorithm.SHA1\n\n const label = encodeURIComponent(`${options.issuer}:${options.accountName}`)\n const params = new URLSearchParams({\n secret: options.secret,\n issuer: options.issuer,\n algorithm,\n digits: String(digits),\n period: String(period),\n })\n\n return `otpauth://totp/${label}?${params.toString()}`\n}\n","/**\n * RFC 4648 Base32 encode/decode for TOTP shared secrets.\n *\n * Authenticator apps and otpauth:// URIs store secrets as Base32 text (A–Z, 2–7),\n * not raw bytes. generateSecret() encodes random bytes; verifyCode() decodes\n * before HMAC. No padding is required for typical OTP secret lengths.\n */\nconst ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'\n\n/** Pack raw bytes into a Base32 string (5 bits per alphabet character). */\nexport function encodeBase32(buffer: Buffer): string {\n let bits = 0\n let value = 0\n let output = ''\n\n for (const byte of buffer) {\n value = (value << 8) | byte\n bits += 8\n\n while (bits >= 5) {\n output += ALPHABET[(value >>> (bits - 5)) & 31]\n bits -= 5\n }\n }\n\n // Emit final partial quintet if the byte stream did not align on 5-bit boundaries.\n if (bits > 0) {\n output += ALPHABET[(value << (5 - bits)) & 31]\n }\n\n return output\n}\n\n/** Unpack a Base32 secret string into the raw key bytes used by HMAC. */\nexport function decodeBase32(encoded: string): Buffer {\n const normalized = encoded.replace(/=+$/, '').toUpperCase()\n let bits = 0\n let value = 0\n const bytes: number[] = []\n\n for (const char of normalized) {\n const index = ALPHABET.indexOf(char)\n if (index === -1) {\n throw new Error(`Invalid base32 character: ${char}`)\n }\n\n value = (value << 5) | index\n bits += 5\n\n if (bits >= 8) {\n bytes.push((value >>> (bits - 8)) & 255)\n bits -= 8\n }\n }\n\n return Buffer.from(bytes)\n}\n","import { randomBytes } from 'crypto'\nimport { encodeBase32 } from './base32.js'\n\n/** 20 bytes → ~32 Base32 chars; matches common authenticator defaults. */\nconst DEFAULT_BYTE_LENGTH = 20\n\n/**\n * Create a cryptographically random TOTP shared secret (Base32).\n * Consumers persist this via StorageAdapter during enroll().\n */\nexport function generateSecret(options?: { length?: number }): string {\n const byteLength = options?.length ?? DEFAULT_BYTE_LENGTH\n // RFC 4226 recommends at least 128 bits (16 bytes) of entropy for the secret.\n if (byteLength < 16) {\n throw new Error('Secret length must be at least 16 bytes')\n }\n return encodeBase32(randomBytes(byteLength))\n}\n","/**\n * RFC 4226 (HOTP) and RFC 6238 (TOTP) — pure functions, no I/O.\n *\n * TOTP is HOTP with counter = floor(unixTime / period). Injectable `timestamp`\n * keeps tests deterministic without mocking Date or global time.\n */\nimport { createHmac } from 'crypto'\nimport { TotpAlgorithm } from '../enums/totp-algorithm.js'\nimport { decodeBase32 } from './base32.js'\n\nexport interface TotpOptions {\n period?: number\n digits?: number\n /** Defaults to Date.now(); pass in tests for fixed codes. */\n timestamp?: number\n algorithm?: TotpAlgorithm\n}\n\nexport interface VerifyCodeOptions extends TotpOptions {\n /** Drift tolerance in 30s (or custom) periods — checks counter ± window. */\n window?: number\n}\n\nfunction algorithmToHashName(algorithm: TotpAlgorithm): string {\n switch (algorithm) {\n case TotpAlgorithm.SHA256:\n return 'sha256'\n case TotpAlgorithm.SHA512:\n return 'sha512'\n default:\n return 'sha1'\n }\n}\n\n/**\n * RFC 4226 §5.1: counter must be an 8-byte big-endian integer in the HMAC input.\n */\nfunction counterToBuffer(counter: number): Buffer {\n const buf = Buffer.alloc(8)\n let value = counter\n for (let i = 7; i >= 0; i--) {\n buf[i] = value & 0xff\n value = Math.floor(value / 256)\n }\n return buf\n}\n\n/**\n * HMAC-based One-Time Password (RFC 4226).\n * TOTP calls this with a time-derived counter; verifyCode may call it for\n * neighboring counters when `window` > 0 (clock skew).\n */\nfunction hotp(\n secret: Buffer,\n counter: number,\n digits: number,\n algorithm: TotpAlgorithm,\n): string {\n const hmac = createHmac(algorithmToHashName(algorithm), secret)\n hmac.update(counterToBuffer(counter))\n const digest = hmac.digest()\n\n // Dynamic truncation (RFC 4226 §5.3): derive a 31-bit value from the HMAC digest.\n const offset = digest[digest.length - 1]! & 0x0f\n const binary =\n ((digest[offset]! & 0x7f) << 24) |\n ((digest[offset + 1]! & 0xff) << 16) |\n ((digest[offset + 2]! & 0xff) << 8) |\n (digest[offset + 3]! & 0xff)\n\n const otp = binary % 10 ** digits\n return otp.toString().padStart(digits, '0')\n}\n\nfunction resolveTimestamp(timestamp?: number): number {\n return timestamp ?? Date.now()\n}\n\n/** Generate the TOTP code for the current (or injected) time slice. */\nexport function generateCode(\n secret: string,\n options?: TotpOptions,\n): string {\n const period = options?.period ?? 30\n const digits = options?.digits ?? 6\n const algorithm = options?.algorithm ?? TotpAlgorithm.SHA1\n const timestamp = resolveTimestamp(options?.timestamp)\n const counter = Math.floor(timestamp / 1000 / period)\n const key = decodeBase32(secret)\n return hotp(key, counter, digits, algorithm)\n}\n\n/**\n * Constant-time comparison is not used here; timing leaks on OTP verify are a\n * secondary concern vs. rate limiting (out of scope for v1). Returns false for\n * malformed input instead of throwing.\n */\nexport function verifyCode(\n secret: string,\n code: string,\n options?: VerifyCodeOptions,\n): boolean {\n const period = options?.period ?? 30\n const digits = options?.digits ?? 6\n const window = options?.window ?? 0\n const algorithm = options?.algorithm ?? TotpAlgorithm.SHA1\n const timestamp = resolveTimestamp(options?.timestamp)\n const counter = Math.floor(timestamp / 1000 / period)\n const key = decodeBase32(secret)\n\n const normalized = code.replace(/\\s/g, '')\n if (!/^\\d+$/.test(normalized) || normalized.length !== digits) {\n return false\n }\n\n for (let drift = -window; drift <= window; drift++) {\n if (hotp(key, counter + drift, digits, algorithm) === normalized) {\n return true\n }\n }\n\n return false\n}\n","/** Lifecycle states for a user's TOTP enrollment record. */\nexport enum EnrollmentStatus {\n Pending = 'pending',\n Active = 'active',\n Revoked = 'revoked',\n}\n","/** Programmatic `error.code` values on TOTPError subclasses. */\nexport enum TOTPErrorCode {\n EnrollmentNotFound = 'ENROLLMENT_NOT_FOUND',\n EnrollmentConflict = 'ENROLLMENT_CONFLICT',\n EnrollmentNotActive = 'ENROLLMENT_NOT_ACTIVE',\n EnrollmentPending = 'ENROLLMENT_PENDING',\n InvalidCode = 'INVALID_CODE',\n}\n","import { TOTPErrorCode } from './enums/error-code.js'\n\n/**\n * Typed errors for enrollment state violations and invalid codes.\n * Catch by subclass or by `error.code` (TOTPErrorCode).\n */\n\nexport class TOTPError extends Error {\n readonly code: TOTPErrorCode\n\n constructor(message: string, code: TOTPErrorCode) {\n super(message)\n this.name = new.target.name\n this.code = code\n }\n}\n\n/** No enrollment row, or revoke() called when status is already revoked. */\nexport class EnrollmentNotFoundError extends TOTPError {\n constructor(message = 'Enrollment not found') {\n super(message, TOTPErrorCode.EnrollmentNotFound)\n }\n}\n\n/** enroll() while status is pending or active. */\nexport class EnrollmentConflictError extends TOTPError {\n constructor(message = 'Enrollment already exists') {\n super(message, TOTPErrorCode.EnrollmentConflict)\n }\n}\n\n/** verify() or regenerateRecoveryCodes() when not active (includes revoked). */\nexport class EnrollmentNotActiveError extends TOTPError {\n constructor(message = 'Enrollment is not active') {\n super(message, TOTPErrorCode.EnrollmentNotActive)\n }\n}\n\n/** verify() before confirm() completes (status still pending). */\nexport class EnrollmentPendingError extends TOTPError {\n constructor(message = 'Enrollment is pending confirmation') {\n super(message, TOTPErrorCode.EnrollmentPending)\n }\n}\n\n/** confirm() when the submitted TOTP does not match. */\nexport class InvalidCodeError extends TOTPError {\n constructor(message = 'Invalid code') {\n super(message, TOTPErrorCode.InvalidCode)\n }\n}\n","import { createHash, randomBytes } from 'crypto'\nimport type { RecoveryCode } from '../adapters/storage.js'\n\n/** 5 bytes → 10 hex chars, formatted as XXXX-XXXX-XXXX for readability. */\nconst RECOVERY_CODE_BYTES = 5\n\n/** SHA-256 hex digest; adapter stores only this, never plaintext codes. */\nexport function hashRecoveryCode(code: string): string {\n return createHash('sha256').update(code.trim()).digest('hex')\n}\n\n/**\n * Generate one-time backup codes. Plaintext is returned to the caller exactly once\n * (confirm / regenerateRecoveryCodes); only hashes are persisted.\n */\nexport function generateRecoveryCodes(\n userId: string,\n count: number,\n): { plaintext: string[]; stored: RecoveryCode[] } {\n const plaintext: string[] = []\n const stored: RecoveryCode[] = []\n const seen = new Set<string>()\n\n while (plaintext.length < count) {\n const code = randomBytes(RECOVERY_CODE_BYTES)\n .toString('hex')\n .toUpperCase()\n .match(/.{1,4}/g)!\n .join('-')\n\n if (seen.has(code)) continue\n seen.add(code)\n plaintext.push(code)\n stored.push({\n userId,\n codeHash: hashRecoveryCode(code),\n usedAt: null,\n })\n }\n\n return { plaintext, stored }\n}\n\n/** Match submitted backup code to an unused stored hash. */\nexport function findMatchingRecoveryCode(\n codes: RecoveryCode[],\n submittedCode: string,\n): RecoveryCode | null {\n const hash = hashRecoveryCode(submittedCode)\n return (\n codes.find((c) => c.codeHash === hash && c.usedAt === null) ?? null\n )\n}\n","/**\n * TOTP MFA orchestration: enrollment state machine, verification, recovery codes.\n *\n * State flow: [none|revoked] --enroll--> pending --confirm--> active --revoke--> revoked\n * Does not issue sessions or JWTs — only the MFA step in a larger auth flow.\n */\nimport type { StorageAdapter, TOTPEnrollment } from '../adapters/storage.js'\nimport { buildOtpAuthUri } from '../crypto/uri.js'\nimport { generateSecret } from '../crypto/secret.js'\nimport { verifyCode } from '../crypto/verify.js'\nimport { EnrollmentStatus } from '../enums/enrollment-status.js'\nimport { TotpAlgorithm } from '../enums/totp-algorithm.js'\nimport {\n EnrollmentConflictError,\n EnrollmentNotActiveError,\n EnrollmentNotFoundError,\n EnrollmentPendingError,\n InvalidCodeError,\n} from '../errors.js'\nimport {\n findMatchingRecoveryCode,\n generateRecoveryCodes,\n} from './recovery.js'\nimport type {\n ConfirmResult,\n EnrollmentResult,\n EnrollmentStatusView,\n TOTPServiceConfig,\n VerifyResult,\n} from './types.js'\n\nexport class TOTPService {\n private readonly storage: StorageAdapter\n private readonly issuer: string\n private readonly digits: 6 | 8\n private readonly period: number\n private readonly algorithm: TotpAlgorithm\n private readonly window: number\n private readonly recoveryCodeCount: number\n\n constructor(config: TOTPServiceConfig) {\n this.storage = config.storage\n this.issuer = config.issuer\n this.digits = config.digits ?? 6\n this.period = config.period ?? 30\n this.algorithm = config.algorithm ?? TotpAlgorithm.SHA1\n this.window = config.window ?? 1\n this.recoveryCodeCount = config.recoveryCodeCount ?? 10\n }\n\n /**\n * Start enrollment: new secret, status pending.\n * Allowed when no record exists or previous enrollment was revoked.\n */\n async enroll(userId: string): Promise<EnrollmentResult> {\n const existing = await this.storage.getEnrollment(userId)\n\n if (\n existing?.status === EnrollmentStatus.Pending ||\n existing?.status === EnrollmentStatus.Active\n ) {\n throw new EnrollmentConflictError()\n }\n\n // Stale backup codes from a prior active enrollment must not survive re-enroll.\n if (existing?.status === EnrollmentStatus.Revoked) {\n await this.storage.deleteRecoveryCodes(userId)\n }\n\n const secret = generateSecret()\n const now = new Date()\n const enrollment: TOTPEnrollment = {\n userId,\n secret,\n status: EnrollmentStatus.Pending,\n createdAt: now,\n confirmedAt: null,\n revokedAt: null,\n }\n\n await this.storage.saveEnrollment(enrollment)\n\n const otpAuthUri = buildOtpAuthUri({\n secret,\n accountName: userId,\n issuer: this.issuer,\n digits: this.digits,\n period: this.period,\n algorithm: this.algorithm,\n })\n\n return { secret, otpAuthUri }\n }\n\n /**\n * Prove the authenticator is configured: first valid TOTP → active.\n * Issues recovery codes (plaintext returned once).\n */\n async confirm(userId: string, code: string): Promise<ConfirmResult> {\n const enrollment = await this.requireEnrollment(userId)\n\n if (enrollment.status !== EnrollmentStatus.Pending) {\n if (enrollment.status === EnrollmentStatus.Active) {\n throw new EnrollmentConflictError('Enrollment is already active')\n }\n throw new EnrollmentNotActiveError()\n }\n\n if (\n !verifyCode(enrollment.secret, code, {\n window: this.window,\n period: this.period,\n digits: this.digits,\n algorithm: this.algorithm,\n })\n ) {\n throw new InvalidCodeError()\n }\n\n const now = new Date()\n await this.storage.updateEnrollment(userId, {\n status: EnrollmentStatus.Active,\n confirmedAt: now,\n })\n\n await this.storage.deleteRecoveryCodes(userId)\n const { plaintext, stored } = generateRecoveryCodes(\n userId,\n this.recoveryCodeCount,\n )\n await this.storage.saveRecoveryCodes(stored)\n\n return { recoveryCodes: plaintext }\n }\n\n /**\n * Login MFA step: try TOTP first, then single-use recovery codes.\n * Invalid TOTP returns { valid: false } rather than throwing.\n */\n async verify(userId: string, code: string): Promise<VerifyResult> {\n const enrollment = await this.requireEnrollment(userId)\n\n if (enrollment.status === EnrollmentStatus.Pending) {\n throw new EnrollmentPendingError()\n }\n\n if (enrollment.status === EnrollmentStatus.Revoked) {\n throw new EnrollmentNotActiveError()\n }\n\n const totpValid = verifyCode(enrollment.secret, code, {\n window: this.window,\n period: this.period,\n digits: this.digits,\n algorithm: this.algorithm,\n })\n\n if (totpValid) {\n return { valid: true, usedRecoveryCode: false }\n }\n\n const recoveryCodes = await this.storage.getRecoveryCodes(userId)\n const match = findMatchingRecoveryCode(recoveryCodes, code)\n\n if (!match) {\n return { valid: false, usedRecoveryCode: false }\n }\n\n await this.storage.markRecoveryCodeUsed(userId, match.codeHash)\n return { valid: true, usedRecoveryCode: true }\n }\n\n /** Disable MFA; removes all recovery codes. Allowed from pending or active. */\n async revoke(userId: string): Promise<void> {\n const enrollment = await this.requireEnrollment(userId)\n\n if (\n enrollment.status !== EnrollmentStatus.Pending &&\n enrollment.status !== EnrollmentStatus.Active\n ) {\n throw new EnrollmentNotFoundError()\n }\n\n await this.storage.updateEnrollment(userId, {\n status: EnrollmentStatus.Revoked,\n revokedAt: new Date(),\n })\n await this.storage.deleteRecoveryCodes(userId)\n }\n\n /** Status snapshot without the shared secret. */\n async getStatus(userId: string): Promise<EnrollmentStatusView | null> {\n const enrollment = await this.storage.getEnrollment(userId)\n if (!enrollment) return null\n\n return {\n userId: enrollment.userId,\n status: enrollment.status,\n createdAt: enrollment.createdAt,\n confirmedAt: enrollment.confirmedAt,\n revokedAt: enrollment.revokedAt,\n }\n }\n\n /** GDPR / account deletion — never throws, safe if user has no data. */\n async delete(userId: string): Promise<void> {\n await this.storage.deleteEnrollment(userId)\n await this.storage.deleteRecoveryCodes(userId)\n }\n\n /** Replace all backup codes; previous codes are invalidated immediately. */\n async regenerateRecoveryCodes(\n userId: string,\n ): Promise<{ recoveryCodes: string[] }> {\n const enrollment = await this.requireEnrollment(userId)\n\n if (enrollment.status !== EnrollmentStatus.Active) {\n throw new EnrollmentNotActiveError()\n }\n\n await this.storage.deleteRecoveryCodes(userId)\n const { plaintext, stored } = generateRecoveryCodes(\n userId,\n this.recoveryCodeCount,\n )\n await this.storage.saveRecoveryCodes(stored)\n\n return { recoveryCodes: plaintext }\n }\n\n private async requireEnrollment(userId: string): Promise<TOTPEnrollment> {\n const enrollment = await this.storage.getEnrollment(userId)\n if (!enrollment) {\n throw new EnrollmentNotFoundError()\n }\n return enrollment\n }\n}\n"]}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Lifecycle states for a user's TOTP enrollment record. */
|
|
2
|
+
declare enum EnrollmentStatus {
|
|
3
|
+
Pending = "pending",
|
|
4
|
+
Active = "active",
|
|
5
|
+
Revoked = "revoked"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Persistence contract — consumers implement StorageAdapter for their database.
|
|
10
|
+
* The library hashes recovery codes before saveRecoveryCodes(); adapters never
|
|
11
|
+
* see plaintext backup codes.
|
|
12
|
+
*/
|
|
13
|
+
/** Per-user TOTP enrollment record and lifecycle state. */
|
|
14
|
+
interface TOTPEnrollment {
|
|
15
|
+
userId: string;
|
|
16
|
+
/** Base32-encoded shared secret (see crypto/base32). */
|
|
17
|
+
secret: string;
|
|
18
|
+
status: EnrollmentStatus;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
confirmedAt: Date | null;
|
|
21
|
+
revokedAt: Date | null;
|
|
22
|
+
}
|
|
23
|
+
/** Hashed backup code row; plaintext is only returned once from confirm(). */
|
|
24
|
+
interface RecoveryCode {
|
|
25
|
+
userId: string;
|
|
26
|
+
codeHash: string;
|
|
27
|
+
usedAt: Date | null;
|
|
28
|
+
}
|
|
29
|
+
interface StorageAdapter {
|
|
30
|
+
/** Upsert: overwrites any existing enrollment for the same userId. */
|
|
31
|
+
saveEnrollment(enrollment: TOTPEnrollment): Promise<void>;
|
|
32
|
+
/** Returns null when no record exists (does not throw). */
|
|
33
|
+
getEnrollment(userId: string): Promise<TOTPEnrollment | null>;
|
|
34
|
+
updateEnrollment(userId: string, patch: Partial<TOTPEnrollment>): Promise<void>;
|
|
35
|
+
deleteEnrollment(userId: string): Promise<void>;
|
|
36
|
+
/** Replaces all recovery codes for the user (batch from one confirm/regenerate). */
|
|
37
|
+
saveRecoveryCodes(codes: RecoveryCode[]): Promise<void>;
|
|
38
|
+
/** Returns [] when none exist. */
|
|
39
|
+
getRecoveryCodes(userId: string): Promise<RecoveryCode[]>;
|
|
40
|
+
markRecoveryCodeUsed(userId: string, codeHash: string): Promise<void>;
|
|
41
|
+
deleteRecoveryCodes(userId: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { EnrollmentStatus as E, type RecoveryCode as R, type StorageAdapter as S, type TOTPEnrollment as T };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Lifecycle states for a user's TOTP enrollment record. */
|
|
2
|
+
declare enum EnrollmentStatus {
|
|
3
|
+
Pending = "pending",
|
|
4
|
+
Active = "active",
|
|
5
|
+
Revoked = "revoked"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Persistence contract — consumers implement StorageAdapter for their database.
|
|
10
|
+
* The library hashes recovery codes before saveRecoveryCodes(); adapters never
|
|
11
|
+
* see plaintext backup codes.
|
|
12
|
+
*/
|
|
13
|
+
/** Per-user TOTP enrollment record and lifecycle state. */
|
|
14
|
+
interface TOTPEnrollment {
|
|
15
|
+
userId: string;
|
|
16
|
+
/** Base32-encoded shared secret (see crypto/base32). */
|
|
17
|
+
secret: string;
|
|
18
|
+
status: EnrollmentStatus;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
confirmedAt: Date | null;
|
|
21
|
+
revokedAt: Date | null;
|
|
22
|
+
}
|
|
23
|
+
/** Hashed backup code row; plaintext is only returned once from confirm(). */
|
|
24
|
+
interface RecoveryCode {
|
|
25
|
+
userId: string;
|
|
26
|
+
codeHash: string;
|
|
27
|
+
usedAt: Date | null;
|
|
28
|
+
}
|
|
29
|
+
interface StorageAdapter {
|
|
30
|
+
/** Upsert: overwrites any existing enrollment for the same userId. */
|
|
31
|
+
saveEnrollment(enrollment: TOTPEnrollment): Promise<void>;
|
|
32
|
+
/** Returns null when no record exists (does not throw). */
|
|
33
|
+
getEnrollment(userId: string): Promise<TOTPEnrollment | null>;
|
|
34
|
+
updateEnrollment(userId: string, patch: Partial<TOTPEnrollment>): Promise<void>;
|
|
35
|
+
deleteEnrollment(userId: string): Promise<void>;
|
|
36
|
+
/** Replaces all recovery codes for the user (batch from one confirm/regenerate). */
|
|
37
|
+
saveRecoveryCodes(codes: RecoveryCode[]): Promise<void>;
|
|
38
|
+
/** Returns [] when none exist. */
|
|
39
|
+
getRecoveryCodes(userId: string): Promise<RecoveryCode[]>;
|
|
40
|
+
markRecoveryCodeUsed(userId: string, codeHash: string): Promise<void>;
|
|
41
|
+
deleteRecoveryCodes(userId: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { EnrollmentStatus as E, type RecoveryCode as R, type StorageAdapter as S, type TOTPEnrollment as T };
|