fix and update

This commit is contained in:
lendry
2026-07-01 15:52:17 +03:00
parent de4310239c
commit f7c01a3963
8 changed files with 152 additions and 25 deletions

View File

@@ -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(`<!DOCTYPE html><html lang="ru"><head><meta charset="utf-8"></head><body><script>
try{if(navigator.login&&navigator.login.setStatus){navigator.login.setStatus('${loggedIn ? 'logged-in' : 'logged-out'}');}}catch(e){}
</script></body></html>`);
}
}

View File

@@ -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 {

View File

@@ -9,12 +9,12 @@ import { AppToastProvider } from '@/components/id/toast-provider';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AppToastProvider>
<AuthProvider>
<PublicSettingsProvider>
<PublicSettingsProvider>
<AuthProvider>
<ProjectHead />
<RealtimeProvider>{children}</RealtimeProvider>
</PublicSettingsProvider>
</AuthProvider>
</AuthProvider>
</PublicSettingsProvider>
</AppToastProvider>
);
}

View File

@@ -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<string | null>(initialAuth.token);
const [user, setUser] = React.useState<PublicUser | null>(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
)}
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
<FedcmApiLoginStatusBridge
active={isSessionReady && hasStoredSession && !isPinLocked}
accessToken={token}
publicApiUrl={publicApiUrl}
/>
</AuthContext.Provider>
);
}

View File

@@ -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;
}

View File

@@ -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<PublicSettingsContextValue>({
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 <PublicSettingsContext.Provider value={value}>{children}</PublicSettingsContext.Provider>;
}

View File

@@ -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"

View File

@@ -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"