fix oauth
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user