119 lines
3.3 KiB
TypeScript
119 lines
3.3 KiB
TypeScript
import { createHash, randomBytes } from 'node:crypto'
|
|
import { createError, type H3Event } from 'h3'
|
|
|
|
import { getAuthRuntimeConfig } from './auth-config'
|
|
import { prisma } from './prisma'
|
|
|
|
export interface IssuedRefreshToken {
|
|
token: string
|
|
expiresAt: Date
|
|
expiresInSec: number
|
|
recordId: string
|
|
}
|
|
|
|
/**
|
|
* Gera um hash seguro do refresh token usando pepper do ambiente.
|
|
* O token bruto nunca é salvo no banco.
|
|
*
|
|
* @param rawToken Refresh token em texto puro.
|
|
* @param pepper Segredo adicional do servidor.
|
|
* @returns Hash SHA-256 do token.
|
|
*/
|
|
export function hashRefreshToken(rawToken: string, pepper: string): string {
|
|
return createHash('sha256').update(`${rawToken}${pepper}`).digest('hex')
|
|
}
|
|
|
|
/**
|
|
* Cria um refresh token aleatório para entregar ao cliente.
|
|
*
|
|
* @returns Token aleatório em formato URL-safe.
|
|
*/
|
|
function generateRawRefreshToken(): string {
|
|
return randomBytes(48).toString('base64url')
|
|
}
|
|
|
|
/**
|
|
* Emite e persiste um novo refresh token para um usuário.
|
|
*
|
|
* @param event Evento HTTP usado para ler config.
|
|
* @param userId ID do usuário dono do token.
|
|
* @returns Metadados do refresh token emitido.
|
|
*/
|
|
export async function issueRefreshToken(event: H3Event, userId: string): Promise<IssuedRefreshToken> {
|
|
const config = getAuthRuntimeConfig(event)
|
|
const token = generateRawRefreshToken()
|
|
const tokenHash = hashRefreshToken(token, config.refreshTokenPepper)
|
|
const expiresAt = new Date(Date.now() + config.refreshTtlSec * 1000)
|
|
|
|
const created = await prisma.refreshToken.create({
|
|
data: {
|
|
userId,
|
|
tokenHash,
|
|
expiresAt
|
|
}
|
|
})
|
|
|
|
return {
|
|
token,
|
|
expiresAt,
|
|
expiresInSec: config.refreshTtlSec,
|
|
recordId: created.id
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rotaciona um refresh token válido:
|
|
* cria um novo token e marca o antigo como revogado.
|
|
*
|
|
* @param event Evento HTTP usado para ler config.
|
|
* @param rawRefreshToken Token atual enviado pelo cliente.
|
|
* @returns Dados do novo refresh token e usuário autenticado.
|
|
* @throws {H3Error} Quando o token for inválido, revogado ou expirado.
|
|
*/
|
|
export async function rotateRefreshToken(event: H3Event, rawRefreshToken: string) {
|
|
const config = getAuthRuntimeConfig(event)
|
|
const tokenHash = hashRefreshToken(rawRefreshToken, config.refreshTokenPepper)
|
|
|
|
const existing = await prisma.refreshToken.findUnique({
|
|
where: { tokenHash },
|
|
include: { user: true }
|
|
})
|
|
|
|
if (!existing || existing.revokedAt || existing.expiresAt.getTime() <= Date.now()) {
|
|
throw createError({ statusCode: 401, statusMessage: 'Invalid refresh token' })
|
|
}
|
|
|
|
const newRawRefreshToken = generateRawRefreshToken()
|
|
const newTokenHash = hashRefreshToken(newRawRefreshToken, config.refreshTokenPepper)
|
|
const newExpiresAt = new Date(Date.now() + config.refreshTtlSec * 1000)
|
|
|
|
const rotated = await prisma.$transaction(async (tx) => {
|
|
const created = await tx.refreshToken.create({
|
|
data: {
|
|
userId: existing.userId,
|
|
tokenHash: newTokenHash,
|
|
expiresAt: newExpiresAt
|
|
}
|
|
})
|
|
|
|
await tx.refreshToken.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
revokedAt: new Date(),
|
|
replacedById: created.id
|
|
}
|
|
})
|
|
|
|
return created
|
|
})
|
|
|
|
return {
|
|
user: existing.user,
|
|
token: newRawRefreshToken,
|
|
expiresAt: rotated.expiresAt,
|
|
expiresInSec: config.refreshTtlSec,
|
|
recordId: rotated.id,
|
|
replacedTokenId: existing.id
|
|
}
|
|
}
|