Frontend Authentication Flow
December 30, 2025 · View on GitHub
This document describes the frontend authentication flow in OpenContracts, including how Auth0 integration works, race condition handling, and the coordination between authentication state and Apollo cache operations.
Overview
OpenContracts uses Auth0 for authentication with an AuthGate pattern that ensures authentication is fully initialized before rendering any protected content. This eliminates race conditions where components might try to make authenticated API requests before the auth token is available.
Key Components
| Component | Location | Purpose |
|---|---|---|
authInitCompleteVar | frontend/src/graphql/cache.ts | Reactive variable signaling when auth init is fully complete, including cache operations |
AuthGate | frontend/src/components/auth/AuthGate.tsx | Guards the app, ensuring auth is resolved before rendering children |
GET_ME query | frontend/src/App.tsx | Fetches backend user details, skipped until both auth_token AND auth_init_complete are true |
useCacheManager | frontend/src/hooks/useCacheManager.ts | Hook for Apollo cache reset operations during auth changes |
Authentication Flow Diagram
Page Load
│
▼
AuthGate shows loading screen
│
▼
Auth0 SDK loads (isLoading: true → false)
│
├─── isAuthenticated && user? ───────────────────┐
│ YES │ NO
│ │ │
│ ▼ ▼
│ getAccessTokenSilently() getAccessTokenSilently()
│ │ (verify: race condition?)
│ ▼ │
│ Set token, user, status ┌────────┴────────┐
│ │ │ │
│ ▼ SUCCESS FAILURE
│ clearStore() [await] │ │
│ │ ▼ ▼
│ ▼ Race condition! Anonymous
│ authInitCompleteVar(true) Handle as auth user
│ │ │ │
│ ▼ ▼ ▼
└───────────┴──────────────────────────┴─────────────────┘
│
▼
authInitialized = true
Render children
│
▼
GET_ME fires (skip condition satisfied)
DiscoveryLanding queries fire
Detailed Flow
1. Initial Load
When the app loads, AuthGate renders a loading screen while authentication initializes:
if (!authInitialized || authStatus === "LOADING") {
return <ModernLoadingDisplay type="auth" message="Initializing OpenContracts" />;
}
2. Auth0 SDK Resolution
Once the Auth0 SDK finishes loading (isLoading: false), AuthGate checks if the user is authenticated.
3a. Authenticated Path (isAuthenticated && user)
- Call
getAccessTokenSilently()to get the access token - Set auth state synchronously before any async operations:
authToken(token); userObj(user); authStatusVar("AUTHENTICATED"); - Clear stale cache data via
resetOnAuthChange()(awaited) - Set
authInitCompleteVar(true)after cache clear completes - Set local
authInitializedstate to true
3b. Unauthenticated Path - Race Condition Handling
When isAuthenticated is false, there's a potential race condition during Auth0 callback where the SDK hasn't updated state yet but tokens exist. AuthGate handles this by:
- Attempting
getAccessTokenSilently()to verify actual auth state - If token obtained: race condition detected - handle as authenticated
- If token fetch fails with expected errors (
login_required, etc.): user is truly anonymous - Set
authInitCompleteVar(true)after resolution
4. Rendering Children
Once authInitialized is true, AuthGate renders its children:
return <>{children}</>;
5. App.tsx Query Behavior
The GET_ME query in App.tsx uses a skip condition that ensures it only fires after auth initialization is complete:
const { data: meData } = useQuery<GetMeOutputs>(GET_ME, {
skip: !auth_token || !auth_init_complete,
fetchPolicy: "network-only",
});
Key Guarantees
-
No query fires until
authInitCompleteVaris true - This preventsclearStore()from aborting in-flight queries -
Race condition handled - If
isAuthenticatedis briefly false during Auth0 callback, we verify viagetAccessTokenSilently() -
No forced login - Users can stay anonymous after logout; login is always opt-in via UI
-
Auth state set before cache clear - Ensures any refetches triggered by cache operations have correct credentials
Reactive Variables
Auth State Variables (cache.ts)
// User object from Auth0
export const userObj = makeVar<User | null>(null);
// JWT access token
export const authToken = makeVar<string>("");
// Auth lifecycle status
export type AuthStatus = "LOADING" | "AUTHENTICATED" | "ANONYMOUS";
export const authStatusVar = makeVar<AuthStatus>("LOADING");
// Signals when auth init (including cache clear) is complete
export const authInitCompleteVar = makeVar<boolean>(false);
// Backend user object (from GET_ME query)
export const backendUserObj = makeVar<UserType | null>(null);
Why Two Completion Signals?
authStatusVar and authInitCompleteVar serve different purposes:
authStatusVar- Set before cache clear to ensure credentials are available for any refetchesauthInitCompleteVar- Set after cache clear to signal it's safe to make new queries
This ordering prevents queries like GET_ME from being aborted by clearStore().
Cache Management
AuthGate uses useCacheManager hook to clear stale data on auth changes:
const { resetOnAuthChange } = useCacheManager();
// On successful auth
await resetOnAuthChange({
reason: "auth0_login",
refetchActive: false,
});
The refetchActive: false option is used because:
- Auth state is already set
- Component mount will trigger necessary queries with correct credentials
Error Handling
Token Fetch Errors
When getAccessTokenSilently() fails:
| Error Code | Meaning | Action |
|---|---|---|
login_required | User needs to log in | Fall back to anonymous |
consent_required | User needs to consent | Fall back to anonymous |
interaction_required | User interaction needed | Fall back to anonymous |
| Other errors | Unexpected failure | Fall back to anonymous + show toast |
No Auto-Redirect
AuthGate deliberately does NOT auto-redirect to login because:
- Users who logged out explicitly want to be anonymous
- Forcing login creates poor UX
- Users can always click the login button if they want to authenticate
LocalStorage Persistence
AuthGate tracks if a user has ever authenticated:
const HAS_AUTHENTICATED_KEY = "oc_has_authenticated";
// Set after successful auth
localStorage.setItem(HAS_AUTHENTICATED_KEY, "true");
This helps distinguish:
- First-time visitors (never authenticated)
- Returning users with expired sessions
Component Usage Guidelines
Do NOT Check Auth Status
With AuthGate, components don't need auth checks:
// BAD - Unnecessary auth checking
const authStatus = useReactiveVar(authStatusVar);
if (authStatus === "LOADING") return <Loader />;
// GOOD - Auth is guaranteed ready by AuthGate
return <Content />;
Use Simple Queries
// BAD - Unnecessary skip logic
const { data } = useQuery(GET_DATA, {
skip: !authToken,
});
// GOOD - Auth is ready, just query
const { data } = useQuery(GET_DATA);
Exception: The GET_ME query in App.tsx is special because it runs at the root level and needs both the token AND confirmation that cache operations are complete.
Refetching Data on Auth Changes
When a component is already mounted and the user logs in or out, queries don't automatically refetch because AuthGate uses refetchActive: false during auth transitions. Components that need fresh data after auth changes should watch authToken and refetch:
import { useEffect, useRef } from "react";
import { useReactiveVar } from "@apollo/client";
import { authToken } from "../graphql/cache";
function MyComponent() {
const auth_token = useReactiveVar(authToken);
const isInitialMount = useRef(true);
const { data, refetch } = useQuery(MY_QUERY, {
fetchPolicy: "cache-and-network",
});
// Refetch when auth state changes (login/logout)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
// Auth state changed - refetch to get updated data
refetch();
}, [auth_token, refetch]);
return <>{/* component content */}</>;
}
When to use this pattern:
- Landing pages showing aggregate stats (counts depend on permissions)
- List views that should show different items based on auth status
- Any component displaying permission-sensitive data that stays mounted across login
Examples in codebase:
DiscoveryLanding.tsx- Refetches community stats and trending contentCorpusQueryList.tsx- Refetches corpus queries when auth changes
Testing
Component Test Setup
When testing components that depend on auth:
// Mock the auth state
import { authToken, authStatusVar, authInitCompleteVar } from "./graphql/cache";
beforeEach(() => {
authToken("test-token");
authStatusVar("AUTHENTICATED");
authInitCompleteVar(true);
});
AuthGate Unit Tests
See frontend/src/components/auth/AuthGate.test.tsx for test examples.
Related Documentation
- Authentication Pattern - Detailed AuthGate pattern documentation
- Routing System - How routing works with auth
- Apollo Cache - Cache management details
Troubleshooting
Empty Lists on Direct Navigation
Symptom: Navigating directly to a URL shows empty content
Cause: Query fired before auth was ready
Solution: Ensure component is inside AuthGate and doesn't have unnecessary skip logic
NS_BINDING_ABORTED Errors
Symptom: Network requests aborted with NS_BINDING_ABORTED
Cause: clearStore() aborting in-flight queries
Solution: Ensure queries wait for authInitCompleteVar to be true before firing
Stale Data After Login
Symptom: Old user's data shows after logging in as different user
Cause: Cache not cleared on auth change
Solution: resetOnAuthChange() should be called and awaited before setting authInitCompleteVar(true)