From 0df7240dc89246d3f93d27e4ff6b40b8d9ab1178 Mon Sep 17 00:00:00 2001 From: lendry Date: Mon, 29 Jun 2026 14:40:35 +0300 Subject: [PATCH] fix and update --- .env.example | 2 +- .../src/controllers/fedcm.controller.ts | 116 +++++++---- .../src/controllers/well-known.controller.ts | 18 +- apps/api-gateway/src/grpc-exception.filter.ts | 19 +- apps/api-gateway/src/lib/fedcm-config.ts | 196 ++++++++++++++++++ apps/api-gateway/src/lib/fedcm-cors.ts | 41 +++- apps/frontend/app/admin/oauth/page.tsx | 23 +- apps/frontend/app/admin/settings/page.tsx | 10 +- .../app/auth/oauth/authorize/page.tsx | 48 ++++- apps/frontend/components/id/auth-provider.tsx | 10 +- .../id/oauth-client-detail-dialog.tsx | 15 +- .../id/oauth-client-one-tap-section.tsx | 137 ++++++++++++ apps/frontend/lib/api.ts | 13 ++ apps/frontend/lib/oauth-popup-bridge.ts | 35 ++++ apps/frontend/lib/oauth-url.ts | 51 ++++- apps/frontend/lib/one-tap-examples.ts | 130 ++++++++++++ apps/frontend/lib/system-settings-catalog.ts | 33 ++- apps/frontend/public/sso-widget.js | 80 ++++--- apps/sso-core/src/domain/fedcm.service.ts | 17 +- .../src/domain/system-settings.seed.ts | 37 +++- docker-compose.yml | 16 +- 21 files changed, 934 insertions(+), 113 deletions(-) create mode 100644 apps/api-gateway/src/lib/fedcm-config.ts create mode 100644 apps/frontend/components/id/oauth-client-one-tap-section.tsx create mode 100644 apps/frontend/lib/oauth-popup-bridge.ts create mode 100644 apps/frontend/lib/one-tap-examples.ts diff --git a/.env.example b/.env.example index 4b3c39f..00aa806 100644 --- a/.env.example +++ b/.env.example @@ -29,7 +29,7 @@ DOMAIN_MINIO= DOMAIN_MINIO_CONSOLE= # Публичные URL (генерируются install.sh) -PUBLIC_API_URL=http://localhost:3000 +PUBLIC_API_URL=http://localhost:3002/idp-api PUBLIC_FRONTEND_URL=http://localhost:3002 PUBLIC_DOCS_URL=http://localhost:3003 PUBLIC_WS_URL=ws://localhost:8085/ws diff --git a/apps/api-gateway/src/controllers/fedcm.controller.ts b/apps/api-gateway/src/controllers/fedcm.controller.ts index 875db7b..64b415a 100644 --- a/apps/api-gateway/src/controllers/fedcm.controller.ts +++ b/apps/api-gateway/src/controllers/fedcm.controller.ts @@ -1,8 +1,10 @@ import { + BadRequestException, Controller, Get, Headers, HttpCode, + NotFoundException, Options, Post, Query, @@ -15,9 +17,20 @@ 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 { + applyFedcmCorsHeaders, + applyFedcmLoginStatus, + applyFedcmPreflightHeaders, + assertFedcmWebIdentityRequest, + requireFedcmCorsOrigin +} from '../lib/fedcm-cors'; +import { + buildFedcmDiscoverPayload, + buildFedcmProviderConfig, + readOneTapEnabled, + resolveFedcmEndpoints +} from '../lib/fedcm-config'; import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie'; -import { resolveFrontendUrl, resolveOAuthIssuer } from '../lib/oauth-issuer'; import { assertSessionUnlocked, verifyAccessToken } from '../session-auth'; type FedcmAccountsResponse = { @@ -39,6 +52,12 @@ export class FedcmController { private readonly jwt: JwtService ) {} + @Options('config.json') + @HttpCode(204) + configPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) { + applyFedcmPreflightHeaders(res, origin); + } + @Options('accounts') @HttpCode(204) accountsPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) { @@ -59,40 +78,33 @@ export class FedcmController { @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'); + async config(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + assertFedcmWebIdentityRequest(req); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); 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 + const enabled = await readOneTapEnabled(this.core); + if (!enabled) { + throw new NotFoundException('One Tap Login отключён'); } - 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 - }; + const endpoints = await resolveFedcmEndpoints(this.core, req); + return buildFedcmProviderConfig(endpoints); + } + + @Get('discover.json') + @ApiOperation({ + summary: 'FedCM discovery', + description: 'Публичные URL FedCM для виджета и диагностики (apiBase, configUrl, webIdentityUrl).' + }) + async discover(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=60'); + res.setHeader('Access-Control-Allow-Origin', '*'); + + const enabled = await readOneTapEnabled(this.core); + const endpoints = await resolveFedcmEndpoints(this.core, req); + return buildFedcmDiscoverPayload(endpoints, enabled); } @Get('accounts') @@ -102,33 +114,44 @@ export class FedcmController { @Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response ): Promise { + assertFedcmWebIdentityRequest(req); applyFedcmCorsHeaders(res, origin); - res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie); if (!session) { + applyFedcmLoginStatus(res, false); return { accounts: [] }; } - const result = (await firstValueFrom( - this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId }) - )) as FedcmAccountsResponse; - - return result ?? { accounts: [] }; + try { + const result = (await firstValueFrom( + this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId }) + )) as FedcmAccountsResponse; + const accounts = result?.accounts ?? []; + applyFedcmLoginStatus(res, accounts.length > 0); + return { accounts }; + } catch { + applyFedcmLoginStatus(res, false); + return { accounts: [] }; + } } @Get('client_metadata') @ApiOperation({ summary: 'FedCM client metadata', description: 'Метаданные клиента для UI FedCM.' }) async clientMetadata( + @Req() req: Request, @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'); + assertFedcmWebIdentityRequest(req); + const rpOrigin = requireFedcmCorsOrigin(origin); + applyFedcmCorsHeaders(res, rpOrigin); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); if (!clientId?.trim()) { - throw new UnauthorizedException('Передайте client_id'); + throw new BadRequestException('Передайте client_id'); } return firstValueFrom(this.core.fedcm.GetClientMetadata({ clientId: clientId.trim() })); @@ -142,8 +165,10 @@ export class FedcmController { @Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response ) { - applyFedcmCorsHeaders(res, origin); - res.setHeader('Content-Type', 'application/json'); + assertFedcmWebIdentityRequest(req); + const rpOrigin = requireFedcmCorsOrigin(origin); + applyFedcmCorsHeaders(res, rpOrigin); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie); if (!session) { @@ -154,9 +179,11 @@ export class FedcmController { 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'); + throw new BadRequestException('Передайте client_id и account_id'); } + applyFedcmLoginStatus(res, true); + return firstValueFrom( this.core.fedcm.IssueIdAssertion({ userId: session.sub, @@ -187,6 +214,7 @@ export class FedcmController { sessionId: payload.sessionId, pinVerified: true }); + applyFedcmLoginStatus(res, 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 dcabe39..9439da3 100644 --- a/apps/api-gateway/src/controllers/well-known.controller.ts +++ b/apps/api-gateway/src/controllers/well-known.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Res } from '@nestjs/common'; +import { Controller, Get, Req, Res } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import type { Response } from 'express'; -import { firstValueFrom } from 'rxjs'; +import type { Request, Response } from 'express'; import { CoreGrpcService } from '../core-grpc.service'; +import { buildFedcmWebIdentityManifest, resolveFedcmEndpoints } from '../lib/fedcm-config'; +import { assertFedcmWebIdentityRequest } from '../lib/fedcm-cors'; import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issuer'; @ApiTags('OpenID Connect') @@ -15,13 +16,12 @@ export class WellKnownController { summary: 'FedCM web identity manifest', description: 'Манифест Federated Credential Management API, указывающий на конфигурацию провайдера.' }) - async webIdentity(@Res({ passthrough: true }) res: Response) { - res.setHeader('Content-Type', 'application/json'); + async webIdentity(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + assertFedcmWebIdentityRequest(req); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'public, max-age=300'); - const issuer = await resolveOAuthIssuer(this.core); - return { - provider_urls: [`${issuer}/fedcm/config.json`] - }; + const endpoints = await resolveFedcmEndpoints(this.core, req); + return buildFedcmWebIdentityManifest(endpoints); } @Get('openid-configuration') diff --git a/apps/api-gateway/src/grpc-exception.filter.ts b/apps/api-gateway/src/grpc-exception.filter.ts index d2037d7..c6a75f4 100644 --- a/apps/api-gateway/src/grpc-exception.filter.ts +++ b/apps/api-gateway/src/grpc-exception.filter.ts @@ -1,8 +1,16 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; import { status as GrpcStatus } from '@grpc/grpc-js'; +import { applyFedcmCorsHeaders } from './lib/fedcm-cors'; interface HttpResponseLike { status(code: number): { json(body: unknown): unknown }; + setHeader?(name: string, value: string): void; +} + +interface HttpRequestLike { + path?: string; + url?: string; + headers?: Record; } function grpcToHttp(code?: number): number { @@ -29,7 +37,16 @@ function grpcToHttp(code?: number): number { @Catch() export class AllExceptionsFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { - const response = host.switchToHttp().getResponse(); + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const requestPath = request.path ?? request.url ?? ''; + const originHeader = request.headers?.origin; + const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader; + + if ((requestPath.includes('/fedcm/') || requestPath.includes('/.well-known/')) && origin && response.setHeader) { + applyFedcmCorsHeaders(response as never, origin); + } if (exception instanceof HttpException) { const statusCode = exception.getStatus(); diff --git a/apps/api-gateway/src/lib/fedcm-config.ts b/apps/api-gateway/src/lib/fedcm-config.ts new file mode 100644 index 0000000..6305963 --- /dev/null +++ b/apps/api-gateway/src/lib/fedcm-config.ts @@ -0,0 +1,196 @@ +import type { Request } from 'express'; +import { firstValueFrom } from 'rxjs'; +import { CoreGrpcService } from '../core-grpc.service'; +import { resolveFrontendUrl, resolveOAuthIssuer } from './oauth-issuer'; + +export interface FedcmEndpoints { + issuer: string; + frontendUrl: string; + projectName: string; + configUrl: string; + accountsEndpoint: string; + clientMetadataEndpoint: string; + idAssertionEndpoint: string; + loginUrl: string; + signupUrl: string; + webIdentityUrl: string; +} + +function normalizeBaseUrl(url: string) { + return url.trim().replace(/\/+$/, ''); +} + +function readRequestHost(req?: Request): string | undefined { + if (!req) { + return undefined; + } + + const forwarded = req.headers['x-forwarded-host']; + const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host; + return host?.split(',')[0]?.trim() || undefined; +} + +function readRequestProto(req?: Request): string { + if (!req) { + return 'https'; + } + + const forwarded = req.headers['x-forwarded-proto']; + const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.protocol; + return proto?.split(',')[0]?.trim() || 'https'; +} + +function hostsMatch(a: string, b: string) { + if (a === b) { + return true; + } + + return a === 'localhost' && b === 'localhost'; +} + +function resolveSameOriginFrontend(storedFrontend: string, req?: Request): string { + const host = readRequestHost(req); + if (!host) { + return storedFrontend; + } + + const requestOrigin = normalizeBaseUrl(`${readRequestProto(req)}://${host}`); + + try { + const frontendUrl = new URL(storedFrontend); + const requestUrl = new URL(requestOrigin); + + if (hostsMatch(frontendUrl.hostname, requestUrl.hostname)) { + return requestOrigin; + } + } catch { + return storedFrontend; + } + + return storedFrontend; +} + +export function resolveSameOriginIssuer(storedIssuer: string, storedFrontend: string, req?: Request): string { + const host = readRequestHost(req); + if (!host) { + return storedIssuer; + } + + const requestOrigin = normalizeBaseUrl(`${readRequestProto(req)}://${host}`); + const sameOriginApi = `${requestOrigin}/idp-api`; + + try { + const issuerUrl = new URL(storedIssuer); + const frontendUrl = new URL(storedFrontend); + const requestUrl = new URL(requestOrigin); + + const hostMatches = + hostsMatch(issuerUrl.hostname, requestUrl.hostname) || hostsMatch(frontendUrl.hostname, requestUrl.hostname); + + if (!hostMatches) { + return storedIssuer; + } + + if (normalizeBaseUrl(storedIssuer) !== sameOriginApi) { + return sameOriginApi; + } + } catch { + return storedIssuer; + } + + return storedIssuer; +} + +function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) { + const base = normalizeBaseUrl(issuer); + const front = normalizeBaseUrl(frontendUrl); + + let webIdentityOrigin = front; + try { + webIdentityOrigin = new URL(base).origin; + } catch { + // keep frontend origin + } + + return { + configUrl: `${base}/fedcm/config.json`, + accountsEndpoint: `${base}/fedcm/accounts`, + clientMetadataEndpoint: `${base}/fedcm/client_metadata`, + idAssertionEndpoint: `${base}/fedcm/id_assertion`, + loginUrl: `${front}/auth/login`, + signupUrl: `${front}/auth/register`, + webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity` + }; +} + +export async function resolveFedcmEndpoints(core: CoreGrpcService, req?: Request): Promise { + const storedIssuer = await resolveOAuthIssuer(core); + const storedFrontend = await resolveFrontendUrl(core); + const frontendUrl = resolveSameOriginFrontend(storedFrontend, req); + const issuer = resolveSameOriginIssuer(storedIssuer, storedFrontend, req); + let projectName = 'MVK ID'; + + try { + const setting = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_NAME' }))) as { value?: string }; + if (setting.value?.trim()) { + projectName = setting.value.trim(); + } + } catch { + // fallback + } + + return { + issuer, + frontendUrl, + projectName, + ...buildFedcmEndpointUrls(issuer, frontendUrl) + }; +} + +export function buildFedcmProviderConfig(endpoints: FedcmEndpoints) { + return { + accounts_endpoint: endpoints.accountsEndpoint, + client_metadata_endpoint: endpoints.clientMetadataEndpoint, + id_assertion_endpoint: endpoints.idAssertionEndpoint, + login_url: endpoints.loginUrl, + branding: { + background_color: '#ffffff', + color: '#3390ec', + icons: [{ url: `${endpoints.frontendUrl}/favicon.ico`, size: 32 }] + } + }; +} + +export function buildFedcmWebIdentityManifest(endpoints: FedcmEndpoints) { + return { + provider_urls: [endpoints.configUrl], + accounts_endpoint: endpoints.accountsEndpoint, + login_url: endpoints.loginUrl + }; +} + +export function buildFedcmDiscoverPayload(endpoints: FedcmEndpoints, enabled = true) { + return { + enabled, + apiBase: endpoints.issuer, + frontendUrl: endpoints.frontendUrl, + projectName: endpoints.projectName, + configUrl: endpoints.configUrl, + webIdentityUrl: endpoints.webIdentityUrl, + accountsEndpoint: endpoints.accountsEndpoint, + loginUrl: endpoints.loginUrl + }; +} + +async function readOneTapEnabled(core: CoreGrpcService): Promise { + try { + const setting = (await firstValueFrom(core.settings.GetSetting({ key: 'ONE_TAP_ENABLED' }))) as { value?: string }; + const raw = setting.value?.trim().toLowerCase(); + if (!raw) return true; + return ['true', '1', 'yes'].includes(raw); + } catch { + return true; + } +} + +export { readOneTapEnabled }; diff --git a/apps/api-gateway/src/lib/fedcm-cors.ts b/apps/api-gateway/src/lib/fedcm-cors.ts index edb694b..fc03658 100644 --- a/apps/api-gateway/src/lib/fedcm-cors.ts +++ b/apps/api-gateway/src/lib/fedcm-cors.ts @@ -1,16 +1,45 @@ -import type { Response } from 'express'; +import { BadRequestException } from '@nestjs/common'; +import type { Request, Response } from 'express'; + +export function normalizeFedcmOrigin(origin?: string) { + return origin?.trim().replace(/\/+$/, '') || undefined; +} 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'); + const normalized = normalizeFedcmOrigin(origin); + if (!normalized) { + return; } + res.setHeader('Access-Control-Allow-Origin', normalized); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Vary', 'Origin'); +} + +export function requireFedcmCorsOrigin(origin?: string) { + const normalized = normalizeFedcmOrigin(origin); + if (!normalized) { + throw new BadRequestException('FedCM требует заголовок Origin'); + } + return normalized; } 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-Allow-Headers', + 'Content-Type, Authorization, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site' + ); res.setHeader('Access-Control-Max-Age', '86400'); } + +export function assertFedcmWebIdentityRequest(req: Request) { + const dest = String(req.headers['sec-fetch-dest'] ?? '').toLowerCase(); + if (dest && dest !== 'webidentity') { + throw new BadRequestException('Недопустимый Sec-Fetch-Dest для FedCM'); + } +} + +export function applyFedcmLoginStatus(res: Response, loggedIn: boolean) { + res.setHeader('Set-Login', loggedIn ? 'logged-in' : 'logged-out'); +} diff --git a/apps/frontend/app/admin/oauth/page.tsx b/apps/frontend/app/admin/oauth/page.tsx index 9d5390c..78c9282 100644 --- a/apps/frontend/app/admin/oauth/page.tsx +++ b/apps/frontend/app/admin/oauth/page.tsx @@ -13,7 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Input } from '@/components/ui/input'; import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api'; import { getAdminLandingPath } from '@/lib/admin-access'; -import { buildAuthorizeUrl, buildOAuthEndpoints, resolveOAuthApiBase } from '@/lib/oauth-url'; +import { buildAuthorizeUrl, buildOAuthEndpoints, resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url'; export default function AdminOAuthPage() { const router = useRouter(); @@ -26,16 +26,22 @@ export default function AdminOAuthPage() { const [dialogOpen, setDialogOpen] = useState(false); const [selectedClient, setSelectedClient] = useState(null); const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null); - const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3000'); + const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3002/idp-api'); + const [frontendBase, setFrontendBase] = useState('http://localhost:3002'); + const [projectName, setProjectName] = useState('MVK ID'); const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] }); - const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase), [oauthApiBase]); + const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase, frontendBase), [oauthApiBase, frontendBase]); const loadPublicSettings = useCallback(async () => { try { const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public'); const settings = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value])); setOauthApiBase(resolveOAuthApiBase(settings)); + setFrontendBase(resolveFrontendBase(settings)); + if (settings.PROJECT_NAME?.trim()) { + setProjectName(settings.PROJECT_NAME.trim()); + } } catch { // оставляем fallback } @@ -182,11 +188,20 @@ export default function AdminOAuthPage() {

