@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.
- package/README.md +168 -0
- package/package.json +27 -0
- package/src/index.ts +21 -0
- package/src/jwt/index.ts +24 -0
- package/src/jwt/sign.ts +129 -0
- package/src/jwt/types.ts +80 -0
- package/src/jwt/utils.ts +109 -0
- package/src/jwt/verify.ts +183 -0
- package/src/oauth/index.ts +7 -0
- package/src/oauth/state.ts +117 -0
- package/src/tokens/index.ts +20 -0
- package/src/tokens/magic.ts +57 -0
- package/src/tokens/refresh.ts +75 -0
- package/src/tokens/signed.ts +61 -0
- package/src/totp/index.ts +15 -0
- package/src/totp/recovery.ts +13 -0
- package/src/totp/totp.ts +171 -0
- package/src/totp/uri.ts +26 -0
- package/src/validation/index.ts +8 -0
- package/src/validation/password.ts +241 -0
- package/tsconfig.json +5 -0
package/src/totp/totp.ts
ADDED
|
@@ -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
|
+
}
|
package/src/totp/uri.ts
ADDED
|
@@ -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,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
|
+
}
|