Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions src/vertex/app/api/auth/[...nextauth]/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -142,17 +143,19 @@ const fetchOAuthProfile = async (
accessToken: string
): Promise<OAuthProfilePayload | null> => {
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));

Expand All @@ -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;
}
Expand Down
43 changes: 43 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary><strong>API Routing & Proxy Stabilization (3)</strong></summary>

- **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.

</details>

<details>
<summary><strong>Authentication & Session Sync (3)</strong></summary>

- **Middleware Bypass**: `SocialAuthSection` now safely appends a `success=<provider>` 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.

</details>

<details>
<summary><strong>Files Modified (11)</strong></summary>

- `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]

</details>

---
## Version 2.0.0
**Released:** June 07, 2026

Expand Down
6 changes: 6 additions & 0 deletions src/vertex/components/features/auth/social-auth-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={cn('w-full space-y-4', className)}>
<div className="grid grid-cols-4 gap-2">
Expand Down
6 changes: 3 additions & 3 deletions src/vertex/core/apis/users.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
20 changes: 17 additions & 3 deletions src/vertex/core/auth/authProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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 <SessionLoadingState />;
}

Expand Down
7 changes: 3 additions & 4 deletions src/vertex/core/auth/oauth-session.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -137,7 +137,7 @@ export const consumeOAuthTokenHandoffFromUrl = (): OAuthTokenHandoff | null => {

return {
token,
provider: provider || null,
provider: provider || getLastUsedOAuthProvider(),
callbackUrl: callbackUrl || null,
};
};
Expand All @@ -149,8 +149,7 @@ export const buildOAuthInitiationUrl = (
provider = 'google',
queryParams?: Record<string, string | undefined>
): string => {
const apiUrl = getApiBaseUrl();
const baseUrl = `${apiUrl}/users/auth/${encodeURIComponent(provider)}`;
const baseUrl = buildServerApiUrl(`/users/auth/${encodeURIComponent(provider)}`);
const params = new URLSearchParams();

if (queryParams) {
Expand Down
15 changes: 8 additions & 7 deletions src/vertex/core/services/network-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,8 +17,7 @@ export const networkService = {
*/
getNetworkCreationRequests: async (token: string, adminSecret: string): Promise<NetworkCreationRequest[]> => {
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: {
Expand Down Expand Up @@ -55,10 +54,13 @@ export const networkService = {
adminSecret: string
): Promise<any> => {
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<string, any> = {
admin_secret: adminSecret.trim(),
Expand Down Expand Up @@ -94,8 +96,7 @@ export const networkService = {
* Submits a new network creation request. Public endpoint — no auth required.
*/
submitNetworkRequest: async (data: NetworkRequestValues): Promise<NetworkRequestActionResponse> => {
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);
Expand Down
25 changes: 15 additions & 10 deletions src/vertex/core/utils/proxyClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'];
Expand All @@ -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(
Expand All @@ -111,7 +119,7 @@ export const createProxyHandler = (options: ProxyOptions = {}) => {
);
}

if (!API_BASE_URL) {
if (!API_URL) {
return NextResponse.json(
{
success: false,
Expand All @@ -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',
Expand Down
47 changes: 47 additions & 0 deletions src/vertex/lib/api-routing.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
};
Loading
Loading