From b94053aebcfe3556d9f3793f12f6a535f8a25fba Mon Sep 17 00:00:00 2001 From: James Bhattarai Date: Thu, 26 Feb 2026 23:38:45 +0545 Subject: [PATCH] Feat: Handle Oauth Callback/Bridge + Microsoft Oauth --- .env.example | 1 + .gitignore | 2 + package-lock.json | 18 ++- src/lib/api.ts | 15 ++- src/pages/auth/LoginPage.tsx | 148 ++++++++++++++++------ src/pages/auth/OAuthCallbackPage.tsx | 174 ++++++++++++-------------- src/pages/user/LinkedAccountsPage.tsx | 46 ++++--- 7 files changed, 247 insertions(+), 157 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0a90d71 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:5000/api/v1 diff --git a/.gitignore b/.gitignore index a547bf3..cea8a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +*.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e1e8e54..d706b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2859,6 +2859,7 @@ "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2876,6 +2877,7 @@ "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2887,6 +2889,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2937,6 +2940,7 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -3169,6 +3173,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3373,6 +3378,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3706,6 +3712,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -3787,7 +3794,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -3885,6 +3893,7 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5406,6 +5415,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5592,6 +5602,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5618,6 +5629,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5631,6 +5643,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6184,6 +6197,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6308,6 +6322,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6487,6 +6502,7 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/src/lib/api.ts b/src/lib/api.ts index b771a10..2458b1b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -132,6 +132,8 @@ export interface WebAuthnLoginCompleteResponse { } // External Auth Types +export type ExternalProviderId = 'google' | 'github' | 'microsoft'; + export interface ExternalProvider { id: string; name: string; @@ -606,12 +608,17 @@ export const api = { listLinkedAccounts: () => request('/auth/external/linked-accounts'), - // Initiate OAuth login flow - initiateLogin: (provider: string, options?: { redirect_uri?: string; organization_id?: string }) => - request(`/auth/external/${provider}/authorize`, { + // Initiate OAuth login flow — returns authorization_url to redirect the browser to + initiateLogin: (provider: string, options?: { redirect_uri?: string; organization_id?: string; flow?: string; oidc_session_id?: string }) => { + const params = new URLSearchParams({ flow: options?.flow ?? 'login' }); + if (options?.redirect_uri) params.set('redirect_uri', options.redirect_uri); + if (options?.organization_id) params.set('organization_id', options.organization_id); + if (options?.oidc_session_id) params.set('oidc_session_id', options.oidc_session_id); + return request(`/auth/external/${provider}/authorize?${params.toString()}`, { method: 'GET', credentials: 'include', - }, false), + }, false); + }, // Initiate account linking flow (requires auth) initiateLink: (provider: string, redirect_uri?: string) => diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 84c3a24..13516d0 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Mail, Lock, ArrowRight, Fingerprint, ArrowLeft, ShieldCheck, Loader2, Smartphone, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -23,10 +23,31 @@ import { } from "@/lib/webauthn"; import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard"; import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; -import { generateCodeVerifier, computeCodeChallenge, generateState, storeOAuthState, OAuthProvider } from "@/lib/oauth"; +import { OAuthProvider } from "@/lib/oauth"; type LoginStep = 'credentials' | 'totp' | 'webauthn' | 'passkey-email' | 'mfa-enrollment' | 'mfa'; +const GATEHOUSE_API = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'; +const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); + +/** + * Complete an OIDC authorization flow after the user has authenticated. + * Sends the bearer token + oidc_session_id to the backend, which generates + * the auth code and returns the redirect URL for the calling application. + */ +async function completeOidcFlow(oidcSessionId: string, token: string): Promise { + const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), + }); + const body = await res.json(); + if (!res.ok || !body.success) { + throw new Error(body.message ?? 'OIDC completion failed'); + } + return body.data.redirect_url as string; +} + export default function LoginPage() { const { login, verifyTotp, refreshUser } = useAuth(); const navigate = useNavigate(); @@ -42,6 +63,28 @@ export default function LoginPage() { const [passkeyEmail, setPasskeyEmail] = useState(""); const [mfaToken, setMfaToken] = useState(null); + // OIDC bridge: if oidc_session_id is in the URL, we're acting as the + // login UI for an OIDC authorization flow (e.g. SecuIRD → Gatehouse). + // After successful login, call /oidc/complete and redirect to the client app. + const oidcSessionId = searchParams.get('oidc_session_id'); + const oidcError = searchParams.get('error'); + + const finishOidcFlow = useCallback(async (token: string) => { + if (!oidcSessionId) return false; + try { + const redirectUrl = await completeOidcFlow(oidcSessionId, token); + window.location.href = redirectUrl; + return true; + } catch (err) { + toast({ + variant: "destructive", + title: "Authorization failed", + description: err instanceof Error ? err.message : "Could not complete OIDC authorization", + }); + return false; + } + }, [oidcSessionId, toast]); + // Check for MFA step from OAuth callback useEffect(() => { if (searchParams.get('step') === 'mfa') { @@ -77,6 +120,10 @@ export default function LoginPage() { } else if (result.requiresMfaEnrollment) { // MFA enrollment required - will be handled by ProtectedLayout // Navigation happens in AuthContext + } else if (oidcSessionId) { + // OIDC bridge: send token back to the Gatehouse backend to complete the flow + const token = tokenManager.getToken(); + if (token) await finishOidcFlow(token); } // If no TOTP, WebAuthn, or MFA enrollment required, navigation happens in AuthContext } catch (error) { @@ -128,12 +175,20 @@ export default function LoginPage() { sessionStorage.removeItem('mfa_token'); sessionStorage.removeItem('mfa_flow'); - // Refresh user context and navigate - await refreshUser(); - navigate('/profile'); + // OIDC bridge: finish the flow if this is an OIDC login + if (oidcSessionId && response.token) { + await finishOidcFlow(response.token); + } else { + await refreshUser(); + navigate('/profile'); + } } else { // Fallback to regular TOTP verification await verifyTotp(totpCode, useBackupCode); + if (oidcSessionId) { + const token = tokenManager.getToken(); + if (token) await finishOidcFlow(token); + } } } catch (error) { if (import.meta.env.DEV) { @@ -173,7 +228,12 @@ export default function LoginPage() { try { await verifyTotp(totpCode, useBackupCode); - // Navigation happens in AuthContext + // OIDC bridge: finish the flow if this is an OIDC login + if (oidcSessionId) { + const token = tokenManager.getToken(); + if (token) await finishOidcFlow(token); + } + // Otherwise navigation happens in AuthContext } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] TOTP verification failed:", error); @@ -229,7 +289,12 @@ export default function LoginPage() { // Token is stored by completeLogin, refresh user and navigate await refreshUser(); - navigate('/profile'); + if (oidcSessionId) { + const token = tokenManager.getToken(); + if (token) await finishOidcFlow(token); + } else { + navigate('/profile'); + } toast({ title: "Welcome back", @@ -295,14 +360,18 @@ export default function LoginPage() { const formattedAssertion = formatLoginAssertion(assertion); const result = await api.webauthn.completeLogin(formattedAssertion); - // Token is stored by completeLogin, refresh user and navigate - await refreshUser(); - navigate('/profile'); - - toast({ - title: "Welcome back", - description: `Signed in as ${result.user.email}`, - }); + // OIDC bridge or normal navigation + if (oidcSessionId) { + const token = tokenManager.getToken(); + if (token) await finishOidcFlow(token); + } else { + await refreshUser(); + navigate('/profile'); + toast({ + title: "Welcome back", + description: `Signed in as ${result.user.email}`, + }); + } } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] WebAuthn verification failed:", error); @@ -351,35 +420,33 @@ export default function LoginPage() { }; /** - * Initiate OAuth login flow for external provider + * Initiate OAuth login flow for external provider. + * + * The backend /authorize endpoint builds the Google auth URL (with the + * backend callback as redirect_uri) and returns it. We then redirect the + * browser to Google. After the user authenticates, Google calls the backend + * callback, the backend exchanges the code for a session token, and + * redirects the browser to /oauth/callback?token=... on the frontend. */ const handleOAuthLogin = async (provider: OAuthProvider) => { setIsLoading(true); - - try { - // Generate PKCE parameters - const codeVerifier = generateCodeVerifier(); - const codeChallenge = await computeCodeChallenge(codeVerifier); - const state = generateState(); - // Store OAuth state for callback validation - storeOAuthState({ - state, - codeVerifier, + try { + // The redirect_uri Google will call is the *backend* callback. + // The backend then redirects to the frontend /oauth/callback with the token. + const backendCallbackUri = `${import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1'}/auth/external/${provider}/callback`; + + // Ask backend for the Google authorization URL + // If we're in an OIDC bridge flow, pass oidc_session_id so it survives the round-trip + const response = await api.externalAuth.initiateLogin(provider, { + redirect_uri: backendCallbackUri, flow: 'login', - provider, - redirectUri: `${window.location.origin}/oauth/callback`, + ...(oidcSessionId ? { oidc_session_id: oidcSessionId } : {}), }); - // Get authorization URL from backend - const response = await api.externalAuth.initiateLogin(provider, state); + // Redirect browser to provider + window.location.href = response.authorization_url; - // Redirect to provider authorization page - const authUrl = new URL(response.authorization_url); - authUrl.searchParams.set('state', response.state || state); - - window.location.href = authUrl.toString(); - } catch (error) { if (import.meta.env.DEV) { console.error("[Gatehouse] OAuth login failed:", error); @@ -794,11 +861,16 @@ export default function LoginPage() {

