diff --git a/apps/frontend/app/auth/oauth/authorize/page.tsx b/apps/frontend/app/auth/oauth/authorize/page.tsx index a2856c3..2352050 100644 --- a/apps/frontend/app/auth/oauth/authorize/page.tsx +++ b/apps/frontend/app/auth/oauth/authorize/page.tsx @@ -12,6 +12,9 @@ import { approveOAuthAuthorization, checkOAuthConsent, fetchOAuthClientPublicInfo, + getApiErrorMessage, + isGatewayUnavailableError, + resetGatewayCircuit, type OAuthConsentCheckResponse } from '@/lib/api'; import { deliverOAuthPopupResult, parsePopupOAuthParams, postOneTapResult } from '@/lib/oauth-popup-bridge'; @@ -47,6 +50,10 @@ function OAuthAuthorizeContent() { const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]); + useEffect(() => { + resetGatewayCircuit(); + }, []); + const clientLabel = clientName ?? consentInfo?.client?.name ?? 'Приложение'; const approve = useCallback(async () => { @@ -63,7 +70,13 @@ function OAuthAuthorizeContent() { } window.location.href = data.redirectUrl; } catch (err) { - setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации'); + const message = getApiErrorMessage(err, 'Ошибка OAuth авторизации'); + setError( + message ?? + (isGatewayUnavailableError(err) + ? 'Сервер API временно недоступен. Подождите несколько секунд и нажмите «Разрешить» снова.' + : 'Ошибка OAuth авторизации') + ); autoApproveStartedRef.current = false; } finally { setSubmitting(false); @@ -113,7 +126,13 @@ function OAuthAuthorizeContent() { } } catch (err) { if (!cancelled) { - setError(err instanceof Error ? err.message : 'Не удалось проверить согласие'); + const message = getApiErrorMessage(err, 'Не удалось проверить согласие'); + setError( + message ?? + (isGatewayUnavailableError(err) + ? 'Сервер API временно недоступен. Подождите несколько секунд — форма обновится автоматически.' + : 'Не удалось проверить согласие') + ); } } finally { if (!cancelled) setCheckingConsent(false); diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 5c7b6ae..20450f6 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -494,7 +494,7 @@ export async function approveOAuthAuthorization(params: URLSearchParams, token: method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(body) - }, token); + }, token, true, { critical: true }); } export async function checkOAuthConsent(params: URLSearchParams, token: string) { @@ -503,11 +503,17 @@ export async function checkOAuthConsent(params: URLSearchParams, token: string) const scope = params.get('scope') ?? 'openid profile'; if (clientId) query.set('client_id', clientId); query.set('scope', scope); - return apiFetch(`/oauth/consent/check?${query.toString()}`, {}, token); + return apiFetch(`/oauth/consent/check?${query.toString()}`, {}, token, true, { critical: true }); } export async function fetchOAuthClientPublicInfo(clientId: string) { - return apiFetch<{ clientId: string; name: string }>(`/oauth/clients/${encodeURIComponent(clientId)}/public`); + return apiFetch<{ clientId: string; name: string }>( + `/oauth/clients/${encodeURIComponent(clientId)}/public`, + {}, + null, + true, + { silent: true } + ); } export async function fetchUserOAuthConsents(userId: string, token: string) { @@ -552,7 +558,9 @@ export async function syncFedcmSession(accessToken?: string | null) { method: 'POST', credentials: 'include' }, - token + token, + true, + { silent: true } ); } catch { // фоновая синхронизация FedCM cookie @@ -681,6 +689,14 @@ export function buildSystemSettingPayload(setting: Pick { +async function fetchWithGatewayRetry( + url: string, + init: RequestInit, + behavior?: ApiFetchBehavior +): Promise { + const maxAttempts = behavior?.critical ? GATEWAY_CRITICAL_RETRY_ATTEMPTS : GATEWAY_RETRY_ATTEMPTS; let lastNetworkError: unknown; - for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { try { - assertGatewayCircuitClosed(); + assertGatewayCircuitClosed(behavior); const response = await fetch(url, init); if (response.ok) { markApiGatewayReady(); } - if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { + if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < maxAttempts) { await sleep(gatewayRetryDelay(attempt)); continue; } if (GATEWAY_RETRY_STATUS.has(response.status)) { - openGatewayCircuit(); + openGatewayCircuit(behavior); } return response; } catch (error) { @@ -885,19 +916,25 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise { +export async function apiFetch( + path: string, + options: RequestInit = {}, + token?: string | null, + allowRetry = true, + behavior: ApiFetchBehavior = {} +): Promise { await acquireApiSlot(); const resolvedToken = resolveAccessToken(token); @@ -924,7 +961,7 @@ export async function apiFetch(path: string, options: RequestInit = {}, token response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, { ...options, headers - }); + }, behavior); } catch (error) { if (error instanceof ApiError) { throw error; @@ -949,7 +986,7 @@ export async function apiFetch(path: string, options: RequestInit = {}, token const refreshed = await trySilentTokenRefresh(); if (refreshed?.accessToken) { releaseCurrentApiSlot(); - return apiFetch(path, options, refreshed.accessToken, false); + return apiFetch(path, options, refreshed.accessToken, false, behavior); } if (refreshed?.requiresPin) { throw error; diff --git a/install.sh b/install.sh index 5b6e073..d5d55a5 100644 --- a/install.sh +++ b/install.sh @@ -3005,6 +3005,85 @@ verify_sso_idp_api_burst() { return 1 } +# FedCM: discover.json на API-домене и /.well-known/web-identity на eTLD+1 (корневой домен). +verify_fedcm_endpoints() { + load_env + local api_domain frontend_domain apex_domain ssl_type code body scheme curl_extra + api_domain="$(env_get DOMAIN_API "")" + frontend_domain="$(env_get DOMAIN_FRONTEND "")" + [[ -n "$api_domain" ]] || return 0 + + ssl_type="$(env_get SSL_TYPE none)" + scheme="http" + curl_extra="" + if [[ "$(env_get USE_NGINX_SSL false)" == "true" || "$ssl_type" != "none" ]]; then + scheme="https" + curl_extra="-k" + fi + + code="$(curl ${curl_extra} -fsS --max-time 8 -o /dev/null -w '%{http_code}' "${scheme}://${api_domain}/fedcm/discover.json" 2>/dev/null || echo 000)" + if [[ "$code" == "200" ]]; then + echo -e " ${GREEN}✔${NC} FedCM discover.json → 200 (${api_domain})" + elif [[ "$code" == "502" ]]; then + echo -e " ${RED}✘${NC} FedCM discover.json → 502 (${api_domain}) — Nginx не проксирует /fedcm на api-gateway" + return 1 + else + echo -e " ${YELLOW}!${NC} FedCM discover.json → HTTP ${code} (${api_domain}, ожидалось 200)" + fi + + apex_domain="$(registrable_domain "${frontend_domain:-$api_domain}")" + if [[ -z "$apex_domain" \ + || "$apex_domain" == "$frontend_domain" \ + || "$apex_domain" == "$api_domain" \ + || "$apex_domain" == "${DOMAIN_DOCS:-}" ]]; then + return 0 + fi + + code="$(curl ${curl_extra} -fsS --max-time 8 -o /dev/null -w '%{http_code}' "${scheme}://${apex_domain}/.well-known/web-identity" 2>/dev/null || echo 000)" + body="$(curl ${curl_extra} -fsS --max-time 8 "${scheme}://${apex_domain}/.well-known/web-identity" 2>/dev/null || true)" + if [[ "$code" == "200" && "$body" == *provider_urls* ]]; then + echo -e " ${GREEN}✔${NC} FedCM web-identity → 200 (${apex_domain})" + 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)." + return 1 + elif [[ "$code" == "502" ]]; then + echo -e " ${RED}✘${NC} FedCM web-identity → 502 (${apex_domain}) — проверьте nginx/conf.d/${NGINX_PREFIX}-fedcm-apex.conf" + return 1 + else + echo -e " ${YELLOW}!${NC} FedCM web-identity → HTTP ${code} (${apex_domain}, ожидалось 200 с provider_urls)" + return 1 + fi +} + +validate_ssl_cert_covers_apex() { + load_env + local apex_domain cert ssl_type sans + apex_domain="$(registrable_domain "${DOMAIN_FRONTEND:-$DOMAIN_API}")" + [[ -n "$apex_domain" ]] || return 0 + if [[ "$apex_domain" == "${DOMAIN_FRONTEND:-}" || "$apex_domain" == "${DOMAIN_API:-}" ]]; then + return 0 + fi + + ssl_type="$(env_get SSL_TYPE none)" + case "$ssl_type" in + selfsigned) cert="${LOCAL_CERTS_DIR}/shared-intranet/fullchain.pem" ;; + custom) cert="${CUSTOM_CERT_TARGET_DIR}/fullchain.pem" ;; + *) return 0 ;; + esac + + [[ -f "$cert" ]] || return 0 + sans="$(openssl x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null || true)" + if [[ "$sans" == *"${apex_domain}"* ]]; then + return 0 + fi + + warn "Сертификат не содержит SAN для ${apex_domain} — FedCM выдаст ERR_CERT_COMMON_NAME_INVALID" + warn "Пересоздайте сертификат: ./install.sh --fix-all (selfsigned) или добавьте ${apex_domain} в custom fullchain.pem" + return 1 +} + write_all_nginx_configs() { ensure_nginx_mode_for_config_write local ssl_type="${1:-$(env_get SSL_TYPE none)}" @@ -3939,6 +4018,8 @@ 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" + 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 fail "Контейнер lendry-id-nginx не запущен. Проверьте: docker compose --profile proxy logs nginx" fi