fix and update
This commit is contained in:
@@ -246,4 +246,21 @@ export class FedcmController {
|
|||||||
) {
|
) {
|
||||||
return this.syncFedcmSessionFromAuthorization(authorization, res);
|
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 { JwtService } from '@nestjs/jwt';
|
||||||
|
import { resolveRegistrableDomain } from './public-url';
|
||||||
|
|
||||||
export const FEDCM_SESSION_COOKIE = 'lendry_fedcm_sess';
|
export const FEDCM_SESSION_COOKIE = 'lendry_fedcm_sess';
|
||||||
|
|
||||||
@@ -12,6 +13,10 @@ export interface FedcmSessionPayload {
|
|||||||
function cookieSecureEnabled() {
|
function cookieSecureEnabled() {
|
||||||
if (process.env.FEDCM_COOKIE_SECURE === 'true') return true;
|
if (process.env.FEDCM_COOKIE_SECURE === 'true') return true;
|
||||||
if (process.env.FEDCM_COOKIE_SECURE === 'false') return false;
|
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';
|
return process.env.NODE_ENV === 'production';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +25,52 @@ function cookieMaxAgeSeconds() {
|
|||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2_592_000;
|
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) {
|
export async function signFedcmSessionPayload(jwt: JwtService, payload: FedcmSessionPayload) {
|
||||||
return jwt.signAsync(
|
return jwt.signAsync(
|
||||||
{ sub: payload.sub, sessionId: payload.sessionId, pinVerified: payload.pinVerified, typ: 'fedcm_session' },
|
{ 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const secure = cookieSecureEnabled();
|
|
||||||
const value = await signFedcmSessionPayload(jwt, payload);
|
const value = await signFedcmSessionPayload(jwt, payload);
|
||||||
if (res.headersSent) {
|
if (res.headersSent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.cookie(FEDCM_SESSION_COOKIE, value, {
|
res.cookie(FEDCM_SESSION_COOKIE, value, buildFedcmCookieOptions());
|
||||||
httpOnly: true,
|
|
||||||
secure,
|
|
||||||
sameSite: secure ? 'none' : 'lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: cookieMaxAgeSeconds() * 1000
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearFedcmSessionCookie(res: Response) {
|
export function clearFedcmSessionCookie(res: Response) {
|
||||||
if (res.headersSent) {
|
if (res.headersSent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const secure = cookieSecureEnabled();
|
res.clearCookie(FEDCM_SESSION_COOKIE, buildFedcmCookieOptions(0));
|
||||||
res.clearCookie(FEDCM_SESSION_COOKIE, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure,
|
|
||||||
sameSite: secure ? 'none' : 'lax',
|
|
||||||
path: '/'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readFedcmSessionCookie(cookieHeader?: string): string | null {
|
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 }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AppToastProvider>
|
<AppToastProvider>
|
||||||
<AuthProvider>
|
|
||||||
<PublicSettingsProvider>
|
<PublicSettingsProvider>
|
||||||
|
<AuthProvider>
|
||||||
<ProjectHead />
|
<ProjectHead />
|
||||||
<RealtimeProvider>{children}</RealtimeProvider>
|
<RealtimeProvider>{children}</RealtimeProvider>
|
||||||
</PublicSettingsProvider>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</PublicSettingsProvider>
|
||||||
</AppToastProvider>
|
</AppToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,14 @@ import {
|
|||||||
resetGatewayCircuit,
|
resetGatewayCircuit,
|
||||||
resetPinRequiredNotification,
|
resetPinRequiredNotification,
|
||||||
scheduleFedcmSessionSync,
|
scheduleFedcmSessionSync,
|
||||||
|
syncFedcmSession,
|
||||||
setPinRequiredHandler,
|
setPinRequiredHandler,
|
||||||
subscribeApiReady,
|
subscribeApiReady,
|
||||||
subscribeApiNotReady,
|
subscribeApiNotReady,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { finalizeFedcmLogin, shouldFinalizeFedcmLogin } from '@/lib/fedcm-login-bridge';
|
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 { useToast } from './toast-provider';
|
||||||
import { PinLockModal } from './pin-lock-modal';
|
import { PinLockModal } from './pin-lock-modal';
|
||||||
import { AppBootstrapScreen } from './app-bootstrap-screen';
|
import { AppBootstrapScreen } from './app-bootstrap-screen';
|
||||||
@@ -123,6 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const { publicApiUrl } = usePublicSettings();
|
||||||
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
|
const initialAuth = React.useMemo(() => readInitialAuthState(), []);
|
||||||
const [token, setToken] = React.useState<string | null>(initialAuth.token);
|
const [token, setToken] = React.useState<string | null>(initialAuth.token);
|
||||||
const [user, setUser] = React.useState<PublicUser | null>(null);
|
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)) {
|
if (typeof window !== 'undefined' && shouldFinalizeFedcmLogin(window.location.pathname)) {
|
||||||
void finalizeFedcmLogin(auth.accessToken);
|
void finalizeFedcmLogin(auth.accessToken);
|
||||||
} else {
|
} else {
|
||||||
|
void syncFedcmSession(auth.accessToken);
|
||||||
scheduleFedcmSessionSync(auth.accessToken, 8000);
|
scheduleFedcmSessionSync(auth.accessToken, 8000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -751,6 +756,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
|
<PinLockModal open={isPinLocked} isSubmitting={pinSubmitting} error={pinError} onSubmit={handlePinUnlock} />
|
||||||
|
<FedcmApiLoginStatusBridge
|
||||||
|
active={isSessionReady && hasStoredSession && !isPinLocked}
|
||||||
|
accessToken={token}
|
||||||
|
publicApiUrl={publicApiUrl}
|
||||||
|
/>
|
||||||
</AuthContext.Provider>
|
</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 { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { DEFAULT_PUBLIC_API_URL, DEFAULT_PUBLIC_FRONTEND_URL, inferApiBaseFromProjectDomain } from '@/lib/project-domains';
|
||||||
|
|
||||||
interface PublicSettingsContextValue {
|
interface PublicSettingsContextValue {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
projectTagline: string;
|
projectTagline: string;
|
||||||
|
publicApiUrl: string;
|
||||||
|
publicFrontendUrl: string;
|
||||||
ldapEnabled: boolean;
|
ldapEnabled: boolean;
|
||||||
ldapUseLdaps: boolean;
|
ldapUseLdaps: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -15,6 +18,8 @@ interface PublicSettingsContextValue {
|
|||||||
const PublicSettingsContext = createContext<PublicSettingsContextValue>({
|
const PublicSettingsContext = createContext<PublicSettingsContextValue>({
|
||||||
projectName: 'MVK ID',
|
projectName: 'MVK ID',
|
||||||
projectTagline: 'Единый аккаунт для сервисов Lendry',
|
projectTagline: 'Единый аккаунт для сервисов Lendry',
|
||||||
|
publicApiUrl: DEFAULT_PUBLIC_API_URL,
|
||||||
|
publicFrontendUrl: DEFAULT_PUBLIC_FRONTEND_URL,
|
||||||
ldapEnabled: false,
|
ldapEnabled: false,
|
||||||
ldapUseLdaps: false,
|
ldapUseLdaps: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@@ -41,17 +46,29 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode
|
|||||||
void refreshPublicSettings();
|
void refreshPublicSettings();
|
||||||
}, [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',
|
projectName: settings.PROJECT_NAME || 'MVK ID',
|
||||||
projectTagline: settings.PROJECT_TAGLINE || 'Единый аккаунт для сервисов Lendry',
|
projectTagline: settings.PROJECT_TAGLINE || 'Единый аккаунт для сервисов Lendry',
|
||||||
|
publicApiUrl: publicApiUrl.replace(/\/+$/, ''),
|
||||||
|
publicFrontendUrl: publicFrontendUrl.replace(/\/+$/, ''),
|
||||||
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
|
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
|
||||||
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
|
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
|
||||||
isLoading,
|
isLoading,
|
||||||
refreshPublicSettings
|
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>;
|
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_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api}
|
||||||
PUBLIC_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002}
|
PUBLIC_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002}
|
||||||
FEDCM_COOKIE_SECURE: ${FEDCM_COOKIE_SECURE:-false}
|
FEDCM_COOKIE_SECURE: ${FEDCM_COOKIE_SECURE:-false}
|
||||||
|
FEDCM_COOKIE_DOMAIN: ${FEDCM_COOKIE_DOMAIN:-}
|
||||||
TRUST_PROXY: ${TRUST_PROXY:-true}
|
TRUST_PROXY: ${TRUST_PROXY:-true}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
|
|||||||
@@ -1102,6 +1102,11 @@ derive_public_urls() {
|
|||||||
|
|
||||||
if [[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then
|
if [[ "$ssl_type" == "letsencrypt" || "$ssl_type" == "selfsigned" || "$ssl_type" == "custom" ]]; then
|
||||||
env_set FEDCM_COOKIE_SECURE "true"
|
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
|
fi
|
||||||
|
|
||||||
SSL_TYPE="$ssl_type"
|
SSL_TYPE="$ssl_type"
|
||||||
|
|||||||
Reference in New Issue
Block a user