fix and update

This commit is contained in:
lendry
2026-07-01 14:34:55 +03:00
parent 55deb5c152
commit 0c9b8e2629
6 changed files with 137 additions and 19 deletions

View File

@@ -1,13 +1,18 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service'; import { CoreGrpcService } from '../core-grpc.service';
import { resolveFrontendUrl, resolveOAuthIssuer } from './oauth-issuer';
import { import {
resolveFrontendUrl,
resolveOAuthIssuer,
resolveProjectDomainApiUrl,
resolveProjectDomainFrontendUrl
} from './oauth-issuer';
import {
isLocalDevBaseUrl,
isValidFedcmEndpointUrl,
normalizePublicBaseUrl, normalizePublicBaseUrl,
preferCanonicalBase, pickBestPublicBase,
resolveFedcmWebIdentityOrigin, resolveFedcmWebIdentityOrigin
resolvePublicApiBaseFromRequest,
resolvePublicFrontendBaseFromRequest
} from './public-url'; } from './public-url';
export interface FedcmEndpoints { export interface FedcmEndpoints {
@@ -39,17 +44,44 @@ function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) {
}; };
} }
export async function resolveFedcmEndpoints(core: CoreGrpcService, req?: Request): Promise<FedcmEndpoints> { async function resolveCanonicalFedcmIssuer(core: CoreGrpcService): Promise<string> {
const storedIssuer = await resolveOAuthIssuer(core); const stored = await resolveOAuthIssuer(core);
const storedFrontend = await resolveFrontendUrl(core); const fromProject = await resolveProjectDomainApiUrl(core);
// ВАЖНО для FedCM: configUrl должен быть стабильным независимо от того, с какого const issuer = pickBestPublicBase(
// хоста пришёл запрос. well-known запрашивается браузером всегда с eTLD+1 [stored, fromProject],
// (например https://idpmvk.lpr/.well-known/web-identity), а discover.json — с stored
// поддомена (https://sso.idpmvk.lpr/idp-api/...). Оба обязаны вернуть ОДИН и тот );
// же configUrl, иначе браузер отвергнет провайдера. Поэтому берём канонический if (isValidFedcmEndpointUrl(issuer)) {
// публичный URL из настроек, а не из текущего хоста запроса. return issuer;
const issuer = preferCanonicalBase(storedIssuer, resolvePublicApiBaseFromRequest(req)); }
const frontendUrl = preferCanonicalBase(storedFrontend, resolvePublicFrontendBaseFromRequest(req)); if (fromProject && isValidFedcmEndpointUrl(fromProject)) {
return fromProject;
}
return issuer;
}
async function resolveCanonicalFedcmFrontend(core: CoreGrpcService): Promise<string> {
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<FedcmEndpoints> {
// 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'; let projectName = 'MVK ID';
try { try {

View File

@@ -51,7 +51,8 @@ export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http
} }
const fromDomain = await resolveProjectDomainUrl(core, true); 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<string> { export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http://localhost:3002'): Promise<string> {
@@ -65,7 +66,15 @@ export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http
} }
const fromDomain = await resolveProjectDomainUrl(core, false); 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<string | undefined> {
return resolveProjectDomainUrl(core, false);
}
export async function resolveProjectDomainApiUrl(core: CoreGrpcService): Promise<string | undefined> {
return resolveProjectDomainUrl(core, true);
} }
export function buildOpenIdConfiguration(issuer: string) { export function buildOpenIdConfiguration(issuer: string) {

View File

@@ -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 { export function readRequestHost(req?: Request): string | undefined {
if (!req) { if (!req) {
return undefined; return undefined;

View File

@@ -83,6 +83,12 @@ export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const finishLoginRedirect = useCallback(() => { 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 = const redirectAfterLogin =
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null; typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null;
if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) { if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) {

View File

@@ -33,6 +33,7 @@ import {
subscribeApiReady, subscribeApiReady,
subscribeApiNotReady, subscribeApiNotReady,
} from '@/lib/api'; } from '@/lib/api';
import { finalizeFedcmLogin, shouldFinalizeFedcmLogin } from '@/lib/fedcm-login-bridge';
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';
@@ -254,7 +255,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}); });
if (auth.pinVerified !== false) { 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] [activatePinLock, clearPinLock, showToast]

View File

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