diff --git a/src/vertex/app/api/auth/[...nextauth]/options.ts b/src/vertex/app/api/auth/[...nextauth]/options.ts index ff41ec856c..ccc8c75263 100644 --- a/src/vertex/app/api/auth/[...nextauth]/options.ts +++ b/src/vertex/app/api/auth/[...nextauth]/options.ts @@ -9,7 +9,8 @@ import type { } from '@/app/types/users'; import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; import logger from '@/lib/logger'; -import { getApiBaseUrl, isHCaptchaEnabled } from '@/lib/envConstants'; +import { isHCaptchaEnabled } from '@/lib/envConstants'; +import { buildServerApiUrl } from '@/lib/api-routing'; import { normalizeOAuthAccessToken } from '@/core/auth/oauth-session'; const isProduction = process.env.NODE_ENV === 'production'; @@ -142,17 +143,19 @@ const fetchOAuthProfile = async ( accessToken: string ): Promise => { try { - const profileUrl = `${getApiBaseUrl()}/users/profile/enhanced`; + const profileUrl = buildServerApiUrl('/users/profile/enhanced'); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + const timeoutId = setTimeout(() => controller.abort(), 10000); + const normalizedAccessToken = normalizeOAuthAccessToken(accessToken); const response = await fetch(profileUrl, { method: 'GET', cache: 'no-store', + credentials: 'include', signal: controller.signal, headers: { Accept: 'application/json', - Authorization: `JWT ${accessToken}`, + ...(normalizedAccessToken ? { Authorization: `JWT ${normalizedAccessToken}` } : {}), }, }).finally(() => clearTimeout(timeoutId)); @@ -167,6 +170,10 @@ const fetchOAuthProfile = async ( return payload.data; } catch (error) { + const errorName = (error as { name?: string })?.name; + if (errorName === 'AbortError') { + return null; + } logger.error('Error fetching OAuth profile', { error }); return null; } diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index beb8381637..487c44b441 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,6 +4,49 @@ --- +## Version 2.0.1 +**Released:** June 09, 2026 + +### Relative API Routing & OAuth Redirect Fixes + +This release resolves critical build-time environment variable issues that caused API routing to fallback to localhost, and hardens the OAuth token handoff flow to eliminate redirect loops. + +
+API Routing & Proxy Stabilization (3) + +- **Relative Client Routes**: Introduced `buildBrowserApiUrl` to strictly return relative paths (`/api/v2/...`) for browser-side requests. This entirely bypasses the localhost fallback bug caused by missing build-time `NEXT_PUBLIC_` environment variables. +- **Server Routes & Strict Env Checks**: Introduced `buildServerApiUrl` for server-side requests and tightened `envConstants.ts` to explicitly require `NEXT_PUBLIC_API_URL` during initialization to fail-fast. +- **Proxy V2 Duplication Fix**: Adjusted the proxy client's path handling to dynamically strip leading `v2/` segments, preventing double `/v2/v2/` URL paths when proxying to the backend. + +
+ +
+Authentication & Session Sync (3) + +- **Middleware Bypass**: `SocialAuthSection` now safely appends a `success=` query parameter to the `redirect_after` URL. This prevents the NextAuth middleware from forcefully intercepting the callback and generating its own localhost callback URL. +- **UI Blocking on Token Handoff**: Added a `useEffect` watcher in `TokenHandoffHandler` to keep the UI explicitly blocked until NextAuth fully broadcasts the `authenticated` state across the React context tree, preventing rogue client-side redirects to `/login`. +- **Resilient Profile Fetching**: Increased the OAuth profile fetch tolerance to 10 seconds, gracefully handled `AbortError`s to prevent noisy server logs, and normalized the OAuth access token to ensure `Authorization` headers are only appended when present. + +
+ +
+Files Modified (11) + +- `app/api/auth/[...nextauth]/options.ts` [MODIFIED] +- `app/changelog.md` [MODIFIED] +- `components/features/auth/social-auth-section.tsx` [MODIFIED] +- `core/apis/users.ts` [MODIFIED] +- `core/auth/authProvider.tsx` [MODIFIED] +- `core/auth/oauth-session.ts` [MODIFIED] +- `core/services/network-service.ts` [MODIFIED] +- `core/utils/proxyClient.ts` [MODIFIED] +- `lib/api-routing.ts` [NEW] +- `lib/envConstants.ts` [MODIFIED] +- `vertex.config.ts` [MODIFIED] + +
+ +--- ## Version 2.0.0 **Released:** June 07, 2026 diff --git a/src/vertex/components/features/auth/social-auth-section.tsx b/src/vertex/components/features/auth/social-auth-section.tsx index f00f7c115c..f366850929 100644 --- a/src/vertex/components/features/auth/social-auth-section.tsx +++ b/src/vertex/components/features/auth/social-auth-section.tsx @@ -109,6 +109,12 @@ export default function SocialAuthSection({ [disabled, redirectPath, showBanner] ); + // Hide social auth completely if the required API URL environment variable is missing + // to prevent runtime crashes when getApiBaseUrl() throws an error during OAuth initiation. + if (!process.env.NEXT_PUBLIC_API_URL) { + return null; + } + return (
diff --git a/src/vertex/core/apis/users.ts b/src/vertex/core/apis/users.ts index e809ea396d..affbcd3a5d 100644 --- a/src/vertex/core/apis/users.ts +++ b/src/vertex/core/apis/users.ts @@ -1,13 +1,13 @@ import { LoginCredentials } from "@/app/types/users"; import createSecureApiClient from "../utils/secureApiProxyClient"; import axios from "axios"; -import { getApiBaseUrl } from "@/lib/envConstants"; +import { buildServerApiUrl } from "@/lib/api-routing"; export const users = { loginWithDetails: async (data: LoginCredentials) => { try { - const apiUrl = getApiBaseUrl(); - const response = await axios.post(`${apiUrl}/users/login-with-details`, data, { + const url = buildServerApiUrl('/users/login-with-details'); + const response = await axios.post(url, data, { timeout: 15000, // 15-second timeout for login request }); return response.data; diff --git a/src/vertex/core/auth/authProvider.tsx b/src/vertex/core/auth/authProvider.tsx index 66f5a39253..eacbb82b3a 100644 --- a/src/vertex/core/auth/authProvider.tsx +++ b/src/vertex/core/auth/authProvider.tsx @@ -591,6 +591,13 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { const [isBootstrapping, setIsBootstrapping] = useState(true); const router = useRouter(); const pathname = usePathname(); + const { update, status } = useSession(); + + useEffect(() => { + if (status === 'authenticated' && isHandlingOAuthRef.current) { + isHandlingOAuthRef.current = false; + } + }, [status]); const waitForSession = useCallback(async () => { const attempts = 8; @@ -627,6 +634,9 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { }); if (result?.ok) { + // Force NextAuth SessionProvider to immediately sync its React context + await update(); + // Wait for session to be fully available before redirecting const session = await waitForSession(); const email = session?.user?.email || ''; @@ -655,23 +665,27 @@ function TokenHandoffHandler({ children }: { children: React.ReactNode }) { } } else { logger.error('[TokenHandoffHandler] OAuth sign-in failed', { error: result?.error }); + isHandlingOAuthRef.current = false; router.push(`/auth-error?error=${encodeURIComponent(result?.error || 'OAuthSignin')}`); } + } else { + isHandlingOAuthRef.current = false; } } catch (error) { logger.error('[TokenHandoffHandler] Error during bootstrap', { error }); + isHandlingOAuthRef.current = false; } finally { if (shouldUnblock) { setIsBootstrapping(false); - isHandlingOAuthRef.current = false; } } }; bootstrap(); - }, [router, pathname, waitForSession]); + }, [router, pathname, waitForSession, update]); - if (isBootstrapping && isHandlingOAuthRef.current) { + // Keep blocking if we successfully handed off the token but NextAuth hasn't flushed its authenticated state yet + if ((isBootstrapping && isHandlingOAuthRef.current) || (status === 'unauthenticated' && isHandlingOAuthRef.current)) { return ; } diff --git a/src/vertex/core/auth/oauth-session.ts b/src/vertex/core/auth/oauth-session.ts index df070bf525..8612cace3b 100644 --- a/src/vertex/core/auth/oauth-session.ts +++ b/src/vertex/core/auth/oauth-session.ts @@ -1,4 +1,4 @@ -import { getApiBaseUrl } from "@/lib/envConstants"; +import { buildServerApiUrl } from "@/lib/api-routing"; const OAUTH_FRAGMENT_TOKEN_KEY = 'token'; const OAUTH_SUCCESS_PROVIDER_KEY = 'success'; @@ -137,7 +137,7 @@ export const consumeOAuthTokenHandoffFromUrl = (): OAuthTokenHandoff | null => { return { token, - provider: provider || null, + provider: provider || getLastUsedOAuthProvider(), callbackUrl: callbackUrl || null, }; }; @@ -149,8 +149,7 @@ export const buildOAuthInitiationUrl = ( provider = 'google', queryParams?: Record ): string => { - const apiUrl = getApiBaseUrl(); - const baseUrl = `${apiUrl}/users/auth/${encodeURIComponent(provider)}`; + const baseUrl = buildServerApiUrl(`/users/auth/${encodeURIComponent(provider)}`); const params = new URLSearchParams(); if (queryParams) { diff --git a/src/vertex/core/services/network-service.ts b/src/vertex/core/services/network-service.ts index e102ebb107..05d541c7f2 100644 --- a/src/vertex/core/services/network-service.ts +++ b/src/vertex/core/services/network-service.ts @@ -1,6 +1,6 @@ import { NetworkCreationRequest, NetworkRequestActionResponse } from "@/core/apis/networks"; import { NetworkRequestValues } from "@/components/features/networks/schema"; -import { getApiBaseUrl } from "@/lib/envConstants"; +import { buildServerApiUrl } from "@/lib/api-routing"; import logger from "@/lib/logger"; import axios from "axios"; import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; @@ -17,8 +17,7 @@ export const networkService = { */ getNetworkCreationRequests: async (token: string, adminSecret: string): Promise => { try { - const baseUrl = getApiBaseUrl(); - const url = `${baseUrl}/devices/network-creation-requests?admin_secret=${adminSecret.trim()}`; + const url = buildServerApiUrl(`/devices/network-creation-requests?admin_secret=${encodeURIComponent(adminSecret.trim())}`); const response = await axios.get(url, { headers: { @@ -55,10 +54,13 @@ export const networkService = { adminSecret: string ): Promise => { try { - const baseUrl = getApiBaseUrl(); // Try putting admin_secret in both URL and body to be safe, // as some AirQo endpoints require it in one or the other. - const url = `${baseUrl}/devices/network-creation-requests/${id}/${action}?admin_secret=${adminSecret.trim()}`; + const encodedId = encodeURIComponent(id.trim()); + const encodedAction = encodeURIComponent(action.trim()); + const url = buildServerApiUrl( + `/devices/network-creation-requests/${encodedId}/${encodedAction}?admin_secret=${encodeURIComponent(adminSecret.trim())}` + ); const payload: Record = { admin_secret: adminSecret.trim(), @@ -94,8 +96,7 @@ export const networkService = { * Submits a new network creation request. Public endpoint — no auth required. */ submitNetworkRequest: async (data: NetworkRequestValues): Promise => { - const baseUrl = getApiBaseUrl(); - const url = `${baseUrl}/devices/network-creation-requests`; + const url = buildServerApiUrl(`/devices/network-creation-requests`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), NETWORK_REQUEST_TIMEOUT_MS); diff --git a/src/vertex/core/utils/proxyClient.ts b/src/vertex/core/utils/proxyClient.ts index ff6e0095d9..c075a52936 100644 --- a/src/vertex/core/utils/proxyClient.ts +++ b/src/vertex/core/utils/proxyClient.ts @@ -2,7 +2,8 @@ import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; import { getServerSession } from 'next-auth'; import { options as authOptions } from '@/app/api/auth/[...nextauth]/options'; import logger from '@/lib/logger'; -import { getApiBaseUrl, getApiToken } from '@/lib/envConstants'; +import { getApiToken } from '@/lib/envConstants'; +import { buildServerApiUrl } from '@/lib/api-routing'; import { NextRequest, NextResponse } from 'next/server'; // Type definitions @@ -84,7 +85,14 @@ export const createProxyHandler = (options: ProxyOptions = {}) => { path = []; } - const targetPath = Array.isArray(path) ? path.join('/') : ''; + let targetPath = Array.isArray(path) ? path.join('/') : ''; + + // Prevent double /v2/ in the final URL because getApiBaseUrl() already provides it + if (targetPath.startsWith('v2/')) { + targetPath = targetPath.slice(3); + } else if (targetPath === 'v2') { + targetPath = ''; + } // Method validation const allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; @@ -96,10 +104,10 @@ export const createProxyHandler = (options: ProxyOptions = {}) => { } try { - // Get API base URL - let API_BASE_URL: string; + // Get API URL + let API_URL: string; try { - API_BASE_URL = getApiBaseUrl(); + API_URL = buildServerApiUrl(targetPath); } catch (envError) { logger.error('Failed to get API base URL from environment:', { error: envError instanceof Error ? envError.message : String(envError) }); return NextResponse.json( @@ -111,7 +119,7 @@ export const createProxyHandler = (options: ProxyOptions = {}) => { ); } - if (!API_BASE_URL) { + if (!API_URL) { return NextResponse.json( { success: false, @@ -121,13 +129,10 @@ export const createProxyHandler = (options: ProxyOptions = {}) => { ); } - // Normalize URL - const normalizedBaseUrl = API_BASE_URL.replace(/\/+$/, ''); - // Configure request const config: ExtendedAxiosConfig = { method: req.method, - url: `${normalizedBaseUrl}/${targetPath}`, + url: API_URL, params: { ...queryParams }, headers: { 'Content-Type': 'application/json', diff --git a/src/vertex/lib/api-routing.ts b/src/vertex/lib/api-routing.ts new file mode 100644 index 0000000000..2d9c80ce35 --- /dev/null +++ b/src/vertex/lib/api-routing.ts @@ -0,0 +1,47 @@ +import { getApiBaseUrl } from './envConstants'; + +const ensureLeadingSlash = (value: string): string => { + if (!value) return ''; + return value.startsWith('/') ? value : `/${value}`; +}; + +const isAbsoluteUrl = (value: string): boolean => { + return /^https?:\/\//i.test(value); +}; + +/** + * Builds an absolute API URL for server-side requests. + * Uses the environment-configured API base URL. + * + * @param inputPath - The path to append to the base URL (e.g. '/users/profile') + * @returns The absolute URL string + */ +export const buildServerApiUrl = (inputPath: string): string => { + const trimmedInput = (inputPath || '').trim(); + + if (isAbsoluteUrl(trimmedInput)) { + return trimmedInput; + } + + const baseUrl = getApiBaseUrl(); // E.g., https://staging-vertex.airqo.net/api/v2 + return `${baseUrl}${ensureLeadingSlash(trimmedInput)}`; +}; + +/** + * Builds a relative API URL for browser-side requests. + * By returning a relative path, the browser natively routes to the current domain, + * completely eliminating "split-brain" environment variable issues on the client. + * + * @param inputPath - The path to format (e.g. '/users/profile') + * @returns The relative URL string (e.g. '/api/v2/users/profile') + */ +export const buildBrowserApiUrl = (inputPath: string): string => { + const trimmedInput = (inputPath || '').trim(); + + if (isAbsoluteUrl(trimmedInput)) { + return trimmedInput; + } + + // The vertex backend sits under /api/v2 relative to the frontend domain + return `/api/v2${ensureLeadingSlash(trimmedInput)}`; +}; diff --git a/src/vertex/lib/envConstants.ts b/src/vertex/lib/envConstants.ts index a0da34b29f..8e40402845 100644 --- a/src/vertex/lib/envConstants.ts +++ b/src/vertex/lib/envConstants.ts @@ -5,14 +5,11 @@ import { stripTrailingSlash } from './utils'; /** - * Gets the default API URL fallback based on the current environment - * @returns {string} The default API URL + * Gets the current environment from environment variables + * @returns {string} The environment name */ -export const getDefaultApiUrl = (): string => { - const env = getEnvironment().toLowerCase(); - if (env === 'production') return 'https://vertex.airqo.net/api/v2'; - if (env === 'staging') return 'https://staging-vertex.airqo.net/api/v2'; - return 'http://localhost:3000'; +export const getEnvironment = (): string => { + return process.env.NEXT_PUBLIC_ENV || 'development'; }; /** @@ -20,7 +17,10 @@ export const getDefaultApiUrl = (): string => { * @returns {string} The API base URL */ export const getApiBaseUrl = (): string => { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || getDefaultApiUrl(); + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + if (!apiUrl) { + throw new Error('API base URL is not defined. Set NEXT_PUBLIC_API_URL in environment variables.'); + } return stripTrailingSlash(apiUrl); }; @@ -113,13 +113,7 @@ export const getHCaptchaSiteKey = (): string => { return process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || ''; }; -/** - * Gets the current environment from environment variables - * @returns {string} The environment name - */ -export const getEnvironment = (): string => { - return process.env.NEXT_PUBLIC_ENV || 'development'; -}; + /** * Whether hCaptcha should be enabled for this deployment. diff --git a/src/vertex/middleware.ts b/src/vertex/middleware.ts index b315466aa4..1ec083d642 100644 --- a/src/vertex/middleware.ts +++ b/src/vertex/middleware.ts @@ -14,13 +14,12 @@ export default withAuth( }, { callbacks: { - authorized: ({ req, token }) => { - // Bypass server-side protection for OAuth callbacks so the client can process the token hash. - // Client-side AuthWrapper will still enforce protection if the token is invalid. - if (req.nextUrl.searchParams.has('success')) { - return true; - } - return Boolean(token); + authorized: () => { + // Delegate route protection entirely to the client-side AuthWrapper. + // This is strictly necessary because the OAuth flow relies on URL hash fragments (#token=...) + // which the server-side middleware cannot read. If we block requests here, + // the Next.js server will strip the callback and force a redirect loop through /login. + return true; }, }, pages: { diff --git a/src/vertex/vertex.config.ts b/src/vertex/vertex.config.ts index 012b37720e..a8334bfdec 100644 --- a/src/vertex/vertex.config.ts +++ b/src/vertex/vertex.config.ts @@ -3,7 +3,7 @@ import { validateVertexConfig, type VertexConfigInput, } from "./core/config/vertex-config"; -import { getDefaultApiUrl } from "./lib/envConstants"; +import { getApiBaseUrl } from "./lib/envConstants"; const config: VertexConfigInput = { ...defaultVertexConfig, @@ -18,9 +18,9 @@ const config: VertexConfigInput = { }, api: { adapter: "airqo", - baseUrl: process.env.NEXT_PUBLIC_API_URL || getDefaultApiUrl(), + baseUrl: getApiBaseUrl(), publicMeasurementsBaseUrl: - process.env.NEXT_PUBLIC_API_BASE_URL || getDefaultApiUrl(), + process.env.NEXT_PUBLIC_API_BASE_URL || getApiBaseUrl(), }, auth: { provider: "airqo",