All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m13s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 37s
CI / test-python-voice (push) Successful in 36s
CI / test-bqas (push) Successful in 34s
When jwtVerify fails (JWT expired), decode the token without expiry check to recover sessionId, validate it against the DB, and reissue a fresh 24h JWT. Fixes investors with old 1h JWTs being locked out on magic link re-click. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
191 lines
5.6 KiB
TypeScript
191 lines
5.6 KiB
TypeScript
import { SignJWT, jwtVerify, decodeJwt } from 'jose'
|
|
import { randomBytes, createHash } from 'crypto'
|
|
import { cookies } from 'next/headers'
|
|
import pool from './db'
|
|
|
|
const COOKIE_NAME = 'pitch_session'
|
|
const SESSION_EXPIRY_HOURS = 24
|
|
const JWT_EXPIRY = `${SESSION_EXPIRY_HOURS}h`
|
|
|
|
function getJwtSecret() {
|
|
const secret = process.env.PITCH_JWT_SECRET
|
|
if (!secret) throw new Error('PITCH_JWT_SECRET not set')
|
|
return new TextEncoder().encode(secret)
|
|
}
|
|
|
|
export function hashToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex')
|
|
}
|
|
|
|
export function generateToken(): string {
|
|
return randomBytes(48).toString('hex')
|
|
}
|
|
|
|
export interface JwtPayload {
|
|
sub: string
|
|
email: string
|
|
sessionId: string
|
|
}
|
|
|
|
export async function createJwt(payload: JwtPayload): Promise<string> {
|
|
return new SignJWT({ ...payload })
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt()
|
|
.setExpirationTime(JWT_EXPIRY)
|
|
.sign(getJwtSecret())
|
|
}
|
|
|
|
export async function verifyJwt(token: string): Promise<JwtPayload | null> {
|
|
try {
|
|
const { payload } = await jwtVerify(token, getJwtSecret())
|
|
return payload as unknown as JwtPayload
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function createSession(
|
|
investorId: string,
|
|
ip: string | null,
|
|
userAgent: string | null
|
|
): Promise<{ sessionId: string; jwt: string }> {
|
|
// Revoke all existing sessions for this investor (single session enforcement)
|
|
await pool.query(
|
|
`UPDATE pitch_sessions SET revoked = true WHERE investor_id = $1 AND revoked = false`,
|
|
[investorId]
|
|
)
|
|
|
|
const sessionToken = generateToken()
|
|
const tokenHash = hashToken(sessionToken)
|
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_HOURS * 60 * 60 * 1000)
|
|
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO pitch_sessions (investor_id, token_hash, ip_address, user_agent, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
|
[investorId, tokenHash, ip, userAgent, expiresAt]
|
|
)
|
|
|
|
const sessionId = rows[0].id
|
|
|
|
// Get investor email for JWT
|
|
const investor = await pool.query(
|
|
`SELECT email FROM pitch_investors WHERE id = $1`,
|
|
[investorId]
|
|
)
|
|
|
|
const jwt = await createJwt({
|
|
sub: investorId,
|
|
email: investor.rows[0].email,
|
|
sessionId,
|
|
})
|
|
|
|
return { sessionId, jwt }
|
|
}
|
|
|
|
export async function validateSession(sessionId: string, investorId: string): Promise<boolean> {
|
|
const { rows } = await pool.query(
|
|
`SELECT id FROM pitch_sessions
|
|
WHERE id = $1 AND investor_id = $2 AND revoked = false AND expires_at > NOW()`,
|
|
[sessionId, investorId]
|
|
)
|
|
return rows.length > 0
|
|
}
|
|
|
|
export async function revokeSession(sessionId: string): Promise<void> {
|
|
await pool.query(
|
|
`UPDATE pitch_sessions SET revoked = true WHERE id = $1`,
|
|
[sessionId]
|
|
)
|
|
}
|
|
|
|
export async function revokeAllSessions(investorId: string): Promise<void> {
|
|
await pool.query(
|
|
`UPDATE pitch_sessions SET revoked = true WHERE investor_id = $1`,
|
|
[investorId]
|
|
)
|
|
}
|
|
|
|
export async function setSessionCookie(jwt: string): Promise<void> {
|
|
const cookieStore = await cookies()
|
|
cookieStore.set(COOKIE_NAME, jwt, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: SESSION_EXPIRY_HOURS * 60 * 60,
|
|
})
|
|
}
|
|
|
|
export async function clearSessionCookie(): Promise<void> {
|
|
const cookieStore = await cookies()
|
|
cookieStore.delete(COOKIE_NAME)
|
|
}
|
|
|
|
export async function getSessionFromCookie(): Promise<JwtPayload | null> {
|
|
const cookieStore = await cookies()
|
|
const token = cookieStore.get(COOKIE_NAME)?.value
|
|
if (!token) return null
|
|
|
|
// Fast path: valid non-expired JWT
|
|
const payload = await verifyJwt(token)
|
|
if (payload) return payload
|
|
|
|
// Slow path: JWT may be expired but DB session could still be valid.
|
|
// Decode without signature/expiry check to recover sessionId + sub.
|
|
try {
|
|
const decoded = decodeJwt(token) as Partial<JwtPayload>
|
|
if (!decoded.sessionId || !decoded.sub) return null
|
|
|
|
const valid = await validateSession(decoded.sessionId, decoded.sub)
|
|
if (!valid) return null
|
|
|
|
// DB session still live — fetch email and reissue a fresh JWT
|
|
const { rows } = await pool.query(
|
|
`SELECT email FROM pitch_investors WHERE id = $1`,
|
|
[decoded.sub]
|
|
)
|
|
if (rows.length === 0) return null
|
|
|
|
const freshJwt = await createJwt({ sub: decoded.sub, email: rows[0].email, sessionId: decoded.sessionId })
|
|
await setSessionCookie(freshJwt)
|
|
|
|
return { sub: decoded.sub, email: rows[0].email, sessionId: decoded.sessionId }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function getClientIp(request: Request): string | null {
|
|
const forwarded = request.headers.get('x-forwarded-for')
|
|
if (forwarded) return forwarded.split(',')[0].trim()
|
|
return null
|
|
}
|
|
|
|
export function validateAdminSecret(request: Request): boolean {
|
|
const secret = process.env.PITCH_ADMIN_SECRET
|
|
if (!secret) return false
|
|
const auth = request.headers.get('authorization')
|
|
if (!auth) return false
|
|
return auth === `Bearer ${secret}`
|
|
}
|
|
|
|
export async function logAudit(
|
|
investorId: string | null,
|
|
action: string,
|
|
details: Record<string, unknown> = {},
|
|
request?: Request,
|
|
slideId?: string,
|
|
sessionId?: string,
|
|
adminId?: string | null,
|
|
targetInvestorId?: string | null,
|
|
): Promise<void> {
|
|
const ip = request ? getClientIp(request) : null
|
|
const ua = request ? request.headers.get('user-agent') : null
|
|
await pool.query(
|
|
`INSERT INTO pitch_audit_logs
|
|
(investor_id, action, details, ip_address, user_agent, slide_id, session_id, admin_id, target_investor_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId, adminId ?? null, targetInvestorId ?? null]
|
|
)
|
|
}
|