forked from jordanlambrecht/tracker-tracker
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.ts
More file actions
152 lines (131 loc) · 5.17 KB
/
auth.ts
File metadata and controls
152 lines (131 loc) · 5.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// src/lib/auth.ts
//
// Functions: hashPassword, verifyPassword, createSession, getSession,
// clearSession, createPendingToken, verifyPendingToken,
// createSetupToken, verifySetupToken
import { hkdfSync } from "node:crypto"
import argon2 from "argon2"
import { EncryptJWT, jwtDecrypt } from "jose"
import { cookies } from "next/headers"
import { shouldSecureCookies } from "@/lib/cookie-security"
const SESSION_COOKIE = "tt_session"
const MAX_AGE_COOKIE = "tt_max_age"
const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
const PENDING_TOKEN_MAX_AGE = 60 // 60 seconds
const SETUP_TOKEN_MAX_AGE = 300 // 5 minutes
function getSessionKey(): Uint8Array {
const secret = process.env.SESSION_SECRET
if (!secret) throw new Error("SESSION_SECRET environment variable is not set")
if (secret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters")
// HKDF-derived key with domain separation — distinct from scheduler wrapping key
return new Uint8Array(hkdfSync("sha256", secret, "", "tracker-tracker:session-v1", 32))
}
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password)
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password)
}
export async function createSession(
encryptionKey: string,
timeoutMinutes?: number | null
): Promise<string> {
const cookieMaxAge = timeoutMinutes && timeoutMinutes > 0 ? timeoutMinutes * 60 : SESSION_MAX_AGE
// JWE expiry is a generous safety net — the real timeout is controlled by
// cookie maxAge + middleware sliding window refresh.
const jweMaxAge = Math.max(cookieMaxAge * 2, SESSION_MAX_AGE)
const token = await new EncryptJWT({ ek: encryptionKey })
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.setIssuedAt()
.setExpirationTime(`${jweMaxAge}s`)
.encrypt(getSessionKey())
const secureCookies = shouldSecureCookies()
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: secureCookies,
sameSite: "strict",
maxAge: cookieMaxAge,
path: "/",
})
// Companion cookie so middleware can refresh maxAge without decrypting the JWE
cookieStore.set(MAX_AGE_COOKIE, String(cookieMaxAge), {
httpOnly: true,
secure: secureCookies,
sameSite: "strict",
maxAge: cookieMaxAge,
path: "/",
})
return token
}
export async function getSession(): Promise<{ encryptionKey: string } | null> {
const cookieStore = await cookies()
const token = cookieStore.get(SESSION_COOKIE)?.value
if (!token) return null
const key = getSessionKey() // Config errors propagate as 500, not silent 401
try {
const { payload } = await jwtDecrypt(token, key)
if (payload.purpose) return null // reject pending/setup tokens
return { encryptionKey: payload.ek as string }
} catch {
return null
}
}
export async function clearSession(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete(SESSION_COOKIE)
cookieStore.delete(MAX_AGE_COOKIE)
}
// ---------------------------------------------------------------------------
// Pending token — short-lived JWE holding the encryption key during 2FA login.
// Returned in the response body (NOT as a cookie) so the client holds it
// temporarily and sends it with the TOTP verification request.
// ---------------------------------------------------------------------------
export async function createPendingToken(encryptionKey: string): Promise<string> {
return new EncryptJWT({ ek: encryptionKey, purpose: "pending" })
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.setIssuedAt()
.setExpirationTime(`${PENDING_TOKEN_MAX_AGE}s`)
.encrypt(getSessionKey())
}
export async function verifyPendingToken(token: string): Promise<{ encryptionKey: string } | null> {
const key = getSessionKey()
try {
const { payload } = await jwtDecrypt(token, key)
if (payload.purpose !== "pending") return null
return { encryptionKey: payload.ek as string }
} catch {
return null
}
}
// ---------------------------------------------------------------------------
// Setup token — short-lived JWE holding the pending TOTP secret and hashed
// backup codes during enrollment. The client sends this back on confirm so
// the server can verify the TOTP code and save to DB without intermediate
// database writes.
// ---------------------------------------------------------------------------
export async function createSetupToken(
totpSecret: string,
backupCodesJson: string
): Promise<string> {
return new EncryptJWT({ ts: totpSecret, bc: backupCodesJson, purpose: "setup" })
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.setIssuedAt()
.setExpirationTime(`${SETUP_TOKEN_MAX_AGE}s`)
.encrypt(getSessionKey())
}
export async function verifySetupToken(
token: string
): Promise<{ totpSecret: string; backupCodesJson: string } | null> {
const key = getSessionKey()
try {
const { payload } = await jwtDecrypt(token, key)
if (payload.purpose !== "setup") return null
return {
totpSecret: payload.ts as string,
backupCodesJson: payload.bc as string,
}
} catch {
return null
}
}