import { ConflictException, ForbiddenException, Injectable, NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Role, SyncOperation } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import * as jwt from 'jsonwebtoken'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; export interface TeamJwtPayload { sub: string; typ: 'team'; } const TOKEN_TTL = '12h'; const BCRYPT_ROUNDS = 10; /// Local (non-Authentik) auth for Gemeinde Teamer: password login plus /// redemption of a TeamerInvite issued by a Gemeinde Verantwortliche/r. Team /// tokens are signed with TEAM_JWT_SECRET and carry `typ: 'team'` so they are /// never mistaken for a guest token. @Injectable() export class TeamAuthService { private readonly secret: string; constructor( private readonly prisma: PrismaClient, private readonly config: ConfigService, private readonly sync: SyncService, ) { this.secret = config.getOrThrow('TEAM_JWT_SECRET'); } async login(email: string, password: string): Promise<{ accessToken: string }> { const user = await this.prisma.user.findUnique({ where: { email: email.toLowerCase() }, include: { memberships: true }, }); if (!user || !user.passwordHash) { throw new UnauthorizedException('Invalid credentials'); } const ok = await bcrypt.compare(password, user.passwordHash); if (!ok) { throw new UnauthorizedException('Invalid credentials'); } return { accessToken: this.sign(user.id) }; } /// Redeems an invite token and creates the local Teamer account + its /// GEMEINDE_TEAMER membership for the invite's Gemeinde. async registerFromInvite(input: { token: string; firstName: string; lastName: string; password: string; email?: string; }): Promise<{ accessToken: string }> { const invite = await this.prisma.teamerInvite.findUnique({ where: { token: input.token }, }); if (!invite || invite.revokedAt) { throw new NotFoundException('Unknown or revoked invite'); } if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) { throw new ForbiddenException('Invite has expired'); } if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) { throw new ForbiddenException('Invite has already been used up'); } if ( invite.email && input.email && input.email.toLowerCase() !== invite.email.toLowerCase() ) { throw new ForbiddenException('Email does not match this invite'); } const email = (invite.email ?? input.email ?? '').toLowerCase(); if (!email) { throw new ConflictException('This invite requires an email address'); } if (await this.prisma.user.findUnique({ where: { email } })) { throw new ConflictException('An account with this email already exists'); } const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS); const user = await this.prisma.user.create({ data: { email, firstName: input.firstName, lastName: input.lastName, passwordHash, kcId: invite.kcId, }, }); const membership = await this.prisma.membership.create({ data: { userId: user.id, kcId: invite.kcId, gemeindeId: invite.gemeindeId, role: Role.GEMEINDE_TEAMER, }, }); const updatedInvite = await this.prisma.teamerInvite.update({ where: { id: invite.id }, data: { usedCount: { increment: 1 } }, }); await this.sync.capture('User', SyncOperation.CREATE, user.id, user); await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership); await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updatedInvite.id, updatedInvite); return { accessToken: this.sign(user.id) }; } private sign(userId: string): string { const payload: TeamJwtPayload = { sub: userId, typ: 'team' }; return jwt.sign(payload, this.secret, { expiresIn: TOKEN_TTL }); } /// Verifies a raw team token (used by the WS handshake path, outside passport). async verify(token: string): Promise { let payload: TeamJwtPayload; try { payload = jwt.verify(token, this.secret) as TeamJwtPayload; } catch { throw new UnauthorizedException('Invalid team token'); } if (payload.typ !== 'team' || !payload.sub) { throw new UnauthorizedException('Not a team token'); } return this.resolve(payload.sub); } async resolve(userId: string): Promise { const user = await this.prisma.user.findFirst({ where: { id: userId, passwordHash: { not: null } }, include: { memberships: true }, }); if (!user) { throw new UnauthorizedException('Team account no longer exists'); } return { userId: user.id, authentikSub: user.authentikSub, email: user.email, memberships: user.memberships.map((m) => ({ kcId: m.kcId, gemeindeId: m.gemeindeId, role: m.role, })), }; } }