import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AuditAction } from '../audit/audit.types'; import { ConsentScope, OnboardingTokenPayload, PublicOnboardInfo, RequestOtpResponse, VerifyOtpResponse } from '@tower/types'; import { ConsentStatus } from '@prisma/client'; const POLICY_VERSION = 'v1'; const OTP_TTL_MIN = 5; const DEFAULT_SCOPES: ConsentScope[] = ['INGEST', 'DISPLAY']; const DEFAULT_RETENTION_DAYS = 90; function hashPhone(phone: string, pepper: string): string { const normalized = phone.replace(/[^\d+]/g, ''); return createHash('sha256').update(`${pepper}:${normalized}`).digest('hex'); } @Injectable() export class OnboardingService { private readonly logger = new Logger(OnboardingService.name); private readonly policyVersion = POLICY_VERSION; constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, private readonly config: ConfigService, private readonly audit: AuditService, ) {} decodeOnboardingToken(token: string): OnboardingTokenPayload { // Phase 2B: token is base64url({groupId, jid, tenantId}) — no signature. // The OTP step (sent via DM to the jid) is the real authentication. try { const json = Buffer.from(token, 'base64url').toString('utf8'); const parsed = JSON.parse(json) as OnboardingTokenPayload; if (!parsed.groupId || !parsed.jid || !parsed.tenantId) { throw new Error('Missing required fields'); } return parsed; } catch { throw new UnauthorizedException('Invalid onboarding link'); } } async getOnboardInfo(token: string): Promise { const payload = this.decodeOnboardingToken(token); const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } }); if (!group) throw new NotFoundException('Group not found'); if (!group.tenantId) { throw new ConflictException('Group is not yet claimed by a tenant'); } const tenant = await this.prisma.tenant.findUnique({ where: { id: payload.tenantId } }); if (!tenant) throw new NotFoundException('Tenant not found'); return { groupName: group.name, tenantName: tenant.name, policyVersion: this.policyVersion, defaultScopes: DEFAULT_SCOPES, defaultRetentionDays: DEFAULT_RETENTION_DAYS, }; } async requestOtp(token: string, phone: string): Promise { const payload = this.decodeOnboardingToken(token); if (payload.jid !== this.normalizeJid(payload.jid)) { // sanity } if (!phone || phone.length < 6) { throw new BadRequestException('Invalid phone number'); } const group = await this.prisma.group.findUnique({ where: { id: payload.groupId } }); if (!group || !group.tenantId) { throw new ConflictException('Group is not claimable'); } const pepper = this.config.get('JWT_SECRET') ?? ''; const phoneHash = hashPhone(phone, pepper); const code = String(Math.floor(100000 + Math.random() * 900000)); const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000); const challengeId = randomBytes(16).toString('hex'); const challenge = await this.prisma.otpChallenge.create({ data: { id: challengeId, tenantId: payload.tenantId, jid: payload.jid, phoneHash, code, scopes: DEFAULT_SCOPES, retentionDays: DEFAULT_RETENTION_DAYS, policyVersion: this.policyVersion, groupId: payload.groupId, expiresAt, }, }); await this.audit.log({ tenantId: payload.tenantId, action: AuditAction.OTP_REQUESTED, resourceType: 'OtpChallenge', resourceId: challenge.id, payload: { jid: payload.jid }, }); return { ok: true, challengeId, expiresInSeconds: OTP_TTL_MIN * 60, }; } async verifyOtp( token: string, challengeId: string, phone: string, code: string, scopes: ConsentScope[], retentionDays?: number, ): Promise { const payload = this.decodeOnboardingToken(token); const pepper = this.config.get('JWT_SECRET') ?? ''; const phoneHash = hashPhone(phone, pepper); const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } }); if (!challenge) throw new NotFoundException('Challenge not found'); if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used'); if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Challenge expired'); if (challenge.code !== code) throw new UnauthorizedException('Invalid code'); if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch'); const effectiveScopes = scopes.length > 0 ? scopes : DEFAULT_SCOPES; const effectiveRetention = retentionDays ?? DEFAULT_RETENTION_DAYS; const user = await this.prisma.towerUser.upsert({ where: { tenantId_phoneHash: { tenantId: payload.tenantId, phoneHash } }, update: { jid: payload.jid, displayName: payload.jid }, create: { tenantId: payload.tenantId, phoneHash, jid: payload.jid, displayName: payload.jid, }, }); const existing = await this.prisma.consentRecord.findFirst({ where: { tenantId: payload.tenantId, groupId: payload.groupId, userId: user.id }, }); let consent; if (existing) { consent = await this.prisma.consentRecord.update({ where: { id: existing.id }, data: { scopes: effectiveScopes, retentionDays: effectiveRetention, policyVersion: this.policyVersion, status: ConsentStatus.GRANTED, revokedAt: null, effectiveAt: new Date(), }, }); } else { consent = await this.prisma.consentRecord.create({ data: { tenantId: payload.tenantId, groupId: payload.groupId, userId: user.id, scopes: effectiveScopes, retentionDays: effectiveRetention, policyVersion: this.policyVersion, status: ConsentStatus.GRANTED, proofEventId: 'pending', }, }); } await this.prisma.consentRecord.update({ where: { id: consent.id }, data: { proofEventId: consent.id }, }); await this.prisma.otpChallenge.update({ where: { id: challengeId }, data: { consumedAt: new Date() }, }); await this.audit.log({ tenantId: payload.tenantId, action: AuditAction.MEMBER_ONBOARDED, resourceType: 'TowerUser', resourceId: user.id, payload: { jid: payload.jid, groupId: payload.groupId, consentId: consent.id, scopes: effectiveScopes, }, }); const memberToken = await this.jwt.signAsync({ kind: 'member', sub: user.id, tenantId: user.tenantId, jid: user.jid, phoneHash: user.phoneHash, } as const); return { memberToken, user: { id: user.id, tenantId: user.tenantId, jid: user.jid, displayName: user.displayName, }, consent: { scopes: consent.scopes as ConsentScope[], retentionDays: consent.retentionDays, policyVersion: consent.policyVersion, }, }; } async memberLogin(phone: string): Promise<{ challengeId: string; expiresInSeconds: number }> { const pepper = this.config.get('JWT_SECRET') ?? ''; const phoneHash = hashPhone(phone, pepper); const user = await this.prisma.towerUser.findFirst({ where: { phoneHash }, select: { id: true, tenantId: true, jid: true }, }); if (!user) throw new NotFoundException('No portal account found for this number. Use your original invite link to register first.'); const code = String(Math.floor(100000 + Math.random() * 900000)); const expiresAt = new Date(Date.now() + OTP_TTL_MIN * 60 * 1000); const challengeId = randomBytes(16).toString('hex'); await this.prisma.otpChallenge.create({ data: { id: challengeId, tenantId: user.tenantId, jid: user.jid, phoneHash, code, scopes: DEFAULT_SCOPES, retentionDays: DEFAULT_RETENTION_DAYS, policyVersion: this.policyVersion, expiresAt, }, }); await this.audit.log({ tenantId: user.tenantId, action: AuditAction.OTP_REQUESTED, resourceType: 'TowerUser', resourceId: user.id, payload: { jid: user.jid, source: 'member-login' }, }); return { challengeId, expiresInSeconds: OTP_TTL_MIN * 60 }; } async memberVerify(challengeId: string, phone: string, code: string): Promise<{ memberToken: string; user: { id: string; tenantId: string; jid: string; displayName: string | null }; }> { const pepper = this.config.get('JWT_SECRET') ?? ''; const phoneHash = hashPhone(phone, pepper); const challenge = await this.prisma.otpChallenge.findUnique({ where: { id: challengeId } }); if (!challenge) throw new NotFoundException('Challenge not found'); if (challenge.consumedAt) throw new UnauthorizedException('Challenge already used'); if (challenge.expiresAt < new Date()) throw new UnauthorizedException('Code expired'); if (challenge.code !== code) throw new UnauthorizedException('Invalid code'); if (challenge.phoneHash !== phoneHash) throw new UnauthorizedException('Phone mismatch'); await this.prisma.otpChallenge.update({ where: { id: challengeId }, data: { consumedAt: new Date() }, }); const user = await this.prisma.towerUser.findFirst({ where: { phoneHash, tenantId: challenge.tenantId }, select: { id: true, tenantId: true, jid: true, displayName: true, phoneHash: true }, }); if (!user) throw new NotFoundException('User not found'); await this.audit.log({ tenantId: user.tenantId, action: AuditAction.OTP_VERIFIED, resourceType: 'TowerUser', resourceId: user.id, payload: { jid: user.jid, source: 'member-login' }, }); const memberToken = await this.jwt.signAsync({ kind: 'member', sub: user.id, tenantId: user.tenantId, jid: user.jid, phoneHash: user.phoneHash, } as const); return { memberToken, user: { id: user.id, tenantId: user.tenantId, jid: user.jid, displayName: user.displayName } }; } private normalizeJid(jid: string): string { return jid.trim(); } }