fix and update
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
192
apps/api-gateway/src/controllers/fedcm.controller.ts
Normal file
192
apps/api-gateway/src/controllers/fedcm.controller.ts
Normal file
@@ -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<FedcmAccountsResponse> {
|
||||
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<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -15,6 +15,7 @@ export class CoreGrpcService implements OnModuleInit {
|
||||
documents!: Record<string, GrpcMethod>;
|
||||
addresses!: Record<string, GrpcMethod>;
|
||||
oauth!: Record<string, GrpcMethod>;
|
||||
fedcm!: Record<string, GrpcMethod>;
|
||||
otp!: Record<string, GrpcMethod>;
|
||||
advancedAuth!: Record<string, GrpcMethod>;
|
||||
notifications!: Record<string, GrpcMethod>;
|
||||
@@ -35,6 +36,7 @@ export class CoreGrpcService implements OnModuleInit {
|
||||
this.documents = this.client.getService<Record<string, GrpcMethod>>('DocumentsService');
|
||||
this.addresses = this.client.getService<Record<string, GrpcMethod>>('AddressesService');
|
||||
this.oauth = this.client.getService<Record<string, GrpcMethod>>('OAuthCoreService');
|
||||
this.fedcm = this.client.getService<Record<string, GrpcMethod>>('FedcmService');
|
||||
this.otp = this.client.getService<Record<string, GrpcMethod>>('OtpService');
|
||||
this.advancedAuth = this.client.getService<Record<string, GrpcMethod>>('AdvancedAuthService');
|
||||
this.family = this.client.getService<Record<string, GrpcMethod>>('FamilyService');
|
||||
|
||||
@@ -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<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const response = http.getResponse<Response>();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((data) => {
|
||||
attachFedcmCookieFromAuthResult(response, this.jwt, data);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
128
apps/api-gateway/src/lib/fedcm-cookie.ts
Normal file
128
apps/api-gateway/src/lib/fedcm-cookie.ts
Normal file
@@ -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<FedcmSessionPayload | null> {
|
||||
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<FedcmSessionPayload | null> {
|
||||
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
|
||||
});
|
||||
}
|
||||
16
apps/api-gateway/src/lib/fedcm-cors.ts
Normal file
16
apps/api-gateway/src/lib/fedcm-cors.ts
Normal file
@@ -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');
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 <OAuthCodeTabs />;
|
||||
case 'one-tap-examples':
|
||||
return <OneTapCodeTabs />;
|
||||
case 'auth-login-examples':
|
||||
return <AuthLoginCodeTabs />;
|
||||
case 'auth-ldap-examples':
|
||||
|
||||
@@ -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',
|
||||
|
||||
42
apps/docs/components/one-tap-code-tabs.tsx
Normal file
42
apps/docs/components/one-tap-code-tabs.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
API (issuer): <code className="rounded bg-zinc-100 px-1.5 py-0.5 dark:bg-zinc-800">{apiBase}</code>
|
||||
{' · '}
|
||||
Frontend / виджет: <code className="rounded bg-zinc-100 px-1.5 py-0.5 dark:bg-zinc-800">{frontendBase}</code>
|
||||
{' — '}
|
||||
из настроек <strong>PUBLIC_API_URL</strong>, <strong>PUBLIC_FRONTEND_URL</strong> и <strong>PROJECT_NAME</strong>.
|
||||
</p>
|
||||
<CodeExampleTabs examples={examples} />
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Функции' },
|
||||
|
||||
@@ -19,6 +19,23 @@ export function resolveOAuthApiBase(settings: Record<string, string>, fallback =
|
||||
return normalizeBaseUrl(fallback);
|
||||
}
|
||||
|
||||
export function resolveFrontendBase(settings: Record<string, string>, 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`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
163
apps/docs/lib/one-tap-examples.ts
Normal file
163
apps/docs/lib/one-tap-examples.ts
Normal file
@@ -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: `<!-- Подключите на любой странице вашего сайта -->
|
||||
<script
|
||||
src="${widgetUrl}"
|
||||
data-client-id="${clientIdPlaceholder}"
|
||||
data-idp-url="${apiBase}"
|
||||
data-idp-frontend-url="${frontendBase}"
|
||||
data-provider-name="${projectName}"
|
||||
data-redirect-uri="${redirectUri}"
|
||||
data-on-success="handleLendryLogin"
|
||||
></script>
|
||||
|
||||
<script>
|
||||
function handleLendryLogin(payload) {
|
||||
// payload.token — id_token (FedCM) или токен из popup
|
||||
// payload.method — 'fedcm' | 'popup'
|
||||
console.log('Вход через', payload.method, payload.token);
|
||||
// Отправьте token на ваш backend для проверки и создания сессии
|
||||
}
|
||||
</script>`
|
||||
},
|
||||
{
|
||||
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: `<script src="${widgetUrl}" data-auto-init="false"></script>
|
||||
<script>
|
||||
LendryIdOneTap.init({
|
||||
clientId: '${clientIdPlaceholder}',
|
||||
idpUrl: '${apiBase}',
|
||||
frontendUrl: '${frontendBase}',
|
||||
providerName: '${projectName}',
|
||||
redirectUri: '${redirectUri}',
|
||||
scope: 'openid profile email'
|
||||
});
|
||||
|
||||
window.addEventListener('lendry-sso-onetap-success', (event) => {
|
||||
const { token, method, accessToken, idToken } = event.detail;
|
||||
console.log(method, token ?? idToken ?? accessToken);
|
||||
});
|
||||
</script>`
|
||||
},
|
||||
{
|
||||
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"`
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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*`
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-sm text-[#667085]">Найдено: {total}</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
|
||||
<TableContainer className="rounded-2xl border border-[#eceef4] bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -191,7 +191,7 @@ export default function AdminBotsPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
@@ -218,7 +218,7 @@ export default function AdminBotsPage() {
|
||||
<p className="mb-4 text-sm text-[#667085]">
|
||||
Пользователи IdP, связанные с Telegram-ботами (BotFather и боты пользователей). Роль: «Бот».
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
|
||||
<TableContainer className="rounded-2xl border border-[#eceef4] bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -270,7 +270,7 @@ export default function AdminBotsPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-[24px] border border-[#eceef4] bg-white">
|
||||
<TableContainer className="rounded-[24px] border border-[#eceef4] bg-white">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@@ -446,7 +446,7 @@ export default function AdminUsersPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={dialog === 'password'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ChatImageLightboxProps {
|
||||
open: boolean;
|
||||
images: ChatMessage[];
|
||||
index: number;
|
||||
mediaUrls: Record<string, { blobUrl: string; fileName?: string } | undefined>;
|
||||
mediaUrls: Record<string, { blobUrl?: string; fileName?: string } | undefined>;
|
||||
onClose: () => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onDownload?: (message: ChatMessage) => void;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatMediaAlbumGridProps {
|
||||
messages: ChatMessage[];
|
||||
mediaUrls: Record<string, { blobUrl: string } | undefined>;
|
||||
mediaUrls: Record<string, { blobUrl?: string } | undefined>;
|
||||
className?: string;
|
||||
onImageClick?: (message: ChatMessage, index: number) => void;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<MediaRecorder | null>(null);
|
||||
const recordingStartedAtRef = useRef<number | null>(null);
|
||||
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
|
||||
const mediaLoadInFlightRef = useRef<Set<string>>(new Set());
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="space-y-2 text-sm text-[#667085]">
|
||||
<p>{media.errorMessage ?? 'Не удалось загрузить файл'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-[#3390ec] hover:underline"
|
||||
onClick={() => void loadChatMedia(message, true)}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!media?.blobUrl || media.status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-[#667085]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? []);
|
||||
|
||||
@@ -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 (
|
||||
<FamilyOverlayProvider>
|
||||
<EmojiSpriteProvider />
|
||||
<div className="min-h-screen overflow-x-hidden bg-white">
|
||||
<div className="min-h-screen bg-white">
|
||||
<Sidebar active={active} />
|
||||
{!fullBleed ? (
|
||||
<div className="fixed left-0 right-0 top-0 z-40 flex items-center justify-between border-b border-[#eceef4] bg-white/95 px-4 py-3 backdrop-blur lg:hidden">
|
||||
<div
|
||||
className="fixed left-0 right-0 top-0 z-40 flex h-[var(--mobile-header-height)] items-center justify-between border-b border-[#eceef4] bg-white/95 px-4 backdrop-blur lg:hidden"
|
||||
style={{ ['--mobile-header-height' as string]: MOBILE_HEADER_HEIGHT, paddingTop: 'env(safe-area-inset-top)' }}
|
||||
>
|
||||
<Link href="/" className="shrink-0">
|
||||
<BrandLogo size="sm" variant="dark" />
|
||||
</Link>
|
||||
@@ -44,9 +49,10 @@ export function IdShell({
|
||||
</div>
|
||||
<main
|
||||
className={cn(
|
||||
'pb-[calc(4.5rem+env(safe-area-inset-bottom))] lg:pb-8 lg:pl-[220px] lg:pr-8',
|
||||
fullBleed ? 'px-0 pt-0 lg:pt-8' : 'px-0 pt-14 lg:pt-8'
|
||||
'min-w-0 pb-[calc(4.5rem+env(safe-area-inset-bottom))] lg:pb-8 lg:pl-[220px] lg:pr-8',
|
||||
fullBleed ? 'px-0 pt-0 lg:pt-8' : 'px-0 pt-[var(--mobile-header-height)] lg:pt-8'
|
||||
)}
|
||||
style={fullBleed ? undefined : ({ ['--mobile-header-height' as string]: MOBILE_HEADER_HEIGHT } as React.CSSProperties)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -2,7 +2,21 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
|
||||
return <table className={cn('w-full caption-bottom text-sm', className)} {...props} />;
|
||||
return <table className={cn('w-full min-w-[720px] caption-bottom text-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function TableContainer({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full max-w-full overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeader({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
|
||||
|
||||
@@ -31,7 +31,7 @@ const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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*`
|
||||
|
||||
274
apps/frontend/public/sso-widget.js
Normal file
274
apps/frontend/public/sso-widget.js
Normal file
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
99
apps/sso-core/src/domain/fedcm.service.ts
Normal file
99
apps/sso-core/src/domain/fedcm.service.ts
Normal file
@@ -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-кода');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -40,7 +39,8 @@ const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
@@ -53,7 +53,8 @@ const MIME_ALIASES: Record<string, string> = {
|
||||
'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':
|
||||
|
||||
Reference in New Issue
Block a user