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"