@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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { JWTPayload, JWTHeader, JWTVerifyOptions } from './types.ts'
|
|
2
|
+
import {
|
|
3
|
+
base64urlDecode,
|
|
4
|
+
verifyHmacSignature,
|
|
5
|
+
getUnixTimestamp,
|
|
6
|
+
getHashAlgorithm,
|
|
7
|
+
} from './utils.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Verify a JWT and return its payload.
|
|
11
|
+
* Zero external dependencies.
|
|
12
|
+
*
|
|
13
|
+
* @param token - The JWT string to verify
|
|
14
|
+
* @param secret - The secret key for HMAC
|
|
15
|
+
* @param options - Verification options
|
|
16
|
+
* @returns The verified payload
|
|
17
|
+
* @throws If the token is invalid, expired, or doesn't meet requirements
|
|
18
|
+
*/
|
|
19
|
+
export async function verifyJWT<T extends JWTPayload = JWTPayload>(
|
|
20
|
+
token: string,
|
|
21
|
+
secret: Uint8Array | string,
|
|
22
|
+
options: JWTVerifyOptions = {}
|
|
23
|
+
): Promise<T> {
|
|
24
|
+
const {
|
|
25
|
+
issuer,
|
|
26
|
+
audience,
|
|
27
|
+
subject,
|
|
28
|
+
algorithms = ['HS256', 'HS384', 'HS512'],
|
|
29
|
+
requiredClaims = [],
|
|
30
|
+
} = options
|
|
31
|
+
|
|
32
|
+
// Split token into parts
|
|
33
|
+
const parts = token.split('.')
|
|
34
|
+
if (parts.length !== 3) {
|
|
35
|
+
throw new Error('Invalid JWT format')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [encodedHeader, encodedPayload, signature] = parts
|
|
39
|
+
|
|
40
|
+
// Decode header and payload
|
|
41
|
+
let header: JWTHeader
|
|
42
|
+
let payload: JWTPayload
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
header = JSON.parse(base64urlDecode(encodedHeader!))
|
|
46
|
+
payload = JSON.parse(base64urlDecode(encodedPayload!))
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error('Invalid JWT encoding')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Verify algorithm is allowed
|
|
52
|
+
if (!algorithms.includes(header.alg as any)) {
|
|
53
|
+
throw new Error(`Algorithm ${header.alg} is not allowed`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Verify signature
|
|
57
|
+
const message = `${encodedHeader}.${encodedPayload}`
|
|
58
|
+
const hashAlg = getHashAlgorithm(header.alg)
|
|
59
|
+
const isValidSignature = verifyHmacSignature(message, signature!, secret, hashAlg)
|
|
60
|
+
|
|
61
|
+
if (!isValidSignature) {
|
|
62
|
+
throw new Error('Invalid JWT signature')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Verify temporal claims
|
|
66
|
+
const now = getUnixTimestamp()
|
|
67
|
+
|
|
68
|
+
if (payload.exp !== undefined && now >= payload.exp) {
|
|
69
|
+
throw new Error('JWT has expired')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (payload.nbf !== undefined && now < payload.nbf) {
|
|
73
|
+
throw new Error('JWT is not yet valid')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Verify issuer
|
|
77
|
+
if (issuer !== undefined) {
|
|
78
|
+
const expectedIssuers = Array.isArray(issuer) ? issuer : [issuer]
|
|
79
|
+
if (!expectedIssuers.includes(payload.iss!)) {
|
|
80
|
+
throw new Error('Invalid JWT issuer')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Verify audience
|
|
85
|
+
if (audience !== undefined) {
|
|
86
|
+
const expectedAudiences = Array.isArray(audience) ? audience : [audience]
|
|
87
|
+
const tokenAudiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]
|
|
88
|
+
|
|
89
|
+
const hasValidAudience = expectedAudiences.some(exp =>
|
|
90
|
+
tokenAudiences.includes(exp)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if (!hasValidAudience) {
|
|
94
|
+
throw new Error('Invalid JWT audience')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Verify subject
|
|
99
|
+
if (subject !== undefined && payload.sub !== subject) {
|
|
100
|
+
throw new Error('Invalid JWT subject')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check required claims
|
|
104
|
+
for (const claim of requiredClaims) {
|
|
105
|
+
if (!(claim in payload)) {
|
|
106
|
+
throw new Error(`Missing required claim: ${claim}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return payload as T
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verify an access token and return the user ID.
|
|
115
|
+
*
|
|
116
|
+
* @param token - The JWT access token
|
|
117
|
+
* @param secret - The secret key
|
|
118
|
+
* @param options - Additional verification options
|
|
119
|
+
* @returns The user ID from the token
|
|
120
|
+
*/
|
|
121
|
+
export async function verifyAccessToken(
|
|
122
|
+
token: string,
|
|
123
|
+
secret: Uint8Array | string,
|
|
124
|
+
options: JWTVerifyOptions = {}
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
const payload = await verifyJWT(token, secret, {
|
|
127
|
+
...options,
|
|
128
|
+
requiredClaims: ['sub', 'type', ...(options.requiredClaims || [])],
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (payload.type !== 'access') {
|
|
132
|
+
throw new Error('Invalid token type')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return payload.sub!
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Verify a refresh token.
|
|
140
|
+
*
|
|
141
|
+
* @param token - The JWT refresh token
|
|
142
|
+
* @param secret - The secret key
|
|
143
|
+
* @param options - Additional verification options
|
|
144
|
+
* @returns The user ID from the token
|
|
145
|
+
*/
|
|
146
|
+
export async function verifyRefreshToken(
|
|
147
|
+
token: string,
|
|
148
|
+
secret: Uint8Array | string,
|
|
149
|
+
options: JWTVerifyOptions = {}
|
|
150
|
+
): Promise<string> {
|
|
151
|
+
const payload = await verifyJWT(token, secret, {
|
|
152
|
+
...options,
|
|
153
|
+
requiredClaims: ['sub', 'type', ...(options.requiredClaims || [])],
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (payload.type !== 'refresh') {
|
|
157
|
+
throw new Error('Invalid token type')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return payload.sub!
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Decode a JWT without verifying it.
|
|
165
|
+
* WARNING: Only use this when you need to read claims before verification.
|
|
166
|
+
* Always verify tokens for authentication!
|
|
167
|
+
*
|
|
168
|
+
* @param token - The JWT string
|
|
169
|
+
* @returns The decoded payload (unverified)
|
|
170
|
+
*/
|
|
171
|
+
export function decodeJWT(token: string): JWTPayload {
|
|
172
|
+
const parts = token.split('.')
|
|
173
|
+
if (parts.length !== 3) {
|
|
174
|
+
throw new Error('Invalid JWT format')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const payload = JSON.parse(base64urlDecode(parts[1]!))
|
|
179
|
+
return payload
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new Error('Invalid JWT encoding')
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomHex } from '@strav/kernel'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OAuth state parameter management for CSRF protection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface OAuthState {
|
|
8
|
+
/** Random state value */
|
|
9
|
+
value: string
|
|
10
|
+
/** Redirect URI after OAuth */
|
|
11
|
+
redirect?: string
|
|
12
|
+
/** Additional data to preserve */
|
|
13
|
+
data?: Record<string, unknown>
|
|
14
|
+
/** Creation timestamp */
|
|
15
|
+
createdAt: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate a secure OAuth state parameter.
|
|
20
|
+
*
|
|
21
|
+
* @param options - State options
|
|
22
|
+
* @returns State object with secure random value
|
|
23
|
+
*/
|
|
24
|
+
export function generateOAuthState(options: {
|
|
25
|
+
redirect?: string
|
|
26
|
+
data?: Record<string, unknown>
|
|
27
|
+
} = {}): OAuthState {
|
|
28
|
+
return {
|
|
29
|
+
value: randomHex(16),
|
|
30
|
+
redirect: options.redirect,
|
|
31
|
+
data: options.data,
|
|
32
|
+
createdAt: Date.now(),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create an OAuth state store with expiration.
|
|
38
|
+
*
|
|
39
|
+
* @param options - Store configuration
|
|
40
|
+
* @returns State store methods
|
|
41
|
+
*/
|
|
42
|
+
export function createOAuthStateStore(options: {
|
|
43
|
+
/** Store state (e.g., in Redis, database, or memory) */
|
|
44
|
+
store: (state: OAuthState) => Promise<void>
|
|
45
|
+
/** Retrieve state by value */
|
|
46
|
+
retrieve: (value: string) => Promise<OAuthState | null>
|
|
47
|
+
/** Delete state after use */
|
|
48
|
+
delete: (value: string) => Promise<void>
|
|
49
|
+
/** TTL in seconds (default: 600 = 10 minutes) */
|
|
50
|
+
ttl?: number
|
|
51
|
+
}) {
|
|
52
|
+
const ttl = options.ttl || 600
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
/**
|
|
56
|
+
* Generate and store a new state.
|
|
57
|
+
*/
|
|
58
|
+
async generate(params?: { redirect?: string; data?: Record<string, unknown> }): Promise<string> {
|
|
59
|
+
const state = generateOAuthState(params)
|
|
60
|
+
await options.store(state)
|
|
61
|
+
return state.value
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Verify a state parameter and retrieve its data.
|
|
66
|
+
*/
|
|
67
|
+
async verify(value: string): Promise<OAuthState | null> {
|
|
68
|
+
const state = await options.retrieve(value)
|
|
69
|
+
|
|
70
|
+
if (!state) {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check expiration
|
|
75
|
+
const age = (Date.now() - state.createdAt) / 1000
|
|
76
|
+
if (age > ttl) {
|
|
77
|
+
await options.delete(value)
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Delete after successful verification (one-time use)
|
|
82
|
+
await options.delete(value)
|
|
83
|
+
return state
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Simple in-memory OAuth state store for development.
|
|
90
|
+
* WARNING: Do not use in production - states are lost on restart!
|
|
91
|
+
*/
|
|
92
|
+
export function createMemoryOAuthStateStore(ttl: number = 600) {
|
|
93
|
+
const states = new Map<string, OAuthState>()
|
|
94
|
+
|
|
95
|
+
// Cleanup expired states periodically
|
|
96
|
+
const cleanup = setInterval(() => {
|
|
97
|
+
const now = Date.now()
|
|
98
|
+
for (const [value, state] of states.entries()) {
|
|
99
|
+
if ((now - state.createdAt) / 1000 > ttl) {
|
|
100
|
+
states.delete(value)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}, 60000) // Every minute
|
|
104
|
+
|
|
105
|
+
return createOAuthStateStore({
|
|
106
|
+
async store(state) {
|
|
107
|
+
states.set(state.value, state)
|
|
108
|
+
},
|
|
109
|
+
async retrieve(value) {
|
|
110
|
+
return states.get(value) || null
|
|
111
|
+
},
|
|
112
|
+
async delete(value) {
|
|
113
|
+
states.delete(value)
|
|
114
|
+
},
|
|
115
|
+
ttl,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Signed opaque tokens
|
|
2
|
+
export {
|
|
3
|
+
createSignedToken,
|
|
4
|
+
verifySignedToken,
|
|
5
|
+
type SignedTokenPayload
|
|
6
|
+
} from './signed.ts'
|
|
7
|
+
|
|
8
|
+
// Magic link tokens
|
|
9
|
+
export {
|
|
10
|
+
createMagicLinkToken,
|
|
11
|
+
verifyMagicLinkToken,
|
|
12
|
+
type MagicLinkPayload
|
|
13
|
+
} from './magic.ts'
|
|
14
|
+
|
|
15
|
+
// Refresh tokens
|
|
16
|
+
export {
|
|
17
|
+
generateRefreshToken,
|
|
18
|
+
createTokenRotation,
|
|
19
|
+
type RefreshTokenPair
|
|
20
|
+
} from './refresh.ts'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createSignedToken, verifySignedToken } from './signed.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Magic link token utilities for passwordless authentication.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface MagicLinkPayload {
|
|
8
|
+
sub: string | number
|
|
9
|
+
typ: 'magic-link'
|
|
10
|
+
email?: string
|
|
11
|
+
redirect?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a magic link token for passwordless authentication.
|
|
16
|
+
*
|
|
17
|
+
* @param userId - The user identifier
|
|
18
|
+
* @param options - Additional options
|
|
19
|
+
* @returns Signed token string
|
|
20
|
+
*/
|
|
21
|
+
export function createMagicLinkToken(
|
|
22
|
+
userId: string | number,
|
|
23
|
+
options: {
|
|
24
|
+
email?: string
|
|
25
|
+
redirect?: string
|
|
26
|
+
expiresInMinutes?: number
|
|
27
|
+
} = {}
|
|
28
|
+
): string {
|
|
29
|
+
const { email, redirect, expiresInMinutes = 15 } = options
|
|
30
|
+
|
|
31
|
+
return createSignedToken(
|
|
32
|
+
{
|
|
33
|
+
sub: userId,
|
|
34
|
+
typ: 'magic-link',
|
|
35
|
+
email,
|
|
36
|
+
redirect,
|
|
37
|
+
},
|
|
38
|
+
expiresInMinutes
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Verify a magic link token.
|
|
44
|
+
*
|
|
45
|
+
* @param token - The token to verify
|
|
46
|
+
* @returns The decoded payload
|
|
47
|
+
* @throws If the token is invalid or expired
|
|
48
|
+
*/
|
|
49
|
+
export function verifyMagicLinkToken(token: string): MagicLinkPayload {
|
|
50
|
+
const payload = verifySignedToken<MagicLinkPayload>(token)
|
|
51
|
+
|
|
52
|
+
if (payload.typ !== 'magic-link') {
|
|
53
|
+
throw new Error('Invalid token type')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return payload
|
|
57
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { randomHex } from '@strav/kernel'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Refresh token utilities for JWT rotation strategies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface RefreshTokenPair {
|
|
8
|
+
/** The access token (short-lived) */
|
|
9
|
+
accessToken: string
|
|
10
|
+
/** The refresh token (long-lived) */
|
|
11
|
+
refreshToken: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a secure refresh token.
|
|
16
|
+
*
|
|
17
|
+
* @param length - Token length in bytes (default: 32)
|
|
18
|
+
* @returns Hex-encoded refresh token
|
|
19
|
+
*/
|
|
20
|
+
export function generateRefreshToken(length: number = 32): string {
|
|
21
|
+
return randomHex(length)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a token rotation strategy helper.
|
|
26
|
+
* This is a factory function that returns methods for your specific storage.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* const rotation = createTokenRotation({
|
|
30
|
+
* async store(userId, token, expiresAt) {
|
|
31
|
+
* await db.refreshTokens.create({ userId, token, expiresAt })
|
|
32
|
+
* },
|
|
33
|
+
* async verify(token) {
|
|
34
|
+
* const record = await db.refreshTokens.findByToken(token)
|
|
35
|
+
* if (!record || record.expiresAt < new Date()) return null
|
|
36
|
+
* return record.userId
|
|
37
|
+
* },
|
|
38
|
+
* async revoke(token) {
|
|
39
|
+
* await db.refreshTokens.deleteByToken(token)
|
|
40
|
+
* }
|
|
41
|
+
* })
|
|
42
|
+
*/
|
|
43
|
+
export function createTokenRotation(options: {
|
|
44
|
+
store: (userId: string | number, token: string, expiresAt: Date) => Promise<void>
|
|
45
|
+
verify: (token: string) => Promise<string | number | null>
|
|
46
|
+
revoke: (token: string) => Promise<void>
|
|
47
|
+
revokeAll?: (userId: string | number) => Promise<void>
|
|
48
|
+
}) {
|
|
49
|
+
return {
|
|
50
|
+
/**
|
|
51
|
+
* Generate and store a new refresh token.
|
|
52
|
+
*/
|
|
53
|
+
async generate(userId: string | number, ttlSeconds: number = 2592000): Promise<string> {
|
|
54
|
+
const token = generateRefreshToken()
|
|
55
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000)
|
|
56
|
+
await options.store(userId, token, expiresAt)
|
|
57
|
+
return token
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Verify a refresh token and return the user ID.
|
|
62
|
+
*/
|
|
63
|
+
verify: options.verify,
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Revoke a specific refresh token.
|
|
67
|
+
*/
|
|
68
|
+
revoke: options.revoke,
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Revoke all refresh tokens for a user.
|
|
72
|
+
*/
|
|
73
|
+
revokeAll: options.revokeAll,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { encrypt } from '@strav/kernel'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Signed, opaque token utilities using Strav's encryption.
|
|
5
|
+
* These tokens are encrypted and tamper-proof.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SignedTokenPayload {
|
|
9
|
+
/** User identifier. */
|
|
10
|
+
sub: string | number
|
|
11
|
+
/** Token purpose. */
|
|
12
|
+
typ: string
|
|
13
|
+
/** Issued at (unix ms). */
|
|
14
|
+
iat: number
|
|
15
|
+
/** Expiration (minutes from iat). */
|
|
16
|
+
exp: number
|
|
17
|
+
/** Extra data. */
|
|
18
|
+
[key: string]: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a signed, encrypted token using `encrypt.seal()`.
|
|
23
|
+
* Tamper-proof and opaque — the user cannot read or modify the payload.
|
|
24
|
+
*
|
|
25
|
+
* @param data - The payload to sign.
|
|
26
|
+
* @param expiresInMinutes - Token lifetime in minutes.
|
|
27
|
+
*/
|
|
28
|
+
export function createSignedToken(
|
|
29
|
+
data: { sub: string | number; typ: string; [key: string]: unknown },
|
|
30
|
+
expiresInMinutes: number
|
|
31
|
+
): string {
|
|
32
|
+
const payload: SignedTokenPayload = {
|
|
33
|
+
...data,
|
|
34
|
+
iat: Date.now(),
|
|
35
|
+
exp: expiresInMinutes,
|
|
36
|
+
}
|
|
37
|
+
return encrypt.seal(payload)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Verify and decode a signed token. Throws if expired or tampered.
|
|
42
|
+
*
|
|
43
|
+
* @returns The original payload.
|
|
44
|
+
* @throws If the token is invalid, expired, or tampered.
|
|
45
|
+
*/
|
|
46
|
+
export function verifySignedToken<T = SignedTokenPayload>(token: string): T {
|
|
47
|
+
const payload = encrypt.unseal<SignedTokenPayload>(token)
|
|
48
|
+
|
|
49
|
+
if (!payload || typeof payload !== 'object' || !payload.iat) {
|
|
50
|
+
throw new Error('Invalid token payload.')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (payload.exp) {
|
|
54
|
+
const expiresAt = payload.iat + payload.exp * 60_000
|
|
55
|
+
if (Date.now() > expiresAt) {
|
|
56
|
+
throw new Error('Token has expired.')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return payload as unknown as T
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// TOTP core functionality
|
|
2
|
+
export {
|
|
3
|
+
generateSecret,
|
|
4
|
+
generateTotp,
|
|
5
|
+
verifyTotp,
|
|
6
|
+
base32Encode,
|
|
7
|
+
base32Decode,
|
|
8
|
+
type TotpOptions
|
|
9
|
+
} from './totp.ts'
|
|
10
|
+
|
|
11
|
+
// Recovery codes
|
|
12
|
+
export { generateRecoveryCodes } from './recovery.ts'
|
|
13
|
+
|
|
14
|
+
// QR code URI generation
|
|
15
|
+
export { totpUri } from './uri.ts'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery code generation for two-factor authentication.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Generate a set of single-use recovery codes (8-char hex each). */
|
|
6
|
+
export function generateRecoveryCodes(count: number): string[] {
|
|
7
|
+
const codes: string[] = []
|
|
8
|
+
for (let i = 0; i < count; i++) {
|
|
9
|
+
const bytes = crypto.getRandomValues(new Uint8Array(4))
|
|
10
|
+
codes.push(Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''))
|
|
11
|
+
}
|
|
12
|
+
return codes
|
|
13
|
+
}
|