Files
breakpilot-core/pitch-deck/lib/auth.ts
Sharang Parnerkar d548ce4199
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
fix(pitch-deck): refresh expired JWT from live DB session on cookie read
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>
2026-04-16 22:18:52 +02:00

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]
)
}