import { createHash, randomBytes } from 'node:crypto'; import { Injectable, NotFoundException } from '@nestjs/common'; import { API_TOKEN_PREFIX, type ApiTokenCreatedView, type ApiTokenScope, type ApiTokenView, type CreateApiTokenInput, } from '@dorfteich/shared'; import { ApiToken, User } from '@prisma/client'; import { AuditService } from '../audit/audit.service'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; /** Throttle for the last-used timestamp — one write per token per minute * keeps busy clients from turning every request into an UPDATE. */ const LAST_USED_WRITE_INTERVAL_MS = 60_000; /** * Personal access tokens (issue #104). The secret (`dt_pat_`) is * returned exactly once at creation and stored as its SHA-256 hash * (auth-tokens pattern). A token authenticates AS its user; scope and the * optional pond restriction only narrow what the public API lets it do — * they never widen permissions. */ @Injectable() export class ApiTokensService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionService, private readonly audit: AuditService, ) {} async create(user: User, input: CreateApiTokenInput): Promise { // The restriction may only name ponds the user can currently see — // anything else would leak pond ids into stored rows and confuse the // settings list. Unknown ids are rejected, not silently dropped. const pondIds = [...new Set(input.pondIds)]; for (const pondId of pondIds) { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null }, select: { id: true }, }); if (!pond || !(await this.permissions.canSeePond(user, pondId))) { throw new NotFoundException({ code: 'pond_not_found' }); } } const raw = `${API_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`; const row = await this.prisma.apiToken.create({ data: { tokenHash: hashApiToken(raw), userId: user.id, name: input.name, scope: input.scope === 'write' ? 'WRITE' : 'READ', pondIds, expiresAt: input.expiresAt ?? null, }, }); await this.audit.record({ action: 'api.token_created', actorId: user.id, targetType: 'api_token', targetId: row.id, details: { name: row.name, scope: input.scope, ponds: pondIds.length }, }); return { ...(await this.viewOf(row)), token: raw }; } async list(user: User): Promise { const rows = await this.prisma.apiToken.findMany({ where: { userId: user.id }, orderBy: { createdAt: 'desc' }, }); return Promise.all(rows.map((row) => this.viewOf(row))); } async revoke(user: User, tokenId: string): Promise { // Owner-scoped update: someone else's token id reads as "not found". const result = await this.prisma.apiToken.updateMany({ where: { id: tokenId, userId: user.id, revokedAt: null }, data: { revokedAt: new Date() }, }); if (result.count === 0) throw new NotFoundException(); await this.audit.record({ action: 'api.token_revoked', actorId: user.id, targetType: 'api_token', targetId: tokenId, }); } /** * Resolves a bearer secret to its live token + user, or null for unknown, * revoked, expired tokens or disabled accounts. Updates the last-used * timestamp (throttled, fire-and-forget). */ async validate(raw: string): Promise<{ user: User; token: ApiToken } | null> { if (!raw.startsWith(API_TOKEN_PREFIX)) return null; const token = await this.prisma.apiToken.findUnique({ where: { tokenHash: hashApiToken(raw) }, include: { user: true }, }); if (!token || token.revokedAt) return null; if (token.expiresAt && token.expiresAt.getTime() <= Date.now()) return null; if (token.user.status !== 'ACTIVE') return null; const lastUsed = token.lastUsedAt?.getTime() ?? 0; if (Date.now() - lastUsed > LAST_USED_WRITE_INTERVAL_MS) { this.prisma.apiToken .update({ where: { id: token.id }, data: { lastUsedAt: new Date() } }) .catch(() => undefined); } const { user, ...rest } = token; return { user, token: rest as ApiToken }; } scopeOf(token: ApiToken): ApiTokenScope { return token.scope === 'WRITE' ? 'write' : 'read'; } private async viewOf(row: ApiToken): Promise { const ponds = row.pondIds.length === 0 ? [] : await this.prisma.pond.findMany({ where: { id: { in: row.pondIds } }, select: { id: true, name: true }, }); return { id: row.id, name: row.name, scope: this.scopeOf(row), ponds: row.pondIds.map((id) => ({ id, name: ponds.find((pond) => pond.id === id)?.name ?? id, })), expiresAt: row.expiresAt?.toISOString() ?? null, revokedAt: row.revokedAt?.toISOString() ?? null, lastUsedAt: row.lastUsedAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), }; } } export function hashApiToken(raw: string): string { return createHash('sha256').update(raw).digest('hex'); }