Skip to content

Commit 0b37acf

Browse files
authored
feat: google auth v2 (#5)
* added http callback handlers * implement login request backend * implement callback session authorization logic * new login flow using backend auth * implement login request expiry * proxy requests for google callback via nextjs backend * remove convex http actions and use http api to invoke convex functions instead * clean up dead code * refactor fragmented google auth endpoints * consolidate callbacks from connect flow and bugfixes 1. ensure connect flow succeeds when linking google to current account with the same email 2. fixed connect flow opening in same window rather than new window * improve separation between connect flows and login flows * improve conventions and grouping of auth apis and tables, fixed google auth config testing * update biome to only be used for files it supports * code cleanup * make callback url a server page instead of api to improve ux for errors
1 parent 0f7490d commit 0b37acf

File tree

24 files changed

+2380
-1160
lines changed

24 files changed

+2380
-1160
lines changed

.vscode/settings.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"editor.formatOnSave": true,
3-
"editor.defaultFormatter": "biomejs.biome",
43
"editor.codeActionsOnSave": {
54
"source.organizeImports.biome": "explicit",
65
"quickfix.biome": "explicit"
@@ -11,16 +10,16 @@
1110
"[javascript]": {
1211
"editor.defaultFormatter": "biomejs.biome"
1312
},
14-
"[typescriptreact]": {
13+
"[json]": {
1514
"editor.defaultFormatter": "biomejs.biome"
1615
},
17-
"[javascriptreact]": {
16+
"[jsonc]": {
1817
"editor.defaultFormatter": "biomejs.biome"
1918
},
20-
"[json]": {
19+
"[typescriptreact]": {
2120
"editor.defaultFormatter": "biomejs.biome"
2221
},
23-
"[jsonc]": {
22+
"[javascriptreact]": {
2423
"editor.defaultFormatter": "biomejs.biome"
2524
},
2625
"[html]": {
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// 1. Imports (external first, then internal)
2+
import { api } from '@workspace/backend/convex/_generated/api';
3+
import { fetchAction } from 'convex/nextjs';
4+
import { Suspense } from 'react';
5+
import { CallbackErrorCard } from '@/components/CallbackErrorCard';
6+
import { CallbackSuccessCard } from '@/components/CallbackSuccessCard';
7+
8+
// 2. Public interfaces and types
9+
export interface GoogleOAuthCallbackPageProps {
10+
searchParams: Promise<{
11+
code?: string;
12+
state?: string;
13+
error?: string;
14+
error_description?: string;
15+
}>;
16+
}
17+
18+
export interface CallbackResult {
19+
success: boolean;
20+
flowType?: 'login' | 'connect';
21+
error?: string;
22+
userName?: string;
23+
}
24+
25+
// 3. Internal interfaces and types (prefixed with _)
26+
// None needed for this file
27+
28+
// 4. Main exported functions/components
29+
/**
30+
* Unified Google OAuth callback page that processes the OAuth response server-side
31+
* and displays appropriate UI based on success or failure. This replaces the API route
32+
* approach for better user experience with proper error handling and UI.
33+
*/
34+
export default async function GoogleOAuthCallbackPage({
35+
searchParams,
36+
}: GoogleOAuthCallbackPageProps) {
37+
const params = await searchParams;
38+
39+
// Handle OAuth errors from Google directly
40+
if (params.error) {
41+
console.error('OAuth error from Google:', params.error, params.error_description);
42+
43+
const userFriendlyError = _getUserFriendlyError(params.error, params.error_description);
44+
45+
return <CallbackErrorCard error={userFriendlyError} flowType="login" />;
46+
}
47+
48+
// Validate required parameters
49+
if (!params.code || !params.state) {
50+
console.error('Missing OAuth parameters:', { code: !!params.code, state: !!params.state });
51+
52+
return (
53+
<CallbackErrorCard
54+
error="Missing required OAuth parameters. Please start the authentication process again."
55+
flowType="login"
56+
/>
57+
);
58+
}
59+
60+
// Process the OAuth callback server-side
61+
let callbackResult: CallbackResult;
62+
63+
try {
64+
const result = await fetchAction(api.auth.google.handleGoogleCallback, {
65+
code: params.code,
66+
state: params.state,
67+
});
68+
69+
if (result.success) {
70+
// Log successful authentication for monitoring
71+
console.info('OAuth callback successful', {
72+
flowType: result.flowType,
73+
timestamp: Date.now(),
74+
});
75+
76+
callbackResult = {
77+
success: true,
78+
flowType: result.flowType,
79+
// Note: We don't have userName from the callback result currently
80+
// This could be enhanced in the future if needed
81+
};
82+
} else {
83+
// Handle Convex action failure
84+
console.error('OAuth callback failed:', result.error);
85+
86+
callbackResult = {
87+
success: false,
88+
flowType: result.flowType,
89+
error: result.error,
90+
};
91+
}
92+
} catch (error) {
93+
// Handle internal server error
94+
console.error('Internal server error during OAuth callback:', error);
95+
96+
callbackResult = {
97+
success: false,
98+
error:
99+
error instanceof Error ? error.message : 'Unknown error occurred during authentication',
100+
};
101+
}
102+
103+
// Return appropriate UI based on result
104+
if (callbackResult.success) {
105+
return (
106+
<Suspense fallback={<_LoadingState />}>
107+
<CallbackSuccessCard
108+
flowType={callbackResult.flowType}
109+
userName={callbackResult.userName}
110+
autoCloseDelay={3}
111+
/>
112+
</Suspense>
113+
);
114+
}
115+
116+
// Handle failure case
117+
return (
118+
<CallbackErrorCard
119+
error={callbackResult.error || 'Authentication failed'}
120+
flowType={callbackResult.flowType || 'login'}
121+
/>
122+
);
123+
}
124+
125+
// 5. Internal helper functions (at bottom)
126+
/**
127+
* Creates user-friendly error messages from OAuth error codes and descriptions.
128+
*/
129+
function _getUserFriendlyError(errorCode: string, errorDescription?: string): string {
130+
const lowerError = errorCode.toLowerCase();
131+
const lowerDescription = errorDescription?.toLowerCase() || '';
132+
133+
if (lowerError.includes('access_denied')) {
134+
return 'You cancelled the authentication process.';
135+
}
136+
137+
if (lowerError.includes('expired')) {
138+
return 'The authentication request has expired. Please try again.';
139+
}
140+
141+
if (lowerError.includes('invalid') || lowerDescription.includes('invalid')) {
142+
return 'The authentication request is invalid. Please start the process again.';
143+
}
144+
145+
if (lowerError.includes('network') || lowerDescription.includes('network')) {
146+
return 'Network error occurred. Please check your connection and try again.';
147+
}
148+
149+
if (lowerError.includes('already_connected') || lowerDescription.includes('already connected')) {
150+
return 'This Google account is already connected to your profile.';
151+
}
152+
153+
if (
154+
lowerError.includes('email_already_exists') ||
155+
lowerDescription.includes('email already exists')
156+
) {
157+
return 'An account with this email already exists. Please try signing in instead.';
158+
}
159+
160+
if (lowerError.includes('feature_disabled')) {
161+
return 'Google authentication is currently unavailable. Please try again later.';
162+
}
163+
164+
// Default message
165+
return 'Authentication was cancelled or failed. Please try again.';
166+
}
167+
168+
/**
169+
* Displays a loading state while processing the OAuth callback.
170+
*/
171+
function _LoadingState() {
172+
return (
173+
<div className="flex min-h-screen flex-col items-center justify-center p-4">
174+
<div className="w-full max-w-md space-y-6 text-center">
175+
<div className="space-y-4">
176+
<div className="mx-auto h-16 w-16 rounded-full bg-blue-100 flex items-center justify-center">
177+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
178+
</div>
179+
<div className="space-y-2">
180+
<h1 className="text-2xl font-semibold text-gray-900">Processing...</h1>
181+
<p className="text-gray-600">Completing your authentication...</p>
182+
</div>
183+
</div>
184+
</div>
185+
</div>
186+
);
187+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { NextResponse } from 'next/server';
2+
3+
// Public interfaces and types
4+
export interface TestResponse {
5+
message: string;
6+
}
7+
8+
/**
9+
* Test endpoint for Google authentication configuration.
10+
* Returns a simple success message to verify the route is working.
11+
*/
12+
export async function GET(): Promise<NextResponse<TestResponse>> {
13+
return NextResponse.json({ message: 'Google auth test route is working!' });
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { NextResponse } from 'next/server';
2+
3+
// Public interfaces and types
4+
export interface AuthTestResponse {
5+
message: string;
6+
}
7+
8+
/**
9+
* Test endpoint for authentication system.
10+
* Returns a simple success message to verify the auth route is working.
11+
*/
12+
export async function GET(): Promise<NextResponse<AuthTestResponse>> {
13+
return NextResponse.json({ message: 'Auth test route is working!' });
14+
}

apps/webapp/src/app/api/test/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { NextResponse } from 'next/server';
2+
3+
// Public interfaces and types
4+
export interface TestResponse {
5+
message: string;
6+
}
7+
8+
/**
9+
* General test endpoint for API routes.
10+
* Returns a simple success message to verify the route is working.
11+
*/
12+
export async function GET(): Promise<NextResponse<TestResponse>> {
13+
return NextResponse.json({ message: 'Test route is working!' });
14+
}

apps/webapp/src/app/app/admin/google-auth/page.tsx

Lines changed: 16 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,17 @@ export default function GoogleAuthConfigPage() {
4646
const [showClientSecret, setShowClientSecret] = useState(false);
4747

4848
// Convex queries and mutations
49-
const configData = useSessionQuery(api.system.thirdPartyAuthConfig.getGoogleAuthConfig);
50-
const updateConfig = useSessionMutation(api.system.thirdPartyAuthConfig.updateGoogleAuthConfig);
51-
const toggleEnabled = useSessionMutation(api.system.thirdPartyAuthConfig.toggleGoogleAuthEnabled);
52-
const testConfig = useSessionAction(api.system.thirdPartyAuthConfig.testGoogleAuthConfig);
53-
const resetConfig = useSessionMutation(api.system.thirdPartyAuthConfig.resetGoogleAuthConfig);
54-
55-
// Computed values
56-
const redirectUris = useMemo(() => _getRedirectUris(), []);
49+
const configData = useSessionQuery(api.system.auth.google.getConfig);
50+
const updateConfig = useSessionMutation(api.system.auth.google.updateConfig);
51+
const toggleEnabled = useSessionMutation(api.system.auth.google.toggleEnabled);
52+
const testConfig = useSessionAction(api.system.auth.google.testConfig);
53+
const resetConfig = useSessionMutation(api.system.auth.google.resetConfig);
54+
55+
// Computed values - generate redirect URIs on frontend
56+
const redirectUris = useMemo(() => {
57+
if (typeof window === 'undefined') return [];
58+
return [`${window.location.origin}/api/auth/google/callback`];
59+
}, []);
5760
const isConfigLoading = configData === undefined;
5861
const isPageLoading = appInfoLoading || isConfigLoading;
5962
const isFullyConfigured = isConfigured && enabled;
@@ -596,37 +599,14 @@ export default function GoogleAuthConfigPage() {
596599
);
597600
}
598601

599-
/**
600-
* Generates redirect URIs based on current domain for OAuth configuration.
601-
*/
602-
function _getRedirectUris(): string[] {
603-
if (typeof window === 'undefined') return [];
604-
605-
const { protocol, host } = window.location;
606-
const baseUrl = `${protocol}//${host}`;
607-
608-
return [
609-
`${baseUrl}/login/google/callback`,
610-
`${baseUrl}/app/profile/connect/google/callback`,
611-
// Add localhost for development if not already localhost
612-
...(host.includes('localhost')
613-
? []
614-
: [
615-
'http://localhost:3000/login/google/callback',
616-
'http://localhost:3000/app/profile/connect/google/callback',
617-
]),
618-
];
619-
}
620-
621602
/**
622603
* Copies text to clipboard and shows success/error toast notification.
623604
*/
624605
async function _copyToClipboard(text: string): Promise<void> {
625606
try {
626607
await navigator.clipboard.writeText(text);
627608
toast.success('Copied to clipboard!');
628-
} catch (error) {
629-
console.error('Failed to copy to clipboard:', error);
609+
} catch (_error) {
630610
toast.error('Failed to copy to clipboard');
631611
}
632612
}
@@ -758,8 +738,7 @@ async function _handleToggleEnabled(
758738
await toggleEnabled({ enabled: newEnabled });
759739
setEnabled(newEnabled);
760740
toast.success(`Google Auth ${newEnabled ? 'enabled' : 'disabled'} successfully`);
761-
} catch (error) {
762-
console.error('Failed to toggle Google Auth:', error);
741+
} catch (_error) {
763742
toast.error('Failed to toggle Google Auth. Please try again.');
764743
}
765744
}
@@ -831,8 +810,7 @@ async function _handleSave(params: _SaveConfigParams): Promise<void> {
831810

832811
toast.success('Google Auth configuration saved successfully');
833812
setIsConfigured(true);
834-
} catch (error) {
835-
console.error('Failed to save configuration:', error);
813+
} catch (_error) {
836814
toast.error('Failed to save configuration. Please try again.');
837815
} finally {
838816
setIsFormLoading(false);
@@ -874,12 +852,8 @@ async function _handleTest(
874852
toast.success(`✅ Configuration is valid: ${result.message}`);
875853
} else {
876854
toast.error(`❌ Configuration failed: ${result.message}`);
877-
if (result.details?.issues) {
878-
console.log('Configuration issues:', result.details.issues);
879-
}
880855
}
881-
} catch (error) {
882-
console.error('Failed to test configuration:', error);
856+
} catch (_error) {
883857
toast.error('Failed to test configuration. Please try again.');
884858
}
885859
}
@@ -910,8 +884,7 @@ async function _handleReset(
910884
setClientId('');
911885
setClientSecret('');
912886
setIsConfigured(false);
913-
} catch (error) {
914-
console.error('Failed to reset configuration:', error);
887+
} catch (_error) {
915888
toast.error('Failed to reset configuration. Please try again.');
916889
}
917890
}

0 commit comments

Comments
 (0)