- Welcome back + {oidcSessionId ? "Sign in to continue" : "Welcome back"}

- Sign in to your account to continue + {oidcSessionId + ? "An application is requesting access to your account" + : "Sign in to your account to continue"}

+ {oidcError && ( +

{oidcError}

+ )}
diff --git a/src/pages/auth/OAuthCallbackPage.tsx b/src/pages/auth/OAuthCallbackPage.tsx index ed3dae2..2a629b5 100644 --- a/src/pages/auth/OAuthCallbackPage.tsx +++ b/src/pages/auth/OAuthCallbackPage.tsx @@ -4,16 +4,37 @@ import { Loader2, AlertCircle, CheckCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useAuth } from "@/contexts/AuthContext"; -import { api, ApiError, tokenManager, OAuthCallbackResponse } from "@/lib/api"; -import { getOAuthState, clearOAuthState } from "@/lib/oauth"; +import { tokenManager } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; type CallbackState = 'loading' | 'success' | 'error'; +const GATEHOUSE_API = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string; +const GATEHOUSE_OIDC = GATEHOUSE_API.replace(/\/api\/v1\/?$/, ''); + +async function completeOidcFlow(oidcSessionId: string, token: string): Promise { + const res = await fetch(`${GATEHOUSE_OIDC}/oidc/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oidc_session_id: oidcSessionId, token }), + }); + const body = await res.json(); + if (!res.ok || !body.success) throw new Error(body.message ?? 'OIDC completion failed'); + return body.data.redirect_url as string; +} + /** - * OAuth callback page that handles the redirect from external OAuth providers. - * Extracts the authorization code and state from the URL, validates the state, - * exchanges the code for tokens, and handles MFA requirements. + * OAuth callback page that handles the redirect from the Gatehouse backend + * after a successful (or failed) OAuth provider authentication. + * + * The backend exchanges the provider code for a session token and then + * redirects the browser here with query params: + * + * Success: ?token=TOKEN&expires_in=86400&flow=login&provider=google&state=STATE + * OIDC bridge: same as above + &oidc_session_id=ID + * Error: ?error=MESSAGE&error_type=TYPE&state=STATE + * Org selection: ?requires_org_selection=1&state=STATE + * Org creation: ?requires_org_creation=1&state=STATE */ export default function OAuthCallbackPage() { const [searchParams] = useSearchParams(); @@ -26,138 +47,101 @@ export default function OAuthCallbackPage() { useEffect(() => { const handleCallback = async () => { - // 1. Extract query parameters from URL - const code = searchParams.get("code"); - const callbackState = searchParams.get("state"); const errorParam = searchParams.get("error"); - const errorDescription = searchParams.get("error_description"); + const errorType = searchParams.get("error_type"); + const token = searchParams.get("token"); + const expiresIn = searchParams.get("expires_in"); + const flowType = searchParams.get("flow") || "login"; + const provider = searchParams.get("provider") || "google"; + const requiresOrgSelection = searchParams.get("requires_org_selection"); + const requiresOrgCreation = searchParams.get("requires_org_creation"); + const oidcSessionId = searchParams.get("oidc_session_id"); - // 2. Handle OAuth errors from provider + // Error from provider or backend if (errorParam) { setStatus('error'); - - // User denied access - if (errorParam === 'access_denied') { + if (errorType === 'ACCESS_DENIED' || errorParam.toLowerCase().includes('denied')) { setError("You denied the authorization request. Please try again if you wish to sign in."); } else { - setError(errorDescription || `Authorization failed: ${errorParam}`); + setError(errorParam); } return; } - // Validate required parameters - if (!code || !callbackState) { + // Organisation selection required + if (requiresOrgSelection) { setStatus('error'); - setError("Missing authorization code or state parameter. Please try signing in again."); + setError("Multiple organizations found. Organization selection is not yet supported in this UI. Please contact your administrator."); return; } - // 3. Validate state parameter (CSRF protection) - const storedState = getOAuthState(callbackState); - if (!storedState) { + // Organisation creation required + if (requiresOrgCreation) { setStatus('error'); - setError("Invalid or expired OAuth state. Please try signing in again."); + setError("No organization found for your account. Please ask an administrator to add you to an organization."); + return; + } + + // Success — token in URL + if (!token) { + setStatus('error'); + setError("No authentication token received. Please try signing in again."); return; } try { - // 4. Exchange authorization code for tokens using the API - const response = await api.externalAuth.handleCallback( - storedState.provider, - code, - callbackState - ); + const expiresAt = expiresIn + ? new Date(Date.now() + parseInt(expiresIn, 10) * 1000).toISOString() + : null; - // Handle error response from backend - if (response.error) { - setStatus('error'); - - // Map error types to user-friendly messages - switch (response.error_type) { - case 'ACCESS_DENIED': - setError("You denied the authorization request. Please try again if you wish to sign in."); - break; - case 'INVALID_REQUEST': - setError("Invalid request. Please try signing in again."); - break; - case 'SERVER_ERROR': - setError("The authentication server encountered an error. Please try again later."); - break; - default: - setError(response.error || "An error occurred during authentication."); - } - - clearOAuthState(callbackState); - return; - } - - // 5. Handle MFA requirement - if (response.requires_mfa && response.mfa_token) { - // Store MFA token for the MFA verification flow - sessionStorage.setItem('mfa_token', response.mfa_token); - sessionStorage.setItem('mfa_flow', 'external_auth'); - clearOAuthState(callbackState); - - // Redirect to login page with MFA step - navigate('/login?step=mfa', { replace: true }); - return; - } - - // 6. Store authentication tokens - if (response.token && response.expires_in) { - tokenManager.setToken(response.token, new Date(Date.now() + response.expires_in * 1000).toISOString()); - } - - // Clear OAuth state (single-use) - clearOAuthState(callbackState); - - // Refresh user context + tokenManager.setToken(token, expiresAt); await refreshUser(); + // ── OIDC bridge: complete the flow and redirect back to the OIDC client ── + if (oidcSessionId) { + try { + const redirectUrl = await completeOidcFlow(oidcSessionId, token); + window.location.href = redirectUrl; + return; + } catch (oidcErr) { + if (import.meta.env.DEV) { + console.error("[Gatehouse] OIDC completion failed after OAuth:", oidcErr); + } + // Fall through to normal flow on failure — user is still logged in + } + } + setStatus('success'); - // Show success toast and redirect toast({ title: "Sign in successful", - description: response.user ? `Welcome, ${response.user.email}` : "You have been signed in successfully.", + description: `Signed in with ${provider.charAt(0).toUpperCase() + provider.slice(1)}`, }); - // 7. Redirect based on flow type setTimeout(() => { - switch (storedState.flowType) { + switch (flowType) { case 'link': navigate('/linked-accounts', { replace: true }); break; case 'register': - navigate('/profile', { replace: true }); - break; case 'login': default: navigate('/profile', { replace: true }); } - }, 1500); + }, 1200); } catch (err) { setStatus('error'); - clearOAuthState(callbackState); - - if (err instanceof ApiError) { - // Handle specific error types - if (err.type === 'STATE_MISMATCH') { - setError("CSRF protection check failed. Please try signing in again."); - } else if (err.code === 401) { - setError("Authentication failed. The authorization code may have expired."); - } else { - setError(err.message || "An unexpected error occurred during authentication."); - } - } else { - setError("An unexpected error occurred. Please try signing in again."); + setError("Failed to load your profile. Please try signing in again."); + if (import.meta.env.DEV) { + console.error("[Gatehouse] OAuth callback refreshUser failed:", err); } } }; handleCallback(); - }, [searchParams, navigate, refreshUser, toast]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); if (status === 'loading') { return ( @@ -182,9 +166,7 @@ export default function OAuthCallbackPage() { Authentication Failed - - {error} - + {error}