import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { PrismaService } from '../infra/prisma.service'; import { MediaService } from './media.service'; import { OAuthCoreService } from './oauth-core.service'; import { SessionService } from './session.service'; import { SettingsService } from './settings.service'; import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util'; @Injectable() export class FedcmService { constructor( private readonly prisma: PrismaService, private readonly session: SessionService, private readonly oauthCore: OAuthCoreService, private readonly settings: SettingsService, private readonly media: MediaService ) {} async getAccounts(userId: string, sessionId: string) { await this.assertExistingSession(userId, sessionId); const user = await this.prisma.user.findFirst({ where: { id: userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE } }); if (!user) { return { accounts: [] }; } assertHumanAccount(user, { asAuth: true }); const consents = await this.prisma.oAuthConsent.findMany({ where: { userId }, include: { client: { select: { clientId: true, isActive: true } } } }); const approvedClients = consents .filter((item) => item.client.isActive) .map((item) => item.client.clientId); const givenName = user.displayName.trim().split(/\s+/)[0] || user.displayName; const picture = await this.media.createFedcmAvatarUrl(user.id); const email = user.email?.trim() || undefined; const tel = user.phone?.trim() || undefined; return { accounts: [ { id: user.id, name: user.displayName, given_name: givenName, email, picture, tel, approved_clients: approvedClients } ] }; } async issueIdAssertion(userId: string, sessionId: string, clientId: string, accountId: string) { if (accountId !== userId) { throw new BadRequestException('account_id не совпадает с активной сессией'); } await this.assertUnlockedSession(userId, sessionId); const client = await this.prisma.oAuthClient.findUnique({ where: { clientId }, include: { scopes: { include: { scope: true } } } }); if (!client?.isActive) { throw new BadRequestException('OAuth-приложение не найдено или отключено'); } const scopeSlugs = client.scopes.map((item) => item.scope.slug); const scope = scopeSlugs.length > 0 ? scopeSlugs.join(' ') : 'openid profile email'; const consent = await this.oauthCore.checkConsent(userId, clientId, scope); if (!consent.granted) { await this.oauthCore.grantConsent(userId, clientId, scope); } const tokens = await this.oauthCore.issueFedcmTokens(userId, clientId, scopeSlugs.length > 0 ? scopeSlugs : ['openid', 'profile', 'email']); return { token: tokens.idToken }; } async getClientMetadata(clientId: string) { const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } }); if (!client?.isActive) { throw new BadRequestException('OAuth-приложение не найдено или отключено'); } const frontendUrl = (await this.settings.getValue('PUBLIC_FRONTEND_URL', 'http://localhost:3002')).replace(/\/+$/, ''); const privacyFallback = `${frontendUrl}/data`; const privacy = (await this.settings.getValue('FEDCM_PRIVACY_POLICY_URL', '')).trim(); const terms = (await this.settings.getValue('FEDCM_TERMS_URL', '')).trim(); return { privacy_policy_url: privacy || privacyFallback, terms_of_service_url: terms || privacyFallback }; } async isOneTapEnabled() { return this.settings.getBoolean('ONE_TAP_ENABLED', true); } private async assertExistingSession(userId: string, sessionId: string) { const state = await this.session.resolveSessionState(sessionId); if (!state || state.userId !== userId) { throw new UnauthorizedException('Сессия недействительна или истекла'); } } private async assertUnlockedSession(userId: string, sessionId: string) { await this.assertExistingSession(userId, sessionId); const state = await this.session.resolveSessionState(sessionId); if (state?.requiresPin) { throw new UnauthorizedException('Требуется подтверждение PIN-кода'); } } }