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 (
+
+ );
+}
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 };
+}
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,
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',
],
};
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,
+}));