fix and update
This commit is contained in:
@@ -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>`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,12 +9,12 @@ import { AppToastProvider } from '@/components/id/toast-provider';
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AppToastProvider>
|
||||
<AuthProvider>
|
||||
<PublicSettingsProvider>
|
||||
<AuthProvider>
|
||||
<ProjectHead />
|
||||
<RealtimeProvider>{children}</RealtimeProvider>
|
||||
</PublicSettingsProvider>
|
||||
</AuthProvider>
|
||||
</PublicSettingsProvider>
|
||||
</AppToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
39
apps/frontend/components/id/fedcm-api-login-status.tsx
Normal file
39
apps/frontend/components/id/fedcm-api-login-status.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user