@svadmin/auth-utils 0.4.1

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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@svadmin/auth-utils",
3
+ "version": "0.4.1",
4
+ "type": "module",
5
+ "description": "Zero-dependency auth utility functions — password hashing, session management, TOTP for svadmin",
6
+ "license": "MIT",
7
+ "keywords": ["svadmin", "auth", "password", "session", "totp", "mfa"],
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/zuohuadong/svadmin",
11
+ "directory": "packages/auth-utils"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./src/index.ts",
16
+ "import": "./src/index.ts"
17
+ },
18
+ "./password": {
19
+ "types": "./src/password.ts",
20
+ "import": "./src/password.ts"
21
+ },
22
+ "./session": {
23
+ "types": "./src/session.ts",
24
+ "import": "./src/session.ts"
25
+ },
26
+ "./totp": {
27
+ "types": "./src/totp.ts",
28
+ "import": "./src/totp.ts"
29
+ }
30
+ },
31
+ "main": "./src/index.ts",
32
+ "types": "./src/index.ts",
33
+ "files": ["src"],
34
+ "devDependencies": {
35
+ "typescript": "^5.7.0"
36
+ }
37
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @svadmin/auth-utils — Unit Tests
3
+ */
4
+ import { describe, test, expect } from 'bun:test';
5
+ import { hashPassword, verifyPassword } from './password';
6
+ import { createSessionManager } from './session';
7
+ import { generateSecret, generateTOTP, verifyTOTP, generateQRUri } from './totp';
8
+
9
+ // ─── Password ────────────────────────────────────────────────
10
+
11
+ describe('password', () => {
12
+ test('hashPassword → verifyPassword (correct)', async () => {
13
+ const hash = await hashPassword('test-password-123');
14
+ expect(hash).toBeTruthy();
15
+ expect(typeof hash).toBe('string');
16
+
17
+ const valid = await verifyPassword('test-password-123', hash);
18
+ expect(valid).toBe(true);
19
+ });
20
+
21
+ test('hashPassword → verifyPassword (wrong password)', async () => {
22
+ const hash = await hashPassword('correct-password');
23
+ const valid = await verifyPassword('wrong-password', hash);
24
+ expect(valid).toBe(false);
25
+ });
26
+
27
+ test('hash format includes algorithm prefix', async () => {
28
+ const hash = await hashPassword('test', { algorithm: 'pbkdf2' });
29
+ expect(hash.startsWith('pbkdf2:')).toBe(true);
30
+ });
31
+
32
+ test('different passwords produce different hashes', async () => {
33
+ const h1 = await hashPassword('password-1', { algorithm: 'pbkdf2' });
34
+ const h2 = await hashPassword('password-2', { algorithm: 'pbkdf2' });
35
+ expect(h1).not.toBe(h2);
36
+ });
37
+
38
+ test('same password produces different hashes (salt)', async () => {
39
+ const h1 = await hashPassword('same-password', { algorithm: 'pbkdf2' });
40
+ const h2 = await hashPassword('same-password', { algorithm: 'pbkdf2' });
41
+ expect(h1).not.toBe(h2); // different salt each time
42
+ });
43
+ });
44
+
45
+ // ─── Session ─────────────────────────────────────────────────
46
+
47
+ describe('session', () => {
48
+ const sessions = createSessionManager('test-secret-key-at-least-32-chars!!');
49
+
50
+ test('create and verify session', async () => {
51
+ const token = await sessions.create({ userId: 'user-123', role: 'admin' });
52
+ expect(typeof token).toBe('string');
53
+ expect(token).toContain('.');
54
+
55
+ const payload = await sessions.verify(token);
56
+ expect(payload).not.toBeNull();
57
+ expect(payload!.sub).toBe('user-123');
58
+ expect(payload!.role).toBe('admin');
59
+ });
60
+
61
+ test('verify rejects tampered token', async () => {
62
+ const token = await sessions.create({ userId: 'user-123' });
63
+ const tampered = token.replace(/^./, 'X');
64
+ const payload = await sessions.verify(tampered);
65
+ expect(payload).toBeNull();
66
+ });
67
+
68
+ test('verify rejects expired token', async () => {
69
+ const shortSession = createSessionManager('test-secret-key-at-least-32-chars!!', { ttl: -1 });
70
+ const token = await shortSession.create({ userId: 'user-123' });
71
+ const payload = await shortSession.verify(token);
72
+ expect(payload).toBeNull();
73
+ });
74
+
75
+ test('verify rejects malformed token', async () => {
76
+ expect(await sessions.verify('not-a-valid-token')).toBeNull();
77
+ expect(await sessions.verify('')).toBeNull();
78
+ expect(await sessions.verify('a.b.c')).toBeNull();
79
+ });
80
+
81
+ test('generateId produces hex string', () => {
82
+ const id = sessions.generateId();
83
+ expect(id).toMatch(/^[0-9a-f]{64}$/);
84
+ });
85
+ });
86
+
87
+ // ─── TOTP ────────────────────────────────────────────────────
88
+
89
+ describe('totp', () => {
90
+ test('generateSecret produces base32 string', () => {
91
+ const secret = generateSecret();
92
+ expect(secret).toBeTruthy();
93
+ expect(secret).toMatch(/^[A-Z2-7]+$/);
94
+ expect(secret.length).toBeGreaterThan(20);
95
+ });
96
+
97
+ test('generateTOTP produces 6-digit string', async () => {
98
+ const secret = generateSecret();
99
+ const code = await generateTOTP(secret);
100
+ expect(code).toMatch(/^\d{6}$/);
101
+ });
102
+
103
+ test('verifyTOTP validates correct code', async () => {
104
+ const secret = generateSecret();
105
+ const code = await generateTOTP(secret);
106
+ const valid = await verifyTOTP(code, secret);
107
+ expect(valid).toBe(true);
108
+ });
109
+
110
+ test('verifyTOTP rejects wrong code', async () => {
111
+ const secret = generateSecret();
112
+ const valid = await verifyTOTP('000000', secret);
113
+ // Might be true by coincidence, but extremely unlikely
114
+ // Just check it doesn't throw
115
+ expect(typeof valid).toBe('boolean');
116
+ });
117
+
118
+ test('generateQRUri produces otpauth:// URI', () => {
119
+ const secret = generateSecret();
120
+ const uri = generateQRUri(secret, 'user@example.com', 'MyApp');
121
+ expect(uri).toContain('otpauth://totp/');
122
+ expect(uri).toContain('MyApp');
123
+ expect(uri).toContain('user%40example.com');
124
+ expect(uri).toContain(secret);
125
+ });
126
+
127
+ test('different secrets produce different codes', async () => {
128
+ const s1 = generateSecret();
129
+ const s2 = generateSecret();
130
+ const c1 = await generateTOTP(s1);
131
+ const c2 = await generateTOTP(s2);
132
+ // Very unlikely to be the same
133
+ expect(s1).not.toBe(s2);
134
+ // Codes might collide, but secrets shouldn't
135
+ });
136
+ });
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @svadmin/auth-utils — Zero-dependency auth utility functions.
3
+ *
4
+ * Provides password hashing, session management, and TOTP for MFA.
5
+ * Works on any runtime (Bun, Node.js, Deno, Cloudflare Workers) via Web Crypto API.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { hashPassword, verifyPassword, createSessionManager, generateSecret, verifyTOTP } from '@svadmin/auth-utils';
10
+ * ```
11
+ */
12
+
13
+ // Password
14
+ export { hashPassword, verifyPassword } from './password';
15
+ export type { PasswordOptions } from './password';
16
+
17
+ // Session
18
+ export { createSessionManager } from './session';
19
+ export type { SessionManager, SessionPayload, SessionOptions } from './session';
20
+
21
+ // TOTP
22
+ export { generateSecret, generateTOTP, verifyTOTP, generateQRUri } from './totp';
23
+ export type { TOTPOptions } from './totp';
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Password hashing and verification utilities.
3
+ *
4
+ * Uses Bun's native `Bun.password` API when available (Argon2id by default),
5
+ * with a Web Crypto API fallback using PBKDF2-SHA256 for non-Bun runtimes.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { hashPassword, verifyPassword } from '@svadmin/auth-utils/password';
10
+ *
11
+ * const hash = await hashPassword('my-password');
12
+ * const valid = await verifyPassword('my-password', hash);
13
+ * ```
14
+ */
15
+
16
+ // ─── Types ────────────────────────────────────────────────────
17
+
18
+ export interface PasswordOptions {
19
+ /**
20
+ * Algorithm to use. Default: 'auto' (uses Bun.password if available, else PBKDF2).
21
+ * - 'argon2id' — requires Bun runtime
22
+ * - 'bcrypt' — requires Bun runtime
23
+ * - 'pbkdf2' — Web Crypto API, works everywhere
24
+ * - 'auto' — best available
25
+ */
26
+ algorithm?: 'argon2id' | 'bcrypt' | 'pbkdf2' | 'auto';
27
+ }
28
+
29
+ // ─── Bun detection ────────────────────────────────────────────
30
+
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ const isBun = typeof (globalThis as any).Bun !== 'undefined';
33
+
34
+ // ─── PBKDF2 fallback (Web Crypto API) ─────────────────────────
35
+
36
+ const PBKDF2_ITERATIONS = 310_000; // OWASP recommended
37
+ const PBKDF2_SALT_LENGTH = 32;
38
+ const PBKDF2_KEY_LENGTH = 64;
39
+
40
+ function toHex(buffer: ArrayBuffer): string {
41
+ return Array.from(new Uint8Array(buffer), b => b.toString(16).padStart(2, '0')).join('');
42
+ }
43
+
44
+ function fromHex(hex: string): Uint8Array {
45
+ const bytes = new Uint8Array(hex.length / 2);
46
+ for (let i = 0; i < hex.length; i += 2) {
47
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
48
+ }
49
+ return bytes;
50
+ }
51
+
52
+ async function pbkdf2Hash(password: string): Promise<string> {
53
+ const salt = crypto.getRandomValues(new Uint8Array(PBKDF2_SALT_LENGTH));
54
+ const key = await crypto.subtle.importKey(
55
+ 'raw',
56
+ new TextEncoder().encode(password),
57
+ 'PBKDF2',
58
+ false,
59
+ ['deriveBits'],
60
+ );
61
+ const derived = await crypto.subtle.deriveBits(
62
+ { name: 'PBKDF2', salt: salt as unknown as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
63
+ key,
64
+ PBKDF2_KEY_LENGTH * 8,
65
+ );
66
+ return `pbkdf2:${PBKDF2_ITERATIONS}:${toHex(salt.buffer)}:${toHex(derived)}`;
67
+ }
68
+
69
+ async function pbkdf2Verify(password: string, stored: string): Promise<boolean> {
70
+ const [, iterStr, saltHex, hashHex] = stored.split(':');
71
+ const iterations = parseInt(iterStr, 10);
72
+ const salt = fromHex(saltHex);
73
+ const key = await crypto.subtle.importKey(
74
+ 'raw',
75
+ new TextEncoder().encode(password),
76
+ 'PBKDF2',
77
+ false,
78
+ ['deriveBits'],
79
+ );
80
+ const derived = await crypto.subtle.deriveBits(
81
+ { name: 'PBKDF2', salt: salt as unknown as BufferSource, iterations, hash: 'SHA-256' },
82
+ key,
83
+ PBKDF2_KEY_LENGTH * 8,
84
+ );
85
+ return toHex(derived) === hashHex;
86
+ }
87
+
88
+ // ─── Public API ───────────────────────────────────────────────
89
+
90
+ /**
91
+ * Hash a password using the best available algorithm.
92
+ *
93
+ * @returns A string containing the algorithm prefix + hash, safe to store in DB.
94
+ */
95
+ export async function hashPassword(
96
+ password: string,
97
+ opts?: PasswordOptions,
98
+ ): Promise<string> {
99
+ const algo = opts?.algorithm ?? 'auto';
100
+
101
+ if ((algo === 'auto' || algo === 'argon2id' || algo === 'bcrypt') && isBun) {
102
+ // Use Bun's native password API
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ const BunPassword = (globalThis as any).Bun as { password: { hash: (p: string, opts?: { algorithm: string }) => Promise<string> } };
105
+ const bunAlgo = algo === 'auto' ? 'argon2id' : algo;
106
+ return BunPassword.password.hash(password, { algorithm: bunAlgo });
107
+ }
108
+
109
+ // Fallback to PBKDF2
110
+ return pbkdf2Hash(password);
111
+ }
112
+
113
+ /**
114
+ * Verify a password against a stored hash.
115
+ *
116
+ * @returns `true` if the password matches, `false` otherwise.
117
+ */
118
+ export async function verifyPassword(
119
+ password: string,
120
+ hash: string,
121
+ ): Promise<boolean> {
122
+ // Auto-detect algorithm from hash format
123
+ if (hash.startsWith('pbkdf2:')) {
124
+ return pbkdf2Verify(password, hash);
125
+ }
126
+
127
+ // Argon2 / bcrypt hashes — use Bun.password.verify
128
+ if (isBun) {
129
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
+ const BunPassword = (globalThis as any).Bun as { password: { verify: (p: string, h: string) => Promise<boolean> } };
131
+ return BunPassword.password.verify(password, hash);
132
+ }
133
+
134
+ throw new Error(
135
+ `[auth-utils] Cannot verify hash format "${hash.substring(0, 10)}...". ` +
136
+ 'Argon2/bcrypt hashes require the Bun runtime.',
137
+ );
138
+ }
package/src/session.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Session token management — generate, validate, and parse session tokens.
3
+ *
4
+ * Uses HMAC-SHA256 signatures for stateless session cookies.
5
+ * The token format is: `base64url(payload).base64url(signature)`
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { createSessionManager } from '@svadmin/auth-utils/session';
10
+ *
11
+ * const sessions = createSessionManager('your-secret-key');
12
+ *
13
+ * // Generate a session token
14
+ * const token = await sessions.create({ userId: '123', role: 'admin' });
15
+ *
16
+ * // Validate and parse
17
+ * const payload = await sessions.verify(token);
18
+ * if (payload) console.log(payload.userId);
19
+ * ```
20
+ */
21
+
22
+ // ─── Types ────────────────────────────────────────────────────
23
+
24
+ export interface SessionPayload {
25
+ /** User ID */
26
+ sub: string;
27
+ /** Issued at (unix seconds) */
28
+ iat: number;
29
+ /** Expiration (unix seconds) */
30
+ exp: number;
31
+ /** Custom data */
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ export interface SessionOptions {
36
+ /** Session TTL in seconds. Default: 86400 (24 hours) */
37
+ ttl?: number;
38
+ /** Cookie name. Default: 'svadmin_session' */
39
+ cookieName?: string;
40
+ }
41
+
42
+ export interface SessionManager {
43
+ /** Create a new signed session token */
44
+ create: (data: Record<string, unknown>) => Promise<string>;
45
+ /** Verify and decode a session token. Returns null if invalid/expired. */
46
+ verify: (token: string) => Promise<SessionPayload | null>;
47
+ /** Generate a random session ID (32 bytes, hex) */
48
+ generateId: () => string;
49
+ }
50
+
51
+ // ─── Base64url helpers ────────────────────────────────────────
52
+
53
+ function base64urlEncode(data: string): string {
54
+ return btoa(data).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
55
+ }
56
+
57
+ function base64urlDecode(str: string): string {
58
+ const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
59
+ return atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
60
+ }
61
+
62
+ // ─── Factory ──────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Create a session manager with HMAC-SHA256 signed tokens.
66
+ *
67
+ * @param secret - Secret key for signing (minimum 32 characters recommended)
68
+ * @param options - Configuration options
69
+ */
70
+ export function createSessionManager(
71
+ secret: string,
72
+ options?: SessionOptions,
73
+ ): SessionManager {
74
+ const ttl = options?.ttl ?? 86400;
75
+ let cryptoKey: CryptoKey | null = null;
76
+
77
+ async function getKey(): Promise<CryptoKey> {
78
+ if (cryptoKey) return cryptoKey;
79
+ cryptoKey = await crypto.subtle.importKey(
80
+ 'raw',
81
+ new TextEncoder().encode(secret),
82
+ { name: 'HMAC', hash: 'SHA-256' },
83
+ false,
84
+ ['sign', 'verify'],
85
+ );
86
+ return cryptoKey;
87
+ }
88
+
89
+ function toHex(buffer: ArrayBuffer): string {
90
+ return Array.from(new Uint8Array(buffer), b => b.toString(16).padStart(2, '0')).join('');
91
+ }
92
+
93
+ async function sign(payload: string): Promise<string> {
94
+ const key = await getKey();
95
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
96
+ return toHex(sig);
97
+ }
98
+
99
+ return {
100
+ async create(data: Record<string, unknown>): Promise<string> {
101
+ const now = Math.floor(Date.now() / 1000);
102
+ const payload: SessionPayload = {
103
+ sub: (data.userId as string) ?? (data.sub as string) ?? '',
104
+ iat: now,
105
+ exp: now + ttl,
106
+ ...data,
107
+ };
108
+ const payloadStr = base64urlEncode(JSON.stringify(payload));
109
+ const signature = await sign(payloadStr);
110
+ return `${payloadStr}.${signature}`;
111
+ },
112
+
113
+ async verify(token: string): Promise<SessionPayload | null> {
114
+ const parts = token.split('.');
115
+ if (parts.length !== 2) return null;
116
+
117
+ const [payloadStr, signature] = parts;
118
+
119
+ // Verify signature
120
+ const expectedSig = await sign(payloadStr);
121
+ if (signature !== expectedSig) return null;
122
+
123
+ // Decode and check expiry
124
+ try {
125
+ const payload = JSON.parse(base64urlDecode(payloadStr)) as SessionPayload;
126
+ const now = Math.floor(Date.now() / 1000);
127
+ if (payload.exp && payload.exp < now) return null;
128
+ return payload;
129
+ } catch {
130
+ return null;
131
+ }
132
+ },
133
+
134
+ generateId(): string {
135
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
136
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
137
+ },
138
+ };
139
+ }
package/src/totp.ts ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * TOTP (Time-based One-Time Password) utilities for MFA.
3
+ *
4
+ * Implements RFC 6238 (TOTP) using Web Crypto API — zero dependencies.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { generateSecret, generateTOTP, verifyTOTP, generateQRUri } from '@svadmin/auth-utils/totp';
9
+ *
10
+ * // Setup: generate a secret and show QR code
11
+ * const secret = generateSecret();
12
+ * const qrUri = generateQRUri(secret, 'user@example.com', 'MyApp');
13
+ *
14
+ * // Verify user's code
15
+ * const valid = await verifyTOTP('123456', secret);
16
+ * ```
17
+ */
18
+
19
+ // ─── Types ────────────────────────────────────────────────────
20
+
21
+ export interface TOTPOptions {
22
+ /** Number of digits. Default: 6 */
23
+ digits?: number;
24
+ /** Time step in seconds. Default: 30 */
25
+ period?: number;
26
+ /** HMAC algorithm. Default: 'SHA-1' (per RFC 6238) */
27
+ algorithm?: 'SHA-1' | 'SHA-256' | 'SHA-512';
28
+ /** Number of time steps to check before/after current. Default: 1 */
29
+ window?: number;
30
+ }
31
+
32
+ // ─── Base32 encoding/decoding ─────────────────────────────────
33
+
34
+ const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
35
+
36
+ function base32Encode(buffer: Uint8Array): string {
37
+ let result = '';
38
+ let bits = 0;
39
+ let value = 0;
40
+ for (const byte of buffer) {
41
+ value = (value << 8) | byte;
42
+ bits += 8;
43
+ while (bits >= 5) {
44
+ result += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f];
45
+ bits -= 5;
46
+ }
47
+ }
48
+ if (bits > 0) {
49
+ result += BASE32_CHARS[(value << (5 - bits)) & 0x1f];
50
+ }
51
+ return result;
52
+ }
53
+
54
+ function base32Decode(encoded: string): Uint8Array {
55
+ const cleaned = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
56
+ const output: number[] = [];
57
+ let bits = 0;
58
+ let value = 0;
59
+ for (const char of cleaned) {
60
+ const idx = BASE32_CHARS.indexOf(char);
61
+ if (idx === -1) continue;
62
+ value = (value << 5) | idx;
63
+ bits += 5;
64
+ if (bits >= 8) {
65
+ output.push((value >>> (bits - 8)) & 0xff);
66
+ bits -= 8;
67
+ }
68
+ }
69
+ return new Uint8Array(output);
70
+ }
71
+
72
+ // ─── HMAC ─────────────────────────────────────────────────────
73
+
74
+ async function hmac(
75
+ algorithm: string,
76
+ key: Uint8Array,
77
+ data: Uint8Array,
78
+ ): Promise<Uint8Array> {
79
+ // Map algo names for Web Crypto
80
+ const algoMap: Record<string, string> = {
81
+ 'SHA-1': 'SHA-1',
82
+ 'SHA-256': 'SHA-256',
83
+ 'SHA-512': 'SHA-512',
84
+ };
85
+ const cryptoKey = await crypto.subtle.importKey(
86
+ 'raw',
87
+ key as unknown as BufferSource,
88
+ { name: 'HMAC', hash: algoMap[algorithm] ?? 'SHA-1' },
89
+ false,
90
+ ['sign'],
91
+ );
92
+ const sig = await crypto.subtle.sign('HMAC', cryptoKey, data as unknown as BufferSource);
93
+ return new Uint8Array(sig);
94
+ }
95
+
96
+ // ─── HOTP (base for TOTP) ─────────────────────────────────────
97
+
98
+ async function hotp(
99
+ secret: Uint8Array,
100
+ counter: number,
101
+ digits: number,
102
+ algorithm: string,
103
+ ): Promise<string> {
104
+ // Convert counter to 8-byte big-endian buffer
105
+ const buffer = new ArrayBuffer(8);
106
+ const view = new DataView(buffer);
107
+ view.setBigUint64(0, BigInt(counter));
108
+
109
+ const hash = await hmac(algorithm, secret, new Uint8Array(buffer));
110
+
111
+ // Dynamic truncation (RFC 4226 §5.4)
112
+ const offset = hash[hash.length - 1] & 0x0f;
113
+ const code =
114
+ ((hash[offset] & 0x7f) << 24) |
115
+ ((hash[offset + 1] & 0xff) << 16) |
116
+ ((hash[offset + 2] & 0xff) << 8) |
117
+ (hash[offset + 3] & 0xff);
118
+
119
+ return (code % 10 ** digits).toString().padStart(digits, '0');
120
+ }
121
+
122
+ // ─── Public API ───────────────────────────────────────────────
123
+
124
+ /**
125
+ * Generate a random TOTP secret (20 bytes, base32 encoded).
126
+ */
127
+ export function generateSecret(byteLength = 20): string {
128
+ const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
129
+ return base32Encode(bytes);
130
+ }
131
+
132
+ /**
133
+ * Generate a TOTP code for the current time.
134
+ *
135
+ * @param secret - Base32-encoded secret
136
+ * @param opts - TOTP options
137
+ * @returns The current TOTP code string
138
+ */
139
+ export async function generateTOTP(
140
+ secret: string,
141
+ opts?: TOTPOptions,
142
+ ): Promise<string> {
143
+ const period = opts?.period ?? 30;
144
+ const digits = opts?.digits ?? 6;
145
+ const algorithm = opts?.algorithm ?? 'SHA-1';
146
+
147
+ const counter = Math.floor(Date.now() / 1000 / period);
148
+ const secretBytes = base32Decode(secret);
149
+ return hotp(secretBytes, counter, digits, algorithm);
150
+ }
151
+
152
+ /**
153
+ * Verify a TOTP code against the secret.
154
+ *
155
+ * Checks the current time step ± window.
156
+ *
157
+ * @param code - The code to verify
158
+ * @param secret - Base32-encoded secret
159
+ * @param opts - TOTP options
160
+ * @returns `true` if the code is valid
161
+ */
162
+ export async function verifyTOTP(
163
+ code: string,
164
+ secret: string,
165
+ opts?: TOTPOptions,
166
+ ): Promise<boolean> {
167
+ const period = opts?.period ?? 30;
168
+ const digits = opts?.digits ?? 6;
169
+ const algorithm = opts?.algorithm ?? 'SHA-1';
170
+ const window = opts?.window ?? 1;
171
+
172
+ const now = Math.floor(Date.now() / 1000 / period);
173
+ const secretBytes = base32Decode(secret);
174
+
175
+ for (let i = -window; i <= window; i++) {
176
+ const expected = await hotp(secretBytes, now + i, digits, algorithm);
177
+ if (timingSafeEqual(code, expected)) return true;
178
+ }
179
+
180
+ return false;
181
+ }
182
+
183
+ /**
184
+ * Generate an `otpauth://` URI for QR code generation.
185
+ *
186
+ * @param secret - Base32-encoded secret
187
+ * @param accountName - User identifier (e.g., email)
188
+ * @param issuer - App name displayed in authenticator
189
+ * @param opts - TOTP options
190
+ */
191
+ export function generateQRUri(
192
+ secret: string,
193
+ accountName: string,
194
+ issuer: string,
195
+ opts?: TOTPOptions,
196
+ ): string {
197
+ const period = opts?.period ?? 30;
198
+ const digits = opts?.digits ?? 6;
199
+ const algorithm = opts?.algorithm ?? 'SHA-1';
200
+
201
+ const params = new URLSearchParams({
202
+ secret,
203
+ issuer,
204
+ algorithm: algorithm.replace('-', ''),
205
+ digits: String(digits),
206
+ period: String(period),
207
+ });
208
+
209
+ return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?${params}`;
210
+ }
211
+
212
+ // ─── Timing-safe comparison ───────────────────────────────────
213
+
214
+ function timingSafeEqual(a: string, b: string): boolean {
215
+ if (a.length !== b.length) return false;
216
+ let result = 0;
217
+ for (let i = 0; i < a.length; i++) {
218
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
219
+ }
220
+ return result === 0;
221
+ }