import { ConflictException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { randomBytes } from 'crypto'; import { User } from '../entities/user.entity.js'; import { GuildNode } from '../entities/guild-node.entity.js'; import { GuildUser } from '../entities/guild-user.entity.js'; import { UserApiKey } from '../entities/user-api-key.entity.js'; import { RegisterDto } from './dto.register.dto.js'; import { LoginDto } from './dto.login.dto.js'; import { AuditService } from '../audit/audit.service.js'; import { parseDurationToSeconds } from './token.util.js'; function signAccessToken(userId: string, email: string): string { const secret = process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string; const expiresIn = parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_EXPIRES_IN ?? '15m', 900); return jwt.sign({ sub: userId, email }, secret, { expiresIn }); } function getAccessTokenExpiresInSeconds(): number { return parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_EXPIRES_IN ?? '15m', 900); } function signRefreshToken(userId: string, email: string): string { const secret = process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET as string; const expiresIn = parseDurationToSeconds(process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_EXPIRES_IN ?? '30d', 2592000); return jwt.sign({ sub: userId, email, typ: 'refresh' }, secret, { expiresIn }); } function signGuildAccessToken(userId: string, email: string, guildNodeId: string): string { const secret = process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string; const expiresIn = getAccessTokenExpiresInSeconds(); return jwt.sign({ sub: userId, email, gid: guildNodeId, typ: 'guild_access' }, secret, { expiresIn }); } @Injectable() export class AuthService { constructor( @InjectRepository(User) private readonly userRepo: Repository, @InjectRepository(GuildNode) private readonly guildNodeRepo: Repository, @InjectRepository(GuildUser) private readonly guildUserRepo: Repository, @InjectRepository(UserApiKey) private readonly apiKeyRepo: Repository, private readonly audit: AuditService, ) {} // Mint a long-lived API key for a user (agent machine credential). async createUserApiKey(email: string, label?: string) { const user = await this.userRepo.findOne({ where: { email } }); if (!user) throw new UnauthorizedException('user not found'); const raw = `fak_${randomBytes(24).toString('hex')}`; await this.apiKeyRepo.save( this.apiKeyRepo.create({ userId: user.id, apiKeyHash: await bcrypt.hash(raw, 10), label: label ?? null, }), ); await this.audit.write({ action: 'auth.apikey.create', actorId: user.id, targetType: 'user', targetId: user.id, detail: JSON.stringify({ label: label ?? null }), }); return { userId: user.id, email: user.email, apiKey: raw }; } // Exchange an agent API key for a normal user session (same shape as login). async agentLogin(apiKey: string) { if (!apiKey) throw new UnauthorizedException('missing api key'); const keys = await this.apiKeyRepo.find(); let userId: string | null = null; for (const k of keys) { if (await bcrypt.compare(apiKey, k.apiKeyHash)) { userId = k.userId; break; } } if (!userId) throw new UnauthorizedException('invalid api key'); const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user) throw new UnauthorizedException('invalid api key'); const accessToken = signAccessToken(user.id, user.email); const refreshToken = signRefreshToken(user.id, user.email); user.refreshTokenHash = await bcrypt.hash(refreshToken, 10); await this.userRepo.save(user); await this.audit.write({ action: 'auth.agent_login', actorId: user.id, targetType: 'user', targetId: user.id, detail: null, }); const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email); return { accessToken, refreshToken, tokenType: 'Bearer', expiresIn: getAccessTokenExpiresInSeconds(), user: { id: user.id, email: user.email, name: user.name ?? user.email }, guilds, guildAccessTokens, }; } // OIDC: find a user by email or auto-provision one (no usable password — // OIDC users never password-login). Returns the userId. async provisionOidcUser(email: string, name?: string): Promise { const e = String(email ?? '').trim(); if (!e) throw new UnauthorizedException('oidc: missing email claim'); let user = await this.userRepo.findOne({ where: { email: e } }); if (!user) { const passwordHash = await bcrypt.hash(`oidc!${randomBytes(24).toString('hex')}`, 10); user = await this.userRepo.save( this.userRepo.create({ email: e, name: (name ?? '').trim() || e, passwordHash, refreshTokenHash: null, }), ); await this.audit.write({ action: 'auth.oidc.provision', actorId: user.id, targetType: 'user', targetId: user.id, detail: JSON.stringify({ email: e }), }); } return user.id; } // Issue a full session for an already-authenticated userId (same shape as // login()). Used by the OIDC ticket exchange. async issueSessionForUserId(userId: string) { const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user) throw new UnauthorizedException('user not found'); const accessToken = signAccessToken(user.id, user.email); const refreshToken = signRefreshToken(user.id, user.email); user.refreshTokenHash = await bcrypt.hash(refreshToken, 10); await this.userRepo.save(user); await this.audit.write({ action: 'auth.oidc.login', actorId: user.id, targetType: 'user', targetId: user.id, detail: null, }); const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email); return { accessToken, refreshToken, tokenType: 'Bearer', expiresIn: getAccessTokenExpiresInSeconds(), user: { id: user.id, email: user.email, name: user.name ?? user.email }, guilds, guildAccessTokens, }; } private async getUserGuildsAndTokens(userId: string, email: string) { const memberships = await this.guildUserRepo.find({ where: { userId, status: 'active' }, }); const nodeIds = memberships.map((x) => x.guildNodeId); if (!nodeIds.length) { return { guilds: [], guildAccessTokens: [] as Array<{ guildNodeId: string; token: string; tokenType: string }> }; } const nodes = await this.guildNodeRepo .createQueryBuilder('n') .where('n.nodeId IN (:...nodeIds)', { nodeIds }) .andWhere('n.status = :status', { status: 'active' }) .getMany(); const guilds = nodes.map((n) => ({ nodeId: n.nodeId, name: n.name, endpoint: n.endpoint, status: n.status, })); const guildAccessTokens = guilds.map((g) => ({ guildNodeId: g.nodeId, token: signGuildAccessToken(userId, email, g.nodeId), tokenType: 'Bearer', expiresIn: getAccessTokenExpiresInSeconds(), })); return { guilds, guildAccessTokens }; } // ------------------------------------------------------------------- // Center-scoped admin (at most one per deployment). // // setAdmin / clearAdmin are reachable only via cli, not HTTP — admin // promotion bypasses every HTTP gate (the admin observes ALL triage // traffic) so we don't want it tunneled through any token / api-key. // // getAdminEmail is exposed to guild backends (api-key auth) so each // guild can mark the admin as a triage observer. // ------------------------------------------------------------------- async getAdminEmail(): Promise<{ email: string; userId: string } | null> { const admin = await this.userRepo.findOne({ where: { isAdmin: true } }); if (!admin) return null; return { email: admin.email, userId: admin.id }; } async setAdmin(email: string): Promise<{ email: string; userId: string }> { const target = await this.userRepo.findOne({ where: { email } }); if (!target) { throw new UnauthorizedException(`user ${email} not found`); } // Enforce at-most-one-admin: clear every existing admin row first // (TypeORM rejects an empty-where update, so target by isAdmin=true // — the no-op cost when no rows match is one cheap query), then set // the target. Two UPDATEs in one txn so a peer never sees two admins. await this.userRepo.manager.transaction(async (txn) => { await txn.update(User, { isAdmin: true }, { isAdmin: false }); await txn.update(User, { id: target.id }, { isAdmin: true }); }); await this.audit.write({ action: 'admin.set', actorId: target.id, targetType: 'user', targetId: target.id, detail: JSON.stringify({ email: target.email }), }); return { email: target.email, userId: target.id }; } async clearAdmin(): Promise<{ cleared: number }> { const res = await this.userRepo.update({ isAdmin: true }, { isAdmin: false }); await this.audit.write({ action: 'admin.clear', actorId: 'cli', targetType: 'user', targetId: 'all', detail: JSON.stringify({ cleared: res.affected ?? 0 }), }); return { cleared: res.affected ?? 0 }; } async register(input: RegisterDto) { const exists = await this.userRepo.findOne({ where: { email: input.email } }); if (exists) { throw new ConflictException('email already exists'); } const passwordHash = await bcrypt.hash(input.password, 10); const user = this.userRepo.create({ email: input.email, name: input.email, passwordHash, refreshTokenHash: null, }); const saved = await this.userRepo.save(user); await this.audit.write({ action: 'auth.register', actorId: saved.id, targetType: 'user', targetId: saved.id, detail: JSON.stringify({ email: saved.email }), }); return { id: saved.id, email: saved.email, name: saved.name ?? saved.email, createdAt: saved.createdAt, }; } async login(input: LoginDto) { const user = await this.userRepo.findOne({ where: { email: input.email } }); if (!user) throw new UnauthorizedException('invalid credentials'); const ok = await bcrypt.compare(input.password, user.passwordHash); if (!ok) throw new UnauthorizedException('invalid credentials'); const accessToken = signAccessToken(user.id, user.email); const refreshToken = signRefreshToken(user.id, user.email); user.refreshTokenHash = await bcrypt.hash(refreshToken, 10); await this.userRepo.save(user); await this.audit.write({ action: 'auth.login', actorId: user.id, targetType: 'user', targetId: user.id, detail: JSON.stringify({ email: user.email }), }); const { guilds, guildAccessTokens } = await this.getUserGuildsAndTokens(user.id, user.email); return { accessToken, refreshToken, tokenType: 'Bearer', expiresIn: getAccessTokenExpiresInSeconds(), user: { id: user.id, email: user.email, name: user.name ?? user.email, }, guilds, guildAccessTokens, }; } async getMe(accessToken: string) { const payload = this.verifyCenterAccessToken(accessToken); const userId = String(payload.sub ?? ''); const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user) throw new UnauthorizedException('user not found'); return { id: user.id, email: user.email, name: user.name ?? user.email }; } async updateMe(accessToken: string, name: string) { const payload = this.verifyCenterAccessToken(accessToken); const userId = String(payload.sub ?? ''); const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user) throw new UnauthorizedException('user not found'); const trimmed = name.trim(); if (!trimmed) throw new ConflictException('name must not be empty'); user.name = trimmed; await this.userRepo.save(user); await this.audit.write({ action: 'auth.update_profile', actorId: user.id, targetType: 'user', targetId: user.id, detail: JSON.stringify({ name: trimmed }), }); return { id: user.id, email: user.email, name: user.name }; } async listMyGuilds(accessToken: string) { const payload = this.verifyCenterAccessToken(accessToken); const userId = String(payload.sub ?? ''); const email = String(payload.email ?? ''); return this.getUserGuildsAndTokens(userId, email); } async joinGuild(accessToken: string, guildNodeId: string) { const payload = this.verifyCenterAccessToken(accessToken); const userId = String(payload.sub ?? ''); if (!userId) throw new UnauthorizedException('invalid access token'); const node = await this.guildNodeRepo.findOne({ where: { nodeId: guildNodeId, status: 'active' } }); if (!node) throw new UnauthorizedException('guild node not found or inactive'); const existed = await this.guildUserRepo.findOne({ where: { userId, guildNodeId } }); if (!existed) { await this.guildUserRepo.save( this.guildUserRepo.create({ userId, guildNodeId, status: 'active' }), ); } else if (existed.status !== 'active') { existed.status = 'active'; await this.guildUserRepo.save(existed); } return { status: 'ok' as const }; } async listGuildMembers(accessToken: string, guildNodeId: string) { const payload = this.verifyCenterAccessToken(accessToken); const userId = String(payload.sub ?? ''); if (!userId) throw new UnauthorizedException('invalid access token'); const selfMembership = await this.guildUserRepo.findOne({ where: { userId, guildNodeId, status: 'active' } }); if (!selfMembership) throw new UnauthorizedException('not a guild member'); const members = await this.guildUserRepo.find({ where: { guildNodeId, status: 'active' } }); const userIds = [...new Set(members.map((m) => m.userId))]; if (!userIds.length) return []; const users = await this.userRepo .createQueryBuilder('u') .where('u.id IN (:...userIds)', { userIds }) .getMany(); const userMap = new Map(users.map((u) => [u.id, u])); return members .map((m) => { const u = userMap.get(m.userId); return { userId: m.userId, email: u?.email ?? '', name: u?.name ?? u?.email ?? '', status: m.status, }; }) .filter((x) => !!x.email); } // Resolve display names (or emails) to userIds, scoped to a guild node's // active members. Called by guild nodes (api-key auth). Unresolved names // are omitted from the result. async resolveNames( guildNodeId: string, names: string[], ): Promise<{ resolved: Record }> { const wanted = [...new Set(names.map((n) => String(n ?? '').trim()).filter(Boolean))]; if (!guildNodeId || !wanted.length) return { resolved: {} }; const members = await this.guildUserRepo.find({ where: { guildNodeId, status: 'active' } }); const userIds = [...new Set(members.map((m) => m.userId))]; if (!userIds.length) return { resolved: {} }; const users = await this.userRepo .createQueryBuilder('u') .where('u.id IN (:...userIds)', { userIds }) .getMany(); const resolved: Record = {}; for (const name of wanted) { const hit = users.find((u) => u.name === name) ?? users.find((u) => u.email === name); if (hit) resolved[name] = hit.id; } return { resolved }; } verifyCenterAccessToken(accessToken: string): jwt.JwtPayload { try { const payload = jwt.verify(accessToken, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload; if (!payload?.sub) throw new Error('invalid'); return payload; } catch { throw new UnauthorizedException('invalid access token'); } } async introspectGuildToken(token: string, guildNodeId: string) { let payload: jwt.JwtPayload; try { payload = jwt.verify(token, process.env.FABRIC_BACKEND_CENTER_JWT_ACCESS_SECRET as string) as jwt.JwtPayload; } catch { return { active: false }; } const userId = String(payload.sub ?? ''); const tokenGuildNodeId = String(payload.gid ?? ''); if (!userId || tokenGuildNodeId !== guildNodeId || payload.typ !== 'guild_access') { return { active: false }; } const membership = await this.guildUserRepo.findOne({ where: { userId, guildNodeId, status: 'active' }, }); if (!membership) return { active: false }; return { active: true, user: { id: userId, email: String(payload.email ?? ''), }, guildNodeId, }; } async refresh(refreshToken: string) { let payload: jwt.JwtPayload; try { payload = jwt.verify(refreshToken, process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET as string) as jwt.JwtPayload; } catch { throw new UnauthorizedException('invalid refresh token'); } const userId = String(payload.sub ?? ''); const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user || !user.refreshTokenHash) { throw new UnauthorizedException('invalid refresh token'); } const tokenOk = await bcrypt.compare(refreshToken, user.refreshTokenHash); if (!tokenOk) throw new UnauthorizedException('invalid refresh token'); const newAccessToken = signAccessToken(user.id, user.email); const newRefreshToken = signRefreshToken(user.id, user.email); user.refreshTokenHash = await bcrypt.hash(newRefreshToken, 10); await this.userRepo.save(user); await this.audit.write({ action: 'auth.refresh', actorId: user.id, targetType: 'user', targetId: user.id, detail: null, }); return { accessToken: newAccessToken, refreshToken: newRefreshToken, tokenType: 'Bearer', expiresIn: getAccessTokenExpiresInSeconds(), }; } async logout(refreshToken: string) { let payload: jwt.JwtPayload; try { payload = jwt.verify(refreshToken, process.env.FABRIC_BACKEND_CENTER_JWT_REFRESH_SECRET as string) as jwt.JwtPayload; } catch { return { status: 'ok' }; } const userId = String(payload.sub ?? ''); if (!userId) return { status: 'ok' }; const user = await this.userRepo.findOne({ where: { id: userId } }); if (!user) return { status: 'ok' }; user.refreshTokenHash = null; await this.userRepo.save(user); await this.audit.write({ action: 'auth.logout', actorId: user.id, targetType: 'user', targetId: user.id, detail: null, }); return { status: 'ok' }; } }