@strav/auth 0.2.13

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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * TOTP (Time-Based One-Time Password) implementation — RFC 6238.
3
+ * Pure Bun crypto, zero external dependencies.
4
+ */
5
+
6
+ import { timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Base32 encoding/decoding (RFC 4648)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
13
+
14
+ export function base32Encode(buffer: Uint8Array): string {
15
+ let result = ''
16
+ let bits = 0
17
+ let value = 0
18
+
19
+ for (const byte of buffer) {
20
+ value = (value << 8) | byte
21
+ bits += 8
22
+ while (bits >= 5) {
23
+ bits -= 5
24
+ result += BASE32_ALPHABET[(value >>> bits) & 0x1f]!
25
+ }
26
+ }
27
+
28
+ if (bits > 0) {
29
+ result += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]!
30
+ }
31
+
32
+ return result
33
+ }
34
+
35
+ export function base32Decode(encoded: string): Uint8Array {
36
+ const cleaned = encoded.replace(/=+$/, '').toUpperCase()
37
+ const bytes: number[] = []
38
+ let bits = 0
39
+ let value = 0
40
+
41
+ for (const char of cleaned) {
42
+ const index = BASE32_ALPHABET.indexOf(char)
43
+ if (index === -1) continue
44
+ value = (value << 5) | index
45
+ bits += 5
46
+ if (bits >= 8) {
47
+ bits -= 8
48
+ bytes.push((value >>> bits) & 0xff)
49
+ }
50
+ }
51
+
52
+ return new Uint8Array(bytes)
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // HOTP — RFC 4226
57
+ // ---------------------------------------------------------------------------
58
+
59
+ async function hotp(secret: Uint8Array, counter: bigint, digits: number): Promise<string> {
60
+ // Counter as 8-byte big-endian buffer
61
+ const counterBuffer = new ArrayBuffer(8)
62
+ const view = new DataView(counterBuffer)
63
+ view.setBigUint64(0, counter)
64
+
65
+ // HMAC-SHA1
66
+ const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-1' }, false, [
67
+ 'sign',
68
+ ])
69
+ const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBuffer))
70
+
71
+ // Dynamic truncation
72
+ const offset = mac[19]! & 0x0f
73
+ const code =
74
+ ((mac[offset]! & 0x7f) << 24) |
75
+ ((mac[offset + 1]! & 0xff) << 16) |
76
+ ((mac[offset + 2]! & 0xff) << 8) |
77
+ (mac[offset + 3]! & 0xff)
78
+
79
+ return String(code % 10 ** digits).padStart(digits, '0')
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // TOTP — RFC 6238
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface TotpOptions {
87
+ digits?: number
88
+ period?: number
89
+ /** Number of time steps to check before/after current (handles clock drift). */
90
+ window?: number
91
+ }
92
+
93
+ /** Generate a TOTP code for the given secret at the current time. */
94
+ export async function generateTotp(secret: Uint8Array, options: TotpOptions = {}): Promise<string> {
95
+ const { digits = 6, period = 30 } = options
96
+ const counter = BigInt(Math.floor(Date.now() / 1000 / period))
97
+ return hotp(secret, counter, digits)
98
+ }
99
+
100
+ /** Verify a TOTP code, allowing for clock drift within the window. */
101
+ export async function verifyTotp(
102
+ secret: Uint8Array,
103
+ code: string,
104
+ options: TotpOptions = {}
105
+ ): Promise<boolean> {
106
+ const { digits = 6, period = 30, window = 1 } = options
107
+ const now = BigInt(Math.floor(Date.now() / 1000 / period))
108
+
109
+ for (let i = -window; i <= window; i++) {
110
+ const expected = await hotp(secret, now + BigInt(i), digits)
111
+ if (timingSafeEqual(code, expected)) return true
112
+ }
113
+
114
+ return false
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Secret generation
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** Generate a random TOTP secret (20 bytes, returned as base32). */
122
+ export function generateSecret(): { raw: Uint8Array; base32: string } {
123
+ const raw = crypto.getRandomValues(new Uint8Array(20))
124
+ return { raw, base32: base32Encode(raw) }
125
+ }
126
+
127
+ /**
128
+ * Build an `otpauth://` URI for QR code generation.
129
+ * Compatible with Google Authenticator, Authy, 1Password, etc.
130
+ */
131
+ export function totpUri(options: {
132
+ secret: string // base32
133
+ issuer: string
134
+ account: string
135
+ digits?: number
136
+ period?: number
137
+ }): string {
138
+ const { secret, issuer, account, digits = 6, period = 30 } = options
139
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`
140
+ const params = new URLSearchParams({
141
+ secret,
142
+ issuer,
143
+ algorithm: 'SHA1',
144
+ digits: String(digits),
145
+ period: String(period),
146
+ })
147
+ return `otpauth://totp/${label}?${params}`
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Recovery codes
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /** Generate a set of single-use recovery codes (8-char hex each). */
155
+ export function generateRecoveryCodes(count: number): string[] {
156
+ const codes: string[] = []
157
+ for (let i = 0; i < count; i++) {
158
+ const bytes = crypto.getRandomValues(new Uint8Array(4))
159
+ codes.push(Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''))
160
+ }
161
+ return codes
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Timing-safe string comparison
166
+ // ---------------------------------------------------------------------------
167
+
168
+ function timingSafeEqual(a: string, b: string): boolean {
169
+ if (a.length !== b.length) return false
170
+ return nodeTimingSafeEqual(Buffer.from(a), Buffer.from(b))
171
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * QR code URI generation for TOTP authenticators.
3
+ */
4
+
5
+ /**
6
+ * Build an `otpauth://` URI for QR code generation.
7
+ * Compatible with Google Authenticator, Authy, 1Password, etc.
8
+ */
9
+ export function totpUri(options: {
10
+ secret: string // base32
11
+ issuer: string
12
+ account: string
13
+ digits?: number
14
+ period?: number
15
+ }): string {
16
+ const { secret, issuer, account, digits = 6, period = 30 } = options
17
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`
18
+ const params = new URLSearchParams({
19
+ secret,
20
+ issuer,
21
+ algorithm: 'SHA1',
22
+ digits: String(digits),
23
+ period: String(period),
24
+ })
25
+ return `otpauth://totp/${label}?${params}`
26
+ }
@@ -0,0 +1,8 @@
1
+ // Password validation
2
+ export {
3
+ validatePassword,
4
+ calculatePasswordStrength,
5
+ generatePassword,
6
+ type PasswordStrength,
7
+ type PasswordPolicy
8
+ } from './password.ts'
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Password validation utilities for enforcing security policies.
3
+ */
4
+
5
+ export interface PasswordStrength {
6
+ /** Overall score from 0-4 (0 = very weak, 4 = very strong) */
7
+ score: number
8
+ /** Human-readable strength label */
9
+ label: 'Very Weak' | 'Weak' | 'Fair' | 'Strong' | 'Very Strong'
10
+ /** Specific issues with the password */
11
+ issues: string[]
12
+ /** Suggestions for improvement */
13
+ suggestions: string[]
14
+ }
15
+
16
+ export interface PasswordPolicy {
17
+ /** Minimum length (default: 8) */
18
+ minLength?: number
19
+ /** Maximum length (default: 128) */
20
+ maxLength?: number
21
+ /** Require at least one uppercase letter */
22
+ requireUppercase?: boolean
23
+ /** Require at least one lowercase letter */
24
+ requireLowercase?: boolean
25
+ /** Require at least one number */
26
+ requireNumbers?: boolean
27
+ /** Require at least one special character */
28
+ requireSpecialChars?: boolean
29
+ /** List of forbidden passwords/patterns */
30
+ blacklist?: string[]
31
+ /** Custom validation function */
32
+ customValidator?: (password: string) => { valid: boolean; message?: string }
33
+ }
34
+
35
+ const DEFAULT_POLICY: PasswordPolicy = {
36
+ minLength: 8,
37
+ maxLength: 128,
38
+ requireUppercase: false,
39
+ requireLowercase: false,
40
+ requireNumbers: false,
41
+ requireSpecialChars: false,
42
+ }
43
+
44
+ /**
45
+ * Check if a password meets the specified policy requirements.
46
+ *
47
+ * @param password - The password to validate
48
+ * @param policy - The password policy to enforce
49
+ * @returns Validation result with specific issues
50
+ */
51
+ export function validatePassword(
52
+ password: string,
53
+ policy: PasswordPolicy = DEFAULT_POLICY
54
+ ): { valid: boolean; errors: string[] } {
55
+ const errors: string[] = []
56
+ const p = { ...DEFAULT_POLICY, ...policy }
57
+
58
+ // Length checks
59
+ if (password.length < p.minLength!) {
60
+ errors.push(`Password must be at least ${p.minLength} characters long`)
61
+ }
62
+ if (password.length > p.maxLength!) {
63
+ errors.push(`Password must not exceed ${p.maxLength} characters`)
64
+ }
65
+
66
+ // Character requirements
67
+ if (p.requireUppercase && !/[A-Z]/.test(password)) {
68
+ errors.push('Password must contain at least one uppercase letter')
69
+ }
70
+ if (p.requireLowercase && !/[a-z]/.test(password)) {
71
+ errors.push('Password must contain at least one lowercase letter')
72
+ }
73
+ if (p.requireNumbers && !/\d/.test(password)) {
74
+ errors.push('Password must contain at least one number')
75
+ }
76
+ if (p.requireSpecialChars && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
77
+ errors.push('Password must contain at least one special character')
78
+ }
79
+
80
+ // Blacklist check
81
+ if (p.blacklist) {
82
+ const lowerPassword = password.toLowerCase()
83
+ for (const forbidden of p.blacklist) {
84
+ if (lowerPassword.includes(forbidden.toLowerCase())) {
85
+ errors.push(`Password contains forbidden word: ${forbidden}`)
86
+ }
87
+ }
88
+ }
89
+
90
+ // Custom validation
91
+ if (p.customValidator) {
92
+ const result = p.customValidator(password)
93
+ if (!result.valid && result.message) {
94
+ errors.push(result.message)
95
+ }
96
+ }
97
+
98
+ return {
99
+ valid: errors.length === 0,
100
+ errors,
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Calculate password strength using various heuristics.
106
+ *
107
+ * @param password - The password to analyze
108
+ * @returns Strength assessment with score and suggestions
109
+ */
110
+ export function calculatePasswordStrength(password: string): PasswordStrength {
111
+ let score = 0
112
+ const issues: string[] = []
113
+ const suggestions: string[] = []
114
+
115
+ // Length scoring
116
+ if (password.length < 6) {
117
+ issues.push('Too short')
118
+ suggestions.push('Use at least 8 characters')
119
+ } else if (password.length < 8) {
120
+ score += 0.5
121
+ suggestions.push('Consider using at least 8 characters')
122
+ } else if (password.length < 12) {
123
+ score += 1
124
+ } else if (password.length < 16) {
125
+ score += 1.5
126
+ } else {
127
+ score += 2
128
+ }
129
+
130
+ // Character diversity
131
+ const hasLowercase = /[a-z]/.test(password)
132
+ const hasUppercase = /[A-Z]/.test(password)
133
+ const hasNumbers = /\d/.test(password)
134
+ const hasSpecialChars = /[^a-zA-Z0-9]/.test(password)
135
+
136
+ const diversity = [hasLowercase, hasUppercase, hasNumbers, hasSpecialChars].filter(Boolean).length
137
+
138
+ if (diversity === 1) {
139
+ issues.push('Uses only one type of character')
140
+ suggestions.push('Mix uppercase, lowercase, numbers, and symbols')
141
+ } else if (diversity === 2) {
142
+ score += 0.5
143
+ suggestions.push('Add numbers or symbols for better security')
144
+ } else if (diversity === 3) {
145
+ score += 1
146
+ } else if (diversity === 4) {
147
+ score += 1.5
148
+ }
149
+
150
+ // Common patterns detection
151
+ if (/^[0-9]+$/.test(password)) {
152
+ issues.push('Contains only numbers')
153
+ score = Math.max(0, score - 1)
154
+ }
155
+ if (/^[a-zA-Z]+$/.test(password)) {
156
+ issues.push('Contains only letters')
157
+ score = Math.max(0, score - 0.5)
158
+ }
159
+ if (/(.)\1{2,}/.test(password)) {
160
+ issues.push('Contains repeated characters')
161
+ suggestions.push('Avoid repeating characters')
162
+ score = Math.max(0, score - 0.5)
163
+ }
164
+ if (/(?:012|123|234|345|456|567|678|789|890|abc|bcd|cde|def)/i.test(password)) {
165
+ issues.push('Contains sequential characters')
166
+ suggestions.push('Avoid sequential patterns')
167
+ score = Math.max(0, score - 0.5)
168
+ }
169
+
170
+ // Common passwords check
171
+ const commonPasswords = ['password', '123456', 'qwerty', 'admin', 'letmein', 'welcome', 'monkey', 'dragon']
172
+ const lowerPassword = password.toLowerCase()
173
+ if (commonPasswords.some(common => lowerPassword.includes(common))) {
174
+ issues.push('Contains common password pattern')
175
+ suggestions.push('Avoid common words and patterns')
176
+ score = Math.max(0, score - 2)
177
+ }
178
+
179
+ // Normalize score to 0-4 range
180
+ score = Math.min(4, Math.max(0, score))
181
+
182
+ // Determine label
183
+ let label: PasswordStrength['label']
184
+ if (score < 1) label = 'Very Weak'
185
+ else if (score < 2) label = 'Weak'
186
+ else if (score < 3) label = 'Fair'
187
+ else if (score < 3.5) label = 'Strong'
188
+ else label = 'Very Strong'
189
+
190
+ return {
191
+ score: Math.round(score),
192
+ label,
193
+ issues,
194
+ suggestions: suggestions.slice(0, 3), // Limit to top 3 suggestions
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Generate a random strong password.
200
+ *
201
+ * @param length - Password length (default: 16)
202
+ * @param options - Character set options
203
+ * @returns Generated password
204
+ */
205
+ export function generatePassword(
206
+ length: number = 16,
207
+ options: {
208
+ uppercase?: boolean
209
+ lowercase?: boolean
210
+ numbers?: boolean
211
+ symbols?: boolean
212
+ } = {}
213
+ ): string {
214
+ const opts = {
215
+ uppercase: true,
216
+ lowercase: true,
217
+ numbers: true,
218
+ symbols: true,
219
+ ...options,
220
+ }
221
+
222
+ let charset = ''
223
+ if (opts.lowercase) charset += 'abcdefghijklmnopqrstuvwxyz'
224
+ if (opts.uppercase) charset += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
225
+ if (opts.numbers) charset += '0123456789'
226
+ if (opts.symbols) charset += '!@#$%^&*()_+-=[]{}|;:,.<>?'
227
+
228
+ if (!charset) {
229
+ throw new Error('At least one character type must be enabled')
230
+ }
231
+
232
+ let password = ''
233
+ const array = new Uint8Array(length)
234
+ crypto.getRandomValues(array)
235
+
236
+ for (let i = 0; i < length; i++) {
237
+ password += charset[array[i]! % charset.length]!
238
+ }
239
+
240
+ return password
241
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }