Files
IdP/apps/frontend/public/sso-widget.js
2026-07-01 12:14:50 +03:00

490 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function lendrySsoWidget(global) {
'use strict';
var MESSAGE_TYPE = 'lendry-sso-onetap';
var DEFAULT_SCOPE = 'openid profile email';
var widgetRootId = 'lendry-sso-onetap-root';
function trimSlash(value) {
return String(value || '').replace(/\/+$/, '');
}
function resolveScriptElement() {
var current = global.document.currentScript;
if (current) return current;
var scripts = global.document.getElementsByTagName('script');
for (var i = scripts.length - 1; i >= 0; i -= 1) {
var src = scripts[i].getAttribute('src') || '';
if (src.indexOf('sso-widget.js') !== -1) {
return scripts[i];
}
}
return null;
}
function resolveIdpApiBase(script) {
var override = script && script.getAttribute('data-idp-url');
if (override) return trimSlash(override);
try {
var origin = new URL(script.src).origin;
return trimSlash(origin + '/idp-api');
} catch (_error) {
return '';
}
}
function resolveFrontendBase(script, idpApiBase) {
var override = script && script.getAttribute('data-idp-frontend-url');
if (override) return trimSlash(override);
try {
return new URL(script.src).origin;
} catch (_error) {
if (idpApiBase.indexOf('/idp-api') !== -1) {
return idpApiBase.replace(/\/idp-api$/, '');
}
return idpApiBase;
}
}
function resolveProviderName(script) {
return (script && script.getAttribute('data-provider-name')) || 'MVK ID';
}
function dispatchSuccess(payload, script) {
var callbackName = script && script.getAttribute('data-on-success');
if (callbackName && typeof global[callbackName] === 'function') {
global[callbackName](payload);
}
try {
global.dispatchEvent(new CustomEvent('lendry-sso-onetap-success', { detail: payload }));
} catch (_error) {
// IE fallback not required
}
}
function supportsFedCM() {
return 'IdentityCredential' in global && global.navigator && typeof global.navigator.credentials !== 'undefined';
}
function isBrowserReachableBaseUrl(url) {
try {
var parsed = new URL(url);
var host = parsed.hostname.toLowerCase();
if (host === 'api-gateway' || host === 'sso-core' || host === 'frontend' || host === 'docs') {
return false;
}
if (host.indexOf('.') === -1 && host !== 'localhost') {
return false;
}
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch (_error) {
return false;
}
}
function loginWithFedCM(idpApiBase, clientId) {
if (!supportsFedCM()) {
return Promise.resolve(null);
}
function requestFedCMConfig(configUrl) {
return global.navigator.credentials
.get({
identity: {
providers: [
{
configURL: configUrl,
configUrl: configUrl,
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'
};
});
}
function requestFedCM(configBase) {
return requestFedCMConfig(configBase + '/fedcm/config.json');
}
return global.fetch(idpApiBase + '/fedcm/discover.json', { credentials: 'omit' })
.then(function (response) {
if (!response.ok) {
return { mode: 'apiBase', value: idpApiBase };
}
return response.json().then(function (payload) {
if (payload && payload.enabled === false) {
return null;
}
if (payload && payload.configUrl && isBrowserReachableBaseUrl(payload.configUrl)) {
return { mode: 'configUrl', value: payload.configUrl, webIdentityUrl: payload.webIdentityUrl };
}
if (payload && payload.apiBase && isBrowserReachableBaseUrl(payload.apiBase)) {
return { mode: 'apiBase', value: trimSlash(payload.apiBase), webIdentityUrl: payload.webIdentityUrl };
}
if (isBrowserReachableBaseUrl(idpApiBase)) {
return { mode: 'apiBase', value: idpApiBase };
}
return null;
});
})
.catch(function () {
if (isBrowserReachableBaseUrl(idpApiBase)) {
return { mode: 'apiBase', value: idpApiBase };
}
return null;
})
.then(function (resolved) {
if (!resolved) {
if (global.console && typeof global.console.warn === 'function') {
global.console.warn(
'[MVK ID] FedCM: URL IdP недоступен из браузера. Укажите публичный data-idp-url (например https://api.idpmvk.lpr), не внутренний Docker-хост.'
);
}
return null;
}
if (resolved.mode === 'configUrl') {
return requestFedCMConfig(resolved.value);
}
return requestFedCM(resolved.value);
})
.catch(function (error) {
if (global.console && typeof global.console.debug === 'function') {
global.console.debug(
'[MVK ID] FedCM недоступен (будет использован popup):',
error,
'Для One Tap без popup: примите SSL для корневого домена IdP (например idpmvk.lpr) и проверьте /.well-known/web-identity.'
);
}
return null;
});
}
function exchangeAuthorizationCode(idpApiBase, clientId, clientSecret, code, redirectUri) {
if (!code || !clientId || !clientSecret) {
return Promise.reject(new Error('Для обмена code нужны clientId, clientSecret и authorization code'));
}
var body = new URLSearchParams();
body.set('grant_type', 'authorization_code');
body.set('code', code);
body.set('client_id', clientId);
body.set('client_secret', clientSecret);
body.set('redirect_uri', redirectUri);
return global.fetch(trimSlash(idpApiBase) + '/oauth/token', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
}).then(function (response) {
return response.json().then(function (payload) {
if (!response.ok) {
var message = payload && (payload.message || payload.error_description || payload.error);
throw new Error(message || 'Не удалось обменять authorization code');
}
return payload;
});
});
}
var SIZE_PRESETS = {
s: { height: 32, font: 13, padX: 12, gap: 8, badge: 20, radius: 16 },
m: { height: 40, font: 14, padX: 16, gap: 10, badge: 24, radius: 20 },
l: { height: 44, font: 15, padX: 18, gap: 10, badge: 28, radius: 22 },
xl: { height: 52, font: 16, padX: 22, gap: 12, badge: 32, radius: 26 }
};
function themePalette(theme) {
if (theme === 'dark') {
return {
bg: '#1f2430',
bgHover: '#2a3040',
border: 'transparent',
borderHover: 'transparent',
text: '#ffffff',
badgeBg: '#ffffff',
badgeColor: '#1f2430'
};
}
return {
bg: '#ffffff',
bgHover: '#f6f8fb',
border: '#e4e8ef',
borderHover: '#d4dae6',
text: '#1f2430',
badgeBg: '#111111',
badgeColor: '#ffffff'
};
}
function readButtonOptions(script, options) {
function attr(name) {
return script ? script.getAttribute(name) : null;
}
var o = options || {};
var size = String(o.buttonSize || attr('data-button-size') || 'xl').toLowerCase();
if (!SIZE_PRESETS[size]) size = 'xl';
var radiusRaw = o.buttonRadius != null ? o.buttonRadius : attr('data-button-radius');
return {
size: size,
theme: String(o.buttonTheme || attr('data-button-theme') || 'light').toLowerCase(),
view: String(o.buttonView || attr('data-button-view') || 'main').toLowerCase(),
icon: String(o.buttonIcon || attr('data-button-icon') || 'id').toLowerCase(),
radius: radiusRaw != null && radiusRaw !== '' ? parseInt(radiusRaw, 10) : null,
container: o.buttonContainer || attr('data-button-container') || null,
colors: {
bg: o.buttonBg || attr('data-button-bg') || null,
bgHover: o.buttonBgHover || attr('data-button-bg-hover') || null,
border: o.buttonBorder || attr('data-button-border') || null,
borderHover: o.buttonBorderHover || attr('data-button-border-hover') || null,
text: o.buttonText || attr('data-button-text') || null
}
};
}
function injectWidgetStyles() {
if (global.document.getElementById('lendry-sso-onetap-style')) return;
var style = global.document.createElement('style');
style.id = 'lendry-sso-onetap-style';
style.textContent =
'#' +
widgetRootId +
'{position:fixed;top:16px;right:16px;z-index:2147483000}' +
'.lendry-sso-onetap-btn{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-weight:600;line-height:1;cursor:pointer;white-space:nowrap;transition:transform .18s ease,box-shadow .18s ease,background .18s ease,border-color .18s ease;box-shadow:0 8px 24px rgba(31,36,48,.12)}' +
'.lendry-sso-onetap-btn:hover{transform:translateY(-1px);box-shadow:0 12px 28px rgba(31,36,48,.16)}' +
'.lendry-sso-onetap-btn:active{transform:translateY(0)}' +
'.lendry-sso-onetap-badge{display:inline-flex;align-items:center;justify-content:center;border-radius:999px;font-weight:700}';
global.document.head.appendChild(style);
}
function applyButtonStyles(button, badge, providerName, btn) {
var preset = SIZE_PRESETS[btn.size] || SIZE_PRESETS.xl;
var palette = themePalette(btn.theme === 'dark' ? 'dark' : 'light');
var bg = btn.colors.bg || palette.bg;
var bgHover = btn.colors.bgHover || palette.bgHover;
var border = btn.colors.border || palette.border;
var borderHover = btn.colors.borderHover || palette.borderHover;
var textColor = btn.colors.text || palette.text;
var radius = btn.radius != null && !isNaN(btn.radius) ? btn.radius : preset.radius;
var iconOnly = btn.view === 'icon';
button.style.height = preset.height + 'px';
button.style.fontSize = preset.font + 'px';
button.style.gap = preset.gap + 'px';
button.style.padding = iconOnly ? '0' : '0 ' + preset.padX + 'px';
button.style.width = iconOnly ? preset.height + 'px' : 'auto';
button.style.borderRadius = radius + 'px';
button.style.background = bg;
button.style.color = textColor;
button.style.border = '1px solid ' + border;
button.addEventListener('mouseenter', function () {
button.style.background = bgHover;
button.style.borderColor = borderHover;
});
button.addEventListener('mouseleave', function () {
button.style.background = bg;
button.style.borderColor = border;
});
if (badge) {
badge.style.minWidth = preset.badge + 'px';
badge.style.height = preset.badge + 'px';
badge.style.padding = '0 ' + Math.round(preset.badge / 4) + 'px';
badge.style.fontSize = Math.round(preset.font * 0.85) + 'px';
badge.style.background = palette.badgeBg;
badge.style.color = palette.badgeColor;
}
}
function createWidget(providerName, onClick, options) {
injectWidgetStyles();
var btn = options || readButtonOptions(null, null);
var iconOnly = btn.view === 'icon';
var mount = null;
if (btn.container) {
mount = global.document.getElementById(btn.container);
if (mount) {
mount.innerHTML = '';
}
}
if (!mount) {
if (global.document.getElementById(widgetRootId)) return;
mount = global.document.createElement('div');
mount.id = widgetRootId;
global.document.body.appendChild(mount);
}
var button = global.document.createElement('button');
button.type = 'button';
button.className = 'lendry-sso-onetap-btn';
button.setAttribute('aria-label', 'Войти через ' + providerName);
var badge = null;
if (btn.icon !== 'none') {
badge = global.document.createElement('span');
badge.className = 'lendry-sso-onetap-badge';
badge.textContent = 'ID';
button.appendChild(badge);
}
if (!iconOnly) {
var label = global.document.createElement('span');
label.textContent = 'Войти через ' + providerName;
button.appendChild(label);
}
applyButtonStyles(button, badge, providerName, btn);
button.addEventListener('click', onClick);
mount.appendChild(button);
}
function buildPopupUrl(frontendBase, clientId, origin, redirectUri, scope) {
var params = new URLSearchParams();
params.set('client_id', clientId);
params.set('redirect_uri', redirectUri);
params.set('response_type', 'code');
params.set('scope', scope || DEFAULT_SCOPE);
params.set('display', 'popup');
params.set('popup_origin', origin);
params.set('state', 'onetap_' + Math.random().toString(36).slice(2));
return frontendBase + '/auth/oauth/authorize?' + params.toString();
}
function openPopup(url, expectedOrigin, onToken) {
var width = 480;
var height = 640;
var left = Math.max(0, Math.round((global.screen.width - width) / 2));
var top = Math.max(0, Math.round((global.screen.height - height) / 2));
// Без noopener/noreferrer: popup должен видеть window.opener для postMessage после «Разрешить».
var features =
'popup=yes,width=' +
width +
',height=' +
height +
',left=' +
left +
',top=' +
top;
var popup = global.open(url, 'lendry_sso_onetap', features);
if (!popup) {
if (global.console && typeof global.console.error === 'function') {
global.console.error('[MVK ID] Не удалось открыть popup. Разрешите всплывающие окна.');
}
return function cleanup() {};
}
function handleMessage(event) {
if (!event || !event.data || event.data.type !== MESSAGE_TYPE) return;
if (expectedOrigin && event.origin !== expectedOrigin && event.origin !== trimSlash(expectedOrigin)) return;
cleanup();
onToken(event.data);
}
function cleanup() {
global.removeEventListener('message', handleMessage);
if (popup && !popup.closed) {
try {
popup.close();
} catch (_error) {
// ignore
}
}
}
global.addEventListener('message', handleMessage);
return cleanup;
}
function initOneTap(options) {
var script = resolveScriptElement();
var clientId = (options && options.clientId) || (script && script.getAttribute('data-client-id'));
if (!clientId) {
if (global.console && typeof global.console.error === 'function') {
global.console.error('[MVK ID] Укажите data-client-id в теге script или clientId в LendryIdOneTap.init().');
}
return;
}
var idpApiBase = trimSlash((options && options.idpUrl) || resolveIdpApiBase(script));
var frontendBase = trimSlash((options && options.frontendUrl) || resolveFrontendBase(script, idpApiBase));
var providerName = (options && options.providerName) || resolveProviderName(script);
var scope = (options && options.scope) || (script && script.getAttribute('data-scope')) || DEFAULT_SCOPE;
var redirectUri =
(options && options.redirectUri) ||
(script && script.getAttribute('data-redirect-uri')) ||
global.location.origin + '/auth/callback';
var buttonOptions = readButtonOptions(script, options);
loginWithFedCM(idpApiBase, clientId).then(function (result) {
if (result && result.token) {
dispatchSuccess(
{
token: result.token,
idToken: result.token,
grantType: 'id_token',
method: 'fedcm'
},
script
);
return;
}
createWidget(
providerName,
function () {
var popupUrl = buildPopupUrl(frontendBase, clientId, global.location.origin, redirectUri, scope);
openPopup(popupUrl, frontendBase, function (payload) {
dispatchSuccess(
{
code: payload.code,
state: payload.state,
grantType: payload.grantType || (payload.code ? 'authorization_code' : undefined),
idToken: payload.idToken,
accessToken: payload.accessToken,
refreshToken: payload.refreshToken,
tokenEndpoint: trimSlash(idpApiBase) + '/oauth/token',
redirectUri: redirectUri,
method: 'popup'
},
script
);
});
},
buttonOptions
);
});
}
global.LendryIdOneTap = {
init: initOneTap,
loginWithFedCM: loginWithFedCM,
exchangeAuthorizationCode: exchangeAuthorizationCode,
MESSAGE_TYPE: MESSAGE_TYPE
};
var scriptEl = resolveScriptElement();
if (scriptEl && scriptEl.getAttribute('data-auto-init') !== 'false') {
if (global.document.readyState === 'loading') {
global.document.addEventListener('DOMContentLoaded', function () {
initOneTap();
});
} else {
initOneTap();
}
}
})(window);