From f7c01a3963be934b704c3112132334e2f5862447 Mon Sep 17 00:00:00 2001 From: lendry Date: Wed, 1 Jul 2026 15:52:17 +0300 Subject: [PATCH] fix and update --- .../src/controllers/fedcm.controller.ts | 17 +++++ apps/api-gateway/src/lib/fedcm-cookie.ts | 70 ++++++++++++++----- apps/frontend/app/providers.tsx | 8 +-- apps/frontend/components/id/auth-provider.tsx | 10 +++ .../components/id/fedcm-api-login-status.tsx | 39 +++++++++++ .../id/public-settings-provider.tsx | 27 +++++-- docker-compose.yml | 1 + install.sh | 5 ++ 8 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 apps/frontend/components/id/fedcm-api-login-status.tsx diff --git a/apps/api-gateway/src/controllers/fedcm.controller.ts b/apps/api-gateway/src/controllers/fedcm.controller.ts index f22a013..504aad9 100644 --- a/apps/api-gateway/src/controllers/fedcm.controller.ts +++ b/apps/api-gateway/src/controllers/fedcm.controller.ts @@ -246,4 +246,21 @@ export class FedcmController { ) { return this.syncFedcmSessionFromAuthorization(authorization, res); } + + @Get('login-status') + @ApiOperation({ + summary: 'FedCM Login Status (API origin)', + description: + 'Минимальная HTML-страница на API-домене: Set-Login + navigator.login.setStatus для Chrome FedCM (origin login_url).' + }) + async loginStatus(@Req() req: Request, @Res() res: Response) { + const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie); + const loggedIn = Boolean(session); + applyFedcmLoginStatus(res, loggedIn); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.status(200).send(``); + } } diff --git a/apps/api-gateway/src/lib/fedcm-cookie.ts b/apps/api-gateway/src/lib/fedcm-cookie.ts index 19a5c97..36457ea 100644 --- a/apps/api-gateway/src/lib/fedcm-cookie.ts +++ b/apps/api-gateway/src/lib/fedcm-cookie.ts @@ -1,5 +1,6 @@ -import type { Response } from 'express'; +import type { CookieOptions, Response } from 'express'; import { JwtService } from '@nestjs/jwt'; +import { resolveRegistrableDomain } from './public-url'; export const FEDCM_SESSION_COOKIE = 'lendry_fedcm_sess'; @@ -12,6 +13,10 @@ export interface FedcmSessionPayload { function cookieSecureEnabled() { if (process.env.FEDCM_COOKIE_SECURE === 'true') return true; if (process.env.FEDCM_COOKIE_SECURE === 'false') return false; + const urls = [process.env.PUBLIC_API_URL, process.env.PUBLIC_FRONTEND_URL]; + if (urls.some((url) => url?.trim().startsWith('https://'))) { + return true; + } return process.env.NODE_ENV === 'production'; } @@ -20,6 +25,52 @@ function cookieMaxAgeSeconds() { return Number.isFinite(parsed) && parsed > 0 ? parsed : 2_592_000; } +/** Общий домен cookie для api.* / sso.* / apex (FedCM accounts на api-домене). */ +export function resolveFedcmCookieDomain(): string | undefined { + const explicit = process.env.FEDCM_COOKIE_DOMAIN?.trim(); + if (explicit) { + if (['none', 'off', 'localhost'].includes(explicit.toLowerCase())) { + return undefined; + } + return explicit.startsWith('.') ? explicit : `.${explicit}`; + } + + for (const raw of [process.env.PUBLIC_API_URL, process.env.PUBLIC_FRONTEND_URL]) { + const candidate = raw?.trim(); + if (!candidate) continue; + try { + const hostname = new URL(candidate).hostname.toLowerCase(); + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') { + return undefined; + } + const apex = resolveRegistrableDomain(hostname); + if (apex.includes('.')) { + return `.${apex}`; + } + } catch { + continue; + } + } + + return undefined; +} + +function buildFedcmCookieOptions(maxAgeMs?: number): CookieOptions { + const secure = cookieSecureEnabled(); + const options: CookieOptions = { + httpOnly: true, + secure, + sameSite: secure ? 'none' : 'lax', + path: '/', + maxAge: maxAgeMs ?? cookieMaxAgeSeconds() * 1000 + }; + const domain = resolveFedcmCookieDomain(); + if (domain) { + options.domain = domain; + } + return options; +} + export async function signFedcmSessionPayload(jwt: JwtService, payload: FedcmSessionPayload) { return jwt.signAsync( { sub: payload.sub, sessionId: payload.sessionId, pinVerified: payload.pinVerified, typ: 'fedcm_session' }, @@ -57,32 +108,19 @@ export async function setFedcmSessionCookie(res: Response, jwt: JwtService, payl return; } - const secure = cookieSecureEnabled(); const value = await signFedcmSessionPayload(jwt, payload); if (res.headersSent) { return; } - res.cookie(FEDCM_SESSION_COOKIE, value, { - httpOnly: true, - secure, - sameSite: secure ? 'none' : 'lax', - path: '/', - maxAge: cookieMaxAgeSeconds() * 1000 - }); + res.cookie(FEDCM_SESSION_COOKIE, value, buildFedcmCookieOptions()); } export function clearFedcmSessionCookie(res: Response) { if (res.headersSent) { return; } - const secure = cookieSecureEnabled(); - res.clearCookie(FEDCM_SESSION_COOKIE, { - httpOnly: true, - secure, - sameSite: secure ? 'none' : 'lax', - path: '/' - }); + res.clearCookie(FEDCM_SESSION_COOKIE, buildFedcmCookieOptions(0)); } export function readFedcmSessionCookie(cookieHeader?: string): string | null { diff --git a/apps/frontend/app/providers.tsx b/apps/frontend/app/providers.tsx index a659759..f688e01 100644 --- a/apps/frontend/app/providers.tsx +++ b/apps/frontend/app/providers.tsx @@ -9,12 +9,12 @@ import { AppToastProvider } from '@/components/id/toast-provider'; export function Providers({ children }: { children: React.ReactNode }) { return ( - - + + {children} - - + + ); } diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 5719d4f..00e7f0d 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -29,11 +29,14 @@ import { resetGatewayCircuit, resetPinRequiredNotification, scheduleFedcmSessionSync, + syncFedcmSession, setPinRequiredHandler, subscribeApiReady, subscribeApiNotReady, } from '@/lib/api'; import { finalizeFedcmLogin, shouldFinalizeFedcmLogin } from '@/lib/fedcm-login-bridge'; +import { FedcmApiLoginStatusBridge } from './fedcm-api-login-status'; +import { usePublicSettings } from './public-settings-provider'; import { useToast } from './toast-provider'; import { PinLockModal } from './pin-lock-modal'; import { AppBootstrapScreen } from './app-bootstrap-screen'; @@ -123,6 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); const { showToast } = useToast(); + const { publicApiUrl } = usePublicSettings(); const initialAuth = React.useMemo(() => readInitialAuthState(), []); const [token, setToken] = React.useState(initialAuth.token); const [user, setUser] = React.useState(null); @@ -258,6 +262,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (typeof window !== 'undefined' && shouldFinalizeFedcmLogin(window.location.pathname)) { void finalizeFedcmLogin(auth.accessToken); } else { + void syncFedcmSession(auth.accessToken); scheduleFedcmSessionSync(auth.accessToken, 8000); } } @@ -751,6 +756,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { children )} + ); } diff --git a/apps/frontend/components/id/fedcm-api-login-status.tsx b/apps/frontend/components/id/fedcm-api-login-status.tsx new file mode 100644 index 0000000..0e3a732 --- /dev/null +++ b/apps/frontend/components/id/fedcm-api-login-status.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useEffect } from 'react'; +import { syncFedcmSession } from '@/lib/api'; +import { DEFAULT_PUBLIC_API_URL } from '@/lib/project-domains'; + +/** Синхронизирует FedCM cookie и Login Status API на origin API-домена (login_url). */ +export function FedcmApiLoginStatusBridge({ + active, + accessToken, + publicApiUrl = DEFAULT_PUBLIC_API_URL +}: { + active: boolean; + accessToken?: string | null; + publicApiUrl?: string; +}) { + useEffect(() => { + if (!active || !accessToken) return; + void syncFedcmSession(accessToken); + }, [accessToken, active]); + + useEffect(() => { + if (!active) return; + const apiBase = publicApiUrl.replace(/\/+$/, ''); + if (!apiBase) return; + + const iframe = document.createElement('iframe'); + iframe.hidden = true; + iframe.title = 'FedCM login status'; + iframe.src = `${apiBase}/fedcm/login-status`; + document.body.appendChild(iframe); + + return () => { + iframe.remove(); + }; + }, [active, publicApiUrl]); + + return null; +} diff --git a/apps/frontend/components/id/public-settings-provider.tsx b/apps/frontend/components/id/public-settings-provider.tsx index ca4cb11..2d87005 100644 --- a/apps/frontend/components/id/public-settings-provider.tsx +++ b/apps/frontend/components/id/public-settings-provider.tsx @@ -2,10 +2,13 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { apiFetch } from '@/lib/api'; +import { DEFAULT_PUBLIC_API_URL, DEFAULT_PUBLIC_FRONTEND_URL, inferApiBaseFromProjectDomain } from '@/lib/project-domains'; interface PublicSettingsContextValue { projectName: string; projectTagline: string; + publicApiUrl: string; + publicFrontendUrl: string; ldapEnabled: boolean; ldapUseLdaps: boolean; isLoading: boolean; @@ -15,6 +18,8 @@ interface PublicSettingsContextValue { const PublicSettingsContext = createContext({ projectName: 'MVK ID', projectTagline: 'Единый аккаунт для сервисов Lendry', + publicApiUrl: DEFAULT_PUBLIC_API_URL, + publicFrontendUrl: DEFAULT_PUBLIC_FRONTEND_URL, ldapEnabled: false, ldapUseLdaps: false, isLoading: true, @@ -41,17 +46,29 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode void refreshPublicSettings(); }, [refreshPublicSettings]); - const value = useMemo( - () => ({ + const value = useMemo(() => { + const projectDomain = settings.PROJECT_DOMAIN?.trim(); + const inferredApi = projectDomain ? inferApiBaseFromProjectDomain(projectDomain) : null; + const publicApiUrl = settings.PUBLIC_API_URL?.trim() || inferredApi || DEFAULT_PUBLIC_API_URL; + const publicFrontendUrl = + settings.PUBLIC_FRONTEND_URL?.trim() || + (projectDomain + ? projectDomain.startsWith('http') + ? projectDomain.replace(/\/+$/, '') + : `https://${projectDomain.replace(/^\/+/, '')}` + : DEFAULT_PUBLIC_FRONTEND_URL); + + return { projectName: settings.PROJECT_NAME || 'MVK ID', projectTagline: settings.PROJECT_TAGLINE || 'Единый аккаунт для сервисов Lendry', + publicApiUrl: publicApiUrl.replace(/\/+$/, ''), + publicFrontendUrl: publicFrontendUrl.replace(/\/+$/, ''), ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()), ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()), isLoading, refreshPublicSettings - }), - [isLoading, refreshPublicSettings, settings.LDAP_ENABLED, settings.LDAP_USE_LDAPS, settings.PROJECT_NAME, settings.PROJECT_TAGLINE] - ); + }; + }, [isLoading, refreshPublicSettings, settings]); return {children}; } diff --git a/docker-compose.yml b/docker-compose.yml index 3ed3d6f..9042e59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -141,6 +141,7 @@ services: PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} PUBLIC_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002} FEDCM_COOKIE_SECURE: ${FEDCM_COOKIE_SECURE:-false} + FEDCM_COOKIE_DOMAIN: ${FEDCM_COOKIE_DOMAIN:-} TRUST_PROXY: ${TRUST_PROXY:-true} ports: - "127.0.0.1:3000:3000" diff --git a/install.sh b/install.sh index b6863c0..8a471aa 100644 --- a/install.sh +++ b/install.sh @@ -1102,6 +1102,11 @@ derive_public_urls() { if [[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then env_set FEDCM_COOKIE_SECURE "true" + local fedcm_apex + fedcm_apex="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")" + if [[ -n "$fedcm_apex" && "$fedcm_apex" != "localhost" ]]; then + env_set FEDCM_COOKIE_DOMAIN ".${fedcm_apex}" + fi fi SSL_TYPE="$ssl_type"