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:
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-configurationReturns: 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
- User clicks "Sign in with SSO"
- Fetch OIDC config from
.well-known - Redirect to
authorization_endpoint - User authenticates with SSO provider
- SSO redirects back with auth code
- Exchange code at
token_endpoint(via backend) - Fetch user info from
userinfo_endpoint - 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
FoxHogThe 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)
BioNavThe 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 expiredSource: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
stateparameter 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
@janus/core SDK, Axios interceptors, singleton promiseKey Exports
AuthProvider- Wrap your app with thisuseAuth()- Access auth state anywhereuseRequireAuth()- Protect components (hook)RequiresAuthentication- Protect components (wrapper)getAccessTokenSilently()- Get a valid token, refreshing if needed