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

ComponentLocationPurpose
authInitCompleteVarfrontend/src/graphql/cache.tsReactive variable signaling when auth init is fully complete, including cache operations
AuthGatefrontend/src/components/auth/AuthGate.tsxGuards the app, ensuring auth is resolved before rendering children
GET_ME queryfrontend/src/App.tsxFetches backend user details, skipped until both auth_token AND auth_init_complete are true
useCacheManagerfrontend/src/hooks/useCacheManager.tsHook 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)

  1. Call getAccessTokenSilently() to get the access token
  2. Set auth state synchronously before any async operations:
    authToken(token);
    userObj(user);
    authStatusVar("AUTHENTICATED");
    
  3. Clear stale cache data via resetOnAuthChange() (awaited)
  4. Set authInitCompleteVar(true) after cache clear completes
  5. Set local authInitialized state 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:

  1. Attempting getAccessTokenSilently() to verify actual auth state
  2. If token obtained: race condition detected - handle as authenticated
  3. If token fetch fails with expected errors (login_required, etc.): user is truly anonymous
  4. 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

  1. No query fires until authInitCompleteVar is true - This prevents clearStore() from aborting in-flight queries

  2. Race condition handled - If isAuthenticated is briefly false during Auth0 callback, we verify via getAccessTokenSilently()

  3. No forced login - Users can stay anonymous after logout; login is always opt-in via UI

  4. 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 refetches
  • authInitCompleteVar - 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 CodeMeaningAction
login_requiredUser needs to log inFall back to anonymous
consent_requiredUser needs to consentFall back to anonymous
interaction_requiredUser interaction neededFall back to anonymous
Other errorsUnexpected failureFall 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 content
  • CorpusQueryList.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.

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)