Skip to main content

Client-Side Authentication Flow Design

Document Overview

This document provides the comprehensive technical specification for implementing client-side authentication flows in the BIO-QMS documentation viewer React application. It covers token-based authentication, NDA verification workflows, React component architecture, and production-grade security patterns.

Companion Document: This document complements auth-mode-switching.md, which covers environment-based mode detection and the AuthProvider abstraction. This document focuses on the React application layer, user experience flows, and client-side implementation details.


Table of Contents

  1. Authentication Flow Architecture
  2. React Auth Context Provider
  3. Token Management
  4. Redirect Flow Implementation
  5. NDA Signing UI (Embedded Flow)
  6. Auth Mode Detection
  7. Protected Route Components
  8. API Client Integration
  9. Error States and UX
  10. Security Hardening
  11. React Component Reference
  12. Testing Strategy
  13. Performance Optimization
  14. Accessibility
  15. Multi-Tab Token Synchronization
  16. Deployment Configuration

1. Authentication Flow Architecture

1.1 High-Level Flow Diagram

1.2 Token Lifecycle States

┌──────────────────────────────────────────────────────────────┐
│ Token Lifecycle │
└──────────────────────────────────────────────────────────────┘

┌─────────────┐
│ NO TOKEN │ ───────────────────────────────────────────┐
│ (Anonymous) │ │
└─────────────┘ │
│ │
│ User initiates login │
▼ │
┌─────────────┐ │
│ REDIRECTING │ │
│ TO AUTH │ │
└─────────────┘ │
│ │
│ Auth service validates & issues token │
▼ │
┌─────────────┐ │
│ TOKEN │ ◄──────── Refresh successful ───────┐ │
│ VALID │ │ │
└─────────────┘ │ │
│ │ │
│ Time passes (< refresh margin) │ │
▼ │ │
┌─────────────┐ │ │
│ TOKEN │ │ │
│ EXPIRING │ ──── Trigger background refresh ────┘ │
└─────────────┘ │
│ │
│ Refresh margin exceeded │
▼ │
┌─────────────┐ │
│ TOKEN │ │
│ EXPIRED │ ──── Refresh failed ────────────────────────┘
└─────────────┘

│ User action requires auth

┌─────────────┐
│ LOGOUT & │
│ REDIRECT │
└─────────────┘

1.3 NDA Verification Flow

┌─────────────────────────────────────────────────────┐
│ NDA Verification Decision Tree │
└─────────────────────────────────────────────────────┘

User authenticated?

├─ NO ──► Redirect to login

└─ YES


Token has nda_status claim?

├─ NO ──► Assume "pending", show NDA gate

└─ YES


nda_status = "accepted"?

├─ YES ──► Grant access to all documents

├─ NO (pending/declined)
│ │
│ └──► Document has nda_required: true?
│ │
│ ├─ YES ──► Block access, show NDA signing prompt
│ │
│ └─ NO ──► Allow access (public document)

└─ Document access decision complete

2. React Auth Context Provider

2.1 AuthContext Interface

The AuthContext provides global authentication state and methods to the entire React component tree.

// src/contexts/AuthContext.tsx

import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
import { AuthProvider, User } from '../auth/AuthProvider.interface';

/**
* Authentication context value exposed to consuming components
*/
interface AuthContextValue {
/** Current authenticated user, null if not authenticated */
user: User | null;

/** Whether the user is currently authenticated */
isAuthenticated: boolean;

/** Whether authentication state is being loaded */
isLoading: boolean;

/** NDA acceptance status of current user */
ndaStatus: 'pending' | 'accepted' | 'declined' | null;

/** Initiate login flow with optional return URL */
login: (returnUrl?: string) => Promise<void>;

/** Logout user and clear session */
logout: () => Promise<void>;

/** Get current valid access token (triggers refresh if needed) */
getToken: () => Promise<string | null>;

/** Force token refresh */
refreshToken: () => Promise<void>;

/** Check if user has specific permission */
hasPermission: (permission: string) => boolean;

/** Check if user can access document based on NDA requirements */
canAccessDocument: (documentMetadata: { nda_required?: boolean }) => boolean;
}

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

interface AuthProviderProps {
/** Auth provider implementation (NullAuthProvider or GCPAuthProvider) */
provider: AuthProvider;
children: React.ReactNode;
}

/**
* AuthProvider component wraps the application and provides authentication state
*/
export function AuthProvider({ provider, children }: AuthProviderProps): JSX.Element {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);

// Track auth provider in ref to avoid re-renders when it changes
const providerRef = useRef(provider);

useEffect(() => {
providerRef.current = provider;
}, [provider]);

// Initialize authentication state on mount
useEffect(() => {
let mounted = true;

async function initAuth() {
try {
setIsLoading(true);

// Initialize provider (load tokens, validate, schedule refresh)
await providerRef.current.initialize();

// Get initial user state
const currentUser = providerRef.current.getUser();

if (mounted) {
setUser(currentUser);
setIsLoading(false);
}
} catch (err) {
console.error('Auth initialization failed:', err);
if (mounted) {
setError(err as Error);
setIsLoading(false);
}
}
}

initAuth();

// Subscribe to auth state changes
const unsubscribe = providerRef.current.onAuthStateChange((newUser) => {
if (mounted) {
setUser(newUser);
setIsLoading(false);
}
});

return () => {
mounted = false;
unsubscribe();
};
}, []); // Empty deps - only run once on mount

// Login handler
const login = useCallback(async (returnUrl?: string) => {
try {
await providerRef.current.login(returnUrl);
} catch (err) {
console.error('Login failed:', err);
setError(err as Error);
throw err;
}
}, []);

// Logout handler
const logout = useCallback(async () => {
try {
await providerRef.current.logout();
setUser(null);
} catch (err) {
console.error('Logout failed:', err);
setError(err as Error);
throw err;
}
}, []);

// Get token (with automatic refresh)
const getToken = useCallback(async () => {
try {
return await providerRef.current.getToken();
} catch (err) {
console.error('Get token failed:', err);
setError(err as Error);
return null;
}
}, []);

// Force token refresh
const refreshToken = useCallback(async () => {
try {
await providerRef.current.refreshToken();
} catch (err) {
console.error('Token refresh failed:', err);
setError(err as Error);
throw err;
}
}, []);

// Permission check
const hasPermission = useCallback((permission: string): boolean => {
if (!user) return false;
return user.permissions?.includes(permission) ?? false;
}, [user]);

// Document access check
const canAccessDocument = useCallback((documentMetadata: { nda_required?: boolean }): boolean => {
// If no NDA required, anyone can access
if (!documentMetadata.nda_required) {
return true;
}

// NDA required - check user's NDA status
if (!user) {
return false; // Not authenticated
}

return user.ndaStatus === 'accepted';
}, [user]);

const contextValue: AuthContextValue = {
user,
isAuthenticated: providerRef.current.isAuthenticated(),
isLoading,
ndaStatus: user?.ndaStatus ?? null,
login,
logout,
getToken,
refreshToken,
hasPermission,
canAccessDocument,
};

// Show error boundary if initialization failed
if (error) {
return (
<div className="auth-error-container" role="alert">
<h1>Authentication Error</h1>
<p>Failed to initialize authentication system.</p>
<pre>{error.message}</pre>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
);
}

