fix oauth

This commit is contained in:
lendry
2026-06-26 09:20:06 +03:00
parent a15be4365c
commit 971d10abf6
3 changed files with 96 additions and 44 deletions

View File

@@ -53,20 +53,23 @@ export class OAuthController {
@Res({ passthrough: true }) res: HttpResponse @Res({ passthrough: true }) res: HttpResponse
) { ) {
const normalized = normalizeAuthorizeQuery(query as Record<string, unknown>); const normalized = normalizeAuthorizeQuery(query as Record<string, unknown>);
let userId = normalized.userId; let userId: string | undefined;
if (!userId && authorization) { if (authorization) {
try { try {
const payload = await verifyAccessToken(this.jwt, authorization); const payload = await verifyAccessToken(this.jwt, authorization);
userId = payload.sub; userId = payload.sub;
} catch { } catch {
// handled below // токен недействителен — не доверяем userId из query
} }
} }
if (!userId) { if (!userId) {
const frontendUrl = await resolveFrontendUrl(this.core); const frontendUrl = await resolveFrontendUrl(this.core);
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, query as Record<string, unknown>); const consentQuery = { ...(query as Record<string, unknown>) };
delete consentQuery.userId;
delete consentQuery.user_id;
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, consentQuery);
res.redirect(302, consentUrl); res.redirect(302, consentUrl);
return; return;
} }

View File

@@ -7,6 +7,15 @@ import { Loader2, ShieldCheck } from 'lucide-react';
import { BrandLogo } from '@/components/id/brand-logo'; import { BrandLogo } from '@/components/id/brand-logo';
import { useAuth } from '@/components/id/auth-provider'; import { useAuth } from '@/components/id/auth-provider';
import { Button } from '@/components/ui/button'; 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() { function OAuthAuthorizeContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -14,49 +23,59 @@ function OAuthAuthorizeContent() {
const { user, token, isPinLocked, isLoading } = useAuth(); const { user, token, isPinLocked, isLoading } = useAuth();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sessionChecked, setSessionChecked] = useState(false);
const oauthQuery = useMemo(() => { const oauthQuery = useMemo(() => buildOAuthQuery(searchParams), [searchParams]);
const params = new URLSearchParams();
searchParams.forEach((value, key) => params.set(key, value));
return params;
}, [searchParams]);
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId'); const clientId = searchParams.get('client_id') ?? searchParams.get('clientId');
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri'); const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri');
const scope = searchParams.get('scope') ?? 'openid profile'; 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(() => { useEffect(() => {
if (isLoading) return; if (isLoading) return;
if (!clientId || !redirectUri) return; if (!clientId || !redirectUri) return;
if (user && token && !isPinLocked) return; if (isPinLocked) return;
const returnUrl = `/auth/oauth/authorize?${oauthQuery.toString()}`; if (user && token) return;
router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`); 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 () => { useEffect(() => {
if (!token || !user) return; if (isLoading || isPinLocked || !token || !user) {
setSubmitting(true); setSessionChecked(false);
setError(null); return;
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);
const response = await fetch(url.toString(), { let cancelled = false;
headers: { void fetchAuthSession(token)
Authorization: `Bearer ${token}`, .then(() => {
Accept: 'application/json' if (!cancelled) setSessionChecked(true);
})
.catch(() => {
if (!cancelled) {
setSessionChecked(false);
router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`);
} }
}); });
if (!response.ok) { return () => {
const payload = (await response.json().catch(() => null)) as { message?: string | string[] } | null; cancelled = true;
const message = Array.isArray(payload?.message) ? payload.message.join(', ') : payload?.message; };
throw new Error(message || 'Не удалось подтвердить доступ'); }, [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) { if (!data.redirectUrl) {
throw new Error('Сервер не вернул redirect URL'); throw new Error('Сервер не вернул redirect URL');
} }
@@ -66,9 +85,27 @@ function OAuthAuthorizeContent() {
} finally { } finally {
setSubmitting(false); 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 ( return (
<div className="flex min-h-[60vh] items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" /> <Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
@@ -76,6 +113,14 @@ function OAuthAuthorizeContent() {
); );
} }
if (isPinLocked) {
return (
<div className="flex min-h-[60vh] items-center justify-center px-4 text-center">
<p className="text-sm text-[#667085]">Подтвердите PIN-код, чтобы продолжить авторизацию приложения.</p>
</div>
);
}
if (!clientId || !redirectUri) { if (!clientId || !redirectUri) {
return ( return (
<div className="mx-auto max-w-md px-4 py-16 text-center"> <div className="mx-auto max-w-md px-4 py-16 text-center">
@@ -88,14 +133,6 @@ function OAuthAuthorizeContent() {
); );
} }
if (!user || !token || isPinLocked) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
</div>
);
}
return ( return (
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-12"> <div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-12">
<div className="mb-8 flex justify-center"> <div className="mb-8 flex justify-center">
@@ -111,7 +148,7 @@ function OAuthAuthorizeContent() {
</p> </p>
<div className="mt-4 space-y-2 rounded-2xl bg-[#f4f5f8] p-4 text-sm"> <div className="mt-4 space-y-2 rounded-2xl bg-[#f4f5f8] p-4 text-sm">
<p> <p>
<span className="text-[#667085]">Пользователь:</span> {user.displayName} <span className="text-[#667085]">Пользователь:</span> {user?.displayName}
</p> </p>
<p> <p>
<span className="text-[#667085]">Scopes:</span> {scope} <span className="text-[#667085]">Scopes:</span> {scope}
@@ -122,11 +159,11 @@ function OAuthAuthorizeContent() {
</div> </div>
{error ? <p className="mt-4 text-sm text-red-600">{error}</p> : null} {error ? <p className="mt-4 text-sm text-red-600">{error}</p> : null}
<div className="mt-6 flex flex-col gap-3 sm:flex-row"> <div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Button className="flex-1 rounded-xl" disabled={submitting} onClick={() => void approve()}> <Button className="flex-1 rounded-xl" disabled={submitting || !authReady} onClick={() => void approve()}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} {submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Разрешить Разрешить
</Button> </Button>
<Button variant="outline" className="flex-1 rounded-xl" disabled={submitting} onClick={() => router.push('/')}> <Button variant="outline" className="flex-1 rounded-xl" disabled={submitting} onClick={cancel}>
Отмена Отмена
</Button> </Button>
</div> </div>

View File

@@ -407,6 +407,18 @@ export async function fetchAuthSession(token?: string | null): Promise<AuthSessi
return apiFetch<AuthSessionResponse>('/auth/session', {}, token); return apiFetch<AuthSessionResponse>('/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<RefreshSessionResponse> { export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH'); throw new ApiError('Refresh token недоступен на сервере', 401, 'NO_REFRESH');