fix and update

This commit is contained in:
lendry
2026-06-29 14:40:35 +03:00
parent 75ccbe5fc4
commit 0df7240dc8
21 changed files with 934 additions and 113 deletions

View File

@@ -13,7 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Input } from '@/components/ui/input';
import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api';
import { getAdminLandingPath } from '@/lib/admin-access';
import { buildAuthorizeUrl, buildOAuthEndpoints, resolveOAuthApiBase } from '@/lib/oauth-url';
import { buildAuthorizeUrl, buildOAuthEndpoints, resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url';
export default function AdminOAuthPage() {
const router = useRouter();
@@ -26,16 +26,22 @@ export default function AdminOAuthPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<OAuthClient | null>(null);
const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null);
const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3000');
const [oauthApiBase, setOauthApiBase] = useState('http://localhost:3002/idp-api');
const [frontendBase, setFrontendBase] = useState('http://localhost:3002');
const [projectName, setProjectName] = useState('MVK ID');
const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] });
const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase), [oauthApiBase]);
const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase, frontendBase), [oauthApiBase, frontendBase]);
const loadPublicSettings = useCallback(async () => {
try {
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
const settings = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
setOauthApiBase(resolveOAuthApiBase(settings));
setFrontendBase(resolveFrontendBase(settings));
if (settings.PROJECT_NAME?.trim()) {
setProjectName(settings.PROJECT_NAME.trim());
}
} catch {
// оставляем fallback
}
@@ -182,11 +188,20 @@ export default function AdminOAuthPage() {
<p className="text-sm text-[#667085]">
Issuer: <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5 text-xs">{oauthEndpoints.issuer}</code>
{' · '}
Frontend: <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5 text-xs">{frontendBase}</code>
{' · '}
<a href={oauthEndpoints.openIdConfigurationUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[#1d4ed8] hover:underline">
OpenID Configuration
<ExternalLink className="h-3.5 w-3.5" />
</a>
</p>
<p className="mt-1 text-xs text-[#667085]">
One Tap Login настраивается в{' '}
<a href="/admin/settings" className="text-[#1d4ed8] hover:underline">
системных настройках
</a>{' '}
(PUBLIC_API_URL, PUBLIC_FRONTEND_URL, ONE_TAP_ENABLED).
</p>
</div>
<Button onClick={() => setDialogOpen(true)} disabled={!user?.canManageOAuth}>
<Plus className="h-4 w-4" />
@@ -244,6 +259,8 @@ export default function AdminOAuthPage() {
<OAuthClientDetailDialog
client={selectedClient}
endpoints={oauthEndpoints}
frontendBase={frontendBase}
projectName={projectName}
open={Boolean(selectedClient)}
onOpenChange={(open) => !open && setSelectedClient(null)}
onCopy={copyText}

View File

@@ -25,7 +25,15 @@ const GROUP_LABELS: Record<string, string> = {
'messaging-sms': 'SMS'
};
const PUBLIC_SETTINGS_REFRESH_KEYS = new Set(['PROJECT_NAME', 'PROJECT_TAGLINE', 'LDAP_ENABLED', 'LDAP_USE_LDAPS']);
const PUBLIC_SETTINGS_REFRESH_KEYS = new Set([
'PROJECT_NAME',
'PROJECT_TAGLINE',
'PUBLIC_API_URL',
'PUBLIC_FRONTEND_URL',
'ONE_TAP_ENABLED',
'LDAP_ENABLED',
'LDAP_USE_LDAPS'
]);
export default function AdminSettingsPage() {
const { token, user } = useAuth();

View File

@@ -15,6 +15,11 @@ import {
fetchOAuthClientPublicInfo,
type OAuthConsentCheckResponse
} from '@/lib/api';
import {
parseAuthorizationRedirect,
parsePopupOAuthParams,
postOneTapResult
} from '@/lib/oauth-popup-bridge';
function buildOAuthQuery(searchParams: URLSearchParams) {
const params = new URLSearchParams();
@@ -38,6 +43,8 @@ function OAuthAuthorizeContent() {
const autoApproveStartedRef = useRef(false);
const oauthQuery = useMemo(() => buildOAuthQuery(searchParams), [searchParams]);
const popupContext = useMemo(() => parsePopupOAuthParams(searchParams), [searchParams]);
const isPopupMode = Boolean(popupContext);
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId');
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri');
@@ -99,6 +106,22 @@ function OAuthAuthorizeContent() {
};
}, [isLoading, isPinLocked, returnUrl, router, token, user]);
const finishPopupFlow = useCallback(
(redirectUrl: string) => {
if (!popupContext) return false;
const parsed = parseAuthorizationRedirect(redirectUrl);
postOneTapResult(popupContext.popupOrigin, {
code: parsed.code ?? undefined,
state: parsed.state ?? undefined,
token: parsed.code ?? undefined,
error: parsed.error ?? undefined,
errorDescription: parsed.errorDescription ?? undefined
});
return true;
},
[popupContext]
);
const approve = useCallback(async () => {
if (!token || !user || isPinLocked) return;
setSubmitting(true);
@@ -108,6 +131,9 @@ function OAuthAuthorizeContent() {
if (!data.redirectUrl) {
throw new Error('Сервер не вернул redirect URL');
}
if (finishPopupFlow(data.redirectUrl)) {
return;
}
window.location.href = data.redirectUrl;
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации');
@@ -115,7 +141,7 @@ function OAuthAuthorizeContent() {
} finally {
setSubmitting(false);
}
}, [isPinLocked, oauthQuery, token, user]);
}, [finishPopupFlow, isPinLocked, oauthQuery, token, user]);
useEffect(() => {
if (!token || !clientId || !sessionChecked || isPinLocked) return;
@@ -149,6 +175,14 @@ function OAuthAuthorizeContent() {
}, [approve, clientId, isPinLocked, oauthQuery, sessionChecked, token]);
const cancel = useCallback(() => {
if (popupContext) {
postOneTapResult(popupContext.popupOrigin, {
error: 'access_denied',
errorDescription: 'Пользователь отклонил запрос'
});
return;
}
if (!redirectUri) {
router.push('/');
return;
@@ -162,7 +196,7 @@ function OAuthAuthorizeContent() {
} catch {
router.push('/');
}
}, [redirectUri, router, state]);
}, [popupContext, redirectUri, router, state]);
const authReady = Boolean(user && token && !isPinLocked && sessionChecked);
const scopeItems = consentInfo?.requestedScopes ?? [];
@@ -205,10 +239,12 @@ function OAuthAuthorizeContent() {
}
return (
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-12">
<div className="mb-8 flex justify-center">
<BrandLogo />
</div>
<div className={`mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 ${isPopupMode ? 'py-6' : 'py-12'}`}>
{!isPopupMode ? (
<div className="mb-8 flex justify-center">
<BrandLogo />
</div>
) : null}
<div className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
<ShieldCheck className="h-6 w-6" />

View File

@@ -22,7 +22,8 @@ import {
PublicUser,
refreshAuthSession,
resetPinRequiredNotification,
setPinRequiredHandler
setPinRequiredHandler,
syncFedcmSession
} from '@/lib/api';
import { useToast } from './toast-provider';
import { PinLockModal } from './pin-lock-modal';
@@ -89,6 +90,10 @@ function applySessionState(
setters.activatePinLock(session.sessionId ?? '');
} else {
setters.clearPinLock();
const accessToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
if (accessToken) {
void syncFedcmSession(accessToken);
}
}
}
@@ -154,6 +159,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
cacheUserProfile(auth.user);
setHasStoredSession(true);
clearPinLock();
if (auth.pinVerified !== false) {
void syncFedcmSession(auth.accessToken);
}
},
[clearPinLock]
);

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { OAuthClient } from '@/lib/api';
import { OAuthEndpoints, buildAuthorizeUrl } from '@/lib/oauth-url';
import { OAuthClientOneTapSection } from '@/components/id/oauth-client-one-tap-section';
function CopyRow({ label, value, onCopy }: { label: string; value: string; onCopy: (value: string, label: string) => void }) {
return (
@@ -24,6 +25,8 @@ function CopyRow({ label, value, onCopy }: { label: string; value: string; onCop
export function OAuthClientDetailDialog({
client,
endpoints,
frontendBase,
projectName,
open,
onOpenChange,
onCopy,
@@ -34,6 +37,8 @@ export function OAuthClientDetailDialog({
}: {
client: OAuthClient | null;
endpoints: OAuthEndpoints;
frontendBase: string;
projectName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCopy: (value: string, label: string) => void;
@@ -136,7 +141,7 @@ export function OAuthClientDetailDialog({
<section className="space-y-3">
<h3 className="font-medium">Подключение OAuth 2.0 / OpenID Connect</h3>
<p className="text-xs text-[#667085]">
Issuer и endpoints берутся из настройки <strong>PUBLIC_API_URL</strong> в админке. В стороннем сервисе укажите issuer = базовый URL API, не authorization endpoint.
Issuer и endpoints берутся из настроек <strong>PUBLIC_API_URL</strong> и <strong>PUBLIC_FRONTEND_URL</strong> в админке.
</p>
<CopyRow label="Issuer (PUBLIC_API_URL)" value={endpoints.issuer} onCopy={onCopy} />
<CopyRow label="Authorization endpoint" value={endpoints.authorizationEndpoint} onCopy={onCopy} />
@@ -146,6 +151,14 @@ export function OAuthClientDetailDialog({
<CopyRow label="Пример authorize URL" value={sampleAuthorizeUrl} onCopy={onCopy} />
</section>
<OAuthClientOneTapSection
client={client}
endpoints={endpoints}
frontendBase={frontendBase}
projectName={projectName}
onCopy={onCopy}
/>
<section className="rounded-2xl border border-[#eceef4] p-4">
<div className="mb-2 flex items-center gap-2 font-medium">
<KeyRound className="h-4 w-4" />

View File

@@ -0,0 +1,137 @@
'use client';
import { useMemo, useState } from 'react';
import { Copy, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { OAuthClient } from '@/lib/api';
import { buildOneTapExamples } from '@/lib/one-tap-examples';
import { OAuthEndpoints } from '@/lib/oauth-url';
function SnippetBlock({ code, onCopy }: { code: string; onCopy: (value: string, label: string) => void }) {
return (
<div className="relative rounded-2xl bg-[#111827] p-4 text-xs text-[#e5e7eb]">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-2 h-8 w-8 text-white hover:bg-white/10 hover:text-white"
aria-label="Скопировать код"
onClick={() => onCopy(code, 'Код интеграции')}
>
<Copy className="h-4 w-4" />
</Button>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all pr-10">{code}</pre>
</div>
);
}
export function OAuthClientOneTapSection({
client,
endpoints,
frontendBase,
projectName,
onCopy
}: {
client: OAuthClient;
endpoints: OAuthEndpoints;
frontendBase: string;
projectName: string;
onCopy: (value: string, label: string) => void;
}) {
const primaryRedirect = client.redirectUris[0] ?? `${frontendBase}/auth/callback`;
const examples = useMemo(
() =>
buildOneTapExamples(
{
apiBase: endpoints.issuer,
frontendBase,
widgetUrl: endpoints.widgetUrl,
fedcmConfigUrl: endpoints.fedcmConfigUrl,
webIdentityUrl: endpoints.webIdentityUrl,
fedcmDiscoverUrl: endpoints.fedcmDiscoverUrl,
projectName
},
client.clientId,
primaryRedirect
),
[client.clientId, endpoints, frontendBase, primaryRedirect, projectName]
);
const [activeTab, setActiveTab] = useState(examples[0]?.id ?? 'widget-script');
return (
<section className="space-y-4 rounded-2xl border border-[#eceef4] p-4">
<div>
<h3 className="font-medium">One Tap Login</h3>
<p className="mt-1 text-xs leading-relaxed text-[#667085]">
FedCM (Chrome/Edge) и виджет <code className="rounded bg-[#f4f5f8] px-1 py-0.5">sso-widget.js</code> с popup fallback.
Убедитесь, что в настройках указаны <strong>PUBLIC_API_URL</strong> и <strong>PUBLIC_FRONTEND_URL</strong>.
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<CopyRowCompact label="FedCM config" value={endpoints.fedcmConfigUrl} onCopy={onCopy} />
<CopyRowCompact label="Web identity" value={endpoints.webIdentityUrl} onCopy={onCopy} />
<CopyRowCompact label="Виджет" value={endpoints.widgetUrl} onCopy={onCopy} />
<CopyRowCompact label="Discover" value={endpoints.fedcmDiscoverUrl} onCopy={onCopy} />
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-2 flex h-auto max-w-full flex-wrap gap-1">
{examples.map((example) => (
<TabsTrigger key={example.id} value={example.id} className="text-xs">
{example.label}
</TabsTrigger>
))}
</TabsList>
{examples.map((example) => (
<TabsContent key={example.id} value={example.id}>
<SnippetBlock code={example.code} onCopy={onCopy} />
</TabsContent>
))}
</Tabs>
<div className="rounded-xl bg-[#eef4ff] px-4 py-3 text-xs text-[#1d4ed8]">
<p className="font-medium">Проверка FedCM</p>
<ol className="mt-2 list-decimal space-y-1 pl-4">
<li>Войдите на IdP ({frontendBase}) в этом браузере.</li>
<li>Откройте сайт клиента с виджетом Chrome покажет нативный диалог или плашку One Tap.</li>
<li>
При ошибке NetworkError проверьте, что <code>PUBLIC_API_URL</code> = <code>{endpoints.issuer}</code>.
</li>
</ol>
<a
href={`${frontendBase.replace(/\/$/, '')}/sso-widget.js`}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 font-medium hover:underline"
>
Открыть sso-widget.js
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</section>
);
}
function CopyRowCompact({
label,
value,
onCopy
}: {
label: string;
value: string;
onCopy: (value: string, label: string) => void;
}) {
return (
<div className="rounded-xl bg-[#f4f5f8] p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-xs font-medium">{label}</span>
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label={`Скопировать ${label}`} onClick={() => onCopy(value, label)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<code className="block break-all text-[11px] text-[#667085]">{value}</code>
</div>
);
}

View File

@@ -494,6 +494,19 @@ export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
return response.json() as Promise<RefreshSessionResponse>;
}
export async function syncFedcmSession(accessToken: string) {
if (typeof window === 'undefined' || !accessToken.trim()) return;
try {
await fetch(`${getApiUrl()}/fedcm/session/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken.trim()}` },
credentials: 'include'
});
} catch {
// фоновая синхронизация FedCM cookie
}
}
async function trySilentTokenRefresh(): Promise<RefreshSessionResponse | null> {
try {
const refreshed = await refreshAuthSession();

View File

@@ -0,0 +1,35 @@
export const ONE_TAP_MESSAGE_TYPE = 'lendry-sso-onetap';
export function parsePopupOAuthParams(searchParams: URLSearchParams) {
const display = searchParams.get('display');
const popupOrigin = searchParams.get('popup_origin');
if (display !== 'popup' || !popupOrigin?.trim()) {
return null;
}
try {
return { popupOrigin: new URL(popupOrigin.trim()).origin };
} catch {
return null;
}
}
export function postOneTapResult(popupOrigin: string, payload: Record<string, unknown>) {
if (!window.opener) {
return false;
}
window.opener.postMessage({ type: ONE_TAP_MESSAGE_TYPE, ...payload }, popupOrigin);
window.close();
return true;
}
export function parseAuthorizationRedirect(redirectUrl: string) {
const url = new URL(redirectUrl);
return {
code: url.searchParams.get('code'),
state: url.searchParams.get('state'),
error: url.searchParams.get('error'),
errorDescription: url.searchParams.get('error_description')
};
}

View File

@@ -2,12 +2,29 @@ export function normalizeBaseUrl(url: string) {
return url.trim().replace(/\/+$/, '');
}
export function resolveOAuthApiBase(settings: Record<string, string>, fallback = 'http://localhost:3000') {
export function resolveOAuthApiBase(settings: Record<string, string>, fallback = 'http://localhost:3002/idp-api') {
const publicApi = settings.PUBLIC_API_URL?.trim();
if (publicApi) {
return normalizeBaseUrl(publicApi);
}
const domain = settings.PROJECT_DOMAIN?.trim();
if (domain) {
if (domain.startsWith('http://') || domain.startsWith('https://')) {
return normalizeBaseUrl(domain);
}
return `https://${domain.replace(/^\/+/, '')}/idp-api`;
}
return normalizeBaseUrl(fallback);
}
export function resolveFrontendBase(settings: Record<string, string>, fallback = 'http://localhost:3002') {
const publicFrontend = settings.PUBLIC_FRONTEND_URL?.trim();
if (publicFrontend) {
return normalizeBaseUrl(publicFrontend);
}
const domain = settings.PROJECT_DOMAIN?.trim();
if (domain) {
if (domain.startsWith('http://') || domain.startsWith('https://')) {
@@ -26,20 +43,48 @@ export interface OAuthEndpoints {
userInfoEndpoint: string;
openIdConfigurationUrl: string;
jwksUrl: string;
webIdentityUrl: string;
fedcmConfigUrl: string;
fedcmDiscoverUrl: string;
fedcmAccountsUrl: string;
fedcmIdAssertionUrl: string;
widgetUrl: string;
}
export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints {
export function buildOAuthEndpoints(apiBase: string, frontendBase?: string): OAuthEndpoints {
const base = normalizeBaseUrl(apiBase);
const front = normalizeBaseUrl(frontendBase ?? deriveFrontendBaseFromApi(base));
let webIdentityOrigin = front;
try {
webIdentityOrigin = new URL(base).origin;
} catch {
// keep frontend origin
}
return {
issuer: base,
authorizationEndpoint: `${base}/oauth/authorize`,
tokenEndpoint: `${base}/oauth/token`,
userInfoEndpoint: `${base}/oauth/userinfo`,
openIdConfigurationUrl: `${base}/.well-known/openid-configuration`,
jwksUrl: `${base}/.well-known/jwks.json`
jwksUrl: `${base}/.well-known/jwks.json`,
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`,
fedcmConfigUrl: `${base}/fedcm/config.json`,
fedcmDiscoverUrl: `${base}/fedcm/discover.json`,
fedcmAccountsUrl: `${base}/fedcm/accounts`,
fedcmIdAssertionUrl: `${base}/fedcm/id_assertion`,
widgetUrl: `${front}/sso-widget.js`
};
}
function deriveFrontendBaseFromApi(apiBase: string) {
if (apiBase.endsWith('/idp-api')) {
return apiBase.replace(/\/idp-api$/, '');
}
return apiBase;
}
export function buildAuthorizeUrl(
apiBase: string,
params: { clientId: string; redirectUri: string; scope: string; userId?: string; state?: string; useStandardParams?: boolean }

View File

@@ -0,0 +1,130 @@
export interface OneTapUrls {
apiBase: string;
frontendBase: string;
widgetUrl: string;
fedcmConfigUrl: string;
webIdentityUrl: string;
fedcmDiscoverUrl: string;
projectName: string;
}
export interface OneTapExample {
id: string;
label: string;
language: string;
code: string;
}
export function buildOneTapUrls(
apiBase: string,
frontendBase: string,
projectName = 'MVK ID'
): OneTapUrls {
const base = apiBase.replace(/\/+$/, '');
const front = frontendBase.replace(/\/+$/, '');
let webIdentityOrigin = front;
try {
webIdentityOrigin = new URL(base).origin;
} catch {
// keep frontend origin
}
return {
apiBase: base,
frontendBase: front,
widgetUrl: `${front}/sso-widget.js`,
fedcmConfigUrl: `${base}/fedcm/config.json`,
fedcmDiscoverUrl: `${base}/fedcm/discover.json`,
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`,
projectName
};
}
export function buildOneTapExamples(urls: OneTapUrls, clientId: string, redirectUri?: string): OneTapExample[] {
const { apiBase, frontendBase, widgetUrl, fedcmConfigUrl, webIdentityUrl, projectName } = urls;
const callbackUri = redirectUri?.trim() || 'https://app.example.com/auth/callback';
return [
{
id: 'widget-script',
label: 'Виджет (script)',
language: 'html',
code: `<script
src="${widgetUrl}"
data-client-id="${clientId}"
data-idp-url="${apiBase}"
data-idp-frontend-url="${frontendBase}"
data-provider-name="${projectName}"
data-redirect-uri="${callbackUri}"
data-on-success="handleLendryLogin"
></script>
<script>
function handleLendryLogin(payload) {
// payload.method — 'fedcm' | 'popup'
// payload.token / payload.idToken / payload.code
console.log('Вход через', payload.method, payload);
}
</script>`
},
{
id: 'sdk-manual',
label: 'SDK вручную',
language: 'javascript',
code: `<script src="${widgetUrl}" data-auto-init="false"></script>
<script>
LendryIdOneTap.init({
clientId: '${clientId}',
idpUrl: '${apiBase}',
frontendUrl: '${frontendBase}',
providerName: '${projectName}',
redirectUri: '${callbackUri}'
});
window.addEventListener('lendry-sso-onetap-success', (event) => {
console.log(event.detail);
});
</script>`
},
{
id: 'fedcm-native',
label: 'FedCM API',
language: 'javascript',
code: `const credential = await navigator.credentials.get({
identity: {
providers: [{
configURL: '${fedcmConfigUrl}',
clientId: '${clientId}'
}]
},
mediation: 'optional'
});
const idToken = credential?.token; // JWT — проверьте на backend`
},
{
id: 'postmessage',
label: 'Popup postMessage',
language: 'javascript',
code: `window.addEventListener('message', (event) => {
if (event.origin !== '${frontendBase}') return;
if (event.data?.type !== 'lendry-sso-onetap') return;
const { code, idToken, token, error } = event.data;
if (error) return console.error(error);
// code — обменяйте на POST ${apiBase}/oauth/token
// idToken / token — проверьте JWT (issuer = ${apiBase})
});`
},
{
id: 'curl-fedcm',
label: 'Диагностика (curl)',
language: 'bash',
code: `curl -sH "Sec-Fetch-Dest: webidentity" ${webIdentityUrl}
curl -sH "Sec-Fetch-Dest: webidentity" ${fedcmConfigUrl}
curl -s ${apiBase}/fedcm/discover.json`
}
];
}

View File

@@ -13,6 +13,7 @@ export interface SystemSettingMeta {
export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
general: 'Проект и брендинг',
onetap: 'One Tap Login / FedCM',
pin: 'PIN-код и блокировка сессии',
auth: 'Аутентификация и регистрация',
ldap: 'LDAP / Active Directory',
@@ -30,7 +31,37 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
label: 'URL API (OAuth issuer)',
group: 'general',
type: 'text',
hint: 'Базовый URL API для OAuth/OIDC. Пример: https://sso.example.ru/idp-api или https://api.example.ru'
hint: 'Базовый URL API для OAuth/OIDC и FedCM. При same-origin деплое: https://ваш-домен/idp-api'
},
{
key: 'PUBLIC_FRONTEND_URL',
label: 'URL Frontend IdP',
group: 'general',
type: 'text',
hint: 'Публичный адрес интерфейса IdP. Используется для login_url FedCM и popup OAuth'
},
{
key: 'ONE_TAP_ENABLED',
label: 'One Tap Login включён',
group: 'onetap',
type: 'boolean',
hint: 'FedCM и виджет sso-widget.js. При отключении остаётся только стандартный OAuth redirect'
},
{
key: 'FEDCM_PRIVACY_POLICY_URL',
label: 'FedCM — политика конфиденциальности',
group: 'onetap',
type: 'text',
hint: 'URL для UI FedCM. Пусто — используется страница «Данные» IdP',
showWhen: { key: 'ONE_TAP_ENABLED', equals: 'true' }
},
{
key: 'FEDCM_TERMS_URL',
label: 'FedCM — условия использования',
group: 'onetap',
type: 'text',
hint: 'URL для UI FedCM. Пусто — используется страница «Данные» IdP',
showWhen: { key: 'ONE_TAP_ENABLED', equals: 'true' }
},
{ key: 'PIN_LOCK_TIMEOUT_MINUTES', label: 'Таймаут блокировки PIN', group: 'pin', type: 'number', unit: 'мин' },
{ key: 'PIN_DELETE_GRACE_MINUTES', label: 'Задержка удаления PIN', group: 'pin', type: 'number', unit: 'мин', hint: 'Сколько ждать после запроса удаления PIN-кода' },

View File

@@ -71,30 +71,66 @@
return Promise.resolve(null);
}
return global.navigator.credentials
.get({
identity: {
providers: [
{
configURL: idpApiBase + '/fedcm/config.json',
clientId: clientId
}
]
},
mediation: 'optional'
function requestFedCM(configBase) {
return global.navigator.credentials
.get({
identity: {
providers: [
{
configURL: configBase + '/fedcm/config.json',
configUrl: configBase + '/fedcm/config.json',
clientId: clientId
}
]
},
mediation: 'optional'
})
.then(function (credential) {
if (!credential || typeof credential !== 'object') return null;
var token = credential.token;
if (!token) return null;
return {
token: token,
method: 'fedcm'
};
});
}
return global.fetch(idpApiBase + '/fedcm/discover.json', { credentials: 'omit' })
.then(function (response) {
if (!response.ok) {
return idpApiBase;
}
return response.json().then(function (payload) {
if (payload && payload.enabled === false) {
return null;
}
if (payload && payload.apiBase) {
return trimSlash(payload.apiBase);
}
return idpApiBase;
});
})
.then(function (credential) {
if (!credential || typeof credential !== 'object') return null;
var token = credential.token;
if (!token) return null;
return {
token: token,
method: 'fedcm'
};
.catch(function () {
return idpApiBase;
})
.then(function (resolvedBase) {
if (!resolvedBase) {
return null;
}
return requestFedCM(resolvedBase);
})
.catch(function (error) {
if (global.console && typeof global.console.warn === 'function') {
global.console.warn('[MVK ID] FedCM недоступен:', error);
global.console.warn(
'[MVK ID] FedCM недоступен:',
error,
'Проверьте доступность',
idpApiBase + '/fedcm/config.json',
'и',
idpApiBase.replace(/\/idp-api$/, '') + '/.well-known/web-identity',
'(PUBLIC_API_URL должен совпадать с data-idp-url виджета)'
);
}
return null;
});
@@ -232,10 +268,6 @@
return;
}
if (supportsFedCM()) {
return;
}
createWidget(providerName, function () {
var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope);
openPopup(popupUrl, frontendBase, function (payload) {