return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}

/**
* Hook to access authentication context
* @throws {Error} if used outside AuthProvider
*/
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);

if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}

return context;
}

2.2 Usage Example

// src/App.tsx

import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { createAuthProvider } from './auth/createAuthProvider';
import { AppRoutes } from './routes';

// Create auth provider based on environment config
const authProvider = createAuthProvider();

export function App(): JSX.Element {
return (
<AuthProvider provider={authProvider}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
);
}

3. Token Management

3.1 Token Storage Strategy

// src/auth/tokenStorage.ts

/**
* Token storage abstraction
* Supports localStorage (dev), httpOnly cookies (production future), memory-only (testing)
*/
export interface TokenStorage {
getAccessToken(): string | null;
getRefreshToken(): string | null;
setTokens(accessToken: string, refreshToken: string): void;
clearTokens(): void;
}

/**
* LocalStorage-based token storage (current implementation)
*/
export class LocalStorageTokenStorage implements TokenStorage {
private accessTokenKey: string;
private refreshTokenKey: string;

constructor(
accessTokenKey: string = 'bio_qms_auth_token',
refreshTokenKey: string = 'bio_qms_refresh_token'
) {
this.accessTokenKey = accessTokenKey;
this.refreshTokenKey = refreshTokenKey;
}

getAccessToken(): string | null {
try {
return localStorage.getItem(this.accessTokenKey);
} catch (error) {
console.error('Failed to read access token from localStorage:', error);
return null;
}
}

getRefreshToken(): string | null {
try {
return localStorage.getItem(this.refreshTokenKey);
} catch (error) {
console.error('Failed to read refresh token from localStorage:', error);
return null;
}
}

setTokens(accessToken: string, refreshToken: string): void {
try {
localStorage.setItem(this.accessTokenKey, accessToken);
localStorage.setItem(this.refreshTokenKey, refreshToken);

// Log storage event for cross-tab sync
window.dispatchEvent(new CustomEvent('bio-qms-token-updated', {
detail: { accessToken, refreshToken }
}));
} catch (error) {
console.error('Failed to store tokens in localStorage:', error);
throw new Error('Token storage failed - localStorage may be full or disabled');
}
}

clearTokens(): void {
try {
localStorage.removeItem(this.accessTokenKey);
localStorage.removeItem(this.refreshTokenKey);

// Log clear event for cross-tab sync
window.dispatchEvent(new CustomEvent('bio-qms-token-cleared'));
} catch (error) {
console.error('Failed to clear tokens from localStorage:', error);
}
}
}

/**
* Memory-only token storage (for testing or high-security mode)
* Tokens lost on page refresh
*/
export class MemoryTokenStorage implements TokenStorage {
private accessToken: string | null = null;
private refreshToken: string | null = null;

getAccessToken(): string | null {
return this.accessToken;
}

getRefreshToken(): string | null {
return this.refreshToken;
}

setTokens(accessToken: string, refreshToken: string): void {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}

clearTokens(): void {
this.accessToken = null;
this.refreshToken = null;
}
}

3.2 Silent Token Refresh

// src/auth/tokenRefresh.ts

import { TokenStorage } from './tokenStorage';

interface TokenRefreshConfig {
/** Auth service base URL */
authServiceUrl: string;

/** Token storage implementation */
storage: TokenStorage;

/** Seconds before expiry to trigger refresh (default: 300 = 5 minutes) */
refreshMarginSeconds?: number;

/** Callback when tokens are refreshed */
onTokensRefreshed?: (accessToken: string, refreshToken: string) => void;

/** Callback when refresh fails */
onRefreshFailed?: (error: Error) => void;
}

/**
* Manages automatic background token refresh
*/
export class TokenRefreshManager {
private config: TokenRefreshConfig;
private refreshTimer: number | null = null;
private isRefreshing: boolean = false;

constructor(config: TokenRefreshConfig) {
this.config = {
refreshMarginSeconds: 300, // 5 minutes default
...config
};
}

/**
* Schedule next token refresh based on current token expiry
*/
scheduleRefresh(accessToken: string): void {
// Clear any existing timer
this.cancelRefresh();

try {
// Decode token to get expiry
const payload = this.decodeToken(accessToken);
const expiryMs = payload.exp * 1000;
const nowMs = Date.now();
const timeToExpiryMs = expiryMs - nowMs;

// Calculate when to refresh (margin before expiry)
const refreshMarginMs = (this.config.refreshMarginSeconds ?? 300) * 1000;
const delayMs = Math.max(0, timeToExpiryMs - refreshMarginMs);

console.debug(`Token expires in ${Math.round(timeToExpiryMs / 1000)}s, scheduling refresh in ${Math.round(delayMs / 1000)}s`);

// Schedule refresh
this.refreshTimer = window.setTimeout(() => {
this.performRefresh();
}, delayMs);

} catch (error) {
console.error('Failed to schedule token refresh:', error);
}
}

/**
* Cancel scheduled refresh
*/
cancelRefresh(): void {
if (this.refreshTimer !== null) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}

/**
* Manually trigger token refresh (can be called by user action)
*/
async manualRefresh(): Promise<void> {
await this.performRefresh();
}

/**
* Perform token refresh (internal)
*/
private async performRefresh(): Promise<void> {
// Prevent concurrent refresh attempts
if (this.isRefreshing) {
console.debug('Token refresh already in progress, skipping');
return;
}

this.isRefreshing = true;

try {
const refreshToken = this.config.storage.getRefreshToken();

if (!refreshToken) {
throw new Error('No refresh token available');
}

// Call refresh endpoint
const response = await fetch(`${this.config.authServiceUrl}/token/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: refreshToken
})
});

if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`);
}

const data = await response.json();

// Store new tokens
this.config.storage.setTokens(data.access_token, data.refresh_token);

// Schedule next refresh
this.scheduleRefresh(data.access_token);

// Notify callback
this.config.onTokensRefreshed?.(data.access_token, data.refresh_token);

console.info('Token refresh successful');

} catch (error) {
console.error('Token refresh failed:', error);

// Clear invalid tokens
this.config.storage.clearTokens();

// Notify callback
this.config.onRefreshFailed?.(error as Error);

} finally {
this.isRefreshing = false;
}
}

/**
* Decode JWT payload (without verification)
*/
private decodeToken(token: string): any {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}

const payload = parts[1];
const decoded = atob(payload);
return JSON.parse(decoded);
}

/**
* Cleanup on unmount
*/
dispose(): void {
this.cancelRefresh();
}
}

3.3 Token Rotation Handling

Token rotation is a security best practice where refresh tokens are single-use. Each refresh request returns a new refresh token, and the old one is invalidated.

// Integrated into TokenRefreshManager.performRefresh()

// After successful refresh:
const data = await response.json();

// data contains:
// {
// access_token: "new_jwt_access_token",
// refresh_token: "new_refresh_token" // <-- New refresh token
// }

// Store BOTH new tokens (old refresh token is now invalid)
this.config.storage.setTokens(data.access_token, data.refresh_token);

Security Benefit: If a refresh token is stolen, the attacker can use it once. The next legitimate refresh by the real user will fail (old token invalidated), alerting the system to potential compromise.


4. Redirect Flow Implementation

