fix and update
This commit is contained in:
@@ -12,6 +12,9 @@ import {
|
|||||||
approveOAuthAuthorization,
|
approveOAuthAuthorization,
|
||||||
checkOAuthConsent,
|
checkOAuthConsent,
|
||||||
fetchOAuthClientPublicInfo,
|
fetchOAuthClientPublicInfo,
|
||||||
|
getApiErrorMessage,
|
||||||
|
isGatewayUnavailableError,
|
||||||
|
resetGatewayCircuit,
|
||||||
type OAuthConsentCheckResponse
|
type OAuthConsentCheckResponse
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { deliverOAuthPopupResult, parsePopupOAuthParams, postOneTapResult } from '@/lib/oauth-popup-bridge';
|
import { deliverOAuthPopupResult, parsePopupOAuthParams, postOneTapResult } from '@/lib/oauth-popup-bridge';
|
||||||
@@ -47,6 +50,10 @@ function OAuthAuthorizeContent() {
|
|||||||
|
|
||||||
const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]);
|
const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetGatewayCircuit();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const clientLabel = clientName ?? consentInfo?.client?.name ?? 'Приложение';
|
const clientLabel = clientName ?? consentInfo?.client?.name ?? 'Приложение';
|
||||||
|
|
||||||
const approve = useCallback(async () => {
|
const approve = useCallback(async () => {
|
||||||
@@ -63,7 +70,13 @@ function OAuthAuthorizeContent() {
|
|||||||
}
|
}
|
||||||
window.location.href = data.redirectUrl;
|
window.location.href = data.redirectUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации');
|
const message = getApiErrorMessage(err, 'Ошибка OAuth авторизации');
|
||||||
|
setError(
|
||||||
|
message ??
|
||||||
|
(isGatewayUnavailableError(err)
|
||||||
|
? 'Сервер API временно недоступен. Подождите несколько секунд и нажмите «Разрешить» снова.'
|
||||||
|
: 'Ошибка OAuth авторизации')
|
||||||
|
);
|
||||||
autoApproveStartedRef.current = false;
|
autoApproveStartedRef.current = false;
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -113,7 +126,13 @@ function OAuthAuthorizeContent() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(err instanceof Error ? err.message : 'Не удалось проверить согласие');
|
const message = getApiErrorMessage(err, 'Не удалось проверить согласие');
|
||||||
|
setError(
|
||||||
|
message ??
|
||||||
|
(isGatewayUnavailableError(err)
|
||||||
|
? 'Сервер API временно недоступен. Подождите несколько секунд — форма обновится автоматически.'
|
||||||
|
: 'Не удалось проверить согласие')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setCheckingConsent(false);
|
if (!cancelled) setCheckingConsent(false);
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ export async function approveOAuthAuthorization(params: URLSearchParams, token:
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
}, token);
|
}, token, true, { critical: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkOAuthConsent(params: URLSearchParams, token: string) {
|
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';
|
const scope = params.get('scope') ?? 'openid profile';
|
||||||
if (clientId) query.set('client_id', clientId);
|
if (clientId) query.set('client_id', clientId);
|
||||||
query.set('scope', scope);
|
query.set('scope', scope);
|
||||||
return apiFetch<OAuthConsentCheckResponse>(`/oauth/consent/check?${query.toString()}`, {}, token);
|
return apiFetch<OAuthConsentCheckResponse>(`/oauth/consent/check?${query.toString()}`, {}, token, true, { critical: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOAuthClientPublicInfo(clientId: string) {
|
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) {
|
export async function fetchUserOAuthConsents(userId: string, token: string) {
|
||||||
@@ -552,7 +558,9 @@ export async function syncFedcmSession(accessToken?: string | null) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
},
|
},
|
||||||
token
|
token,
|
||||||
|
true,
|
||||||
|
{ silent: true }
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// фоновая синхронизация FedCM cookie
|
// фоновая синхронизация FedCM cookie
|
||||||
@@ -681,6 +689,14 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
|
|||||||
|
|
||||||
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
|
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
|
||||||
const GATEWAY_RETRY_ATTEMPTS = 2;
|
const GATEWAY_RETRY_ATTEMPTS = 2;
|
||||||
|
const GATEWAY_CRITICAL_RETRY_ATTEMPTS = 4;
|
||||||
|
|
||||||
|
export interface ApiFetchBehavior {
|
||||||
|
/** Пользовательский сценарий (OAuth, вход) — не блокировать circuit breaker. */
|
||||||
|
critical?: boolean;
|
||||||
|
/** Опциональный/фоновый запрос — не открывать circuit breaker при ошибке. */
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800;
|
const GATEWAY_STABLE_PROBE_INTERVAL_MS = 800;
|
||||||
const GATEWAY_STABLE_MAX_ROUNDS = 8;
|
const GATEWAY_STABLE_MAX_ROUNDS = 8;
|
||||||
const API_MAX_CONCURRENT = 6;
|
const API_MAX_CONCURRENT = 6;
|
||||||
@@ -710,6 +726,11 @@ export function resetApiGatewayWarmup() {
|
|||||||
gatewayCircuitOpenUntil = 0;
|
gatewayCircuitOpenUntil = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сбрасывает circuit breaker после кратковременных 502 — для OAuth и других интерактивных экранов. */
|
||||||
|
export function resetGatewayCircuit(): void {
|
||||||
|
gatewayCircuitOpenUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function invalidateApiGatewayReady() {
|
export function invalidateApiGatewayReady() {
|
||||||
gatewayReadyResolved = false;
|
gatewayReadyResolved = false;
|
||||||
gatewayReadyAt = 0;
|
gatewayReadyAt = 0;
|
||||||
@@ -719,7 +740,12 @@ export function invalidateApiGatewayReady() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openGatewayCircuit(): void {
|
function shouldBypassGatewayCircuit(behavior?: ApiFetchBehavior): boolean {
|
||||||
|
return Boolean(behavior?.critical || behavior?.silent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGatewayCircuit(behavior?: ApiFetchBehavior): void {
|
||||||
|
if (shouldBypassGatewayCircuit(behavior)) return;
|
||||||
gatewayReadyResolved = false;
|
gatewayReadyResolved = false;
|
||||||
gatewayReadyAt = 0;
|
gatewayReadyAt = 0;
|
||||||
stableGatewayPromise = null;
|
stableGatewayPromise = null;
|
||||||
@@ -729,8 +755,8 @@ function openGatewayCircuit(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertGatewayCircuitClosed(): void {
|
function assertGatewayCircuitClosed(behavior?: ApiFetchBehavior): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined' || shouldBypassGatewayCircuit(behavior)) return;
|
||||||
if (Date.now() < gatewayCircuitOpenUntil) {
|
if (Date.now() < gatewayCircuitOpenUntil) {
|
||||||
throw new ApiError('Сервер API временно недоступен. Запрос отложен, чтобы не перегружать консоль ошибками.', 503, 'GATEWAY_UNAVAILABLE');
|
throw new ApiError('Сервер API временно недоступен. Запрос отложен, чтобы не перегружать консоль ошибками.', 503, 'GATEWAY_UNAVAILABLE');
|
||||||
}
|
}
|
||||||
@@ -863,21 +889,26 @@ function gatewayRetryDelay(attempt: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Повтор при кратковременном 502/503/504 без сброса всего UI в bootstrap. */
|
/** Повтор при кратковременном 502/503/504 без сброса всего UI в bootstrap. */
|
||||||
async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Response> {
|
async function fetchWithGatewayRetry(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
behavior?: ApiFetchBehavior
|
||||||
|
): Promise<Response> {
|
||||||
|
const maxAttempts = behavior?.critical ? GATEWAY_CRITICAL_RETRY_ATTEMPTS : GATEWAY_RETRY_ATTEMPTS;
|
||||||
let lastNetworkError: unknown;
|
let lastNetworkError: unknown;
|
||||||
for (let attempt = 0; attempt < GATEWAY_RETRY_ATTEMPTS; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
assertGatewayCircuitClosed();
|
assertGatewayCircuitClosed(behavior);
|
||||||
const response = await fetch(url, init);
|
const response = await fetch(url, init);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
markApiGatewayReady();
|
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));
|
await sleep(gatewayRetryDelay(attempt));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (GATEWAY_RETRY_STATUS.has(response.status)) {
|
if (GATEWAY_RETRY_STATUS.has(response.status)) {
|
||||||
openGatewayCircuit();
|
openGatewayCircuit(behavior);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -885,19 +916,25 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
|
|||||||
if (isGatewayUnavailableError(error)) {
|
if (isGatewayUnavailableError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
if (attempt + 1 < maxAttempts) {
|
||||||
await sleep(gatewayRetryDelay(attempt));
|
await sleep(gatewayRetryDelay(attempt));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
openGatewayCircuit();
|
openGatewayCircuit(behavior);
|
||||||
throw mapFetchError(error);
|
throw mapFetchError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openGatewayCircuit();
|
openGatewayCircuit(behavior);
|
||||||
throw mapFetchError(lastNetworkError);
|
throw mapFetchError(lastNetworkError);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
|
export async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
token?: string | null,
|
||||||
|
allowRetry = true,
|
||||||
|
behavior: ApiFetchBehavior = {}
|
||||||
|
): Promise<T> {
|
||||||
await acquireApiSlot();
|
await acquireApiSlot();
|
||||||
|
|
||||||
const resolvedToken = resolveAccessToken(token);
|
const resolvedToken = resolveAccessToken(token);
|
||||||
@@ -924,7 +961,7 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
|
|||||||
response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, {
|
response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers
|
headers
|
||||||
});
|
}, behavior);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -949,7 +986,7 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
|
|||||||
const refreshed = await trySilentTokenRefresh();
|
const refreshed = await trySilentTokenRefresh();
|
||||||
if (refreshed?.accessToken) {
|
if (refreshed?.accessToken) {
|
||||||
releaseCurrentApiSlot();
|
releaseCurrentApiSlot();
|
||||||
return apiFetch<T>(path, options, refreshed.accessToken, false);
|
return apiFetch<T>(path, options, refreshed.accessToken, false, behavior);
|
||||||
}
|
}
|
||||||
if (refreshed?.requiresPin) {
|
if (refreshed?.requiresPin) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
81
install.sh
81
install.sh
@@ -3005,6 +3005,85 @@ verify_sso_idp_api_burst() {
|
|||||||
return 1
|
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() {
|
write_all_nginx_configs() {
|
||||||
ensure_nginx_mode_for_config_write
|
ensure_nginx_mode_for_config_write
|
||||||
local ssl_type="${1:-$(env_get SSL_TYPE none)}"
|
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_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 в браузере)"
|
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"
|
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
|
if ! docker_nginx_container_running; then
|
||||||
fail "Контейнер lendry-id-nginx не запущен. Проверьте: docker compose --profile proxy logs nginx"
|
fail "Контейнер lendry-id-nginx не запущен. Проверьте: docker compose --profile proxy logs nginx"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user