From 0c9b8e26294e1399ab10c49ab11bbcdf812b84f4 Mon Sep 17 00:00:00 2001 From: lendry Date: Wed, 1 Jul 2026 14:34:55 +0300 Subject: [PATCH] fix and update --- apps/api-gateway/src/lib/fedcm-config.ts | 64 ++++++++++++++----- apps/api-gateway/src/lib/oauth-issuer.ts | 13 +++- apps/api-gateway/src/lib/public-url.ts | 19 ++++++ apps/frontend/app/auth/login/page.tsx | 6 ++ apps/frontend/components/id/auth-provider.tsx | 7 +- apps/frontend/lib/fedcm-login-bridge.ts | 47 ++++++++++++++ 6 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 apps/frontend/lib/fedcm-login-bridge.ts diff --git a/apps/api-gateway/src/lib/fedcm-config.ts b/apps/api-gateway/src/lib/fedcm-config.ts index 590ac5b..29cd8f5 100644 --- a/apps/api-gateway/src/lib/fedcm-config.ts +++ b/apps/api-gateway/src/lib/fedcm-config.ts @@ -1,13 +1,18 @@ import type { Request } from 'express'; import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; -import { resolveFrontendUrl, resolveOAuthIssuer } from './oauth-issuer'; import { + resolveFrontendUrl, + resolveOAuthIssuer, + resolveProjectDomainApiUrl, + resolveProjectDomainFrontendUrl +} from './oauth-issuer'; +import { + isLocalDevBaseUrl, + isValidFedcmEndpointUrl, normalizePublicBaseUrl, - preferCanonicalBase, - resolveFedcmWebIdentityOrigin, - resolvePublicApiBaseFromRequest, - resolvePublicFrontendBaseFromRequest + pickBestPublicBase, + resolveFedcmWebIdentityOrigin } from './public-url'; export interface FedcmEndpoints { @@ -39,17 +44,44 @@ function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) { }; } -export async function resolveFedcmEndpoints(core: CoreGrpcService, req?: Request): Promise { - const storedIssuer = await resolveOAuthIssuer(core); - const storedFrontend = await resolveFrontendUrl(core); - // ВАЖНО для FedCM: configUrl должен быть стабильным независимо от того, с какого - // хоста пришёл запрос. well-known запрашивается браузером всегда с eTLD+1 - // (например https://idpmvk.lpr/.well-known/web-identity), а discover.json — с - // поддомена (https://sso.idpmvk.lpr/idp-api/...). Оба обязаны вернуть ОДИН и тот - // же configUrl, иначе браузер отвергнет провайдера. Поэтому берём канонический - // публичный URL из настроек, а не из текущего хоста запроса. - const issuer = preferCanonicalBase(storedIssuer, resolvePublicApiBaseFromRequest(req)); - const frontendUrl = preferCanonicalBase(storedFrontend, resolvePublicFrontendBaseFromRequest(req)); +async function resolveCanonicalFedcmIssuer(core: CoreGrpcService): Promise { + const stored = await resolveOAuthIssuer(core); + const fromProject = await resolveProjectDomainApiUrl(core); + const issuer = pickBestPublicBase( + [stored, fromProject], + stored + ); + if (isValidFedcmEndpointUrl(issuer)) { + return issuer; + } + if (fromProject && isValidFedcmEndpointUrl(fromProject)) { + return fromProject; + } + return issuer; +} + +async function resolveCanonicalFedcmFrontend(core: CoreGrpcService): Promise { + const stored = await resolveFrontendUrl(core); + const fromProject = await resolveProjectDomainFrontendUrl(core); + const frontend = pickBestPublicBase( + [stored, fromProject], + stored + ); + if (isValidFedcmEndpointUrl(frontend) && !isLocalDevBaseUrl(frontend)) { + return frontend; + } + if (fromProject && isValidFedcmEndpointUrl(fromProject)) { + return fromProject; + } + return frontend; +} + +export async function resolveFedcmEndpoints(core: CoreGrpcService, _req?: Request): Promise { + // FedCM: web-identity (idpmvk.lpr) и config.json (api.idpmvk.lpr) ОБЯЗАНЫ + // возвращать одинаковые login_url / accounts_endpoint / configUrl. + // Хост запроса разный — никогда не выводим URL из req.headers.host. + const issuer = await resolveCanonicalFedcmIssuer(core); + const frontendUrl = await resolveCanonicalFedcmFrontend(core); let projectName = 'MVK ID'; try { diff --git a/apps/api-gateway/src/lib/oauth-issuer.ts b/apps/api-gateway/src/lib/oauth-issuer.ts index 895b338..cb9a128 100644 --- a/apps/api-gateway/src/lib/oauth-issuer.ts +++ b/apps/api-gateway/src/lib/oauth-issuer.ts @@ -51,7 +51,8 @@ export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http } const fromDomain = await resolveProjectDomainUrl(core, true); - return pickBestPublicBase([fromDb, envIssuer, fromDomain], fallback); + // env первым: в Docker .env перекрывает seed localhost в БД после первого деплоя. + return pickBestPublicBase([envIssuer, fromDb, fromDomain], fallback); } export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http://localhost:3002'): Promise { @@ -65,7 +66,15 @@ export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http } const fromDomain = await resolveProjectDomainUrl(core, false); - return pickBestPublicBase([fromDb, envFrontend, fromDomain], fallback); + return pickBestPublicBase([envFrontend, fromDb, fromDomain], fallback); +} + +export async function resolveProjectDomainFrontendUrl(core: CoreGrpcService): Promise { + return resolveProjectDomainUrl(core, false); +} + +export async function resolveProjectDomainApiUrl(core: CoreGrpcService): Promise { + return resolveProjectDomainUrl(core, true); } export function buildOpenIdConfiguration(issuer: string) { diff --git a/apps/api-gateway/src/lib/public-url.ts b/apps/api-gateway/src/lib/public-url.ts index a93232b..586de6b 100644 --- a/apps/api-gateway/src/lib/public-url.ts +++ b/apps/api-gateway/src/lib/public-url.ts @@ -47,6 +47,25 @@ export function isBrowserReachableBaseUrl(url: string) { } } +/** Абсолютный HTTPS (или localhost HTTP) URL для FedCM login_url / configUrl. */ +export function isValidFedcmEndpointUrl(url: string) { + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return false; + } + if (isInternalHostname(parsed.hostname)) { + return false; + } + if (isLocalDevBaseUrl(url)) { + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } + return parsed.protocol === 'https:'; + } catch { + return false; + } +} + export function readRequestHost(req?: Request): string | undefined { if (!req) { return undefined; diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index 988f099..5b8d1c4 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -83,6 +83,12 @@ export default function LoginPage() { const router = useRouter(); const finishLoginRedirect = useCallback(() => { + if (typeof window !== 'undefined') { + const fedcmLoginApi = (navigator as Navigator & { login?: { setStatus?: (status: string) => void } }).login; + if (window.opener && typeof fedcmLoginApi?.setStatus === 'function') { + return; + } + } const redirectAfterLogin = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null; if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) { diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 6b09585..5719d4f 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -33,6 +33,7 @@ import { subscribeApiReady, subscribeApiNotReady, } from '@/lib/api'; +import { finalizeFedcmLogin, shouldFinalizeFedcmLogin } from '@/lib/fedcm-login-bridge'; import { useToast } from './toast-provider'; import { PinLockModal } from './pin-lock-modal'; import { AppBootstrapScreen } from './app-bootstrap-screen'; @@ -254,7 +255,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); if (auth.pinVerified !== false) { - scheduleFedcmSessionSync(auth.accessToken, 8000); + if (typeof window !== 'undefined' && shouldFinalizeFedcmLogin(window.location.pathname)) { + void finalizeFedcmLogin(auth.accessToken); + } else { + scheduleFedcmSessionSync(auth.accessToken, 8000); + } } }, [activatePinLock, clearPinLock, showToast] diff --git a/apps/frontend/lib/fedcm-login-bridge.ts b/apps/frontend/lib/fedcm-login-bridge.ts new file mode 100644 index 0000000..c1ebb10 --- /dev/null +++ b/apps/frontend/lib/fedcm-login-bridge.ts @@ -0,0 +1,47 @@ +import { syncFedcmSession } from '@/lib/api'; + +type FedcmLoginNavigator = Navigator & { + login?: { + setStatus: (status: 'logged-in' | 'logged-out') => void; + }; +}; + +type FedcmIdentityProviderGlobal = { + close?: () => void; +}; + +declare global { + interface Window { + IdentityProvider?: FedcmIdentityProviderGlobal; + } +} + +/** Завершение входа в popup FedCM: cookie + Set-Login для браузера. */ +export async function finalizeFedcmLogin(accessToken: string) { + if (typeof window === 'undefined' || !accessToken) { + return; + } + + await syncFedcmSession(accessToken); + + const loginApi = (navigator as FedcmLoginNavigator).login; + if (loginApi?.setStatus) { + try { + loginApi.setStatus('logged-in'); + } catch { + // FedCM API может быть недоступен вне popup-контекста + } + } + + if (window.IdentityProvider?.close) { + try { + window.IdentityProvider.close(); + } catch { + // закрытие необязательно — браузер сам закроет popup после setStatus + } + } +} + +export function shouldFinalizeFedcmLogin(pathname: string) { + return pathname.startsWith('/auth/login'); +}