4.1 Pre-Redirect: Save Current Location

// src/utils/redirectFlow.ts

/**
* Save current location before redirecting to auth service
* Uses sessionStorage so state is preserved across redirect
*/
export function saveRedirectLocation(): void {
const currentLocation = {
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
};

try {
sessionStorage.setItem('bio_qms_redirect_location', JSON.stringify(currentLocation));
console.debug('Saved redirect location:', currentLocation);
} catch (error) {
console.error('Failed to save redirect location:', error);
}
}

/**
* Retrieve saved location after returning from auth service
*/
export function getRedirectLocation(): string | null {
try {
const saved = sessionStorage.getItem('bio_qms_redirect_location');

if (!saved) {
return null;
}

const location = JSON.parse(saved);
const fullPath = `${location.pathname}${location.search}${location.hash}`;

// Clear saved location
sessionStorage.removeItem('bio_qms_redirect_location');

console.debug('Retrieved redirect location:', fullPath);
return fullPath;

} catch (error) {
console.error('Failed to retrieve redirect location:', error);
return null;
}
}

4.2 Redirect URL Construction

// src/auth/redirectHelpers.ts

/**
* Construct auth service redirect URL
*/
export function buildAuthRedirectUrl(
authServiceBaseUrl: string,
projectId: string,
returnUrl?: string
): string {
const url = new URL(`${authServiceBaseUrl}/nda`);

// Add project parameter
url.searchParams.set('project', projectId);

// Add return URL (defaults to current origin)
const finalReturnUrl = returnUrl || `${window.location.origin}/auth/callback`;
url.searchParams.set('redirect', encodeURIComponent(finalReturnUrl));

return url.toString();
}

/**
* Example usage:
* buildAuthRedirectUrl('https://auth.coditect.ai', 'bio-qms')
*
* Returns:
* https://auth.coditect.ai/nda?project=bio-qms&redirect=https%3A%2F%2Fbio-qms.docs.coditect.ai%2Fauth%2Fcallback
*/

4.3 Post-Redirect: Extract Tokens

// src/auth/callbackHandler.ts

/**
* Extract tokens from URL query parameters after OAuth redirect
*/
export function extractTokensFromUrl(): {
accessToken: string;
refreshToken: string;
} | null {
const params = new URLSearchParams(window.location.search);

const accessToken = params.get('token');
const refreshToken = params.get('refresh_token');

if (!accessToken || !refreshToken) {
return null;
}

return { accessToken, refreshToken };
}

/**
* Clean tokens from URL (prevent leak via browser history)
*/
export function cleanTokensFromUrl(): void {
const url = new URL(window.location.href);

// Remove token parameters
url.searchParams.delete('token');
url.searchParams.delete('refresh_token');

// Replace current history entry (don't create new one)
window.history.replaceState({}, '', url.toString());

console.debug('Cleaned tokens from URL');
}

/**
* Complete callback handling flow
*/
export async function handleAuthCallback(
storage: TokenStorage
): Promise<string | null> {
// Extract tokens from URL
const tokens = extractTokensFromUrl();

if (!tokens) {
console.warn('No tokens found in callback URL');
return null;
}

// Store tokens
storage.setTokens(tokens.accessToken, tokens.refreshToken);

// Clean URL
cleanTokensFromUrl();

// Get saved redirect location
const redirectLocation = getRedirectLocation();

return redirectLocation || '/';
}

4.4 Error Handling: Auth Service Unavailable

// src/auth/authServiceHealth.ts

/**
* Check if auth service is reachable before redirecting
*/
export async function checkAuthServiceHealth(
authServiceUrl: string,
timeoutMs: number = 5000
): Promise<{ available: boolean; error?: string }> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

try {
const response = await fetch(`${authServiceUrl}/health`, {
method: 'GET',
signal: controller.signal
});

clearTimeout(timeoutId);

if (response.ok) {
return { available: true };
} else {
return {
available: false,
error: `Auth service returned ${response.status}`
};
}
} catch (error) {
clearTimeout(timeoutId);

if (error.name === 'AbortError') {
return {
available: false,
error: 'Auth service health check timed out'
};
}

return {
available: false,
error: `Auth service unreachable: ${error.message}`
};
}
}

/**
* Wrap login redirect with health check
*/
export async function safeRedirectToAuth(
authServiceUrl: string,
projectId: string
): Promise<void> {
// Check health first
const health = await checkAuthServiceHealth(authServiceUrl);

if (!health.available) {
throw new Error(
`Cannot redirect to auth service: ${health.error}. Please check your network connection and try again.`
);
}

// Save current location
saveRedirectLocation();

// Build redirect URL
const redirectUrl = buildAuthRedirectUrl(authServiceUrl, projectId);

// Redirect
window.location.href = redirectUrl;
}

5. NDA Signing UI (Embedded Flow)

5.1 NDA Document Component

// src/components/NDA/NDADocumentViewer.tsx

import React, { useState, useEffect, useRef } from 'react';

interface NDADocumentViewerProps {
/** NDA document content (markdown or HTML) */
content: string;

/** Callback when user scrolls to bottom */
onReachedBottom?: () => void;

/** Whether user has scrolled to bottom */
hasReachedBottom?: boolean;
}

/**
* Display NDA document with scroll tracking
*/
export function NDADocumentViewer({
content,
onReachedBottom,
hasReachedBottom = false
}: NDADocumentViewerProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);

useEffect(() => {
const container = containerRef.current;
if (!container) return;

function handleScroll() {
const scrollTop = container!.scrollTop;
const scrollHeight = container!.scrollHeight;
const clientHeight = container!.clientHeight;

// Calculate scroll progress (0 to 100)
const progress = (scrollTop / (scrollHeight - clientHeight)) * 100;
setScrollProgress(Math.min(100, Math.max(0, progress)));

// Detect bottom reached (within 20px threshold)
if (scrollHeight - scrollTop - clientHeight < 20 && !hasReachedBottom) {
onReachedBottom?.();
}
}

container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [hasReachedBottom, onReachedBottom]);

return (
<div className="nda-document-viewer">
{/* Scroll progress indicator */}
<div className="scroll-progress-bar" role="progressbar" aria-valuenow={scrollProgress} aria-valuemin={0} aria-valuemax={100}>
<div className="scroll-progress-fill" style={{ width: `${scrollProgress}%` }} />
</div>

{/* Document content */}
<div
ref={containerRef}
className="nda-content"
role="article"
aria-label="Non-Disclosure Agreement"
>
<div dangerouslySetInnerHTML={{ __html: content }} />
</div>

{/* Bottom reached indicator */}
{hasReachedBottom && (
<div className="bottom-reached-badge" role="status" aria-live="polite">
Document fully reviewed
</div>
)}
</div>
);
}

5.2 E-Signature Capture Component

// src/components/NDA/ESignatureCapture.tsx

import React, { useState, useRef } from 'react';

interface ESignatureCaptureProps {
/** Callback when signature is captured */
onSignature: (signature: string, method: 'text' | 'draw') => void;

/** Required user name for validation */
requiredName?: string;
}

