@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,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,7 @@
1
+ // OAuth state management
2
+ export {
3
+ generateOAuthState,
4
+ createOAuthStateStore,
5
+ createMemoryOAuthStateStore,
6
+ type OAuthState
7
+ } from './state.ts'
@@ -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
+ }