Initial Release help-frontend

Dr. Frontend

Login Example

A generalized SSO authentication reference implementation synthesizing patterns from BioNav (Janus SDK) and Spring Platform (manual OIDC flow).

Live Demo

Checking authentication...

Architecture Patterns

Auth State Machine

Clear state transitions prevent race conditions and edge cases:

loadinglogged-outauthenticated
any statefailed

AuthContext Interface

Standardized interface used by Auth0, Okta, and custom implementations.getAccessTokenSilently is the key method — it returns a valid token, transparently refreshing if the current one is expired.

interface AuthContext {
  isAuthenticated: boolean;
  isLoading: boolean;
  user: User | null;
  error: Error | null;
  loginWithRedirect(): Promise<void>;
  logout(): Promise<void>;
  // Returns a valid access token, refreshing silently if expired.
  // Client-side: calls token_endpoint with refresh_token grant.
  // BFF: the backend refreshes transparently via httpOnly cookie.
  getAccessTokenSilently(): Promise<string>;
}

OIDC Configuration Discovery

Fetch endpoints dynamically from your SSO provider:

https://sso.company.com/.well-known/openid-configuration

Returns: authorization_endpoint, token_endpoint,userinfo_endpoint, end_session_endpoint

Protected Route Pattern (Hook)

Use the useRequireAuth hook to protect components:

function ProtectedPage() {
  const { isLoading, user } = useRequireAuth();

  if (isLoading) return <Spinner />;
  
  return <div>Welcome, {user?.name}!</div>;
}

// Or use the declarative wrapper:
<RequiresAuthentication>
  <SecretContent />
</RequiresAuthentication>

OAuth2 Authorization Code Flow

  1. User clicks "Sign in with SSO"
  2. Fetch OIDC config from .well-known
  3. Redirect to authorization_endpoint
  4. User authenticates with SSO provider
  5. SSO redirects back with auth code
  6. Exchange code at token_endpoint (via backend)
  7. Fetch user info from userinfo_endpoint
  8. Store tokens and update auth state

Token Refresh Patterns

Access tokens expire (typically 5–60 min). Two patterns handle renewal without forcing the user to re-authenticate:

Pattern 1: Client-Side Refresh

FoxHog

The SPA stores the refresh token in localStorage and calls the token endpoint directly when the access token expires. A singleton promise prevents concurrent refresh requests from racing.

let refreshPromise: Promise | null = null;

async function getStoredTokensAndRefreshIfNeeded() {
  const tokens = JSON.parse(localStorage.getItem("oauthTokens"));

  if (isExpired(tokens.expiresAt) && tokens.refreshToken) {
    // Reuse in-flight refresh to prevent race conditions
    if (!refreshPromise) {
      refreshPromise = janusApp
        .refreshTokens(redirectURI, tokens.refreshToken)
        .finally(() => {  refreshPromise = null; });
    }
    return await refreshPromise;
  }

  return tokens;
}

// Axios interceptor: attach token on every request
apiClient.interceptors.request.use(async (config) => {
  const tokens = await getStoredTokensAndRefreshIfNeeded();
  config.headers.Authorization = `Bearer ${tokens.idToken.encoded}`;
  return config;
});

// Axios interceptor: retry on 401 with a fresh token
apiClient.interceptors.response.use(
  (res) => res,
  async (error) => {
    if (error.response?.status === 401 && !error.config._retry) {
      error.config._retry = true;
      await refreshTokens();
      return apiClient(error.config);
    }
    return Promise.reject(error);
  }
);

Source:FoxHog — JanusAuth.ts + apiClient.ts

Pros: No backend needed, works with any OIDC provider, simpler deployment.

Cons: Refresh token in localStorage is accessible to XSS. Client secret cannot be used (public client).

Pattern 2: Backend-for-Frontend (BFF)

BioNav

The SPA never sees the refresh token. A backend proxy handles the OAuth flow, stores tokens in httpOnly cookies, and exposes /auth/login,/auth/me, and /auth/logout endpoints. The frontend just calls fetch with credentials: "include".

// Start login — redirect to backend, which redirects to Janus
async function startLogin() {
  window.location.href = `${config.apiUrl}/auth/login`;
}

// Check session — backend validates the httpOnly cookie
async function checkSession(): Promise<boolean> {
  const res = await fetch(`${config.apiUrl}/auth/me`, {
    credentials: "include",  // sends httpOnly cookie
  });
  if (!res.ok) return false;

  const user = await res.json();
  setAuthenticated(user);  // update nanostores
  return true;
}

// API calls — backend proxies to upstream with the real token
const res = await fetch(`${config.apiUrl}/some-data`, {
  credentials: "include",
});
// Backend attaches the access token from the session cookie
// and refreshes it transparently if expired

Source:BioNav — BFF auth migration (MR !129)

Pros: Refresh token never reaches the browser. Can use confidential client with client secret. Immune to XSS token theft.

Cons: Requires a backend service. Adds latency for proxied API calls. More infrastructure to deploy.

Which pattern should I use?

Use client-side refresh when you don't have a backend, your app is internal-only, and you're using a public OIDC client (like Janus with PKCE). This is the simpler path — FoxHog ships this way.

Use BFF when you already have a backend (FastAPI, Express, etc.), need confidential client auth, or want to keep tokens out of the browser entirely. BioNav migrated to this pattern to eliminate client-side token management.

Both patterns use the same Janus SSO provider. The difference is where the token exchange and refresh happen — in the browser or on the server.

Security Best Practices

  • • Use PKCE for public clients (SPAs) — prevents authorization code interception
  • • Validate state parameter to prevent CSRF
  • • Exchange codes server-side to protect client secret (BFF pattern)
  • • Use httpOnly cookies for refresh tokens when possible (BFF pattern)
  • • Coalesce concurrent refresh calls with a singleton promise (client-side pattern)
  • • Add a 401 response interceptor that retries with a fresh token
  • • Proactively refresh before expiry (e.g. 60s margin) to avoid failed requests
  • • Validate token signatures and claims on the backend

Implementation Comparison

FoxHog:Client-side refresh via @janus/core SDK, Axios interceptors, singleton promise
BioNav:BFF pattern, backend handles OAuth + refresh, httpOnly cookies, nanostores
This example:Generalized patterns from both, with mock token lifecycle

Key Exports

  • AuthProvider - Wrap your app with this
  • useAuth() - Access auth state anywhere
  • useRequireAuth() - Protect components (hook)
  • RequiresAuthentication - Protect components (wrapper)
  • getAccessTokenSilently() - Get a valid token, refreshing if needed