Issuer: {oauthEndpoints.issuer} {' · '} + Frontend: {frontendBase} + {' · '} OpenID Configuration

+

+ One Tap Login настраивается в{' '} + + системных настройках + {' '} + (PUBLIC_API_URL, PUBLIC_FRONTEND_URL, ONE_TAP_ENABLED). +

+
{code}
+ + ); +} + +export function OAuthClientOneTapSection({ + client, + endpoints, + frontendBase, + projectName, + onCopy +}: { + client: OAuthClient; + endpoints: OAuthEndpoints; + frontendBase: string; + projectName: string; + onCopy: (value: string, label: string) => void; +}) { + const primaryRedirect = client.redirectUris[0] ?? `${frontendBase}/auth/callback`; + const examples = useMemo( + () => + buildOneTapExamples( + { + apiBase: endpoints.issuer, + frontendBase, + widgetUrl: endpoints.widgetUrl, + fedcmConfigUrl: endpoints.fedcmConfigUrl, + webIdentityUrl: endpoints.webIdentityUrl, + fedcmDiscoverUrl: endpoints.fedcmDiscoverUrl, + projectName + }, + client.clientId, + primaryRedirect + ), + [client.clientId, endpoints, frontendBase, primaryRedirect, projectName] + ); + const [activeTab, setActiveTab] = useState(examples[0]?.id ?? 'widget-script'); + + return ( +
+
+

One Tap Login

+

+ FedCM (Chrome/Edge) и виджет sso-widget.js с popup fallback. + Убедитесь, что в настройках указаны PUBLIC_API_URL и PUBLIC_FRONTEND_URL. +

+
+ +
+ + + + +
+ + + + {examples.map((example) => ( + + {example.label} + + ))} + + {examples.map((example) => ( + + + + ))} + + +
+

Проверка FedCM

+
    +
  1. Войдите на IdP ({frontendBase}) в этом браузере.
  2. +
  3. Откройте сайт клиента с виджетом — Chrome покажет нативный диалог или плашку One Tap.
  4. +
  5. + При ошибке NetworkError проверьте, что PUBLIC_API_URL = {endpoints.issuer}. +
  6. +
+ + Открыть sso-widget.js + + +
+
+ ); +} + +function CopyRowCompact({ + label, + value, + onCopy +}: { + label: string; + value: string; + onCopy: (value: string, label: string) => void; +}) { + return ( +
+
+ {label} + +
+ {value} +
+ ); +} diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 2292854..5b351b7 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -494,6 +494,19 @@ export async function refreshAuthSession(): Promise { return response.json() as Promise; } +export async function syncFedcmSession(accessToken: string) { + if (typeof window === 'undefined' || !accessToken.trim()) return; + try { + await fetch(`${getApiUrl()}/fedcm/session/sync`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken.trim()}` }, + credentials: 'include' + }); + } catch { + // фоновая синхронизация FedCM cookie + } +} + async function trySilentTokenRefresh(): Promise { try { const refreshed = await refreshAuthSession(); diff --git a/apps/frontend/lib/oauth-popup-bridge.ts b/apps/frontend/lib/oauth-popup-bridge.ts new file mode 100644 index 0000000..2dc2b68 --- /dev/null +++ b/apps/frontend/lib/oauth-popup-bridge.ts @@ -0,0 +1,35 @@ +export const ONE_TAP_MESSAGE_TYPE = 'lendry-sso-onetap'; + +export function parsePopupOAuthParams(searchParams: URLSearchParams) { + const display = searchParams.get('display'); + const popupOrigin = searchParams.get('popup_origin'); + if (display !== 'popup' || !popupOrigin?.trim()) { + return null; + } + + try { + return { popupOrigin: new URL(popupOrigin.trim()).origin }; + } catch { + return null; + } +} + +export function postOneTapResult(popupOrigin: string, payload: Record) { + if (!window.opener) { + return false; + } + + window.opener.postMessage({ type: ONE_TAP_MESSAGE_TYPE, ...payload }, popupOrigin); + window.close(); + return true; +} + +export function parseAuthorizationRedirect(redirectUrl: string) { + const url = new URL(redirectUrl); + return { + code: url.searchParams.get('code'), + state: url.searchParams.get('state'), + error: url.searchParams.get('error'), + errorDescription: url.searchParams.get('error_description') + }; +} diff --git a/apps/frontend/lib/oauth-url.ts b/apps/frontend/lib/oauth-url.ts index a1be9bd..8303b16 100644 --- a/apps/frontend/lib/oauth-url.ts +++ b/apps/frontend/lib/oauth-url.ts @@ -2,12 +2,29 @@ export function normalizeBaseUrl(url: string) { return url.trim().replace(/\/+$/, ''); } -export function resolveOAuthApiBase(settings: Record, fallback = 'http://localhost:3000') { +export function resolveOAuthApiBase(settings: Record, fallback = 'http://localhost:3002/idp-api') { const publicApi = settings.PUBLIC_API_URL?.trim(); if (publicApi) { return normalizeBaseUrl(publicApi); } + const domain = settings.PROJECT_DOMAIN?.trim(); + if (domain) { + if (domain.startsWith('http://') || domain.startsWith('https://')) { + return normalizeBaseUrl(domain); + } + return `https://${domain.replace(/^\/+/, '')}/idp-api`; + } + + 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://')) { @@ -26,20 +43,48 @@ export interface OAuthEndpoints { userInfoEndpoint: string; openIdConfigurationUrl: string; jwksUrl: string; + webIdentityUrl: string; + fedcmConfigUrl: string; + fedcmDiscoverUrl: string; + fedcmAccountsUrl: string; + fedcmIdAssertionUrl: string; + widgetUrl: string; } -export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints { +export function buildOAuthEndpoints(apiBase: string, frontendBase?: string): OAuthEndpoints { const base = normalizeBaseUrl(apiBase); + const front = normalizeBaseUrl(frontendBase ?? deriveFrontendBaseFromApi(base)); + let webIdentityOrigin = front; + + try { + webIdentityOrigin = new URL(base).origin; + } catch { + // keep frontend origin + } + return { issuer: base, authorizationEndpoint: `${base}/oauth/authorize`, 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: `${webIdentityOrigin}/.well-known/web-identity`, + fedcmConfigUrl: `${base}/fedcm/config.json`, + fedcmDiscoverUrl: `${base}/fedcm/discover.json`, + fedcmAccountsUrl: `${base}/fedcm/accounts`, + fedcmIdAssertionUrl: `${base}/fedcm/id_assertion`, + widgetUrl: `${front}/sso-widget.js` }; } +function deriveFrontendBaseFromApi(apiBase: string) { + if (apiBase.endsWith('/idp-api')) { + return apiBase.replace(/\/idp-api$/, ''); + } + return apiBase; +} + export function buildAuthorizeUrl( apiBase: string, params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string; useStandardParams?: boolean } diff --git a/apps/frontend/lib/one-tap-examples.ts b/apps/frontend/lib/one-tap-examples.ts new file mode 100644 index 0000000..d4d7c92 --- /dev/null +++ b/apps/frontend/lib/one-tap-examples.ts @@ -0,0 +1,130 @@ +export interface OneTapUrls { + apiBase: string; + frontendBase: string; + widgetUrl: string; + fedcmConfigUrl: string; + webIdentityUrl: string; + fedcmDiscoverUrl: string; + projectName: string; +} + +export interface OneTapExample { + id: string; + label: string; + language: string; + code: string; +} + +export function buildOneTapUrls( + apiBase: string, + frontendBase: string, + projectName = 'MVK ID' +): OneTapUrls { + const base = apiBase.replace(/\/+$/, ''); + const front = frontendBase.replace(/\/+$/, ''); + let webIdentityOrigin = front; + + try { + webIdentityOrigin = new URL(base).origin; + } catch { + // keep frontend origin + } + + return { + apiBase: base, + frontendBase: front, + widgetUrl: `${front}/sso-widget.js`, + fedcmConfigUrl: `${base}/fedcm/config.json`, + fedcmDiscoverUrl: `${base}/fedcm/discover.json`, + webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`, + projectName + }; +} + +export function buildOneTapExamples(urls: OneTapUrls, clientId: string, redirectUri?: string): OneTapExample[] { + const { apiBase, frontendBase, widgetUrl, fedcmConfigUrl, webIdentityUrl, projectName } = urls; + const callbackUri = redirectUri?.trim() || 'https://app.example.com/auth/callback'; + + return [ + { + id: 'widget-script', + label: 'Виджет (script)', + language: 'html', + code: ` + +` + }, + { + id: 'sdk-manual', + label: 'SDK вручную', + language: 'javascript', + code: ` +` + }, + { + id: 'fedcm-native', + label: 'FedCM API', + language: 'javascript', + code: `const credential = await navigator.credentials.get({ + identity: { + providers: [{ + configURL: '${fedcmConfigUrl}', + clientId: '${clientId}' + }] + }, + mediation: 'optional' +}); + +const idToken = credential?.token; // JWT — проверьте на backend` + }, + { + id: 'postmessage', + label: 'Popup postMessage', + language: 'javascript', + code: `window.addEventListener('message', (event) => { + if (event.origin !== '${frontendBase}') return; + if (event.data?.type !== 'lendry-sso-onetap') return; + + const { code, idToken, token, error } = event.data; + if (error) return console.error(error); + + // code — обменяйте на POST ${apiBase}/oauth/token + // idToken / token — проверьте JWT (issuer = ${apiBase}) +});` + }, + { + id: 'curl-fedcm', + label: 'Диагностика (curl)', + language: 'bash', + code: `curl -sH "Sec-Fetch-Dest: webidentity" ${webIdentityUrl} +curl -sH "Sec-Fetch-Dest: webidentity" ${fedcmConfigUrl} +curl -s ${apiBase}/fedcm/discover.json` + } + ]; +} diff --git a/apps/frontend/lib/system-settings-catalog.ts b/apps/frontend/lib/system-settings-catalog.ts index a8eabca..8f00c49 100644 --- a/apps/frontend/lib/system-settings-catalog.ts +++ b/apps/frontend/lib/system-settings-catalog.ts @@ -13,6 +13,7 @@ export interface SystemSettingMeta { export const SYSTEM_SETTING_GROUPS: Record = { general: 'Проект и брендинг', + onetap: 'One Tap Login / FedCM', pin: 'PIN-код и блокировка сессии', auth: 'Аутентификация и регистрация', ldap: 'LDAP / Active Directory', @@ -30,7 +31,37 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [ label: 'URL API (OAuth issuer)', group: 'general', type: 'text', - hint: 'Базовый URL API для OAuth/OIDC. Пример: https://sso.example.ru/idp-api или https://api.example.ru' + hint: 'Базовый URL API для OAuth/OIDC и FedCM. При same-origin деплое: https://ваш-домен/idp-api' + }, + { + key: 'PUBLIC_FRONTEND_URL', + label: 'URL Frontend IdP', + group: 'general', + type: 'text', + hint: 'Публичный адрес интерфейса IdP. Используется для login_url FedCM и popup OAuth' + }, + { + key: 'ONE_TAP_ENABLED', + label: 'One Tap Login включён', + group: 'onetap', + type: 'boolean', + hint: 'FedCM и виджет sso-widget.js. При отключении остаётся только стандартный OAuth redirect' + }, + { + key: 'FEDCM_PRIVACY_POLICY_URL', + label: 'FedCM — политика конфиденциальности', + group: 'onetap', + type: 'text', + hint: 'URL для UI FedCM. Пусто — используется страница «Данные» IdP', + showWhen: { key: 'ONE_TAP_ENABLED', equals: 'true' } + }, + { + key: 'FEDCM_TERMS_URL', + label: 'FedCM — условия использования', + group: 'onetap', + type: 'text', + hint: 'URL для UI FedCM. Пусто — используется страница «Данные» IdP', + showWhen: { key: 'ONE_TAP_ENABLED', equals: 'true' } }, { key: 'PIN_LOCK_TIMEOUT_MINUTES', label: 'Таймаут блокировки PIN', group: 'pin', type: 'number', unit: 'мин' }, { key: 'PIN_DELETE_GRACE_MINUTES', label: 'Задержка удаления PIN', group: 'pin', type: 'number', unit: 'мин', hint: 'Сколько ждать после запроса удаления PIN-кода' }, diff --git a/apps/frontend/public/sso-widget.js b/apps/frontend/public/sso-widget.js index 79ee6df..5076c9e 100644 --- a/apps/frontend/public/sso-widget.js +++ b/apps/frontend/public/sso-widget.js @@ -71,30 +71,66 @@ return Promise.resolve(null); } - return global.navigator.credentials - .get({ - identity: { - providers: [ - { - configURL: idpApiBase + '/fedcm/config.json', - clientId: clientId - } - ] - }, - mediation: 'optional' + function requestFedCM(configBase) { + return global.navigator.credentials + .get({ + identity: { + providers: [ + { + configURL: configBase + '/fedcm/config.json', + configUrl: configBase + '/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' + }; + }); + } + + return global.fetch(idpApiBase + '/fedcm/discover.json', { credentials: 'omit' }) + .then(function (response) { + if (!response.ok) { + return idpApiBase; + } + return response.json().then(function (payload) { + if (payload && payload.enabled === false) { + return null; + } + if (payload && payload.apiBase) { + return trimSlash(payload.apiBase); + } + return idpApiBase; + }); }) - .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 () { + return idpApiBase; + }) + .then(function (resolvedBase) { + if (!resolvedBase) { + return null; + } + return requestFedCM(resolvedBase); }) .catch(function (error) { if (global.console && typeof global.console.warn === 'function') { - global.console.warn('[MVK ID] FedCM недоступен:', error); + global.console.warn( + '[MVK ID] FedCM недоступен:', + error, + 'Проверьте доступность', + idpApiBase + '/fedcm/config.json', + 'и', + idpApiBase.replace(/\/idp-api$/, '') + '/.well-known/web-identity', + '(PUBLIC_API_URL должен совпадать с data-idp-url виджета)' + ); } return null; }); @@ -232,10 +268,6 @@ return; } - if (supportsFedCM()) { - return; - } - createWidget(providerName, function () { var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope); openPopup(popupUrl, frontendBase, function (payload) { diff --git a/apps/sso-core/src/domain/fedcm.service.ts b/apps/sso-core/src/domain/fedcm.service.ts index 68c2dd0..ab5a1e6 100644 --- a/apps/sso-core/src/domain/fedcm.service.ts +++ b/apps/sso-core/src/domain/fedcm.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/ import { PrismaService } from '../infra/prisma.service'; import { OAuthCoreService } from './oauth-core.service'; import { SessionService } from './session.service'; +import { SettingsService } from './settings.service'; import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util'; @Injectable() @@ -9,7 +10,8 @@ export class FedcmService { constructor( private readonly prisma: PrismaService, private readonly session: SessionService, - private readonly oauthCore: OAuthCoreService + private readonly oauthCore: OAuthCoreService, + private readonly settings: SettingsService ) {} async getAccounts(userId: string, sessionId: string) { @@ -81,12 +83,21 @@ export class FedcmService { throw new BadRequestException('OAuth-приложение не найдено или отключено'); } + const frontendUrl = (await this.settings.getValue('PUBLIC_FRONTEND_URL', 'http://localhost:3002')).replace(/\/+$/, ''); + const privacyFallback = `${frontendUrl}/data`; + const privacy = (await this.settings.getValue('FEDCM_PRIVACY_POLICY_URL', '')).trim(); + const terms = (await this.settings.getValue('FEDCM_TERMS_URL', '')).trim(); + return { - privacy_policy_url: 'https://id.lendry.ru/data', - terms_of_service_url: 'https://id.lendry.ru/data' + privacy_policy_url: privacy || privacyFallback, + terms_of_service_url: terms || privacyFallback }; } + async isOneTapEnabled() { + return this.settings.getBoolean('ONE_TAP_ENABLED', true); + } + private async assertUnlockedSession(userId: string, sessionId: string) { const state = await this.session.resolveSessionState(sessionId); if (!state || state.userId !== userId) { diff --git a/apps/sso-core/src/domain/system-settings.seed.ts b/apps/sso-core/src/domain/system-settings.seed.ts index e1a4454..fe1b171 100644 --- a/apps/sso-core/src/domain/system-settings.seed.ts +++ b/apps/sso-core/src/domain/system-settings.seed.ts @@ -7,9 +7,29 @@ export const DEFAULT_SYSTEM_SETTINGS = [ { key: 'PROJECT_DOMAIN', value: 'id.lendry.ru', description: 'Основной домен IdP (без пути)' }, { key: 'PUBLIC_API_URL', - value: 'http://localhost:3000', + value: 'http://localhost:3002/idp-api', description: 'Публичный базовый URL API для OAuth и OpenID Connect (issuer). Пример: https://sso.example.ru/idp-api' }, + { + key: 'PUBLIC_FRONTEND_URL', + value: 'http://localhost:3002', + description: 'Публичный URL frontend IdP. Используется для login_url FedCM, popup OAuth и виджета sso-widget.js' + }, + { + key: 'ONE_TAP_ENABLED', + value: 'true', + description: 'Разрешить One Tap Login (FedCM и виджет sso-widget.js) для OAuth-клиентов' + }, + { + key: 'FEDCM_PRIVACY_POLICY_URL', + value: '', + description: 'URL политики конфиденциальности для UI FedCM. Пусто — страница «Данные» IdP' + }, + { + key: 'FEDCM_TERMS_URL', + value: '', + description: 'URL условий использования для UI FedCM. Пусто — страница «Данные» IdP' + }, { key: 'PIN_LOCK_TIMEOUT_MINUTES', value: '15', description: 'Через сколько минут неактивности сессия блокируется PIN-кодом' }, { key: 'PIN_DELETE_GRACE_MINUTES', value: '1440', description: 'Задержка перед окончательным удалением PIN-кода после запроса (минуты)' }, { key: 'PIN_REQUIRE_ON_DELETE', value: 'true', description: 'Требовать текущий PIN-код при запросе удаления защиты' }, @@ -102,6 +122,8 @@ export const PUBLIC_SETTING_KEYS = [ 'PROJECT_TAGLINE', 'PROJECT_DOMAIN', 'PUBLIC_API_URL', + 'PUBLIC_FRONTEND_URL', + 'ONE_TAP_ENABLED', 'REGISTRATION_ENABLED', 'MAINTENANCE_MODE', 'LDAP_ENABLED', @@ -133,5 +155,18 @@ export class SystemSettingsSeedService implements OnModuleInit { update: { value: envPublicApiUrl } }); } + + const envPublicFrontendUrl = process.env.PUBLIC_FRONTEND_URL?.trim(); + if (envPublicFrontendUrl) { + await this.prisma.systemSetting.upsert({ + where: { key: 'PUBLIC_FRONTEND_URL' }, + create: { + key: 'PUBLIC_FRONTEND_URL', + value: envPublicFrontendUrl, + description: 'Публичный URL frontend IdP. Используется для login_url FedCM, popup OAuth и виджета sso-widget.js' + }, + update: { value: envPublicFrontendUrl } + }); + } } } diff --git a/docker-compose.yml b/docker-compose.yml index 055a28a..b44785b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,7 +92,7 @@ services: MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_BUCKET: ${MINIO_BUCKET:-lendry-id} MINIO_USE_SSL: ${MINIO_USE_SSL:-false} - PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000} + PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} RABBITMQ_URL: amqp://${RABBITMQ_DEFAULT_USER:-lendry}:${RABBITMQ_DEFAULT_PASS:-lendry_password}@rabbitmq:5672/ LDAP_AUTH_URL: http://ldap-auth:8086 ports: @@ -135,7 +135,7 @@ services: MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_BUCKET: ${MINIO_BUCKET:-lendry-id} MINIO_USE_SSL: ${MINIO_USE_SSL:-false} - PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000} + PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} ports: - "127.0.0.1:3000:3000" depends_on: @@ -201,7 +201,7 @@ services: dockerfile: apps/frontend/Dockerfile args: NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org} - NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000} + NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} NEXT_PUBLIC_WS_URL: ${PUBLIC_WS_URL:-ws://localhost:8085/ws} INTERNAL_API_URL: ${INTERNAL_API_URL:-http://api-gateway:3000} INTERNAL_WS_URL: ${INTERNAL_WS_URL:-http://media-ws:8085} @@ -211,7 +211,7 @@ services: PORT: 3000 HOSTNAME: 0.0.0.0 NEXT_TELEMETRY_DISABLED: 1 - NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000} + NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} NEXT_PUBLIC_WS_URL: ${PUBLIC_WS_URL:-ws://localhost:8085/ws} INTERNAL_API_URL: ${INTERNAL_API_URL:-http://api-gateway:3000} INTERNAL_WS_URL: ${INTERNAL_WS_URL:-http://media-ws:8085} @@ -238,9 +238,9 @@ services: container_name: lendry-id-tauri-apk-builder restart: "no" environment: - VITE_API_URL: ${PUBLIC_API_URL:-http://localhost:3000} + VITE_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} VITE_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002} - PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000} + PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} PUBLIC_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002} volumes: - ./apps/frontend/public/downloads:/out @@ -252,7 +252,7 @@ services: dockerfile: apps/docs/Dockerfile args: NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org} - NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000} + NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} container_name: lendry-id-docs restart: unless-stopped depends_on: @@ -262,7 +262,7 @@ services: PORT: 3000 HOSTNAME: 0.0.0.0 NEXT_TELEMETRY_DISABLED: 1 - NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000} + NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} INTERNAL_API_URL: http://api-gateway:3000 ports: - "127.0.0.1:3003:3000"