From 0d79965261419fca39dfb14e16293d97b5bdd103 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Wed, 4 Nov 2026 14:01:28 +0100 Subject: [PATCH 1/4] Add OAuth consent API client --- services/oauth.service.ts | 45 +++++++++++++++++++++++++++++++++++++++ types/oauth.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 services/oauth.service.ts create mode 100644 types/oauth.ts diff --git a/services/oauth.service.ts b/services/oauth.service.ts new file mode 100644 index 000000000..aba26ee10 --- /dev/null +++ b/services/oauth.service.ts @@ -0,0 +1,45 @@ +import { ApiClient } from './client'; +import { + transformOAuthConsentRequest, + type OAuthConsentRequest, + type OAuthConsentRequestApiResponse, + type OAuthRedirectApiResponse, +} from '@/types/oauth'; + +type ConsentApiResponse = OAuthConsentRequestApiResponse | OAuthRedirectApiResponse; + +export type OAuthConsentResult = + | { kind: 'consent'; request: OAuthConsentRequest } + | { kind: 'redirect'; url: string }; + +/** + * Service for handling OAuth consent requests and decisions. + * Both calls forward the client's authorization request unchanged as + * the query string, and the backend validates it each time, + * so redirect URLs only ever come from the backend. + */ +export class OAuthService { + private static readonly BASE_PATH = '/api/oauth/authorize/'; + + /** + * Fetches the OAuth consent request based on the query string. + * Returns either a consent request or a redirect URL. + */ + static async getConsentRequest(query: string): Promise { + const response = await ApiClient.get(`${this.BASE_PATH}?${query}`); + if ('redirect_url' in response) { + return { kind: 'redirect', url: response.redirect_url }; + } + return { kind: 'consent', request: transformOAuthConsentRequest(response) }; + } + + /** + * Returns the URL that takes the user back to the client with their decision. + */ + static async decide(query: string, allow: boolean): Promise { + const response = await ApiClient.post(`${this.BASE_PATH}?${query}`, { + allow, + }); + return response.redirect_url; + } +} diff --git a/types/oauth.ts b/types/oauth.ts new file mode 100644 index 000000000..60ac5acb0 --- /dev/null +++ b/types/oauth.ts @@ -0,0 +1,34 @@ +import { createTransformer } from './transformer'; + +export interface OAuthScope { + name: string; + description: string; +} + +export interface OAuthConsentRequestApiResponse { + client_name: string; + redirect_host: string; + loopback_redirect: boolean; + scopes: OAuthScope[]; +} + +export interface OAuthRedirectApiResponse { + redirect_url: string; +} + +export interface OAuthConsentRequest { + clientName: string; + redirectHost: string; + loopbackRedirect: boolean; + scopes: OAuthScope[]; +} + +export const transformOAuthConsentRequest = createTransformer< + OAuthConsentRequestApiResponse, + OAuthConsentRequest +>((raw) => ({ + clientName: raw.client_name, + redirectHost: raw.redirect_host, + loopbackRedirect: raw.loopback_redirect, + scopes: raw.scopes, +})); From 626e8de3c235fab31740e0880387ba73a081b4ad Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Wed, 4 Nov 2026 14:04:35 +0100 Subject: [PATCH 2/4] Require sign-in for `/oauth/authorize` The consent page fetches the authorization request as soon as it loads and doesn't check for a session itself. A signed-out user would hit a generic error with no way to sign in. Gating the route redirects them to sign in, and the callback URL keeps the OAuth query string, so they land back on the consent screen afterwards. --- proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/proxy.ts b/proxy.ts index be655cf71..7524e3400 100644 --- a/proxy.ts +++ b/proxy.ts @@ -60,5 +60,6 @@ export const config = { '/lists', '/list/:path*', '/proposal/:path*', + '/oauth/authorize', ], }; From df068dfee0c5868997a61b9de8f8bb3f89527531 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Wed, 4 Nov 2026 14:06:48 +0100 Subject: [PATCH 3/4] Block framing and indexing of `/oauth/authorize` The consent page has an Allow button that grants another app access to the user's account, which makes it a clickjacking target. Forbid all framing with CSP and `X-Frame-Options` for older browsers. Also mark it `noindex`, since it's a per-request page. --- next.config.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/next.config.js b/next.config.js index 1813b2f9b..0ca23f527 100644 --- a/next.config.js +++ b/next.config.js @@ -249,6 +249,15 @@ const nextConfig = { }, ], }, + { + // The OAuth consent page must never be framed or indexed + source: '/oauth/authorize', + headers: [ + { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Robots-Tag', value: 'noindex' }, + ], + }, ], images: { unoptimized: false, From b36128e40354701e04e03a69210a473b223c8ab5 Mon Sep 17 00:00:00 2001 From: Gregor Zurowski Date: Wed, 4 Nov 2026 14:07:51 +0100 Subject: [PATCH 4/4] Add OAuth consent page The page passes the client's authorization request to the backend unchanged, and every redirect URL comes from the backend, so the frontend never builds one. If the backend answers with a redirect instead of a consent request, the page follows it straight away. --- app/oauth/authorize/page.tsx | 85 ++++++++++++++++++++++++++++++++++++ hooks/useOAuthConsent.ts | 63 ++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 app/oauth/authorize/page.tsx create mode 100644 hooks/useOAuthConsent.ts diff --git a/app/oauth/authorize/page.tsx b/app/oauth/authorize/page.tsx new file mode 100644 index 000000000..73b344910 --- /dev/null +++ b/app/oauth/authorize/page.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { Suspense } from 'react'; +import Image from 'next/image'; +import { useSearchParams } from 'next/navigation'; +import { AlertTriangle, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { useOAuthConsent } from '@/hooks/useOAuthConsent'; + +function OAuthConsent() { + const searchParams = useSearchParams(); + const { request, error, isSubmitting, decide } = useOAuthConsent(searchParams.toString()); + + if (error) { + return ( + <> +

Can’t connect this app

+

{error}

+ + ); + } + + if (!request) { + return ; + } + + return ( + <> +

+ {request.clientName} wants to access your ResearchHub account +

+

This will allow it to:

+
    + {request.scopes.map((scope) => ( +
  • {scope.description}
  • + ))} +
+

+ You’ll be sent back to{' '} + {request.redirectHost}. +

+ {request.loopbackRedirect && ( +
+ +

+ This app runs on your own computer. Only allow access if you just started connecting it + to ResearchHub. +

+
+ )} +
+ + +
+ + ); +} + +export default function OAuthAuthorizePage() { + return ( +
+
+ ResearchHub Logo + + + +
+
+ ); +} diff --git a/hooks/useOAuthConsent.ts b/hooks/useOAuthConsent.ts new file mode 100644 index 000000000..89f017e6b --- /dev/null +++ b/hooks/useOAuthConsent.ts @@ -0,0 +1,63 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { OAuthService } from '@/services/oauth.service'; +import { ApiError } from '@/services/types'; +import type { OAuthConsentRequest } from '@/types/oauth'; + +// A 400 means the client or its redirect URI is invalid, so the error stays on +// the page instead of going back to the client. +function getErrorMessage(error: unknown): string { + if (error instanceof ApiError && error.status === 400) { + const description = (error.errors as Record | undefined)?.error_description; + if (typeof description === 'string') return description; + return 'This app sent an invalid authorization request.'; + } + return 'Something went wrong. Please try again.'; +} + +/** + * Loads a client's authorization request for the consent page and sends the + * user back to the client with their decision. + */ +export function useOAuthConsent(query: string) { + const [request, setRequest] = useState(null); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + + OAuthService.getConsentRequest(query) + .then((result) => { + if (cancelled) return; + if (result.kind === 'redirect') { + window.location.assign(result.url); + return; + } + setRequest(result.request); + }) + .catch((err) => { + if (!cancelled) setError(getErrorMessage(err)); + }); + + return () => { + cancelled = true; + }; + }, [query]); + + const decide = useCallback( + async (allow: boolean) => { + setIsSubmitting(true); + try { + window.location.assign(await OAuthService.decide(query, allow)); + } catch (err) { + setError(getErrorMessage(err)); + setIsSubmitting(false); + } + }, + [query] + ); + + return { request, error, isSubmitting, decide }; +}