diff --git a/apps/api-gateway/package.json b/apps/api-gateway/package.json index 1c5de41..2d89055 100644 --- a/apps/api-gateway/package.json +++ b/apps/api-gateway/package.json @@ -20,11 +20,13 @@ "@nestjs/swagger": "^11.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "cookie-parser": "^1.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, "devDependencies": { "@nestjs/cli": "^11.0.14", + "@types/cookie-parser": "^1.4.10", "@types/node": "^24.10.1", "typescript": "^5.9.3" } diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts index 56cbba1..108b7b2 100644 --- a/apps/api-gateway/src/app.module.ts +++ b/apps/api-gateway/src/app.module.ts @@ -14,6 +14,7 @@ import { ProfileController } from './controllers/profile.controller'; import { DocumentsController } from './controllers/documents.controller'; import { AddressesController } from './controllers/addresses.controller'; import { OAuthController } from './controllers/oauth.controller'; +import { FedcmController } from './controllers/fedcm.controller'; import { WellKnownController, OAuthAuthorizeDiscoveryController } from './controllers/well-known.controller'; import { AdvancedAuthController } from './controllers/advanced-auth.controller'; import { FamilyController } from './controllers/family.controller'; @@ -25,6 +26,7 @@ import { AdminBotController } from './controllers/admin-bot.controller'; import { TelegramBotApiController } from './controllers/telegram-bot-api.controller'; import { CoreGrpcService } from './core-grpc.service'; import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard'; +import { FedcmCookieInterceptor } from './interceptors/fedcm-cookie.interceptor'; @Module({ imports: [ @@ -59,7 +61,7 @@ import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.gua } ]) ], - controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, TelegramBotApiController], - providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard] + controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, FedcmController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, TelegramBotApiController], + providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard, FedcmCookieInterceptor] }) export class AppModule {} diff --git a/apps/api-gateway/src/controllers/auth.controller.ts b/apps/api-gateway/src/controllers/auth.controller.ts index 006d83b..03855d2 100644 --- a/apps/api-gateway/src/controllers/auth.controller.ts +++ b/apps/api-gateway/src/controllers/auth.controller.ts @@ -1,13 +1,15 @@ -import { Body, Controller, Get, Headers, Ip, Post } from '@nestjs/common'; +import { Body, Controller, Get, Headers, Ip, Post, UseInterceptors } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { BeginTotpLoginDto, IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto, VerifyTotpLoginDto } from '../dto/auth.dto'; import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth'; +import { FedcmCookieInterceptor } from '../interceptors/fedcm-cookie.interceptor'; @ApiTags('Аутентификация') @Controller('auth') +@UseInterceptors(FedcmCookieInterceptor) export class AuthController { constructor( private readonly core: CoreGrpcService, diff --git a/apps/api-gateway/src/controllers/fedcm.controller.ts b/apps/api-gateway/src/controllers/fedcm.controller.ts new file mode 100644 index 0000000..875db7b --- /dev/null +++ b/apps/api-gateway/src/controllers/fedcm.controller.ts @@ -0,0 +1,192 @@ +import { + Controller, + Get, + Headers, + HttpCode, + Options, + Post, + Query, + Req, + Res, + UnauthorizedException +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { firstValueFrom } from 'rxjs'; +import type { Request, Response } from 'express'; +import { CoreGrpcService } from '../core-grpc.service'; +import { applyFedcmCorsHeaders, applyFedcmPreflightHeaders } from '../lib/fedcm-cors'; +import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie'; +import { resolveFrontendUrl, resolveOAuthIssuer } from '../lib/oauth-issuer'; +import { assertSessionUnlocked, verifyAccessToken } from '../session-auth'; + +type FedcmAccountsResponse = { + accounts: Array<{ + id: string; + name: string; + given_name?: string; + email?: string; + picture?: string; + approved_clients?: string[]; + }>; +}; + +@ApiTags('FedCM') +@Controller('fedcm') +export class FedcmController { + constructor( + private readonly core: CoreGrpcService, + private readonly jwt: JwtService + ) {} + + @Options('accounts') + @HttpCode(204) + accountsPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) { + applyFedcmPreflightHeaders(res, origin); + } + + @Options('id_assertion') + @HttpCode(204) + idAssertionPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) { + applyFedcmPreflightHeaders(res, origin); + } + + @Options('client_metadata') + @HttpCode(204) + clientMetadataPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) { + applyFedcmPreflightHeaders(res, origin); + } + + @Get('config.json') + @ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' }) + async config(@Res({ passthrough: true }) res: Response) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=300'); + + const issuer = await resolveOAuthIssuer(this.core); + const frontendUrl = await resolveFrontendUrl(this.core); + let projectName = 'MVK ID'; + + try { + const setting = (await firstValueFrom(this.core.settings.GetSetting({ key: 'PROJECT_NAME' }))) as { value?: string }; + if (setting.value?.trim()) { + projectName = setting.value.trim(); + } + } catch { + // fallback + } + + return { + accounts_endpoint: `${issuer}/fedcm/accounts`, + client_metadata_endpoint: `${issuer}/fedcm/client_metadata`, + id_assertion_endpoint: `${issuer}/fedcm/id_assertion`, + login_url: `${frontendUrl}/auth/login`, + signup_url: `${frontendUrl}/auth/register`, + branding: { + background_color: '#ffffff', + color: '#3390ec', + icons: [{ url: `${frontendUrl}/favicon.ico`, size: 32 }] + }, + accounts: { + include_anonymous: false + }, + fields: ['name', 'email', 'picture'], + display: projectName + }; + } + + @Get('accounts') + @ApiOperation({ summary: 'FedCM accounts', description: 'Возвращает аккаунты пользователя по FedCM-сессии (cookie).' }) + async accounts( + @Req() req: Request, + @Headers('origin') origin: string | undefined, + @Res({ passthrough: true }) res: Response + ): Promise { + applyFedcmCorsHeaders(res, origin); + res.setHeader('Content-Type', 'application/json'); + + const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie); + if (!session) { + return { accounts: [] }; + } + + const result = (await firstValueFrom( + this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId }) + )) as FedcmAccountsResponse; + + return result ?? { accounts: [] }; + } + + @Get('client_metadata') + @ApiOperation({ summary: 'FedCM client metadata', description: 'Метаданные клиента для UI FedCM.' }) + async clientMetadata( + @Query('client_id') clientId: string | undefined, + @Headers('origin') origin: string | undefined, + @Res({ passthrough: true }) res: Response + ) { + applyFedcmCorsHeaders(res, origin); + res.setHeader('Content-Type', 'application/json'); + + if (!clientId?.trim()) { + throw new UnauthorizedException('Передайте client_id'); + } + + return firstValueFrom(this.core.fedcm.GetClientMetadata({ clientId: clientId.trim() })); + } + + @Post('id_assertion') + @HttpCode(200) + @ApiOperation({ summary: 'FedCM id assertion', description: 'Выдаёт OIDC id_token для выбранного аккаунта и OAuth-клиента.' }) + async idAssertion( + @Req() req: Request, + @Headers('origin') origin: string | undefined, + @Res({ passthrough: true }) res: Response + ) { + applyFedcmCorsHeaders(res, origin); + res.setHeader('Content-Type', 'application/json'); + + const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie); + if (!session) { + throw new UnauthorizedException('Сессия FedCM не найдена'); + } + + const body = (req.body ?? {}) as Record; + const clientId = String(body.client_id ?? body.clientId ?? '').trim(); + const accountId = String(body.account_id ?? body.accountId ?? '').trim(); + if (!clientId || !accountId) { + throw new UnauthorizedException('Передайте client_id и account_id'); + } + + return firstValueFrom( + this.core.fedcm.IssueIdAssertion({ + userId: session.sub, + sessionId: session.sessionId, + clientId, + accountId + }) + ); + } + + @Post('session/sync') + @HttpCode(200) + @ApiOperation({ + summary: 'Синхронизировать FedCM cookie', + description: 'Устанавливает HttpOnly cookie для FedCM по Bearer access token (для уже авторизованных пользователей IdP).' + }) + async syncSession( + @Headers('authorization') authorization: string | undefined, + @Res({ passthrough: true }) res: Response + ) { + const payload = await verifyAccessToken(this.jwt, authorization); + if (!payload.sessionId) { + throw new UnauthorizedException('Сессия не найдена'); + } + await assertSessionUnlocked(this.core, payload); + await setFedcmSessionCookie(res, this.jwt, { + sub: payload.sub, + sessionId: payload.sessionId, + pinVerified: true + }); + return { synced: true }; + } +} diff --git a/apps/api-gateway/src/controllers/well-known.controller.ts b/apps/api-gateway/src/controllers/well-known.controller.ts index a2f2fdc..dcabe39 100644 --- a/apps/api-gateway/src/controllers/well-known.controller.ts +++ b/apps/api-gateway/src/controllers/well-known.controller.ts @@ -1,5 +1,7 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Res } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issuer'; @@ -8,6 +10,20 @@ import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issue export class WellKnownController { constructor(private readonly core: CoreGrpcService) {} + @Get('web-identity') + @ApiOperation({ + summary: 'FedCM web identity manifest', + description: 'Манифест Federated Credential Management API, указывающий на конфигурацию провайдера.' + }) + async webIdentity(@Res({ passthrough: true }) res: Response) { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=300'); + const issuer = await resolveOAuthIssuer(this.core); + return { + provider_urls: [`${issuer}/fedcm/config.json`] + }; + } + @Get('openid-configuration') @ApiOperation({ summary: 'OpenID Connect Discovery', diff --git a/apps/api-gateway/src/core-grpc.service.ts b/apps/api-gateway/src/core-grpc.service.ts index 0fb5bd4..5b01eb0 100644 --- a/apps/api-gateway/src/core-grpc.service.ts +++ b/apps/api-gateway/src/core-grpc.service.ts @@ -15,6 +15,7 @@ export class CoreGrpcService implements OnModuleInit { documents!: Record; addresses!: Record; oauth!: Record; + fedcm!: Record; otp!: Record; advancedAuth!: Record; notifications!: Record; @@ -35,6 +36,7 @@ export class CoreGrpcService implements OnModuleInit { this.documents = this.client.getService>('DocumentsService'); this.addresses = this.client.getService>('AddressesService'); this.oauth = this.client.getService>('OAuthCoreService'); + this.fedcm = this.client.getService>('FedcmService'); this.otp = this.client.getService>('OtpService'); this.advancedAuth = this.client.getService>('AdvancedAuthService'); this.family = this.client.getService>('FamilyService'); diff --git a/apps/api-gateway/src/interceptors/fedcm-cookie.interceptor.ts b/apps/api-gateway/src/interceptors/fedcm-cookie.interceptor.ts new file mode 100644 index 0000000..72b3176 --- /dev/null +++ b/apps/api-gateway/src/interceptors/fedcm-cookie.interceptor.ts @@ -0,0 +1,21 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Observable, tap } from 'rxjs'; +import type { Response } from 'express'; +import { attachFedcmCookieFromAuthResult } from '../lib/fedcm-cookie'; + +@Injectable() +export class FedcmCookieInterceptor implements NestInterceptor { + constructor(private readonly jwt: JwtService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const http = context.switchToHttp(); + const response = http.getResponse(); + + return next.handle().pipe( + tap((data) => { + attachFedcmCookieFromAuthResult(response, this.jwt, data); + }) + ); + } +} diff --git a/apps/api-gateway/src/lib/fedcm-cookie.ts b/apps/api-gateway/src/lib/fedcm-cookie.ts new file mode 100644 index 0000000..28b9161 --- /dev/null +++ b/apps/api-gateway/src/lib/fedcm-cookie.ts @@ -0,0 +1,128 @@ +import type { Response } from 'express'; +import { JwtService } from '@nestjs/jwt'; + +export const FEDCM_SESSION_COOKIE = 'lendry_fedcm_sess'; + +export interface FedcmSessionPayload { + sub: string; + sessionId: string; + pinVerified: boolean; +} + +function cookieSecureEnabled() { + if (process.env.FEDCM_COOKIE_SECURE === 'true') return true; + if (process.env.FEDCM_COOKIE_SECURE === 'false') return false; + return process.env.NODE_ENV === 'production'; +} + +function cookieMaxAgeSeconds() { + const parsed = Number(process.env.FEDCM_COOKIE_MAX_AGE ?? 2_592_000); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 2_592_000; +} + +export async function signFedcmSessionPayload(jwt: JwtService, payload: FedcmSessionPayload) { + return jwt.signAsync( + { sub: payload.sub, sessionId: payload.sessionId, pinVerified: payload.pinVerified, typ: 'fedcm_session' }, + { + secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret', + expiresIn: cookieMaxAgeSeconds(), + issuer: 'id.lendry.ru' + } + ); +} + +export async function verifyFedcmSessionToken(jwt: JwtService, token: string): Promise { + try { + const payload = await jwt.verifyAsync<{ sub: string; sessionId: string; pinVerified?: boolean; typ?: string }>(token, { + secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret', + issuer: 'id.lendry.ru' + }); + if (payload.typ !== 'fedcm_session' || !payload.sub || !payload.sessionId) { + return null; + } + if (payload.pinVerified === false) { + return null; + } + return { sub: payload.sub, sessionId: payload.sessionId, pinVerified: true }; + } catch { + return null; + } +} + +export async function setFedcmSessionCookie(res: Response, jwt: JwtService, payload: FedcmSessionPayload) { + if (!payload.sub || !payload.sessionId || payload.pinVerified === false) { + return; + } + + const secure = cookieSecureEnabled(); + const value = await signFedcmSessionPayload(jwt, payload); + + res.cookie(FEDCM_SESSION_COOKIE, value, { + httpOnly: true, + secure, + sameSite: secure ? 'none' : 'lax', + path: '/', + maxAge: cookieMaxAgeSeconds() * 1000 + }); +} + +export function clearFedcmSessionCookie(res: Response) { + const secure = cookieSecureEnabled(); + res.clearCookie(FEDCM_SESSION_COOKIE, { + httpOnly: true, + secure, + sameSite: secure ? 'none' : 'lax', + path: '/' + }); +} + +export function readFedcmSessionCookie(cookieHeader?: string): string | null { + if (!cookieHeader) return null; + const parts = cookieHeader.split(';'); + for (const part of parts) { + const [rawKey, ...rawValue] = part.trim().split('='); + if (rawKey === FEDCM_SESSION_COOKIE) { + const value = rawValue.join('=').trim(); + return value || null; + } + } + return null; +} + +export async function resolveFedcmSessionFromRequest( + jwt: JwtService, + cookieHeader?: string +): Promise { + const token = readFedcmSessionCookie(cookieHeader); + if (!token) return null; + return verifyFedcmSessionToken(jwt, token); +} + +export function attachFedcmCookieFromAuthResult( + res: Response, + jwt: JwtService, + result: unknown +): void { + if (!result || typeof result !== 'object') return; + const data = result as { + accessToken?: string; + sessionId?: string; + pinVerified?: boolean; + requiresPin?: boolean; + user?: { id?: string }; + }; + + if (data.requiresPin || data.pinVerified === false || !data.sessionId) { + clearFedcmSessionCookie(res); + return; + } + + const userId = data.user?.id; + if (!userId) return; + + void setFedcmSessionCookie(res, jwt, { + sub: userId, + sessionId: data.sessionId, + pinVerified: true + }); +} diff --git a/apps/api-gateway/src/lib/fedcm-cors.ts b/apps/api-gateway/src/lib/fedcm-cors.ts new file mode 100644 index 0000000..edb694b --- /dev/null +++ b/apps/api-gateway/src/lib/fedcm-cors.ts @@ -0,0 +1,16 @@ +import type { Response } from 'express'; + +export function applyFedcmCorsHeaders(res: Response, origin?: string) { + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Vary', 'Origin'); + } +} + +export function applyFedcmPreflightHeaders(res: Response, origin?: string) { + applyFedcmCorsHeaders(res, origin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site'); + res.setHeader('Access-Control-Max-Age', '86400'); +} diff --git a/apps/api-gateway/src/main.ts b/apps/api-gateway/src/main.ts index 0187411..5457f16 100644 --- a/apps/api-gateway/src/main.ts +++ b/apps/api-gateway/src/main.ts @@ -1,11 +1,13 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './grpc-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.use(cookieParser()); app.enableCors({ origin: true, credentials: true }); app.useGlobalPipes( new ValidationPipe({ diff --git a/apps/docs/components/doc-block-renderer.tsx b/apps/docs/components/doc-block-renderer.tsx index 3b37139..2fa786c 100644 --- a/apps/docs/components/doc-block-renderer.tsx +++ b/apps/docs/components/doc-block-renderer.tsx @@ -1,5 +1,6 @@ import type { DocBlock } from '@/lib/docs-pages'; import { OAuthCodeTabs } from '@/components/oauth-code-tabs'; +import { OneTapCodeTabs } from '@/components/one-tap-code-tabs'; import { AuthLoginCodeTabs } from '@/components/auth-code-tabs'; import { AuthLdapCodeTabs } from '@/components/auth-ldap-code-tabs'; import { BotCodeTabs } from '@/components/bot-code-tabs'; @@ -67,6 +68,8 @@ export function DocBlockRenderer({ block }: { block: DocBlock }) { ); case 'oauth-examples': return ; + case 'one-tap-examples': + return ; case 'auth-login-examples': return ; case 'auth-ldap-examples': diff --git a/apps/docs/components/docs-home-content.tsx b/apps/docs/components/docs-home-content.tsx index 6d5bf00..d77f149 100644 --- a/apps/docs/components/docs-home-content.tsx +++ b/apps/docs/components/docs-home-content.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { ArrowRight, BookOpen, Bot, Code2, Rocket, Shield } from 'lucide-react'; +import { ArrowRight, BookOpen, Bot, Code2, MousePointerClick, Rocket, Shield } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { docNavigation, groupDocNavigation } from '@/lib/navigation'; @@ -20,6 +20,12 @@ const highlights = [ description: 'Стандартный OpenID Connect: Discovery, client_id, PKCE, form-urlencoded token. Примеры для PHP без доработки OidcProvider.', href: '/docs/oauth' }, + { + icon: MousePointerClick, + title: 'One Tap Login', + description: 'FedCM и виджет sso-widget.js: вход в один клик на сайтах-клиентов без полного редиректа.', + href: '/docs/one-tap-login' + }, { icon: Bot, title: 'Telegram Bot API', diff --git a/apps/docs/components/one-tap-code-tabs.tsx b/apps/docs/components/one-tap-code-tabs.tsx new file mode 100644 index 0000000..1145694 --- /dev/null +++ b/apps/docs/components/one-tap-code-tabs.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { fetchPublicSettingsClient } from '@/lib/api'; +import { buildOneTapExamples, buildOneTapUrls } from '@/lib/one-tap-examples'; +import { resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url'; +import { CodeExampleTabs } from '@/components/code-example-tabs'; + +export function OneTapCodeTabs() { + const [apiBase, setApiBase] = useState('http://localhost:3000'); + const [frontendBase, setFrontendBase] = useState('http://localhost:3002'); + const [projectName, setProjectName] = useState('MVK ID'); + + useEffect(() => { + void fetchPublicSettingsClient() + .then((settings) => { + setApiBase(resolveOAuthApiBase(settings)); + setFrontendBase(resolveFrontendBase(settings)); + if (settings.PROJECT_NAME?.trim()) { + setProjectName(settings.PROJECT_NAME.trim()); + } + }) + .catch(() => undefined); + }, []); + + const urls = useMemo(() => buildOneTapUrls(apiBase, frontendBase, projectName), [apiBase, frontendBase, projectName]); + const examples = useMemo(() => buildOneTapExamples(urls), [urls]); + + return ( +
+

+ API (issuer): {apiBase} + {' · '} + Frontend / виджет: {frontendBase} + {' — '} + из настроек PUBLIC_API_URL, PUBLIC_FRONTEND_URL и PROJECT_NAME. +

+ +
+ ); + +} diff --git a/apps/docs/lib/api-endpoints.ts b/apps/docs/lib/api-endpoints.ts index 9ca41d4..dff2eb4 100644 --- a/apps/docs/lib/api-endpoints.ts +++ b/apps/docs/lib/api-endpoints.ts @@ -66,6 +66,48 @@ export const apiReference: ApiTagGroup[] = [ } ] }, + { + tag: 'FedCM / One Tap Login', + endpoints: [ + { + method: 'GET', + path: '/.well-known/web-identity', + summary: 'FedCM web identity manifest', + description: 'Манифест Federated Credential Management: provider_urls → /fedcm/config.json.' + }, + { + method: 'GET', + path: '/fedcm/config.json', + summary: 'Конфигурация FedCM IdP', + description: 'accounts_endpoint, id_assertion_endpoint, login_url, branding.' + }, + { + method: 'GET', + path: '/fedcm/accounts', + summary: 'Список аккаунтов FedCM', + description: 'Credentialed GET по cookie lendry_fedcm_sess. CORS с credentials для RP.' + }, + { + method: 'POST', + path: '/fedcm/id_assertion', + summary: 'Выдача id_token FedCM', + description: 'Тело: client_id, account_id. Возвращает { token } — OIDC id_token.' + }, + { + method: 'GET', + path: '/fedcm/client_metadata', + summary: 'Метаданные клиента FedCM', + description: 'Query: client_id. privacy_policy_url, terms_of_service_url.' + }, + { + method: 'POST', + path: '/fedcm/session/sync', + summary: 'Синхронизация FedCM cookie', + description: 'Bearer access token → установка HttpOnly cookie lendry_fedcm_sess.', + auth: true + } + ] + }, { tag: 'Профиль и биометрия', endpoints: [ diff --git a/apps/docs/lib/docs-pages.ts b/apps/docs/lib/docs-pages.ts index 7d25079..a739171 100644 --- a/apps/docs/lib/docs-pages.ts +++ b/apps/docs/lib/docs-pages.ts @@ -5,6 +5,7 @@ export type DocBlock = | { type: 'callout'; variant: 'info' | 'warning' | 'tip'; title: string; text: string } | { type: 'table'; headers: string[]; rows: string[][] } | { type: 'oauth-examples' } + | { type: 'one-tap-examples' } | { type: 'auth-login-examples' } | { type: 'auth-ldap-examples' } | { type: 'bot-examples' } @@ -956,6 +957,23 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт title: 'Примеры интеграции', blocks: [{ type: 'oauth-examples' }] }, + { + id: 'one-tap', + title: 'One Tap Login', + blocks: [ + { + type: 'paragraph', + text: 'Для входа в один клик на сторонних сайтах (FedCM + виджет sso-widget.js) см. отдельный раздел документации.' + }, + { + type: 'list', + items: [ + 'One Tap Login — FedCM endpoints, подключение sso-widget.js, popup fallback', + 'Требуется зарегистрированный OAuth client_id и redirect_uri вашего сайта' + ] + } + ] + }, { id: 'scopes', title: 'Scopes', @@ -974,6 +992,201 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт } ] }, + { + slug: 'one-tap-login', + title: 'One Tap Login', + description: 'Вход в один клик для сайтов-клиентов: FedCM (основной) и JS SDK с popup (fallback).', + sections: [ + { + id: 'overview', + title: 'Обзор', + blocks: [ + { + type: 'paragraph', + text: 'One Tap Login позволяет пользователям вашего сайта войти через Lendry ID без полного редиректа на страницу авторизации. Поддерживаются два режима: Federated Credential Management (FedCM) в современных браузерах и виджет sso-widget.js с popup-окном для остальных.' + }, + { + type: 'callout', + variant: 'info', + title: 'Предварительные требования', + text: 'Создайте OAuth-приложение в админ-панели (RBAC → OAuth приложения): укажите redirect_uri вашего сайта, scopes openid profile email и сохраните client_id. Подробнее — раздел «OAuth 2.0 / OIDC».' + }, + { + type: 'list', + items: [ + 'FedCM — браузер показывает нативный диалог «Войти как …», если пользователь уже залогинен на домене IdP', + 'Fallback — скрипт sso-widget.js рисует плашку «Войти через …» в правом верхнем углу и открывает popup с OAuth', + 'Оба режима возвращают OIDC id_token (FedCM) или authorization code / токены (popup) — проверяйте на backend' + ] + } + ] + }, + { + id: 'fedcm-endpoints', + title: 'FedCM endpoints', + blocks: [ + { + type: 'paragraph', + text: 'Endpoints размещены на PUBLIC_API_URL (issuer). Локально: http://localhost:3000. При same-origin деплое: https://ваш-домен/idp-api. CORS настроен с credentials: true для запросов с сайтов-клиентов.' + }, + { + type: 'table', + headers: ['Endpoint', 'Метод', 'Описание'], + rows: [ + ['{PUBLIC_API_URL}/.well-known/web-identity', 'GET', 'Манифест FedCM → provider_urls'], + ['{PUBLIC_API_URL}/fedcm/config.json', 'GET', 'Конфигурация IdP (accounts, id_assertion, branding)'], + ['{PUBLIC_API_URL}/fedcm/accounts', 'GET', 'Список аккаунтов по cookie lendry_fedcm_sess'], + ['{PUBLIC_API_URL}/fedcm/id_assertion', 'POST', 'Выдача id_token для client_id + account_id'], + ['{PUBLIC_API_URL}/fedcm/client_metadata', 'GET', '?client_id=… — privacy/terms для UI FedCM'], + ['{PUBLIC_API_URL}/fedcm/session/sync', 'POST', 'Установить FedCM cookie по Bearer access token'] + ] + }, + { + type: 'callout', + variant: 'warning', + title: 'FedCM cookie', + text: 'FedCM использует HttpOnly cookie lendry_fedcm_sess на домене IdP (SameSite=None; Secure в production). Cookie устанавливается при входе на IdP или через POST /fedcm/session/sync. localStorage JWT с сайта клиента для FedCM не подходит.' + } + ] + }, + { + id: 'widget', + title: 'Подключение виджета (sso-widget.js)', + blocks: [ + { + type: 'paragraph', + text: 'Скрипт размещён на frontend IdP по адресу {PUBLIC_FRONTEND_URL}/sso-widget.js. Достаточно одного тега script — инициализация выполняется автоматически при загрузке страницы.' + }, + { + type: 'table', + headers: ['Атрибут data-*', 'Обязательный', 'Описание'], + rows: [ + ['data-client-id', 'Да', 'client_id OAuth-приложения из админки'], + ['data-idp-url', 'Нет', 'PUBLIC_API_URL; по умолчанию origin скрипта + /idp-api'], + ['data-idp-frontend-url', 'Нет', 'URL frontend IdP для popup; по умолчанию origin скрипта'], + ['data-provider-name', 'Нет', 'Название в плашке (по умолчанию MVK ID)'], + ['data-redirect-uri', 'Нет', 'redirect_uri OAuth; по умолчанию origin + /auth/callback'], + ['data-scope', 'Нет', 'Scopes OAuth (по умолчанию openid profile email)'], + ['data-on-success', 'Нет', 'Имя глобальной функции-callback'], + ['data-auto-init', 'Нет', 'false — отключить автозапуск; вызовите LendryIdOneTap.init()'] + ] + }, + { + type: 'callout', + variant: 'tip', + title: 'Логика выбора режима', + text: 'Если браузер поддерживает IdentityCredential — скрипт вызывает FedCM. Если нет — показывается плашка в правом верхнем углу; по клику открывается popup OAuth (параметр display=popup).' + } + ] + }, + { + id: 'fedcm-integration', + title: 'Интеграция FedCM вручную', + blocks: [ + { + type: 'paragraph', + text: 'Если вы не используете sso-widget.js, можно вызвать FedCM API напрямую. configURL должен указывать на /fedcm/config.json провайдера.' + }, + { + type: 'code', + language: 'javascript', + title: 'Минимальный пример', + code: `const credential = await navigator.credentials.get({ + identity: { + providers: [{ + configURL: 'https://id.lendry.ru/idp-api/fedcm/config.json', + clientId: 'YOUR_CLIENT_ID' + }] + }, + mediation: 'optional' +}); + +const idToken = credential?.token; +// Передайте idToken на ваш backend для проверки` + }, + { + type: 'list', + items: [ + 'mediation: "optional" — показать диалог только при наличии сессии IdP', + 'mediation: "required" — всегда показывать UI выбора аккаунта', + 'mediation: "silent" — без UI; вернёт ошибку, если пользователь не залогинен' + ] + } + ] + }, + { + id: 'popup-fallback', + title: 'Popup fallback и postMessage', + blocks: [ + { + type: 'paragraph', + text: 'При клике на виджет открывается popup с OAuth authorize. После успешного входа IdP отправляет результат родительскому окну через window.postMessage с типом lendry-sso-onetap.' + }, + { + type: 'code', + language: 'javascript', + title: 'Обработка на сайте клиента', + code: `window.addEventListener('message', (event) => { + if (event.data?.type !== 'lendry-sso-onetap') return; + // Проверяйте event.origin — домен вашего IdP + const { token, idToken, accessToken, code, method } = event.data; + console.log('Вход через', method); +});` + }, + { + type: 'callout', + variant: 'info', + title: 'redirect_uri', + text: 'redirect_uri должен быть зарегистрирован у OAuth-клиента. Для SPA часто используют https://your-app.com/auth/callback — на этой странице backend обменивает code на токены, либо popup передаёт токен через postMessage.' + } + ] + }, + { + id: 'verify-token', + title: 'Проверка токена на backend', + blocks: [ + { + type: 'paragraph', + text: 'FedCM возвращает id_token (JWT). Проверьте подпись (HS256, секрет JWT_ACCESS_SECRET IdP), issuer = PUBLIC_API_URL и aud = ваш client_id. Альтернатива — обмен authorization code через POST /oauth/token и запрос GET /oauth/userinfo.' + }, + { + type: 'list', + items: [ + 'Не доверяйте id_token только на frontend — всегда валидируйте на сервере', + 'client_secret храните только на backend при обмене code → token', + 'После проверки создайте локальную сессию пользователя (cookie / JWT вашего приложения)' + ] + } + ] + }, + { + id: 'examples', + title: 'Примеры интеграции', + blocks: [{ type: 'one-tap-examples' }] + }, + { + id: 'local-dev', + title: 'Локальная разработка', + blocks: [ + { + type: 'table', + headers: ['Сервис', 'URL'], + rows: [ + ['API (FedCM endpoints)', 'http://localhost:3000'], + ['Frontend (sso-widget.js)', 'http://localhost:3002/sso-widget.js'], + ['Документация', 'http://localhost:3003/docs/one-tap-login'] + ] + }, + { + type: 'callout', + variant: 'tip', + title: 'Тест FedCM локально', + text: 'FedCM cookie в dev использует SameSite=Lax (без Secure). Войдите на http://localhost:3002, затем откройте тестовую страницу клиента с подключённым виджетом. Chrome может требовать флаг chrome://flags/#identity-credentials-api или HTTPS для FedCM — в этом случае проверяйте popup fallback.' + } + ] + } + ] + }, { slug: 'ldap', title: 'LDAP / LDAPS', diff --git a/apps/docs/lib/navigation.ts b/apps/docs/lib/navigation.ts index 48e2de2..10e0e8d 100644 --- a/apps/docs/lib/navigation.ts +++ b/apps/docs/lib/navigation.ts @@ -10,6 +10,7 @@ export const docNavigation: DocNavItem[] = [ { slug: 'deployment', title: 'Развёртывание на сервере', group: 'Введение' }, { slug: 'authentication', title: 'Аутентификация', group: 'Интеграция' }, { slug: 'oauth', title: 'OAuth 2.0 / OIDC', group: 'Интеграция' }, + { slug: 'one-tap-login', title: 'One Tap Login', group: 'Интеграция' }, { slug: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' }, { slug: 'sessions', title: 'Сессии, PIN и удаление аккаунта', group: 'Безопасность' }, { slug: 'family-chat', title: 'Семья и чат', group: 'Функции' }, diff --git a/apps/docs/lib/oauth-url.ts b/apps/docs/lib/oauth-url.ts index eebd389..5e6f425 100644 --- a/apps/docs/lib/oauth-url.ts +++ b/apps/docs/lib/oauth-url.ts @@ -19,6 +19,23 @@ export function resolveOAuthApiBase(settings: Record, fallback = return normalizeBaseUrl(fallback); } +export function resolveFrontendBase(settings: Record, fallback = 'http://localhost:3002') { + const publicFrontend = settings.PUBLIC_FRONTEND_URL?.trim(); + if (publicFrontend) { + return normalizeBaseUrl(publicFrontend); + } + + const domain = settings.PROJECT_DOMAIN?.trim(); + if (domain) { + if (domain.startsWith('http://') || domain.startsWith('https://')) { + return normalizeBaseUrl(domain); + } + return `https://${domain.replace(/^\/+/, '')}`; + } + + return normalizeBaseUrl(fallback); +} + export interface OAuthEndpoints { issuer: string; authorizationEndpoint: string; @@ -26,6 +43,10 @@ export interface OAuthEndpoints { userInfoEndpoint: string; openIdConfigurationUrl: string; jwksUrl: string; + webIdentityUrl: string; + fedcmConfigUrl: string; + fedcmAccountsUrl: string; + fedcmIdAssertionUrl: string; } export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints { @@ -36,7 +57,11 @@ export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints { tokenEndpoint: `${base}/oauth/token`, userInfoEndpoint: `${base}/oauth/userinfo`, openIdConfigurationUrl: `${base}/.well-known/openid-configuration`, - jwksUrl: `${base}/.well-known/jwks.json` + jwksUrl: `${base}/.well-known/jwks.json`, + webIdentityUrl: `${base}/.well-known/web-identity`, + fedcmConfigUrl: `${base}/fedcm/config.json`, + fedcmAccountsUrl: `${base}/fedcm/accounts`, + fedcmIdAssertionUrl: `${base}/fedcm/id_assertion` }; } diff --git a/apps/docs/lib/one-tap-examples.ts b/apps/docs/lib/one-tap-examples.ts new file mode 100644 index 0000000..1a4812f --- /dev/null +++ b/apps/docs/lib/one-tap-examples.ts @@ -0,0 +1,163 @@ +import type { OAuthExample } from '@/lib/oauth-examples'; + +export interface OneTapUrls { + apiBase: string; + frontendBase: string; + widgetUrl: string; + fedcmConfigUrl: string; + webIdentityUrl: string; + projectName: string; +} + +export function buildOneTapUrls( + apiBase: string, + frontendBase: string, + projectName = 'MVK ID' +): OneTapUrls { + const base = apiBase.replace(/\/+$/, ''); + const front = frontendBase.replace(/\/+$/, ''); + return { + apiBase: base, + frontendBase: front, + widgetUrl: `${front}/sso-widget.js`, + fedcmConfigUrl: `${base}/fedcm/config.json`, + webIdentityUrl: `${base}/.well-known/web-identity`, + projectName + }; +} + +export function buildOneTapExamples(urls: OneTapUrls, clientIdPlaceholder = 'YOUR_CLIENT_ID'): OAuthExample[] { + const { apiBase, frontendBase, widgetUrl, fedcmConfigUrl, projectName } = urls; + const redirectUri = 'https://app.example.com/auth/callback'; + + return [ + { + id: 'widget-script', + label: 'Виджет (script tag)', + language: 'html', + code: ` + + +` + }, + { + id: 'fedcm-native', + label: 'FedCM (нативный API)', + language: 'javascript', + code: `// Работает в Chrome/Edge при поддержке IdentityCredential. +// Пользователь должен быть залогинен на ${frontendBase} (FedCM cookie). + +async function loginWithFedCM() { + if (!('IdentityCredential' in window)) { + throw new Error('FedCM не поддерживается в этом браузере'); + } + + const credential = await navigator.credentials.get({ + identity: { + providers: [{ + configURL: '${fedcmConfigUrl}', + clientId: '${clientIdPlaceholder}' + }] + }, + mediation: 'optional' + }); + + if (!credential?.token) { + throw new Error('Пользователь отменил вход или сессия IdP отсутствует'); + } + + return credential.token; // OIDC id_token +} + +loginWithFedCM() + .then((idToken) => fetch('/api/auth/lendry', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ idToken }) + })) + .catch(console.error);` + }, + { + id: 'sdk-manual', + label: 'SDK — ручной вызов', + language: 'javascript', + code: ` +` + }, + { + id: 'verify-backend', + label: 'Проверка токена на backend', + language: 'javascript', + code: `// Node.js — после получения id_token от FedCM или popup +import jwt from 'jsonwebtoken'; + +const ISSUER = '${apiBase}'; +const CLIENT_ID = '${clientIdPlaceholder}'; + +function verifyIdToken(idToken) { + const payload = jwt.verify(idToken, process.env.IDP_JWT_SECRET, { + issuer: ISSUER, + audience: CLIENT_ID + }); + return payload; // { sub, email, name, ... } +} + +// Альтернатива: userinfo по access_token +async function fetchProfile(accessToken) { + const res = await fetch('${apiBase}/oauth/userinfo', { + headers: { Authorization: \`Bearer \${accessToken}\` } + }); + if (!res.ok) throw new Error('userinfo failed'); + return res.json(); +}` + }, + { + id: 'curl-fedcm', + label: 'FedCM endpoints (curl)', + language: 'bash', + code: `# Манифест FedCM +curl -s ${apiBase}/.well-known/web-identity | jq + +# Конфигурация провайдера +curl -s ${apiBase}/fedcm/config.json | jq + +# Список аккаунтов (нужна cookie lendry_fedcm_sess после входа на IdP) +curl -s ${apiBase}/fedcm/accounts \\ + -H "Cookie: lendry_fedcm_sess=..." \\ + --include + +# Синхронизация FedCM cookie для уже залогиненного пользователя IdP +curl -s -X POST ${apiBase}/fedcm/session/sync \\ + -H "Authorization: Bearer ACCESS_TOKEN"` + } + ]; +} diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index 5171bdb..767f13e 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -17,6 +17,10 @@ const nextConfig: NextConfig = { source: '/oauth/:path*', destination: `${internalApiUrl}/oauth/:path*` }, + { + source: '/fedcm/:path*', + destination: `${internalApiUrl}/fedcm/:path*` + }, { source: '/.well-known/:path*', destination: `${internalApiUrl}/.well-known/:path*` diff --git a/apps/frontend/app/admin/bots/page.tsx b/apps/frontend/app/admin/bots/page.tsx index e8c96ae..37f7d9a 100644 --- a/apps/frontend/app/admin/bots/page.tsx +++ b/apps/frontend/app/admin/bots/page.tsx @@ -8,7 +8,7 @@ import { useAuth } from '@/components/id/auth-provider'; import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { AdminBotMetrics, AdminUser, @@ -107,7 +107,7 @@ export default function AdminBotsPage() {

Найдено: {total}

-
+ @@ -191,7 +191,7 @@ export default function AdminBotsPage() { )}
-
+ {totalPages > 1 ? (
@@ -218,7 +218,7 @@ export default function AdminBotsPage() {

Пользователи IdP, связанные с Telegram-ботами (BotFather и боты пользователей). Роль: «Бот».

-
+ @@ -270,7 +270,7 @@ export default function AdminBotsPage() { )}
-
+
); diff --git a/apps/frontend/app/admin/users/page.tsx b/apps/frontend/app/admin/users/page.tsx index 85046c5..a355a56 100644 --- a/apps/frontend/app/admin/users/page.tsx +++ b/apps/frontend/app/admin/users/page.tsx @@ -11,7 +11,7 @@ import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { AdminPermission, AdminRole, AdminUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus } from '@/lib/api'; import { getAdminLandingPath } from '@/lib/admin-access'; import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons'; @@ -324,7 +324,7 @@ export default function AdminUsersPage() { -
+ {loading ? (
@@ -446,7 +446,7 @@ export default function AdminUsersPage() { )} -
+
!open && setDialog(null)}> diff --git a/apps/frontend/components/chat/chat-image-lightbox.tsx b/apps/frontend/components/chat/chat-image-lightbox.tsx index 80ed37a..ca10664 100644 --- a/apps/frontend/components/chat/chat-image-lightbox.tsx +++ b/apps/frontend/components/chat/chat-image-lightbox.tsx @@ -10,7 +10,7 @@ interface ChatImageLightboxProps { open: boolean; images: ChatMessage[]; index: number; - mediaUrls: Record; + mediaUrls: Record; onClose: () => void; onIndexChange: (index: number) => void; onDownload?: (message: ChatMessage) => void; diff --git a/apps/frontend/components/chat/chat-media-album-grid.tsx b/apps/frontend/components/chat/chat-media-album-grid.tsx index f9fc24c..4f7aeba 100644 --- a/apps/frontend/components/chat/chat-media-album-grid.tsx +++ b/apps/frontend/components/chat/chat-media-album-grid.tsx @@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'; interface ChatMediaAlbumGridProps { messages: ChatMessage[]; - mediaUrls: Record; + mediaUrls: Record; className?: string; onImageClick?: (message: ChatMessage, index: number) => void; } diff --git a/apps/frontend/components/chat/chat-media-compose-dialog.tsx b/apps/frontend/components/chat/chat-media-compose-dialog.tsx index 44fb7b8..ed4b13f 100644 --- a/apps/frontend/components/chat/chat-media-compose-dialog.tsx +++ b/apps/frontend/components/chat/chat-media-compose-dialog.tsx @@ -275,7 +275,7 @@ export function ChatMediaComposeDialog({ type="file" className="hidden" multiple - accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt" + accept="*/*" onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length) onAddFiles(files); diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index 4e7ab5b..921b2df 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -152,10 +152,12 @@ interface PresignedUploadResponse { } interface LoadedChatMedia { - blobUrl: string; - expiresAt: number; + blobUrl?: string; + expiresAt?: number; fileName?: string; mimeType?: string; + status: 'loading' | 'ready' | 'error'; + errorMessage?: string; } function formatMessageTime(value: string) { @@ -318,6 +320,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const mediaRecorderRef = useRef(null); const recordingStartedAtRef = useRef(null); const mediaUrlsRef = useRef>({}); + const mediaLoadInFlightRef = useRef>(new Set()); const fileInputRef = useRef(null); const dragDepthRef = useRef(0); @@ -678,7 +681,37 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { if (!token || !activeRoomId || !message.storageKey) return; const storageKey = message.storageKey; const existing = mediaUrlsRef.current[storageKey]; - if (!force && existing && !shouldRefreshMedia(existing.expiresAt)) return; + if (!force && existing?.status === 'ready' && existing.blobUrl && existing.expiresAt && !shouldRefreshMedia(existing.expiresAt)) { + return; + } + if (mediaLoadInFlightRef.current.has(storageKey) && !force) { + return; + } + + if (message.isEncrypted && activeRoomIsE2E) { + if (peerKeyLoading) return; + if (!peerE2eKey || !user?.id || !message.content) { + setMediaUrls((current) => ({ + ...current, + [storageKey]: { + ...current[storageKey], + status: 'error', + errorMessage: 'Не удалось расшифровать файл' + } + })); + return; + } + } + + mediaLoadInFlightRef.current.add(storageKey); + setMediaUrls((current) => ({ + ...current, + [storageKey]: { + ...current[storageKey], + status: 'loading', + errorMessage: undefined + } + })); let meta = parseMessageMetadata(message.metadataJson); let fileName = meta.fileName || message.content || undefined; @@ -709,23 +742,50 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { blobUrl, expiresAt: mediaExpiresAtMs(access.expiresAt), fileName, - mimeType + mimeType, + status: 'ready' } }; }); - } catch { - // ignore failed media loads in background + } catch (error) { + setMediaUrls((current) => ({ + ...current, + [storageKey]: { + ...current[storageKey], + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Не удалось загрузить файл' + } + })); + } finally { + mediaLoadInFlightRef.current.delete(storageKey); } }, - [activeRoomId, activeRoomIsE2E, peerE2eKey, token, user?.id] + [activeRoomId, activeRoomIsE2E, peerE2eKey, peerKeyLoading, token, user?.id] ); + const cacheUploadedMedia = useCallback((storageKey: string, file: File) => { + const blobUrl = createBlobObjectUrl(file); + setMediaUrls((current) => { + revokeBlobObjectUrl(current[storageKey]?.blobUrl); + return { + ...current, + [storageKey]: { + blobUrl, + expiresAt: Date.now() + 30 * 60_000, + fileName: file.name, + mimeType: file.type || resolveChatContentType(file), + status: 'ready' + } + }; + }); + }, []); + useEffect(() => { if (!token || !activeRoomId) return; messages.forEach((message) => { if (message.storageKey) void loadChatMedia(message); }); - }, [activeRoomId, loadChatMedia, messages, token]); + }, [activeRoomId, loadChatMedia, messages, peerE2eKey, peerKeyLoading, token]); useEffect(() => { if (!token || !activeRoomId) return; @@ -733,7 +793,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { messages.forEach((message) => { if (!message.storageKey) return; const cached = mediaUrlsRef.current[message.storageKey]; - if (cached && shouldRefreshMedia(cached.expiresAt)) { + if (cached?.status === 'ready' && cached.expiresAt && shouldRefreshMedia(cached.expiresAt)) { void loadChatMedia(message, true); } }); @@ -1323,6 +1383,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { token ); appendMessage(message); + cacheUploadedMedia(presigned.storageKey, file); void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка'); @@ -1366,7 +1427,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { peerE2eKey, encryptOutgoing }); - uploaded.forEach((message) => appendMessage(message)); + uploaded.forEach((message, index) => { + appendMessage(message); + const sourceFile = mediaComposer.items[index]?.file; + if (message.storageKey && sourceFile) { + cacheUploadedMedia(message.storageKey, sourceFile); + } + }); mediaComposer.close(); void loadGroup(); } catch (error) { @@ -1972,7 +2039,21 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const media = mediaUrls[message.storageKey]; const meta = parseMessageMetadata(message.metadataJson); const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл'; - if (!media?.blobUrl) { + if (media?.status === 'error') { + return ( +
+

{media.errorMessage ?? 'Не удалось загрузить файл'}

+ +
+ ); + } + if (!media?.blobUrl || media.status === 'loading') { return (
@@ -2275,7 +2356,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { type="file" className="hidden" multiple - accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt" + accept="*/*" onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length) queueMediaFiles(files); diff --git a/apps/frontend/components/family/mini-family-chat.tsx b/apps/frontend/components/family/mini-family-chat.tsx index d6b266f..8e9df0d 100644 --- a/apps/frontend/components/family/mini-family-chat.tsx +++ b/apps/frontend/components/family/mini-family-chat.tsx @@ -1386,7 +1386,7 @@ export function MiniFamilyChat() { ref={fileInputRef} type="file" multiple - accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip,.rar,.7z" + accept="*/*" className="hidden" onChange={(event) => { const files = Array.from(event.target.files ?? []); diff --git a/apps/frontend/components/id/shell.tsx b/apps/frontend/components/id/shell.tsx index 0bc037c..bde9d85 100644 --- a/apps/frontend/components/id/shell.tsx +++ b/apps/frontend/components/id/shell.tsx @@ -11,6 +11,8 @@ import { BrandLogo } from '@/components/id/brand-logo'; import Link from 'next/link'; import { cn } from '@/lib/utils'; +const MOBILE_HEADER_HEIGHT = 'calc(3.5rem + env(safe-area-inset-top))'; + export function IdShell({ active, children, @@ -25,10 +27,13 @@ export function IdShell({ return ( -
+
{!fullBleed ? ( -
+
@@ -44,9 +49,10 @@ export function IdShell({
) { - return ; + return
; +} + +export function TableContainer({ className, children, ...props }: React.HTMLAttributes) { + return ( +
+ {children} +
+ ); } export function TableHeader({ className, ...props }: React.HTMLAttributes) { diff --git a/apps/frontend/lib/chat-media.ts b/apps/frontend/lib/chat-media.ts index af44638..be68857 100644 --- a/apps/frontend/lib/chat-media.ts +++ b/apps/frontend/lib/chat-media.ts @@ -31,7 +31,7 @@ const EXTENSION_TO_MIME: Record = { rar: 'application/vnd.rar', '7z': 'application/x-7z-compressed', json: 'application/json', - xml: 'application/xml' + apk: 'application/vnd.android.package-archive' }; const MIME_ALIASES: Record = { @@ -110,6 +110,7 @@ export function fileIconLabel(fileName?: string, mimeType?: string) { if (['xls', 'xlsx', 'csv'].includes(extension)) return 'XLS'; if (['ppt', 'pptx'].includes(extension)) return 'PPT'; if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP'; + if (extension === 'apk') return 'APK'; if (extension) return extension.toUpperCase().slice(0, 4); return 'FILE'; } diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index b7eeda9..6024e54 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -24,6 +24,10 @@ const nextConfig: NextConfig = { source: '/oauth/:path*', destination: `${internalApiUrl}/oauth/:path*` }, + { + source: '/fedcm/:path*', + destination: `${internalApiUrl}/fedcm/:path*` + }, { source: '/.well-known/:path*', destination: `${internalApiUrl}/.well-known/:path*` diff --git a/apps/frontend/public/sso-widget.js b/apps/frontend/public/sso-widget.js new file mode 100644 index 0000000..79ee6df --- /dev/null +++ b/apps/frontend/public/sso-widget.js @@ -0,0 +1,274 @@ +(function lendrySsoWidget(global) { + 'use strict'; + + var MESSAGE_TYPE = 'lendry-sso-onetap'; + var DEFAULT_SCOPE = 'openid profile email'; + var widgetRootId = 'lendry-sso-onetap-root'; + + function trimSlash(value) { + return String(value || '').replace(/\/+$/, ''); + } + + function resolveScriptElement() { + var current = global.document.currentScript; + if (current) return current; + var scripts = global.document.getElementsByTagName('script'); + for (var i = scripts.length - 1; i >= 0; i -= 1) { + var src = scripts[i].getAttribute('src') || ''; + if (src.indexOf('sso-widget.js') !== -1) { + return scripts[i]; + } + } + return null; + } + + function resolveIdpApiBase(script) { + var override = script && script.getAttribute('data-idp-url'); + if (override) return trimSlash(override); + try { + var origin = new URL(script.src).origin; + return trimSlash(origin + '/idp-api'); + } catch (_error) { + return ''; + } + } + + function resolveFrontendBase(script, idpApiBase) { + var override = script && script.getAttribute('data-idp-frontend-url'); + if (override) return trimSlash(override); + try { + return new URL(script.src).origin; + } catch (_error) { + if (idpApiBase.indexOf('/idp-api') !== -1) { + return idpApiBase.replace(/\/idp-api$/, ''); + } + return idpApiBase; + } + } + + function resolveProviderName(script) { + return (script && script.getAttribute('data-provider-name')) || 'MVK ID'; + } + + function dispatchSuccess(payload, script) { + var callbackName = script && script.getAttribute('data-on-success'); + if (callbackName && typeof global[callbackName] === 'function') { + global[callbackName](payload); + } + try { + global.dispatchEvent(new CustomEvent('lendry-sso-onetap-success', { detail: payload })); + } catch (_error) { + // IE fallback not required + } + } + + function supportsFedCM() { + return 'IdentityCredential' in global && global.navigator && typeof global.navigator.credentials !== 'undefined'; + } + + function loginWithFedCM(idpApiBase, clientId) { + if (!supportsFedCM()) { + return Promise.resolve(null); + } + + return global.navigator.credentials + .get({ + identity: { + providers: [ + { + configURL: idpApiBase + '/fedcm/config.json', + clientId: clientId + } + ] + }, + mediation: 'optional' + }) + .then(function (credential) { + if (!credential || typeof credential !== 'object') return null; + var token = credential.token; + if (!token) return null; + return { + token: token, + method: 'fedcm' + }; + }) + .catch(function (error) { + if (global.console && typeof global.console.warn === 'function') { + global.console.warn('[MVK ID] FedCM недоступен:', error); + } + return null; + }); + } + + function injectWidgetStyles() { + if (global.document.getElementById('lendry-sso-onetap-style')) return; + var style = global.document.createElement('style'); + style.id = 'lendry-sso-onetap-style'; + style.textContent = + '#' + + widgetRootId + + '{position:fixed;top:16px;right:16px;z-index:2147483000;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}' + + '.lendry-sso-onetap-btn{display:flex;align-items:center;gap:10px;padding:10px 16px;border-radius:999px;border:1px solid #e4e8ef;background:#fff;color:#1f2430;font-size:14px;font-weight:600;line-height:1;cursor:pointer;box-shadow:0 8px 24px rgba(31,36,48,.12);transition:transform .18s ease,box-shadow .18s ease}' + + '.lendry-sso-onetap-btn:hover{transform:translateY(-1px);box-shadow:0 12px 28px rgba(31,36,48,.16)}' + + '.lendry-sso-onetap-btn:active{transform:translateY(0)}' + + '.lendry-sso-onetap-badge{display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;padding:0 8px;border-radius:999px;background:#111;color:#fff;font-size:12px;font-weight:700}'; + global.document.head.appendChild(style); + } + + function createWidget(providerName, onClick) { + injectWidgetStyles(); + if (global.document.getElementById(widgetRootId)) return; + + var root = global.document.createElement('div'); + root.id = widgetRootId; + + var button = global.document.createElement('button'); + button.type = 'button'; + button.className = 'lendry-sso-onetap-btn'; + button.setAttribute('aria-label', 'Войти через ' + providerName); + + var badge = global.document.createElement('span'); + badge.className = 'lendry-sso-onetap-badge'; + badge.textContent = 'ID'; + + var label = global.document.createElement('span'); + label.textContent = 'Войти через ' + providerName; + + button.appendChild(badge); + button.appendChild(label); + button.addEventListener('click', onClick); + + root.appendChild(button); + global.document.body.appendChild(root); + } + + function buildPopupUrl(frontendBase, clientId, origin, redirectUri, scope) { + var params = new URLSearchParams(); + params.set('client_id', clientId); + params.set('redirect_uri', redirectUri); + params.set('response_type', 'code'); + params.set('scope', scope || DEFAULT_SCOPE); + params.set('display', 'popup'); + params.set('popup_origin', origin); + params.set('state', 'onetap_' + Math.random().toString(36).slice(2)); + return frontendBase + '/auth/oauth/authorize?' + params.toString(); + } + + function openPopup(url, expectedOrigin, onToken) { + var width = 480; + var height = 640; + var left = Math.max(0, Math.round((global.screen.width - width) / 2)); + var top = Math.max(0, Math.round((global.screen.height - height) / 2)); + var features = + 'popup=yes,width=' + + width + + ',height=' + + height + + ',left=' + + left + + ',top=' + + top + + ',noopener,noreferrer'; + + var popup = global.open(url, 'lendry_sso_onetap', features); + if (!popup) { + if (global.console && typeof global.console.error === 'function') { + global.console.error('[MVK ID] Не удалось открыть popup. Разрешите всплывающие окна.'); + } + return function cleanup() {}; + } + + function handleMessage(event) { + if (!event || !event.data || event.data.type !== MESSAGE_TYPE) return; + if (expectedOrigin && event.origin !== expectedOrigin && event.origin !== trimSlash(expectedOrigin)) return; + + cleanup(); + onToken(event.data); + } + + function cleanup() { + global.removeEventListener('message', handleMessage); + if (popup && !popup.closed) { + try { + popup.close(); + } catch (_error) { + // ignore + } + } + } + + global.addEventListener('message', handleMessage); + return cleanup; + } + + function initOneTap(options) { + var script = resolveScriptElement(); + var clientId = (options && options.clientId) || (script && script.getAttribute('data-client-id')); + if (!clientId) { + if (global.console && typeof global.console.error === 'function') { + global.console.error('[MVK ID] Укажите data-client-id в теге script или clientId в LendryIdOneTap.init().'); + } + return; + } + + var idpApiBase = trimSlash((options && options.idpUrl) || resolveIdpApiBase(script)); + var frontendBase = trimSlash((options && options.frontendUrl) || resolveFrontendBase(script, idpApiBase)); + var providerName = (options && options.providerName) || resolveProviderName(script); + var scope = (options && options.scope) || (script && script.getAttribute('data-scope')) || DEFAULT_SCOPE; + var redirectUri = + (options && options.redirectUri) || + (script && script.getAttribute('data-redirect-uri')) || + global.location.origin + '/auth/callback'; + + loginWithFedCM(idpApiBase, clientId).then(function (result) { + if (result && result.token) { + dispatchSuccess( + { + token: result.token, + method: 'fedcm' + }, + script + ); + return; + } + + if (supportsFedCM()) { + return; + } + + createWidget(providerName, function () { + var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope); + openPopup(popupUrl, frontendBase, function (payload) { + dispatchSuccess( + { + token: payload.token, + accessToken: payload.accessToken, + idToken: payload.idToken, + refreshToken: payload.refreshToken, + code: payload.code, + method: 'popup' + }, + script + ); + }); + }); + }); + } + + global.LendryIdOneTap = { + init: initOneTap, + loginWithFedCM: loginWithFedCM, + MESSAGE_TYPE: MESSAGE_TYPE + }; + + var scriptEl = resolveScriptElement(); + if (scriptEl && scriptEl.getAttribute('data-auto-init') !== 'false') { + if (global.document.readyState === 'loading') { + global.document.addEventListener('DOMContentLoaded', function () { + initOneTap(); + }); + } else { + initOneTap(); + } + } +})(window); diff --git a/apps/sso-core/src/app.module.ts b/apps/sso-core/src/app.module.ts index ff2ad25..ae258b6 100644 --- a/apps/sso-core/src/app.module.ts +++ b/apps/sso-core/src/app.module.ts @@ -20,6 +20,7 @@ import { ProfileService } from './domain/profile.service'; import { DocumentsService } from './domain/documents.service'; import { AddressesService } from './domain/addresses.service'; import { OAuthCoreService } from './domain/oauth-core.service'; +import { FedcmService } from './domain/fedcm.service'; import { OAuthIssuerService } from './domain/oauth-issuer.service'; import { OtpService } from './domain/otp.service'; import { AdvancedAuthService } from './domain/advanced-auth.service'; @@ -74,6 +75,7 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser DocumentsService, AddressesService, OAuthCoreService, + FedcmService, OAuthIssuerService, OtpService, AdvancedAuthService, diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index fd0f0b4..2251314 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -14,6 +14,7 @@ import { ProfileService } from './profile.service'; import { DocumentsService } from './documents.service'; import { AddressesService } from './addresses.service'; import { OAuthCoreService } from './oauth-core.service'; +import { FedcmService } from './fedcm.service'; import { OtpService } from './otp.service'; import { AdvancedAuthService } from './advanced-auth.service'; import { FamilyService } from './family.service'; @@ -42,6 +43,7 @@ export class AuthGrpcController { private readonly documents: DocumentsService, private readonly addresses: AddressesService, private readonly oauthCore: OAuthCoreService, + private readonly fedcm: FedcmService, private readonly otp: OtpService, private readonly advancedAuth: AdvancedAuthService, private readonly family: FamilyService, @@ -831,6 +833,21 @@ export class AuthGrpcController { return this.oauthCore.getClientPublicInfo(command.clientId); } + @GrpcMethod('FedcmService', 'GetAccounts') + fedcmGetAccounts(command: { userId: string; sessionId: string }) { + return this.fedcm.getAccounts(command.userId, command.sessionId); + } + + @GrpcMethod('FedcmService', 'IssueIdAssertion') + fedcmIssueIdAssertion(command: { userId: string; sessionId: string; clientId: string; accountId: string }) { + return this.fedcm.issueIdAssertion(command.userId, command.sessionId, command.clientId, command.accountId); + } + + @GrpcMethod('FedcmService', 'GetClientMetadata') + fedcmGetClientMetadata(command: { clientId: string }) { + return this.fedcm.getClientMetadata(command.clientId); + } + @GrpcMethod('OtpService', 'SendOtp') sendOtp(command: { target: string; channel: string; purpose: string; userId?: string }) { return this.otp.send(command); diff --git a/apps/sso-core/src/domain/fedcm.service.ts b/apps/sso-core/src/domain/fedcm.service.ts new file mode 100644 index 0000000..68c2dd0 --- /dev/null +++ b/apps/sso-core/src/domain/fedcm.service.ts @@ -0,0 +1,99 @@ +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { PrismaService } from '../infra/prisma.service'; +import { OAuthCoreService } from './oauth-core.service'; +import { SessionService } from './session.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 + ) {} + + async getAccounts(userId: string, sessionId: string) { + await this.assertUnlockedSession(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; + + return { + accounts: [ + { + id: user.id, + name: user.displayName, + given_name: givenName, + email: user.email ?? undefined, + picture: user.avatarUrl ?? undefined, + 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-приложение не найдено или отключено'); + } + + return { + privacy_policy_url: 'https://id.lendry.ru/data', + terms_of_service_url: 'https://id.lendry.ru/data' + }; + } + + private async assertUnlockedSession(userId: string, sessionId: string) { + const state = await this.session.resolveSessionState(sessionId); + if (!state || state.userId !== userId) { + throw new UnauthorizedException('Сессия недействительна или истекла'); + } + if (state.requiresPin) { + throw new UnauthorizedException('Требуется подтверждение PIN-кода'); + } + } +} diff --git a/apps/sso-core/src/domain/oauth-core.service.ts b/apps/sso-core/src/domain/oauth-core.service.ts index f8b019e..a2dc7f5 100644 --- a/apps/sso-core/src/domain/oauth-core.service.ts +++ b/apps/sso-core/src/domain/oauth-core.service.ts @@ -316,6 +316,10 @@ export class OAuthCoreService { return { clientId: client.clientId, name: client.name }; } + async issueFedcmTokens(userId: string, clientId: string, scopes: string[]) { + return this.issueOAuthTokens(userId, clientId, scopes); + } + private parseScopeSlugs(scope: string) { return [...new Set(scope.split(/\s+/).map((item) => item.trim()).filter(Boolean))]; } diff --git a/apps/sso-core/src/infra/chat-media.util.ts b/apps/sso-core/src/infra/chat-media.util.ts index cf3b3cf..abd333c 100644 --- a/apps/sso-core/src/infra/chat-media.util.ts +++ b/apps/sso-core/src/infra/chat-media.util.ts @@ -5,8 +5,7 @@ const BLOCKED_CHAT_MEDIA_TYPES = new Set([ 'text/html', 'application/xhtml+xml', 'application/x-httpd-php', - 'application/x-sh', - 'application/x-msdownload' + 'application/x-sh' ]); const EXTENSION_TO_MIME: Record = { @@ -40,7 +39,8 @@ const EXTENSION_TO_MIME: Record = { rar: 'application/vnd.rar', '7z': 'application/x-7z-compressed', json: 'application/json', - xml: 'application/xml' + xml: 'application/xml', + apk: 'application/vnd.android.package-archive' }; const MIME_ALIASES: Record = { @@ -53,7 +53,8 @@ const MIME_ALIASES: Record = { 'image/jpg': 'image/jpeg', 'image/pjpeg': 'image/jpeg', 'application/x-pdf': 'application/pdf', - 'application/x-zip-compressed': 'application/zip' + 'application/x-zip-compressed': 'application/zip', + 'application/vnd.android.package-archive': 'application/vnd.android.package-archive' }; export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'FILE'; @@ -75,38 +76,29 @@ export function normalizeChatContentType(contentType?: string | null, fileName?: return aliased || 'application/octet-stream'; } +function isBlockedDownloadType(normalized: string, fileName?: string | null) { + if (!BLOCKED_CHAT_MEDIA_TYPES.has(normalized)) { + return false; + } + const extension = extractFileExtension(fileName); + if (normalized === 'application/x-msdownload' && extension === 'apk') { + return false; + } + return true; +} + export function assertChatMediaContentType(contentType?: string | null, fileName?: string | null) { const normalized = normalizeChatContentType(contentType, fileName); - if (BLOCKED_CHAT_MEDIA_TYPES.has(normalized)) { + if (isBlockedDownloadType(normalized, fileName)) { throw new Error('Этот тип файла нельзя отправить в чат'); } const [category] = normalized.split('/'); - if (category === 'image' || category === 'audio' || category === 'video') { + if (category === 'image' || category === 'audio' || category === 'video' || category === 'text' || category === 'application') { return normalized; } - const allowedApplicationTypes = new Set([ - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.ms-powerpoint', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip', - 'application/vnd.rar', - 'application/x-7z-compressed', - 'application/json', - 'application/xml', - 'application/octet-stream' - ]); - - if (category === 'text' || allowedApplicationTypes.has(normalized)) { - return normalized; - } - - throw new Error('Неподдерживаемый тип медиафайла для чата'); + return 'application/octet-stream'; } export function extensionForChatContentType(contentType: string, fileName?: string | null) { @@ -156,6 +148,8 @@ export function extensionForChatContentType(contentType: string, fileName?: stri return 'ppt'; case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': return 'pptx'; + case 'application/vnd.android.package-archive': + return 'apk'; case 'text/plain': return 'txt'; case 'text/csv': diff --git a/install.sh b/install.sh index 09a7e72..d037743 100644 --- a/install.sh +++ b/install.sh @@ -1816,6 +1816,12 @@ ${nginx_proxy_headers} ${nginx_proxy_headers} } + location /fedcm/ { + proxy_pass ${api_upstream}/fedcm/; + proxy_http_version 1.1; +${nginx_proxy_headers} + } + EOF } diff --git a/package-lock.json b/package-lock.json index b58ef33..690f8ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,11 +35,13 @@ "@nestjs/swagger": "^11.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "cookie-parser": "^1.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, "devDependencies": { "@nestjs/cli": "^11.0.14", + "@types/cookie-parser": "^1.4.10", "@types/node": "^24.10.1", "typescript": "^5.9.3" } @@ -7276,6 +7278,39 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -7305,6 +7340,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -7312,6 +7374,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7375,6 +7445,22 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -7395,6 +7481,29 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -8561,6 +8670,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", diff --git a/shared/proto/identity.proto b/shared/proto/identity.proto index bc9c5e3..af21d59 100644 --- a/shared/proto/identity.proto +++ b/shared/proto/identity.proto @@ -13,6 +13,12 @@ service OAuthCoreService { rpc GetOAuthClientPublicInfo (GetOAuthClientPublicInfoRequest) returns (OAuthClientPublicInfo); } +service FedcmService { + rpc GetAccounts (FedcmSessionRequest) returns (FedcmAccountsResponse); + rpc IssueIdAssertion (FedcmIdAssertionRequest) returns (FedcmIdAssertionResponse); + rpc GetClientMetadata (FedcmClientMetadataRequest) returns (FedcmClientMetadataResponse); +} + service OtpService { rpc SendOtp (SendOtpRequest) returns (OtpResponse); rpc VerifyOtp (VerifyOtpRequest) returns (OtpVerifyResponse); @@ -107,6 +113,44 @@ message GetOAuthClientPublicInfoRequest { string clientId = 1; } +message FedcmSessionRequest { + string userId = 1; + string sessionId = 2; +} + +message FedcmAccount { + string id = 1; + string name = 2; + optional string given_name = 3; + optional string email = 4; + optional string picture = 5; + repeated string approved_clients = 6; +} + +message FedcmAccountsResponse { + repeated FedcmAccount accounts = 1; +} + +message FedcmIdAssertionRequest { + string userId = 1; + string sessionId = 2; + string clientId = 3; + string accountId = 4; +} + +message FedcmIdAssertionResponse { + string token = 1; +} + +message FedcmClientMetadataRequest { + string clientId = 1; +} + +message FedcmClientMetadataResponse { + string privacy_policy_url = 1; + string terms_of_service_url = 2; +} + message CheckOAuthConsentRequest { string userId = 1; string clientId = 2;