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:

+ +

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