101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
import { createHash, randomBytes } from 'node:crypto'
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
|
import { and, eq, gt, isNull } from 'drizzle-orm'
|
|
import { config } from '../config.js'
|
|
import { db } from '../db/client.js'
|
|
import { refreshTokens, users } from '../db/schema.js'
|
|
import { errors } from './errors.js'
|
|
|
|
export const REFRESH_COOKIE = 'ff_refresh'
|
|
|
|
export function hashToken(value: string) {
|
|
return createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
export function generateRefreshToken() {
|
|
return randomBytes(48).toString('base64url')
|
|
}
|
|
|
|
export function refreshCookieOptions() {
|
|
return {
|
|
httpOnly: true,
|
|
secure: config.NODE_ENV === 'production',
|
|
sameSite: 'lax' as const,
|
|
path: '/api/v1/auth',
|
|
maxAge: config.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60,
|
|
}
|
|
}
|
|
|
|
export function signAccessToken(app: FastifyInstance, user: { id: string; email: string; displayName: string }) {
|
|
return app.jwt.sign({
|
|
sub: user.id,
|
|
email: user.email,
|
|
displayName: user.displayName,
|
|
tokenType: 'access' as const,
|
|
}, { expiresIn: config.ACCESS_TOKEN_TTL })
|
|
}
|
|
|
|
export async function createRefreshSession(input: {
|
|
userId: string
|
|
userAgent?: string
|
|
ipAddress?: string
|
|
}) {
|
|
const token = generateRefreshToken()
|
|
const expiresAt = new Date(Date.now() + config.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000)
|
|
const [session] = await db.insert(refreshTokens).values({
|
|
userId: input.userId,
|
|
tokenHash: hashToken(token),
|
|
expiresAt,
|
|
userAgent: input.userAgent,
|
|
ipAddress: input.ipAddress,
|
|
}).returning()
|
|
if (!session) throw new Error('Failed to create refresh session')
|
|
return { token, session }
|
|
}
|
|
|
|
export async function rotateRefreshSession(token: string, meta: { userAgent?: string; ipAddress?: string }) {
|
|
const [current] = await db.select().from(refreshTokens).where(and(
|
|
eq(refreshTokens.tokenHash, hashToken(token)),
|
|
isNull(refreshTokens.revokedAt),
|
|
gt(refreshTokens.expiresAt, new Date()),
|
|
)).limit(1)
|
|
|
|
if (!current) throw errors.unauthorized('Refresh session is invalid or expired')
|
|
|
|
const [user] = await db.select({
|
|
id: users.id,
|
|
email: users.email,
|
|
displayName: users.displayName,
|
|
emailVerifiedAt: users.emailVerifiedAt,
|
|
disabledAt: users.disabledAt,
|
|
}).from(users).where(eq(users.id, current.userId)).limit(1)
|
|
|
|
if (!user || user.disabledAt) throw errors.unauthorized('Account is unavailable')
|
|
if (config.EMAIL_VERIFICATION_REQUIRED && !user.emailVerifiedAt) throw errors.emailVerificationRequired()
|
|
|
|
const next = await createRefreshSession({ userId: user.id, ...meta })
|
|
await db.update(refreshTokens).set({
|
|
revokedAt: new Date(),
|
|
replacedById: next.session.id,
|
|
}).where(eq(refreshTokens.id, current.id))
|
|
|
|
return { user, token: next.token }
|
|
}
|
|
|
|
export async function revokeRefreshSession(token?: string) {
|
|
if (!token) return
|
|
await db.update(refreshTokens).set({ revokedAt: new Date() }).where(and(
|
|
eq(refreshTokens.tokenHash, hashToken(token)),
|
|
isNull(refreshTokens.revokedAt),
|
|
))
|
|
}
|
|
|
|
export async function requireAuth(request: FastifyRequest, _reply: FastifyReply) {
|
|
try {
|
|
await request.jwtVerify()
|
|
} catch {
|
|
throw errors.unauthorized()
|
|
}
|
|
if (request.user.tokenType !== 'access') throw errors.unauthorized()
|
|
}
|