diff --git a/apps/api-gateway/src/lib/fedcm-config.ts b/apps/api-gateway/src/lib/fedcm-config.ts index 510275f..590ac5b 100644 --- a/apps/api-gateway/src/lib/fedcm-config.ts +++ b/apps/api-gateway/src/lib/fedcm-config.ts @@ -77,7 +77,7 @@ export function buildFedcmProviderConfig(endpoints: FedcmEndpoints) { login_url: endpoints.loginUrl, branding: { background_color: '#ffffff', - color: '#3390ec', + color: '#1f2430', icons: [{ url: `${endpoints.frontendUrl}/favicon.ico`, size: 32 }] } }; diff --git a/apps/api-gateway/src/lib/oauth-issuer.ts b/apps/api-gateway/src/lib/oauth-issuer.ts index 7ddf2e2..895b338 100644 --- a/apps/api-gateway/src/lib/oauth-issuer.ts +++ b/apps/api-gateway/src/lib/oauth-issuer.ts @@ -1,6 +1,6 @@ import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; -import { normalizePublicBaseUrl, isInternalHostname } from './public-url'; +import { normalizePublicBaseUrl, pickBestPublicBase, isInternalHostname } from './public-url'; function normalizeBaseUrl(url: string) { return normalizePublicBaseUrl(url); @@ -23,68 +23,49 @@ function sanitizeStoredPublicUrl(url: string) { return normalized; } -export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http://localhost:3000'): Promise { - const envIssuer = process.env.PUBLIC_API_URL?.trim(); - try { - const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_API_URL' }))) as { value?: string }; - const fromDb = sanitizeStoredPublicUrl(response.value ?? ''); - if (fromDb) { - return fromDb; - } - } catch { - // fallback ниже - } - - if (envIssuer) { - return normalizeBaseUrl(envIssuer); - } - +async function resolveProjectDomainUrl(core: CoreGrpcService, withIdpApiPath = false): Promise { try { const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_DOMAIN' }))) as { value?: string }; const domain = response.value?.trim(); - if (domain) { - if (domain.startsWith('http://') || domain.startsWith('https://')) { - return appendIdpApiPath(domain); - } - return appendIdpApiPath(`https://${domain.replace(/^\/+/, '')}`); + if (!domain) { + return undefined; } + if (domain.startsWith('http://') || domain.startsWith('https://')) { + return withIdpApiPath ? appendIdpApiPath(domain) : normalizeBaseUrl(domain); + } + const base = `https://${domain.replace(/^\/+/, '')}`; + return withIdpApiPath ? appendIdpApiPath(base) : base; + } catch { + return undefined; + } +} + +export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http://localhost:3000'): Promise { + const envIssuer = sanitizeStoredPublicUrl(process.env.PUBLIC_API_URL?.trim() ?? ''); + let fromDb: string | undefined; + try { + const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_API_URL' }))) as { value?: string }; + fromDb = sanitizeStoredPublicUrl(response.value ?? ''); } catch { // fallback ниже } - return normalizeBaseUrl(fallback); + const fromDomain = await resolveProjectDomainUrl(core, true); + return pickBestPublicBase([fromDb, envIssuer, fromDomain], fallback); } export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http://localhost:3002'): Promise { - const envFrontend = process.env.PUBLIC_FRONTEND_URL?.trim(); - if (envFrontend) { - return normalizeBaseUrl(envFrontend); - } - + const envFrontend = sanitizeStoredPublicUrl(process.env.PUBLIC_FRONTEND_URL?.trim() ?? ''); + let fromDb: string | undefined; try { const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_FRONTEND_URL' }))) as { value?: string }; - const fromDb = sanitizeStoredPublicUrl(response.value ?? ''); - if (fromDb) { - return fromDb; - } + fromDb = sanitizeStoredPublicUrl(response.value ?? ''); } catch { // fallback ниже } - try { - const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_DOMAIN' }))) as { value?: string }; - const domain = response.value?.trim(); - if (domain) { - if (domain.startsWith('http://') || domain.startsWith('https://')) { - return normalizeBaseUrl(domain); - } - return `https://${domain.replace(/^\/+/, '')}`; - } - } catch { - // fallback ниже - } - - return normalizeBaseUrl(fallback); + const fromDomain = await resolveProjectDomainUrl(core, false); + return pickBestPublicBase([fromDb, envFrontend, fromDomain], fallback); } export function buildOpenIdConfiguration(issuer: string) { diff --git a/apps/api-gateway/src/lib/public-url.ts b/apps/api-gateway/src/lib/public-url.ts index d55ae50..a93232b 100644 --- a/apps/api-gateway/src/lib/public-url.ts +++ b/apps/api-gateway/src/lib/public-url.ts @@ -103,14 +103,47 @@ export function resolvePublicFrontendBaseFromRequest(req?: Request): string | nu * из настроек (PUBLIC_API_URL / PUBLIC_FRONTEND_URL), а на хост запроса * переключаемся только если в настройках указан внутренний Docker-хост. */ +export function isLocalDevHostname(hostname: string) { + const host = hostname.trim().toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'; +} + +export function isLocalDevBaseUrl(url: string) { + try { + return isLocalDevHostname(new URL(url).hostname); + } catch { + return false; + } +} + +/** Выбирает публичный URL: приоритет у реального домена, не localhost из .env/seed. */ +export function pickBestPublicBase(candidates: Array, fallback: string) { + const normalized = candidates + .map((candidate) => candidate?.trim()) + .filter((candidate): candidate is string => Boolean(candidate)) + .map((candidate) => normalizePublicBaseUrl(candidate)); + + const productionReachable = normalized.find( + (candidate) => isBrowserReachableBaseUrl(candidate) && !isLocalDevBaseUrl(candidate) + ); + if (productionReachable) { + return productionReachable; + } + + const reachable = normalized.find((candidate) => isBrowserReachableBaseUrl(candidate)); + if (reachable) { + return reachable; + } + + if (normalized[0]) { + return normalized[0]; + } + + return normalizePublicBaseUrl(fallback); +} + export function preferCanonicalBase(stored: string, fromRequest: string | null) { - if (stored && isBrowserReachableBaseUrl(stored)) { - return normalizePublicBaseUrl(stored); - } - if (fromRequest && isBrowserReachableBaseUrl(fromRequest)) { - return normalizePublicBaseUrl(fromRequest); - } - return normalizePublicBaseUrl(stored); + return pickBestPublicBase([stored, fromRequest], stored || fromRequest || ''); } export function preferBrowserReachableBase(stored: string, fromRequest: string | null) { diff --git a/apps/frontend/lib/oauth-popup-bridge.ts b/apps/frontend/lib/oauth-popup-bridge.ts index 64cca19..efc82c5 100644 --- a/apps/frontend/lib/oauth-popup-bridge.ts +++ b/apps/frontend/lib/oauth-popup-bridge.ts @@ -40,7 +40,7 @@ export function deliverOAuthPopupResult( const delivered = postOneTapResult(popupContext.popupOrigin, { code: parsed.code ?? undefined, state: parsed.state ?? undefined, - token: parsed.code ?? undefined, + grantType: parsed.code ? 'authorization_code' : undefined, error: parsed.error ?? undefined, errorDescription: parsed.errorDescription ?? undefined }); diff --git a/apps/frontend/public/sso-widget.js b/apps/frontend/public/sso-widget.js index 88ca218..855c97d 100644 --- a/apps/frontend/public/sso-widget.js +++ b/apps/frontend/public/sso-widget.js @@ -158,17 +158,45 @@ return requestFedCM(resolved.value); }) .catch(function (error) { - if (global.console && typeof global.console.warn === 'function') { - global.console.warn( - '[MVK ID] FedCM недоступен:', + if (global.console && typeof global.console.debug === 'function') { + global.console.debug( + '[MVK ID] FedCM недоступен (будет использован popup):', error, - 'Проверьте публичный URL IdP в data-idp-url и доступность /.well-known/web-identity на домене IdP (не /idp-api/.well-known).' + 'Для 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 }, @@ -406,6 +434,8 @@ dispatchSuccess( { token: result.token, + idToken: result.token, + grantType: 'id_token', method: 'fedcm' }, script @@ -420,11 +450,14 @@ openPopup(popupUrl, frontendBase, function (payload) { dispatchSuccess( { - token: payload.token, - accessToken: payload.accessToken, - idToken: payload.idToken, - refreshToken: payload.refreshToken, 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 @@ -439,6 +472,7 @@ global.LendryIdOneTap = { init: initOneTap, loginWithFedCM: loginWithFedCM, + exchangeAuthorizationCode: exchangeAuthorizationCode, MESSAGE_TYPE: MESSAGE_TYPE }; diff --git a/docker-compose.yml b/docker-compose.yml index ee88bed..3ed3d6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -139,6 +139,7 @@ services: MINIO_BUCKET: ${MINIO_BUCKET:-lendry-id} MINIO_USE_SSL: ${MINIO_USE_SSL:-false} PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3002/idp-api} + PUBLIC_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002} FEDCM_COOKIE_SECURE: ${FEDCM_COOKIE_SECURE:-false} TRUST_PROXY: ${TRUST_PROXY:-true} ports: diff --git a/install.sh b/install.sh index d5d55a5..540e8a8 100644 --- a/install.sh +++ b/install.sh @@ -3046,7 +3046,11 @@ verify_fedcm_endpoints() { return 0 elif [[ "$code" == "000" ]]; then echo -e " ${RED}✘${NC} FedCM web-identity недоступен (${apex_domain}) — ERR_CERT или DNS" - echo " Добавьте ${apex_domain} в hosts/DNS и примите сертификат (FedCM всегда запрашивает eTLD+1)." + echo " Добавьте ${apex_domain} в hosts/DNS и примите сертификат в браузере на каждом клиенте:" + echo " откройте https://${apex_domain}/.well-known/web-identity → «Дополнительно» → принять риск." + if [[ "$(env_get SSL_TYPE none)" == "selfsigned" ]]; then + echo " Надёжнее: импортируйте ${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem в «Доверенные корневые центры»." + fi return 1 elif [[ "$code" == "502" ]]; then echo -e " ${RED}✘${NC} FedCM web-identity → 502 (${apex_domain}) — проверьте nginx/conf.d/${NGINX_PREFIX}-fedcm-apex.conf" @@ -3084,6 +3088,40 @@ validate_ssl_cert_covers_apex() { return 1 } +# Синхронизирует PUBLIC_* из .env в SystemSetting — иначе FedCM login_url остаётся localhost:3002. +sync_public_system_settings() { + load_env + [[ -n "${PUBLIC_API_URL:-}" || -n "${PUBLIC_FRONTEND_URL:-}" ]] || return 0 + if ! docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -q '^lendry-id-postgres$'; then + warn "Postgres недоступен — PUBLIC_* в БД не обновлены (FedCM login_url может указывать на localhost)" + return 0 + fi + + local pg_user pg_pass pg_db sql + pg_user="$(env_get POSTGRES_USER lendry)" + pg_pass="$(env_get POSTGRES_PASSWORD lendry_password)" + pg_db="$(env_get POSTGRES_DB lendry_id)" + sql="" + + if [[ -n "${PUBLIC_API_URL:-}" ]]; then + sql+="UPDATE \"SystemSetting\" SET value='${PUBLIC_API_URL}', \"updatedAt\"=NOW() WHERE key='PUBLIC_API_URL';" + fi + if [[ -n "${PUBLIC_FRONTEND_URL:-}" ]]; then + sql+="UPDATE \"SystemSetting\" SET value='${PUBLIC_FRONTEND_URL}', \"updatedAt\"=NOW() WHERE key='PUBLIC_FRONTEND_URL';" + fi + if [[ -n "${DOMAIN_FRONTEND:-}" ]]; then + sql+="UPDATE \"SystemSetting\" SET value='${DOMAIN_FRONTEND}', \"updatedAt\"=NOW() WHERE key='PROJECT_DOMAIN';" + fi + + [[ -n "$sql" ]] || return 0 + if docker_cmd exec -e PGPASSWORD="$pg_pass" lendry-id-postgres \ + psql -U "$pg_user" -d "$pg_db" -v ON_ERROR_STOP=1 -c "$sql" >/dev/null 2>&1; then + ok "SystemSetting: PUBLIC_API_URL / PUBLIC_FRONTEND_URL синхронизированы из .env" + else + warn "Не удалось обновить PUBLIC_* в SystemSetting — проверьте значения в админке → Настройки" + fi +} + write_all_nginx_configs() { ensure_nginx_mode_for_config_write local ssl_type="${1:-$(env_get SSL_TYPE none)}" @@ -4018,6 +4056,7 @@ action_fix_all_errors() { check_sso_idp_api_proxy || fail "SSO /idp-api/health недоступен — см. nginx/conf.d/${NGINX_PREFIX}-frontend.conf" check_sso_frontend_root || fail "SSO / → 502 — главная страница недоступна (502 Bad Gateway в браузере)" verify_sso_idp_api_burst || warn "Нестабильный /idp-api/health — при 502 в браузере: docker compose --profile proxy exec nginx nginx -s reload" + sync_public_system_settings || true validate_ssl_cert_covers_apex || true verify_fedcm_endpoints || warn "FedCM endpoints недоступны — см. DNS/SSL для eTLD+1 и nginx/conf.d/${NGINX_PREFIX}-fedcm-apex.conf" if ! docker_nginx_container_running; then