491 lines
17 KiB
JavaScript
491 lines
17 KiB
JavaScript
(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,
|
||
fields: ['name', 'email', 'picture', 'tel']
|
||
}
|
||
]
|
||
},
|
||
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);
|