/**
* E-signature capture with text input or drawing
*/
export function ESignatureCapture({
onSignature,
requiredName
}: ESignatureCaptureProps): JSX.Element {
const [method, setMethod] = useState<'text' | 'draw'>('text');
const [textSignature, setTextSignature] = useState('');
const [isDrawing, setIsDrawing] = useState(false);

const canvasRef = useRef<HTMLCanvasElement>(null);
const [hasDrawn, setHasDrawn] = useState(false);

// Drawing handlers
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
setIsDrawing(true);
const canvas = canvasRef.current;
if (!canvas) return;

const ctx = canvas.getContext('2d');
if (!ctx) return;

const rect = canvas.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
};

const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;

const canvas = canvasRef.current;
if (!canvas) return;

const ctx = canvas.getContext('2d');
if (!ctx) return;

const rect = canvas.getBoundingClientRect();
ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);
ctx.stroke();

setHasDrawn(true);
};

const stopDrawing = () => {
setIsDrawing(false);
};

const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;

const ctx = canvas.getContext('2d');
if (!ctx) return;

ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasDrawn(false);
};

const handleSubmit = () => {
if (method === 'text') {
if (!textSignature.trim()) {
alert('Please enter your name to sign');
return;
}

// Validate against required name if provided
if (requiredName && textSignature.trim().toLowerCase() !== requiredName.toLowerCase()) {
alert(`Signature must match your name: ${requiredName}`);
return;
}

onSignature(textSignature.trim(), 'text');

} else if (method === 'draw') {
if (!hasDrawn) {
alert('Please draw your signature');
return;
}

const canvas = canvasRef.current;
if (!canvas) return;

// Convert canvas to data URL
const dataUrl = canvas.toDataURL('image/png');
onSignature(dataUrl, 'draw');
}
};

return (
<div className="e-signature-capture">
<h3>Electronic Signature</h3>

{/* Method selection */}
<div className="signature-method-tabs" role="tablist">
<button
role="tab"
aria-selected={method === 'text'}
onClick={() => setMethod('text')}
className={method === 'text' ? 'active' : ''}
>
Type Name
</button>
<button
role="tab"
aria-selected={method === 'draw'}
onClick={() => setMethod('draw')}
className={method === 'draw' ? 'active' : ''}
>
Draw Signature
</button>
</div>

{/* Text signature */}
{method === 'text' && (
<div className="text-signature-panel" role="tabpanel">
<label htmlFor="text-signature-input">
Type your full name exactly as it appears on your account:
</label>
<input
id="text-signature-input"
type="text"
value={textSignature}
onChange={(e) => setTextSignature(e.target.value)}
placeholder={requiredName || 'Your full name'}
className="signature-text-input"
autoComplete="name"
/>
{requiredName && (
<p className="signature-hint">
Must match: <strong>{requiredName}</strong>
</p>
)}
</div>
)}

{/* Draw signature */}
{method === 'draw' && (
<div className="draw-signature-panel" role="tabpanel">
<canvas
ref={canvasRef}
width={400}
height={150}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
className="signature-canvas"
aria-label="Signature drawing area"
/>
<button onClick={clearCanvas} className="clear-signature-btn">
Clear
</button>
</div>
)}

{/* Submit button */}
<button
onClick={handleSubmit}
className="submit-signature-btn"
disabled={(method === 'text' && !textSignature.trim()) || (method === 'draw' && !hasDrawn)}
>
Sign Document
</button>
</div>
);
}

5.3 Complete NDA Signing Flow Component

// src/pages/NDASigningPage.tsx

import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { NDADocumentViewer } from '../components/NDA/NDADocumentViewer';
import { ESignatureCapture } from '../components/NDA/ESignatureCapture';

