Release v1.0.0
This commit is contained in:
100
server/src/lib/auth.ts
Normal file
100
server/src/lib/auth.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user