diff --git a/apps/api-gateway/src/controllers/oauth.controller.ts b/apps/api-gateway/src/controllers/oauth.controller.ts index 253eff2..ca7e3ae 100644 --- a/apps/api-gateway/src/controllers/oauth.controller.ts +++ b/apps/api-gateway/src/controllers/oauth.controller.ts @@ -53,20 +53,23 @@ export class OAuthController { @Res({ passthrough: true }) res: HttpResponse ) { const normalized = normalizeAuthorizeQuery(query as Record); - let userId = normalized.userId; + let userId: string | undefined; - if (!userId && authorization) { + if (authorization) { try { const payload = await verifyAccessToken(this.jwt, authorization); userId = payload.sub; } catch { - // handled below + // токен недействителен — не доверяем userId из query } } if (!userId) { const frontendUrl = await resolveFrontendUrl(this.core); - const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, query as Record); + const consentQuery = { ...(query as Record) }; + delete consentQuery.userId; + delete consentQuery.user_id; + const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, consentQuery); res.redirect(302, consentUrl); return; } diff --git a/apps/frontend/app/auth/oauth/authorize/page.tsx b/apps/frontend/app/auth/oauth/authorize/page.tsx index 1e4a5b1..4bc9597 100644 --- a/apps/frontend/app/auth/oauth/authorize/page.tsx +++ b/apps/frontend/app/auth/oauth/authorize/page.tsx @@ -7,6 +7,15 @@ import { Loader2, ShieldCheck } from 'lucide-react'; import { BrandLogo } from '@/components/id/brand-logo'; import { useAuth } from '@/components/id/auth-provider'; import { Button } from '@/components/ui/button'; +import { approveOAuthAuthorization, fetchAuthSession } from '@/lib/api'; + +function buildOAuthQuery(searchParams: URLSearchParams) { + const params = new URLSearchParams(); + searchParams.forEach((value, key) => { + if (key !== 'userId') params.set(key, value); + }); + return params; +} function OAuthAuthorizeContent() { const searchParams = useSearchParams(); @@ -14,49 +23,59 @@ function OAuthAuthorizeContent() { const { user, token, isPinLocked, isLoading } = useAuth(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [sessionChecked, setSessionChecked] = useState(false); - const oauthQuery = useMemo(() => { - const params = new URLSearchParams(); - searchParams.forEach((value, key) => params.set(key, value)); - return params; - }, [searchParams]); + const oauthQuery = useMemo(() => buildOAuthQuery(searchParams), [searchParams]); const clientId = searchParams.get('client_id') ?? searchParams.get('clientId'); const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri'); const scope = searchParams.get('scope') ?? 'openid profile'; + const state = searchParams.get('state'); + + const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]); + + useEffect(() => { + if (!searchParams.has('userId')) return; + router.replace(returnUrl); + }, [returnUrl, router, searchParams]); useEffect(() => { if (isLoading) return; if (!clientId || !redirectUri) return; - if (user && token && !isPinLocked) return; - const returnUrl = `/auth/oauth/authorize?${oauthQuery.toString()}`; + if (isPinLocked) return; + if (user && token) return; router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`); - }, [clientId, isLoading, isPinLocked, oauthQuery, redirectUri, router, token, user]); + }, [clientId, isLoading, isPinLocked, redirectUri, returnUrl, router, token, user]); - const approve = useCallback(async () => { - if (!token || !user) return; - setSubmitting(true); - setError(null); - try { - const apiBase = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, ''); - const url = new URL(`${apiBase}/oauth/authorize`); - oauthQuery.forEach((value, key) => url.searchParams.set(key, value)); - url.searchParams.set('userId', user.id); + useEffect(() => { + if (isLoading || isPinLocked || !token || !user) { + setSessionChecked(false); + return; + } - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json' + let cancelled = false; + void fetchAuthSession(token) + .then(() => { + if (!cancelled) setSessionChecked(true); + }) + .catch(() => { + if (!cancelled) { + setSessionChecked(false); + router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`); } }); - if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { message?: string | string[] } | null; - const message = Array.isArray(payload?.message) ? payload.message.join(', ') : payload?.message; - throw new Error(message || 'Не удалось подтвердить доступ'); - } + return () => { + cancelled = true; + }; + }, [isLoading, isPinLocked, returnUrl, router, token, user]); - const data = (await response.json()) as { redirectUrl?: string }; + const approve = useCallback(async () => { + if (!token || !user || isPinLocked) return; + setSubmitting(true); + setError(null); + try { + const data = await approveOAuthAuthorization(oauthQuery, token); if (!data.redirectUrl) { throw new Error('Сервер не вернул redirect URL'); } @@ -66,9 +85,27 @@ function OAuthAuthorizeContent() { } finally { setSubmitting(false); } - }, [oauthQuery, token, user]); + }, [isPinLocked, oauthQuery, token, user]); - if (isLoading) { + const cancel = useCallback(() => { + if (!redirectUri) { + router.push('/'); + return; + } + try { + const url = new URL(redirectUri); + url.searchParams.set('error', 'access_denied'); + url.searchParams.set('error_description', 'Пользователь отклонил запрос'); + if (state) url.searchParams.set('state', state); + window.location.href = url.toString(); + } catch { + router.push('/'); + } + }, [redirectUri, router, state]); + + const authReady = Boolean(user && token && !isPinLocked && sessionChecked); + + if (isLoading || (clientId && redirectUri && !authReady && !isPinLocked)) { return (
@@ -76,6 +113,14 @@ function OAuthAuthorizeContent() { ); } + if (isPinLocked) { + return ( +
+

Подтвердите PIN-код, чтобы продолжить авторизацию приложения.

+
+ ); + } + if (!clientId || !redirectUri) { return (
@@ -88,14 +133,6 @@ function OAuthAuthorizeContent() { ); } - if (!user || !token || isPinLocked) { - return ( -
- -
- ); - } - return (
@@ -111,7 +148,7 @@ function OAuthAuthorizeContent() {

- Пользователь: {user.displayName} + Пользователь: {user?.displayName}

Scopes: {scope} @@ -122,11 +159,11 @@ function OAuthAuthorizeContent() {

{error ?

{error}

: null}
- -
diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 4f7a929..c5127f5 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -407,6 +407,18 @@ export async function fetchAuthSession(token?: string | null): Promise('/auth/session', {}, token); } +export async function approveOAuthAuthorization(params: URLSearchParams, token: string) { + const query = new URLSearchParams(); + params.forEach((value, key) => { + if (key !== 'userId') query.set(key, value); + }); + return apiFetch<{ redirectUrl?: string }>( + `/oauth/authorize?${query.toString()}`, + { headers: { Accept: 'application/json' } }, + token + ); +} + export async function refreshAuthSession(): Promise { if (typeof window === 'undefined') { throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH');