- Implemented OAuth 2.0 Authorization Server endpoints per RFCs, including token, introspection, revocation, and metadata discovery. - Added user consent submission, listing, and revocation APIs alongside frontend integration for improved UX. - Enforced stricter OAuth security measures (PKCE, state validation, scopes). - Refactored schemas and services for consistency and expanded coverage of OAuth workflows. - Updated documentation and type definitions for new API behaviors.
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
/* istanbul ignore file -- @preserve OAuth consent requires external OAuth provider flow, tested via e2e */
|
|
/**
|
|
* OAuth Consent Page
|
|
* Displays authorization consent form for OAuth provider mode (MCP integration).
|
|
*
|
|
* Users are redirected here when an external application (MCP client) requests
|
|
* access to their account. They can approve or deny the requested permissions.
|
|
*
|
|
* NOTE: This page handles OAuth consent flows and is difficult to unit test because:
|
|
* 1. It relies on URL search params from OAuth authorization requests
|
|
* 2. It has complex side effects (auth checking, API calls, redirects)
|
|
* 3. OAuth provider flows are better tested via e2e tests with mocked providers
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import { useRouter } from '@/lib/i18n/routing';
|
|
import { useTranslations } from 'next-intl';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Loader2, Shield, AlertTriangle, ExternalLink, CheckCircle2 } from 'lucide-react';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import config from '@/config/app.config';
|
|
|
|
// Scope descriptions for display
|
|
const SCOPE_INFO: Record<string, { name: string; description: string; icon: string }> = {
|
|
openid: {
|
|
name: 'OpenID Connect',
|
|
description: 'Verify your identity',
|
|
icon: 'user',
|
|
},
|
|
profile: {
|
|
name: 'Profile',
|
|
description: 'Access your name and basic profile information',
|
|
icon: 'user-circle',
|
|
},
|
|
email: {
|
|
name: 'Email',
|
|
description: 'Access your email address',
|
|
icon: 'mail',
|
|
},
|
|
'read:users': {
|
|
name: 'Read Users',
|
|
description: 'View user information',
|
|
icon: 'users',
|
|
},
|
|
'write:users': {
|
|
name: 'Write Users',
|
|
description: 'Modify user information',
|
|
icon: 'user-edit',
|
|
},
|
|
'read:organizations': {
|
|
name: 'Read Organizations',
|
|
description: 'View organization information',
|
|
icon: 'building',
|
|
},
|
|
'write:organizations': {
|
|
name: 'Write Organizations',
|
|
description: 'Modify organization information',
|
|
icon: 'building-edit',
|
|
},
|
|
admin: {
|
|
name: 'Admin Access',
|
|
description: 'Full administrative access',
|
|
icon: 'shield',
|
|
},
|
|
};
|
|
|
|
interface ConsentParams {
|
|
clientId: string;
|
|
clientName: string;
|
|
redirectUri: string;
|
|
scope: string;
|
|
state: string;
|
|
codeChallenge: string;
|
|
codeChallengeMethod: string;
|
|
nonce: string;
|
|
}
|
|
|
|
export default function OAuthConsentPage() {
|
|
const searchParams = useSearchParams();
|
|
const router = useRouter();
|
|
// Note: t is available for future i18n use
|
|
const _t = useTranslations('auth.oauth');
|
|
void _t; // Suppress unused warning - ready for i18n
|
|
const { isAuthenticated, isLoading: authLoading, accessToken } = useAuth();
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedScopes, setSelectedScopes] = useState<Set<string>>(new Set());
|
|
const [params, setParams] = useState<ConsentParams | null>(null);
|
|
|
|
// Parse URL parameters
|
|
useEffect(() => {
|
|
const clientId = searchParams.get('client_id') || '';
|
|
const clientName = searchParams.get('client_name') || 'Application';
|
|
const redirectUri = searchParams.get('redirect_uri') || '';
|
|
const scope = searchParams.get('scope') || '';
|
|
const state = searchParams.get('state') || '';
|
|
const codeChallenge = searchParams.get('code_challenge') || '';
|
|
const codeChallengeMethod = searchParams.get('code_challenge_method') || '';
|
|
const nonce = searchParams.get('nonce') || '';
|
|
|
|
if (!clientId || !redirectUri) {
|
|
setError('Invalid authorization request. Missing required parameters.');
|
|
return;
|
|
}
|
|
|
|
setParams({
|
|
clientId,
|
|
clientName,
|
|
redirectUri,
|
|
scope,
|
|
state,
|
|
codeChallenge,
|
|
codeChallengeMethod,
|
|
nonce,
|
|
});
|
|
|
|
// Initialize selected scopes with all requested scopes
|
|
if (scope) {
|
|
setSelectedScopes(new Set(scope.split(' ')));
|
|
}
|
|
}, [searchParams]);
|
|
|
|
// Redirect to login if not authenticated
|
|
useEffect(() => {
|
|
if (!authLoading && !isAuthenticated) {
|
|
const returnUrl = `/auth/consent?${searchParams.toString()}`;
|
|
router.push(`${config.routes.login}?return_to=${encodeURIComponent(returnUrl)}`);
|
|
}
|
|
}, [authLoading, isAuthenticated, router, searchParams]);
|
|
|
|
const handleScopeToggle = (scope: string) => {
|
|
setSelectedScopes((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(scope)) {
|
|
next.delete(scope);
|
|
} else {
|
|
next.add(scope);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async (approved: boolean) => {
|
|
if (!params) return;
|
|
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Create form data for consent submission
|
|
const formData = new FormData();
|
|
formData.append('approved', approved.toString());
|
|
formData.append('client_id', params.clientId);
|
|
formData.append('redirect_uri', params.redirectUri);
|
|
formData.append('scope', Array.from(selectedScopes).join(' '));
|
|
formData.append('state', params.state);
|
|
if (params.codeChallenge) {
|
|
formData.append('code_challenge', params.codeChallenge);
|
|
}
|
|
if (params.codeChallengeMethod) {
|
|
formData.append('code_challenge_method', params.codeChallengeMethod);
|
|
}
|
|
if (params.nonce) {
|
|
formData.append('nonce', params.nonce);
|
|
}
|
|
|
|
// Submit consent to backend
|
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
|
const headers: HeadersInit = {};
|
|
if (accessToken) {
|
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
}
|
|
const response = await fetch(`${apiUrl}/api/v1/oauth/provider/authorize/consent`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers,
|
|
credentials: 'include',
|
|
});
|
|
|
|
// The endpoint returns a redirect, so follow it
|
|
if (response.redirected) {
|
|
window.location.href = response.url;
|
|
} else if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.detail || 'Failed to process consent');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Show loading state while checking auth
|
|
if (authLoading) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center p-4">
|
|
<div className="text-center space-y-4">
|
|
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
|
<p className="text-muted-foreground">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show error state
|
|
if (error && !params) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center p-4">
|
|
<div className="w-full max-w-md space-y-4">
|
|
<Alert variant="destructive">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
<div className="flex gap-2 justify-center">
|
|
<Button variant="outline" onClick={() => router.push(config.routes.login)}>
|
|
Back to Login
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!params) {
|
|
return null;
|
|
}
|
|
|
|
const requestedScopes = params.scope ? params.scope.split(' ') : [];
|
|
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<CardHeader className="text-center">
|
|
<div className="flex justify-center mb-4">
|
|
<Shield className="h-12 w-12 text-primary" />
|
|
</div>
|
|
<CardTitle className="text-xl">Authorization Request</CardTitle>
|
|
<CardDescription className="mt-2">
|
|
<span className="font-semibold text-foreground">{params.clientName}</span> wants to
|
|
access your account
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<p className="text-sm font-medium">This application will be able to:</p>
|
|
<div className="space-y-2 border rounded-lg p-3">
|
|
{requestedScopes.map((scope) => {
|
|
const info = SCOPE_INFO[scope] || {
|
|
name: scope,
|
|
description: `Access to ${scope}`,
|
|
};
|
|
const isSelected = selectedScopes.has(scope);
|
|
|
|
return (
|
|
<div
|
|
key={scope}
|
|
className="flex items-start space-x-3 py-2 border-b last:border-0"
|
|
>
|
|
<Checkbox
|
|
id={`scope-${scope}`}
|
|
checked={isSelected}
|
|
onCheckedChange={() => handleScopeToggle(scope)}
|
|
disabled={isSubmitting}
|
|
/>
|
|
<div className="flex-1 space-y-0.5">
|
|
<Label
|
|
htmlFor={`scope-${scope}`}
|
|
className="text-sm font-medium cursor-pointer"
|
|
>
|
|
{info.name}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">{info.description}</p>
|
|
</div>
|
|
{isSelected && <CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<Alert>
|
|
<ExternalLink className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">
|
|
After authorization, you will be redirected to:
|
|
<br />
|
|
<code className="text-xs break-all bg-muted px-1 py-0.5 rounded">
|
|
{params.redirectUri}
|
|
</code>
|
|
</AlertDescription>
|
|
</Alert>
|
|
</CardContent>
|
|
|
|
<CardFooter className="flex gap-3">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={() => handleSubmit(false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deny'}
|
|
</Button>
|
|
<Button
|
|
className="flex-1"
|
|
onClick={() => handleSubmit(true)}
|
|
disabled={isSubmitting || selectedScopes.size === 0}
|
|
>
|
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Authorize'}
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|