🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
547 lines
19 KiB
TypeScript
547 lines
19 KiB
TypeScript
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<User>,
|
|
@InjectRepository(GuildNode)
|
|
private readonly guildNodeRepo: Repository<GuildNode>,
|
|
@InjectRepository(GuildUser)
|
|
private readonly guildUserRepo: Repository<GuildUser>,
|
|
@InjectRepository(UserApiKey)
|
|
private readonly apiKeyRepo: Repository<UserApiKey>,
|
|
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<string> {
|
|
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<string, string> }> {
|
|
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<string, string> = {};
|
|
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' };
|
|
}
|
|
}
|