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

@@ -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-кода' },