/**
* Complete NDA signing page with document review, consent, and e-signature
*/
export function NDASigningPage(): JSX.Element {
const { user, refreshToken } = useAuth();
const navigate = useNavigate();

const [ndaContent, setNdaContent] = useState<string>('');
const [hasReachedBottom, setHasReachedBottom] = useState(false);
const [consentChecked, setConsentChecked] = useState(false);
const [signature, setSignature] = useState<{ value: string; method: 'text' | 'draw' } | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

// Load NDA content on mount
useEffect(() => {
async function loadNDA() {
try {
const response = await fetch('/api/nda/document');
const data = await response.json();
setNdaContent(data.content);
} catch (err) {
console.error('Failed to load NDA:', err);
setError('Failed to load NDA document. Please try again.');
}
}

loadNDA();
}, []);

const handleSignature = (signatureValue: string, method: 'text' | 'draw') => {
setSignature({ value: signatureValue, method });
};

const handleSubmit = async () => {
if (!hasReachedBottom) {
alert('Please read the entire document before signing');
return;
}

if (!consentChecked) {
alert('Please check the consent checkbox');
return;
}

if (!signature) {
alert('Please provide your signature');
return;
}

setIsSubmitting(true);
setError(null);

try {
// Submit NDA acceptance
const response = await fetch('/api/nda/accept', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await refreshToken()}`
},
body: JSON.stringify({
signature: signature.value,
signature_method: signature.method,
timestamp: new Date().toISOString(),
user_id: user?.id,
user_email: user?.email,
consent: true
})
});

if (!response.ok) {
throw new Error(`NDA acceptance failed: ${response.status}`);
}

const data = await response.json();

// Refresh user token to get updated nda_status claim
await refreshToken();

// Redirect to original destination or home
const returnUrl = sessionStorage.getItem('bio_qms_redirect_location');
navigate(returnUrl || '/', { replace: true });

} catch (err) {
console.error('NDA submission failed:', err);
setError('Failed to submit NDA. Please try again.');
} finally {
setIsSubmitting(false);
}
};

return (
<div className="nda-signing-page">
<header className="nda-header">
<h1>Non-Disclosure Agreement</h1>
<p>Please review and sign the following agreement to access confidential documents.</p>
</header>

{error && (
<div className="error-banner" role="alert">
{error}
</div>
)}

{/* NDA Document */}
<section className="nda-document-section">
<NDADocumentViewer
content={ndaContent}
onReachedBottom={() => setHasReachedBottom(true)}
hasReachedBottom={hasReachedBottom}
/>
</section>

{/* Consent Checkbox */}
<section className="nda-consent-section">
<label className="consent-checkbox-label">
<input
type="checkbox"
checked={consentChecked}
onChange={(e) => setConsentChecked(e.target.checked)}
disabled={!hasReachedBottom}
/>
<span>
I have read and understood the entire Non-Disclosure Agreement above, and I agree to be bound by its terms.
I understand that by signing this agreement, I am legally obligated to maintain confidentiality.
</span>
</label>
<p className="consent-timestamp">
Signed on: {new Date().toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' })}
</p>
</section>

{/* E-Signature */}
<section className="nda-signature-section">
<ESignatureCapture
onSignature={handleSignature}
requiredName={user?.name}
/>
</section>

{/* Submit */}
<footer className="nda-footer">
<button
onClick={handleSubmit}
disabled={!hasReachedBottom || !consentChecked || !signature || isSubmitting}
className="nda-submit-btn"
>
{isSubmitting ? 'Submitting...' : 'Submit Agreement'}
</button>
<p className="nda-legal-notice">
By submitting this agreement, you acknowledge that your electronic signature has the same legal effect as a handwritten signature.
</p>
</footer>
</div>
);
}

6. Auth Mode Detection

6.1 Runtime Mode Detection Component

// src/components/AuthModeIndicator.tsx

import React from 'react';
import { getAuthConfig } from '../config/auth.config';

/**
* Visual indicator for development mode (none auth)
* Shows watermark when authentication is bypassed
*/
export function AuthModeIndicator(): JSX.Element | null {
const config = getAuthConfig();

// Only show indicator in development mode
if (config.mode !== 'none') {
return null;
}

return (
<div
className="auth-mode-indicator"
role="banner"
aria-label="Development mode indicator"
style={{
position: 'fixed',
bottom: '10px',
right: '10px',
background: 'rgba(255, 193, 7, 0.9)',
color: '#000',
padding: '8px 16px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold',
zIndex: 9999,
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
}}
>
DEV MODE - UNAUTHENTICATED
</div>
);
}

6.2 Build-Time Environment Injection

// vite-env.d.ts (TypeScript environment declarations)

/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_AUTH_MODE: 'none' | 'gcp';
readonly VITE_AUTH_ISSUER?: string;
readonly VITE_AUTH_AUDIENCE?: string;
readonly VITE_AUTH_JWKS_URI?: string;
readonly VITE_AUTH_LOGIN_URL?: string;
readonly VITE_AUTH_REFRESH_MARGIN_SECONDS?: string;
readonly VITE_AUTH_TOKEN_STORAGE_KEY?: string;
readonly VITE_AUTH_REFRESH_TOKEN_KEY?: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
// src/config/auth.config.ts

export interface AuthConfig {
mode: 'none' | 'gcp';
issuer?: string;
audience?: string;
jwksUri?: string;
loginUrl?: string;
refreshMarginSeconds: number;
tokenStorageKey: string;
refreshTokenKey: string;
}

/**
* Load auth configuration from Vite environment variables
*/
export function getAuthConfig(): AuthConfig {
const mode = import.meta.env.VITE_AUTH_MODE as 'none' | 'gcp';

if (!mode) {
throw new Error('VITE_AUTH_MODE environment variable is required');
}

if (!['none', 'gcp'].includes(mode)) {
throw new Error(`Invalid VITE_AUTH_MODE: ${mode}. Must be "none" or "gcp".`);
}

const config: AuthConfig = {
mode,
refreshMarginSeconds: parseInt(import.meta.env.VITE_AUTH_REFRESH_MARGIN_SECONDS || '300'),
tokenStorageKey: import.meta.env.VITE_AUTH_TOKEN_STORAGE_KEY || 'bio_qms_auth_token',
refreshTokenKey: import.meta.env.VITE_AUTH_REFRESH_TOKEN_KEY || 'bio_qms_refresh_token',
};

if (mode === 'gcp') {
config.issuer = import.meta.env.VITE_AUTH_ISSUER;
config.audience = import.meta.env.VITE_AUTH_AUDIENCE;
config.jwksUri = import.meta.env.VITE_AUTH_JWKS_URI;
config.loginUrl = import.meta.env.VITE_AUTH_LOGIN_URL;

// Validate required GCP fields
const required = ['issuer', 'audience', 'jwksUri', 'loginUrl'];
const missing = required.filter((key) => !config[key as keyof AuthConfig]);

if (missing.length > 0) {
throw new Error(`Missing required GCP auth config: ${missing.join(', ')}`);
}
}

return config;
}

7. Protected Route Components

7.1 Basic Protected Route

// src/components/routes/ProtectedRoute.tsx

import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';

interface ProtectedRouteProps {
children: React.ReactNode;
}

/**
* Route wrapper that requires authentication
*/
export function ProtectedRoute({ children }: ProtectedRouteProps): JSX.Element {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();

if (isLoading) {
return (
<div className="loading-container" role="status" aria-live="polite">
<div className="spinner" />
<p>Checking authentication...</p>
</div>
);
}

if (!isAuthenticated) {
// Redirect to login, preserving intended destination
return <Navigate to="/login" state={{ from: location }} replace />;
}

return <>{children}</>;
}

7.2 NDA-Gated Route

// src/components/routes/NDAGatedRoute.tsx

import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';

interface NDAGatedRouteProps {
children: React.ReactNode;
}

/**
* Route wrapper that requires NDA acceptance
*/
export function NDAGatedRoute({ children }: NDAGatedRouteProps): JSX.Element {
const { isAuthenticated, isLoading, ndaStatus } = useAuth();
const location = useLocation();

if (isLoading) {
return (
<div className="loading-container" role="status" aria-live="polite">
<div className="spinner" />
<p>Checking authentication...</p>
</div>
);
}

if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}

if (ndaStatus !== 'accepted') {
// Redirect to NDA signing page
return <Navigate to="/nda/sign" state={{ from: location }} replace />;
}

return <>{children}</>;
}

7.3 Permission-Based Route

// src/components/routes/PermissionRoute.tsx

import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';

interface PermissionRouteProps {
children: React.ReactNode;
/** Required permission string */
permission: string;
/** Optional fallback page */
fallbackPath?: string;
}

/**
* Route wrapper that requires specific permission
*/
export function PermissionRoute({
children,
permission,
fallbackPath = '/access-denied'
}: PermissionRouteProps): JSX.Element {
const { isAuthenticated, isLoading, hasPermission } = useAuth();

if (isLoading) {
return (
<div className="loading-container" role="status" aria-live="polite">
<div className="spinner" />
<p>Checking permissions...</p>
</div>
);
}

if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}

if (!hasPermission(permission)) {
return <Navigate to={fallbackPath} replace />;
}

return <>{children}</>;
}

7.4 Router Configuration Example

// src/routes/index.tsx

import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { ProtectedRoute } from '../components/routes/ProtectedRoute';
import { NDAGatedRoute } from '../components/routes/NDAGatedRoute';
import { PermissionRoute } from '../components/routes/PermissionRoute';

import { HomePage } from '../pages/HomePage';
import { LoginPage } from '../pages/LoginPage';
import { DocumentViewerPage } from '../pages/DocumentViewerPage';
import { ConfidentialDocPage } from '../pages/ConfidentialDocPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { NDASigningPage } from '../pages/NDASigningPage';
import { AccessDeniedPage } from '../pages/AccessDeniedPage';

export function AppRoutes(): JSX.Element {
return (
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/access-denied" element={<AccessDeniedPage />} />

{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
}
/>

<Route
path="/docs/:docId"
element={
<ProtectedRoute>
<DocumentViewerPage />
</ProtectedRoute>
}
/>

{/* NDA-gated routes */}
<Route path="/nda/sign" element={<NDASigningPage />} />

<Route
path="/confidential/:docId"
element={
<NDAGatedRoute>
<ConfidentialDocPage />
</NDAGatedRoute>
}
/>

{/* Permission-gated routes */}
<Route
path="/admin"
element={
<PermissionRoute permission="admin:access">
<AdminDashboardPage />
</PermissionRoute>
}
/>
</Routes>
);
}

8. API Client Integration

8.1 Axios Interceptor for Token Attachment

// src/api/client.ts

import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { AuthProvider } from '../auth/AuthProvider.interface';

/**
* Create API client with auth interceptors
*/
export function createApiClient(
baseURL: string,
authProvider: AuthProvider
): AxiosInstance {
const client = axios.create({
baseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});

// Request interceptor: attach token
client.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Skip token attachment for public endpoints
if (config.url?.includes('/public/')) {
return config;
}

try {
const token = await authProvider.getToken();

if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} catch (error) {
console.error('Failed to get token for request:', error);
}

return config;
},
(error) => Promise.reject(error)
);

// Response interceptor: handle 401 (unauthorized)
client.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };

// Handle 401 Unauthorized
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

try {
// Attempt token refresh
await authProvider.refreshToken();

// Retry original request with new token
const newToken = await authProvider.getToken();
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
}

return client(originalRequest);

} catch (refreshError) {
// Refresh failed - redirect to login
console.error('Token refresh failed, redirecting to login:', refreshError);
await authProvider.logout();
window.location.href = '/login';

return Promise.reject(refreshError);
}
}

return Promise.reject(error);
}
);

return client;
}

8.2 Usage Example

// src/hooks/useDocuments.ts

import { useState, useEffect } from 'react';
import { apiClient } from '../api/client';
import { useAuth } from '../contexts/AuthContext';

interface Document {
id: string;
title: string;
content: string;
nda_required: boolean;
}

export function useDocuments() {
const { canAccessDocument } = useAuth();
const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
async function fetchDocuments() {
try {
setLoading(true);

const response = await apiClient.get<Document[]>('/documents');

// Filter out NDA-required documents if user hasn't signed
const accessible = response.data.filter(doc =>
canAccessDocument({ nda_required: doc.nda_required })
);

setDocuments(accessible);
setError(null);

} catch (err) {
console.error('Failed to fetch documents:', err);
setError(err as Error);
} finally {
setLoading(false);
}
}

fetchDocuments();
}, [canAccessDocument]);

return { documents, loading, error };
}

9. Error States and UX

9.1 Error Types and User Messages

// src/auth/errorMessages.ts

export const AUTH_ERROR_MESSAGES = {
TOKEN_EXPIRED: {
title: 'Session Expired',
message: 'Your session has expired. Please log in again.',
action: 'Log In',
},
INVALID_TOKEN: {
title: 'Authentication Failed',
message: 'Your authentication is invalid. Please log in again.',
action: 'Log In',
},
NETWORK_ERROR: {
title: 'Connection Error',
message: 'Cannot connect to authentication service. Please check your internet connection.',
action: 'Retry',
},
AUTH_SERVICE_DOWN: {
title: 'Service Unavailable',
message: 'Authentication service is temporarily unavailable. Please try again in a few minutes.',
action: 'Retry',
},
NDA_REQUIRED: {
title: 'NDA Required',
message: 'This document requires NDA acceptance. Please sign the NDA to continue.',
action: 'Sign NDA',
},
REFRESH_FAILED: {
title: 'Session Renewal Failed',
message: 'Could not renew your session. Please log in again.',
action: 'Log In',
},
} as const;

9.2 Error Display Component

// src/components/ErrorDisplay.tsx

import React from 'react';
import { AlertCircle, RefreshCw, LogIn } from 'lucide-react';

interface ErrorDisplayProps {
errorCode: keyof typeof AUTH_ERROR_MESSAGES;
onAction?: () => void;
}

export function ErrorDisplay({ errorCode, onAction }: ErrorDisplayProps): JSX.Element {
const error = AUTH_ERROR_MESSAGES[errorCode];

return (
<div className="error-display" role="alert" aria-live="assertive">
<div className="error-icon">
<AlertCircle size={48} />
</div>

<h2 className="error-title">{error.title}</h2>
<p className="error-message">{error.message}</p>

{onAction && (
<button onClick={onAction} className="error-action-btn">
{errorCode === 'NETWORK_ERROR' || errorCode === 'AUTH_SERVICE_DOWN' ? (
<RefreshCw size={16} />
) : (
<LogIn size={16} />
)}
{error.action}
</button>
)}
</div>
);
}

9.3 Exponential Backoff Retry

// src/utils/retry.ts

/**
* Retry a function with exponential backoff
*/
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000,
onRetry?: (attempt: number, error: Error) => void
): Promise<T> {
let lastError: Error | null = null;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;

if (attempt === maxRetries) {
throw lastError;
}

// Calculate delay with exponential backoff + jitter
const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000;

console.warn(`Retry attempt ${attempt}/${maxRetries} after ${delay}ms`);
onRetry?.(attempt, lastError);

await new Promise(resolve => setTimeout(resolve, delay));
}
}

throw lastError;
}

/**
* Usage example:
*
* const data = await retryWithBackoff(
* () => apiClient.get('/data'),
* 3,
* 1000,
* (attempt, error) => {
* showToast(`Retry ${attempt}/3: ${error.message}`);
* }
* );
*/

10. Security Hardening

10.1 XSS Protection

// src/security/xss.ts

import DOMPurify from 'dompurify';

/**
* Sanitize HTML content to prevent XSS attacks
*/
export function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 's', 'a', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
SAFE_FOR_TEMPLATES: true,
});
}

/**
* Usage in React components:
*
* <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(content) }} />
*/

10.2 CSRF Protection

// src/security/csrf.ts

/**
* Get CSRF token from meta tag or cookie
*/
export function getCsrfToken(): string | null {
// Try meta tag first (set by server)
const metaTag = document.querySelector('meta[name="csrf-token"]');
if (metaTag) {
return metaTag.getAttribute('content');
}

// Try cookie
const cookieMatch = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
if (cookieMatch) {
return decodeURIComponent(cookieMatch[1]);
}

return null;
}

/**
* Attach CSRF token to API requests
*/
export function attachCsrfToken(headers: Record<string, string>): void {
const token = getCsrfToken();
if (token) {
headers['X-CSRF-Token'] = token;
}
}

10.3 Content Security Policy

<!-- index.html -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://auth.coditect.ai https://api.coditect.ai;
frame-ancestors 'none';
base-uri 'self';
form-action 'self' https://auth.coditect.ai;
upgrade-insecure-requests;
">

When migrating from localStorage to httpOnly cookies:

// Backend: Set-Cookie header configuration

Set-Cookie: bio_qms_auth_token=<JWT>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
Set-Cookie: bio_qms_refresh_token=<REFRESH>; HttpOnly; Secure; SameSite=Strict; Path=/api/token/refresh; Max-Age=2592000

Attributes:

  • HttpOnly: Prevents JavaScript access (XSS protection)
  • Secure: Only sent over HTTPS
  • SameSite=Strict: Prevents CSRF attacks
  • Path: Restricts cookie scope
  • Max-Age: Explicit expiry

11. React Component Reference

11.1 Component Tree

App
├── AuthProvider (context)
│ └── AppRoutes
│ ├── LoginPage
│ ├── NDASigningPage
│ │ ├── NDADocumentViewer
│ │ └── ESignatureCapture
│ ├── ProtectedRoute
│ │ └── HomePage
│ ├── NDAGatedRoute
│ │ └── ConfidentialDocPage
│ └── PermissionRoute
│ └── AdminDashboardPage
└── AuthModeIndicator (dev watermark)

11.2 Props Reference

AuthProvider

interface AuthProviderProps {
provider: AuthProvider;
children: React.ReactNode;
}

ProtectedRoute

interface ProtectedRouteProps {
children: React.ReactNode;
}

NDAGatedRoute

interface NDAGatedRouteProps {
children: React.ReactNode;
}

NDADocumentViewer

interface NDADocumentViewerProps {
content: string;
onReachedBottom?: () => void;
hasReachedBottom?: boolean;
}

ESignatureCapture

interface ESignatureCaptureProps {
onSignature: (signature: string, method: 'text' | 'draw') => void;
requiredName?: string;
}

12. Testing Strategy

12.1 Unit Tests

// src/contexts/__tests__/AuthContext.test.tsx

import { renderHook, waitFor } from '@testing-library/react';
import { AuthProvider, useAuth } from '../AuthContext';
import { NullAuthProvider } from '../../auth/NullAuthProvider';

describe('AuthContext', () => {
it('should provide auth state from provider', async () => {
const provider = new NullAuthProvider();

const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider provider={provider}>{children}</AuthProvider>
);

const { result } = renderHook(() => useAuth(), { wrapper });

await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toBeTruthy();
});
});

it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
});
});

12.2 Integration Tests

// src/components/__tests__/ProtectedRoute.integration.test.tsx

import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from '../../contexts/AuthContext';
import { ProtectedRoute } from '../routes/ProtectedRoute';
import { GCPAuthProvider } from '../../auth/GCPAuthProvider';

describe('ProtectedRoute Integration', () => {
it('should redirect to login when not authenticated', () => {
const provider = new GCPAuthProvider({
issuer: 'https://auth.test.com',
audience: 'test',
jwksUri: 'https://auth.test.com/.well-known/jwks.json',
loginUrl: 'https://auth.test.com/login',
refreshMarginSeconds: 300,
tokenStorageKey: 'test_token',
refreshTokenKey: 'test_refresh',
});

render(
<BrowserRouter>
<AuthProvider provider={provider}>
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
</AuthProvider>
</BrowserRouter>
);

// Should not see protected content
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();

// Should redirect to login
expect(window.location.pathname).toBe('/login');
});
});

12.3 E2E Tests (Cypress)

// cypress/e2e/auth-flow.cy.ts

describe('Authentication Flow', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.clearCookies();
});

it('should complete full login flow', () => {
// Visit protected page
cy.visit('/docs/confidential-doc');

// Should redirect to login
cy.url().should('include', '/login');

// Click login button (simulates redirect to auth service)
cy.contains('Sign In').click();

// Simulate auth service callback with tokens
cy.visit('/?token=mock_jwt_token&refresh_token=mock_refresh_token');

// Should extract tokens and redirect to intended page
cy.url().should('include', '/docs/confidential-doc');

// Tokens should be stored
cy.window().then((win) => {
expect(win.localStorage.getItem('bio_qms_auth_token')).to.exist;
});

// Should see protected content
cy.contains('Confidential Document').should('be.visible');
});

it('should show NDA signing flow for unsigned users', () => {
// Set token with nda_status: "pending"
cy.window().then((win) => {
win.localStorage.setItem('bio_qms_auth_token', createMockJWT({ nda_status: 'pending' }));
});

// Visit NDA-required page
cy.visit('/confidential/secret-doc');

// Should redirect to NDA signing
cy.url().should('include', '/nda/sign');

// Should show NDA document
cy.contains('Non-Disclosure Agreement').should('be.visible');

// Scroll to bottom
cy.get('.nda-content').scrollTo('bottom');

// Wait for bottom detection
cy.contains('Document fully reviewed').should('be.visible');

// Check consent
cy.get('input[type="checkbox"]').check();

// Sign
cy.get('input[type="text"]').type('Test User');
cy.contains('Sign Document').click();

// Should submit and redirect
cy.url().should('include', '/confidential/secret-doc');
});
});

13. Performance Optimization

13.1 Token Validation Caching

// src/auth/tokenCache.ts

interface CachedTokenValidation {
valid: boolean;
expiresAt: number;
cachedAt: number;
}

const validationCache = new Map<string, CachedTokenValidation>();

/**
* Cache token validation results for 1 minute
*/
export function getCachedValidation(token: string): boolean | null {
const cached = validationCache.get(token);

if (!cached) {
return null;
}

const now = Date.now();

// Cache expired
if (now > cached.cachedAt + 60000) {
validationCache.delete(token);
return null;
}

// Token expired
if (now > cached.expiresAt) {
validationCache.delete(token);
return false;
}

return cached.valid;
}

export function setCachedValidation(token: string, valid: boolean, expiresAt: number): void {
validationCache.set(token, {
valid,
expiresAt,
cachedAt: Date.now(),
});
}

13.2 Lazy Loading Protected Routes

// src/routes/index.tsx

import React, { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { LoadingSpinner } from '../components/LoadingSpinner';

// Lazy load heavy components
const AdminDashboardPage = lazy(() => import('../pages/AdminDashboardPage'));
const ConfidentialDocPage = lazy(() => import('../pages/ConfidentialDocPage'));

export function AppRoutes(): JSX.Element {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route
path="/admin"
element={
<PermissionRoute permission="admin:access">
<AdminDashboardPage />
</PermissionRoute>
}
/>

<Route
path="/confidential/:docId"
element={
<NDAGatedRoute>
<ConfidentialDocPage />
</NDAGatedRoute>
}
/>
</Routes>
</Suspense>
);
}

13.3 Memoized Auth Context Value

Already implemented in AuthContext with useCallback for all methods and stable references.


14. Accessibility

14.1 ARIA Labels and Roles

// All interactive elements have proper ARIA attributes

// Loading state
<div role="status" aria-live="polite">
<div className="spinner" aria-label="Loading" />
<p>Checking authentication...</p>
</div>

// Error messages
<div role="alert" aria-live="assertive">
<h2>Authentication Error</h2>
<p>{errorMessage}</p>
</div>

// NDA document
<div role="article" aria-label="Non-Disclosure Agreement">
{content}
</div>

// Signature canvas
<canvas aria-label="Signature drawing area" />

// Auth mode indicator
<div role="banner" aria-label="Development mode indicator">
DEV MODE - UNAUTHENTICATED
</div>

14.2 Keyboard Navigation

// All components support keyboard navigation

// Login button
<button onClick={handleLogin} aria-label="Sign in with CODITECT">
Sign In
</button>

// Consent checkbox
<label>
<input type="checkbox" />
<span>I agree to the terms</span>
</label>

// NDA signature tabs
<div role="tablist">
<button role="tab" aria-selected={method === 'text'}>
Type Name
</button>
<button role="tab" aria-selected={method === 'draw'}>
Draw Signature
</button>
</div>

14.3 Screen Reader Support

// Announce state changes

import { announce } from './utils/a11y';

// After successful login
announce('Successfully authenticated. Welcome back!', 'polite');

// After token refresh
announce('Session renewed', 'polite');

// On authentication error
announce('Authentication failed. Please try again.', 'assertive');

// Implementation
export function announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
const container = document.getElementById('a11y-announcer') || createAnnouncerElement();
container.setAttribute('aria-live', priority);
container.textContent = message;

// Clear after 3 seconds
setTimeout(() => {
container.textContent = '';
}, 3000);
}

function createAnnouncerElement(): HTMLElement {
const div = document.createElement('div');
div.id = 'a11y-announcer';
div.className = 'sr-only';
div.setAttribute('role', 'status');
document.body.appendChild(div);
return div;
}

15. Multi-Tab Token Synchronization

15.1 BroadcastChannel API

// src/auth/tabSync.ts

/**
* Synchronize authentication state across browser tabs
*/
export class TabAuthSync {
private channel: BroadcastChannel;
private onTokenUpdate: (accessToken: string, refreshToken: string) => void;
private onLogout: () => void;

constructor(
channelName: string = 'bio-qms-auth',
onTokenUpdate: (accessToken: string, refreshToken: string) => void,
onLogout: () => void
) {
this.channel = new BroadcastChannel(channelName);
this.onTokenUpdate = onTokenUpdate;
this.onLogout = onLogout;

this.channel.addEventListener('message', this.handleMessage.bind(this));
}

private handleMessage(event: MessageEvent): void {
const { type, payload } = event.data;

switch (type) {
case 'TOKEN_UPDATED':
console.debug('Tab sync: tokens updated in another tab');
this.onTokenUpdate(payload.accessToken, payload.refreshToken);
break;

case 'LOGOUT':
console.debug('Tab sync: logout in another tab');
this.onLogout();
break;
}
}

/**
* Broadcast token update to other tabs
*/
broadcastTokenUpdate(accessToken: string, refreshToken: string): void {
this.channel.postMessage({
type: 'TOKEN_UPDATED',
payload: { accessToken, refreshToken }
});
}

/**
* Broadcast logout to other tabs
*/
broadcastLogout(): void {
this.channel.postMessage({
type: 'LOGOUT'
});
}

/**
* Close channel on unmount
*/
dispose(): void {
this.channel.close();
}
}

15.2 Integration with AuthProvider

// src/auth/GCPAuthProvider.ts (excerpt)

import { TabAuthSync } from './tabSync';

export class GCPAuthProvider implements AuthProvider {
private tabSync: TabAuthSync;

constructor(config: GCPAuthConfig) {
// ... existing initialization

// Set up tab synchronization
this.tabSync = new TabAuthSync(
'bio-qms-auth',
(accessToken, refreshToken) => {
// Another tab updated tokens
this.tokenCache = accessToken;
this.userCache = this.decodeToken(accessToken);
this.scheduleRefresh(accessToken);
this.notifyAuthStateChange(this.userCache);
},
() => {
// Another tab logged out
this.tokenCache = null;
this.userCache = null;
this.notifyAuthStateChange(null);
}
);
}

private async storeToken(accessToken: string, refreshToken: string): Promise<void> {
localStorage.setItem(this.config.tokenStorageKey, accessToken);
localStorage.setItem(this.config.refreshTokenKey, refreshToken);

// Broadcast to other tabs
this.tabSync.broadcastTokenUpdate(accessToken, refreshToken);
}

async logout(): Promise<void> {
// ... existing logout logic

// Broadcast to other tabs
this.tabSync.broadcastLogout();
}
}

16. Deployment Configuration

16.1 Environment Files

.env.development (local dev, no auth):

VITE_AUTH_MODE=none

.env.staging (staging with auth):

VITE_AUTH_MODE=gcp
VITE_AUTH_ISSUER=https://auth-staging.coditect.ai
VITE_AUTH_AUDIENCE=bio-qms-staging.docs.coditect.ai
VITE_AUTH_JWKS_URI=https://auth-staging.coditect.ai/.well-known/jwks.json
VITE_AUTH_LOGIN_URL=https://auth-staging.coditect.ai/login
VITE_AUTH_REFRESH_MARGIN_SECONDS=300
VITE_AUTH_TOKEN_STORAGE_KEY=bio_qms_staging_auth_token
VITE_AUTH_REFRESH_TOKEN_KEY=bio_qms_staging_refresh_token

.env.production (production with auth):

VITE_AUTH_MODE=gcp
VITE_AUTH_ISSUER=https://auth.coditect.ai
VITE_AUTH_AUDIENCE=bio-qms.docs.coditect.ai
VITE_AUTH_JWKS_URI=https://auth.coditect.ai/.well-known/jwks.json
VITE_AUTH_LOGIN_URL=https://auth.coditect.ai/login
VITE_AUTH_REFRESH_MARGIN_SECONDS=300
VITE_AUTH_TOKEN_STORAGE_KEY=bio_qms_auth_token
VITE_AUTH_REFRESH_TOKEN_KEY=bio_qms_refresh_token

16.2 Build Validation Script

#!/bin/bash
# scripts/validate-auth-config.sh

set -e

ENV_FILE="${1:-.env.production}"

echo "Validating auth configuration for $ENV_FILE..."

if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: Environment file not found: $ENV_FILE"
exit 1
fi

source "$ENV_FILE"

# Check required variables
if [ -z "$VITE_AUTH_MODE" ]; then
echo "ERROR: VITE_AUTH_MODE is required"
exit 1
fi

if [ "$VITE_AUTH_MODE" != "none" ] && [ "$VITE_AUTH_MODE" != "gcp" ]; then
echo "ERROR: VITE_AUTH_MODE must be 'none' or 'gcp', got: $VITE_AUTH_MODE"
exit 1
fi

# Production-specific checks
if [[ "$ENV_FILE" == *"production"* ]]; then
if [ "$VITE_AUTH_MODE" = "none" ]; then
echo "ERROR: Production builds MUST use gcp auth mode"
exit 1
fi

echo "Production auth mode validated: gcp"
fi

# GCP mode validation
if [ "$VITE_AUTH_MODE" = "gcp" ]; then
REQUIRED_VARS=(
"VITE_AUTH_ISSUER"
"VITE_AUTH_AUDIENCE"
"VITE_AUTH_JWKS_URI"
"VITE_AUTH_LOGIN_URL"
)

for VAR in "${REQUIRED_VARS[@]}"; do
if [ -z "${!VAR}" ]; then
echo "ERROR: $VAR is required for gcp auth mode"
exit 1
fi
done

echo "GCP auth configuration validated"
fi

echo "Auth configuration valid for $ENV_FILE"

16.3 CI/CD Integration

# .github/workflows/deploy-production.yml

name: Deploy to Production

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Validate auth configuration
run: |
./scripts/validate-auth-config.sh .env.production

- name: Build application
env:
VITE_AUTH_MODE: gcp
VITE_AUTH_ISSUER: https://auth.coditect.ai
VITE_AUTH_AUDIENCE: bio-qms.docs.coditect.ai
VITE_AUTH_JWKS_URI: https://auth.coditect.ai/.well-known/jwks.json
VITE_AUTH_LOGIN_URL: https://auth.coditect.ai/login
run: |
npm ci
npm run build

- name: Deploy to Cloud Run
run: |
gcloud builds submit --config=cloudbuild.yaml

Document Status

Version: 1.0.0 Status: Active Created: 2026-02-16 Last Updated: 2026-02-16 Next Review: 2026-03-16

Related Documents:

  • auth-mode-switching.md - Environment-based auth mode configuration
  • ADR-196 - NDA-Gated Conditional Access
  • security-hardening.md - Comprehensive security guidelines

Change Log:

DateVersionChangesAuthor
2026-02-161.0.0Initial specificationfrontend-react-typescript-expert

End of Document