Environment-Based Authentication Mode Switching
Document Overview
This document provides the complete technical specification for implementing environment-based authentication mode switching in the BIO-QMS documentation viewer. The system supports two distinct modes:
nonemode: Unauthenticated access for local development, demos, and presentationsgcpmode: JWT-based authentication with validation against auth.coditect.ai for cloud deployments
This specification covers architecture, implementation patterns, security considerations, error handling, and testing strategies to ensure zero-breach security posture while maintaining developer productivity.
Table of Contents
- Architecture Overview
- Environment Variable Specification
- Auth Mode:
none - Auth Mode:
gcp - Build-Time Configuration Injection
- Runtime Auth Provider Abstraction
- JWT Token Specification
- Token Validation Flow
- Token Refresh Strategy
- Auth State Management in React
- Protected Route Component
- Login and Redirect Flow
- Error Handling
- Security Considerations
- Testing Strategy
- Graceful Degradation
- Audit Logging
- Configuration Examples
- Implementation Checklist
- References
1. Architecture Overview
1.1 Design Principles
The authentication mode switching architecture adheres to the following principles:
- Environment-Driven Configuration: Auth mode determined at build time via environment variables
- Provider Abstraction: Common interface for all auth providers, enabling testability and extensibility
- Zero-Trust Security: All requests validated in
gcpmode, with signature verification and token expiry checks - Developer Experience: Zero-friction local development with
nonemode, no auth setup required - Fail-Secure Defaults: Invalid configuration defaults to most restrictive mode
- Stateless Operation: JWT tokens carry all required claims, no server-side session state
- Graceful Degradation: Clear error messages and fallback behavior when auth service unavailable
1.2 System Context
┌─────────────────────────────────────────────────────────────────┐
│ BIO-QMS Documentation Viewer │
│ │
│ ┌───────────────┐ ┌──────────────┐ │
│ │ Vite Build │────────▶│ App Bundle │ │
│ │ (inject env) │ │ + Auth Mode │ │
│ └───────────────┘ └──────┬───────┘ │
│ │ │
│ ┌───────────────▼───────────────┐ │
│ │ Auth Mode Router │ │
│ │ (runtime mode selection) │ │
│ └───────┬───────────────┬───────┘ │
│ │ │ │
│ ┌─────────────▼─┐ ┌──▼──────────────┐ │
│ │ NullAuthProvider│ │ GCPAuthProvider │ │
│ │ (mode: none) │ │ (mode: gcp) │ │
│ └─────────────────┘ └──────┬───────────┘ │
│ │ │
│ ┌───────────▼──────────┐ │
│ │ auth.coditect.ai │ │
│ │ (JWT validation) │ │
│ └──────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
1.3 Auth Mode State Machine
┌──────────────┐
│ App Start │
└──────┬───────┘
│
┌──────▼───────┐
│ Read Auth │
│ Mode from │
│ Config │
└──────┬───────┘
│
┌──────────▼──────────┐
│ Mode = "none" ? │
└──┬──────────────┬───┘
│ Yes │ No
┌────────▼───┐ ┌────▼─────────┐
│ Load Null │ │ Load GCP │
│ Auth │ │ Auth │
│ Provider │ │ Provider │
└────────┬───┘ └────┬─────────┘
│ │
│ ┌────▼─────────┐
│ │ Check Token │
│ │ in Storage │
│ └────┬─────────┘
│ │
│ ┌───────▼────────┐
│ │ Token Valid? │
│ └───┬────────┬───┘
│ │ Yes │ No
│ ┌─────▼──┐ ┌──▼──────┐
│ │ Auth │ │ Redirect│
│ │ Success│ │ to Login│
│ └────┬───┘ └─────────┘
│ │
┌────────▼─────────▼───┐
│ Render Application │
└──────────────────────┘
2. Environment Variable Specification
2.1 Primary Configuration Variable
Variable Name: VITE_AUTH_MODE
Type: Enum ("none" | "gcp")
Required: Yes
Default Behavior: If unset or invalid, application FAILS TO START with configuration error
Description: Determines the authentication mode for the application at build time.
2.2 Valid Values
| Value | Description | Use Case |
|---|---|---|
none | No authentication required | Local development, demos, presentations, CI/CD testing |
gcp | GCP JWT authentication via auth.coditect.ai | Cloud deployments, production, staging |
2.3 Supporting Configuration Variables
For gcp Mode
| Variable | Type | Required | Default | Description |
|---|---|---|---|---|
VITE_AUTH_ISSUER | string | Yes | "auth.coditect.ai" | JWT issuer for validation |
VITE_AUTH_AUDIENCE | string | Yes | "bio-qms.docs.coditect.ai" | Expected JWT audience claim |
VITE_AUTH_JWKS_URI | string | Yes | "https://auth.coditect.ai/.well-known/jwks.json" | Public key endpoint for JWT signature verification |
VITE_AUTH_LOGIN_URL | string | Yes | "https://auth.coditect.ai/login" | Login redirect endpoint |
VITE_AUTH_REFRESH_MARGIN_SECONDS | number | No | 300 | Seconds before expiry to trigger refresh (5 minutes) |
VITE_AUTH_TOKEN_STORAGE_KEY | string | No | "bio_qms_auth_token" | LocalStorage key for token |
VITE_AUTH_REFRESH_TOKEN_KEY | string | No | "bio_qms_refresh_token" | LocalStorage key for refresh token |
For none Mode
No additional variables required. All auth-related config ignored when VITE_AUTH_MODE=none.
2.4 Configuration Validation
On application startup, validate configuration:
interface AuthConfig {
mode: "none" | "gcp";
issuer?: string;
audience?: string;
jwksUri?: string;
loginUrl?: string;
refreshMarginSeconds?: number;
tokenStorageKey?: string;
refreshTokenKey?: string;
}
function validateAuthConfig(config: AuthConfig): void {
if (!config.mode) {
throw new Error("VITE_AUTH_MODE is required");
}
if (!["none", "gcp"].includes(config.mode)) {
throw new Error(`Invalid VITE_AUTH_MODE: ${config.mode}. Must be "none" or "gcp".`);
}
if (config.mode === "gcp") {
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(", ")}`);
}
// Validate URLs
try {
new URL(config.jwksUri!);
new URL(config.loginUrl!);
} catch (e) {
throw new Error(`Invalid URL in auth config: ${e.message}`);
}
}
}
3. Auth Mode: none
3.1 Behavior Specification
When VITE_AUTH_MODE=none, the application operates in completely unauthenticated mode:
- No login required: All routes accessible without authentication
- No token validation: No JWT checks, signature verification, or expiry validation
- No network requests: Zero calls to auth.coditect.ai or any auth service
- Full feature access: All documents, search, navigation, presentation mode available
- No user identity: No user ID, email, or organization context
- No NDA gating: All documents visible regardless of NDA status metadata
3.2 Use Cases
- Local Development: Engineers developing features without auth service dependency
- Offline Demos: Sales presentations without network connectivity
- CI/CD Testing: Automated tests running in isolated environments
- Screenshot Generation: Automated screenshot capture for documentation
- Public Previews: Temporary public access for specific document sets (manually deployed)
3.3 Implementation
NullAuthProvider provides the auth interface with no-op implementations:
export class NullAuthProvider implements AuthProvider {
async initialize(): Promise<void> {
// No-op: no initialization needed
}
async getToken(): Promise<string | null> {
// Always return null - no token concept in none mode
return null;
}
async validateToken(token: string): Promise<boolean> {
// No validation - not used in none mode
return true;
}
async refreshToken(): Promise<string | null> {
// No refresh - not used in none mode
return null;
}
async login(returnUrl?: string): Promise<void> {
// No-op: no login in none mode
console.warn("Login called in 'none' auth mode - ignoring");
}
async logout(): Promise<void> {
// No-op: no logout in none mode
console.warn("Logout called in 'none' auth mode - ignoring");
}
isAuthenticated(): boolean {
// Always authenticated in none mode
return true;
}
getUser(): User | null {
// Return mock user for none mode
return {
id: "local-dev-user",
email: "dev@localhost",
name: "Local Development User",
orgId: "local-org",
ndaStatus: "accepted", // All documents visible
};
}
onAuthStateChange(callback: (user: User | null) => void): () => void {
// Immediately invoke callback with mock user
callback(this.getUser());
// Return no-op unsubscribe
return () => {};
}
}
3.4 Security Considerations for none Mode
WARNING: none mode bypasses ALL authentication and authorization. NEVER deploy to production with none mode enabled.
Safeguards:
- Build-time check: CI/CD pipeline MUST validate
VITE_AUTH_MODE=gcpbefore production deployment - Environment detection: If
window.location.hostnameis a production domain, log warning if mode isnone - Watermark indicator: Development builds with
nonemode show "DEV MODE - UNAUTHENTICATED" watermark - Audit logging: Log all builds with
nonemode, including build timestamp and commit hash
Environment Detection:
function validateProductionMode(): void {
const prodDomains = ["docs.coditect.ai", "bio-qms.docs.coditect.ai"];
const isProdDomain = prodDomains.some((domain) =>
window.location.hostname.includes(domain)
);
if (isProdDomain && import.meta.env.VITE_AUTH_MODE === "none") {
// Log critical error
console.error("CRITICAL: Production domain detected with auth mode 'none'");
// Send to monitoring (if available)
if (window.gtag) {
window.gtag("event", "auth_mode_mismatch", {
hostname: window.location.hostname,
mode: "none",
severity: "critical",
});
}
// Show prominent warning banner
showCriticalWarningBanner("Authentication disabled on production domain");
}
}
4. Auth Mode: gcp
4.1 Behavior Specification
When VITE_AUTH_MODE=gcp, the application enforces JWT-based authentication:
- Login required: Unauthenticated users redirected to auth.coditect.ai login
- Token validation: All JWTs verified for signature, expiry, audience, and NDA status
- Automatic refresh: Tokens refreshed before expiry using refresh token rotation
- Session management: Token stored in localStorage with httpOnly cookie fallback (if available)
- NDA gating: Documents marked
nda_required: truehidden unless user hasndaStatus: "accepted" - Organization isolation: Users only see documents for their organization (if org-scoped)
- Secure logout: Tokens cleared from storage and session invalidated with auth service
4.2 User Experience Flow
First-Time User (No Token)
- User navigates to
https://bio-qms.docs.coditect.ai/ - App detects no token in storage
- App redirects to
https://auth.coditect.ai/login?return_to=https://bio-qms.docs.coditect.ai/ - User completes login (email/password, OAuth, SSO)
- Auth service validates credentials and generates JWT + refresh token
- Auth service redirects back to
https://bio-qms.docs.coditect.ai/?token=<JWT>&refresh_token=<REFRESH> - App extracts tokens from URL, validates JWT, stores in localStorage
- App clears tokens from URL (prevents leak via browser history)
- App renders with authenticated state
Returning User (Valid Token)
- User navigates to app
- App reads token from localStorage
- App validates token (expiry, signature, audience)
- Token valid → render app immediately
- Background: schedule token refresh if < 5 minutes to expiry
Token Expired (Refresh Available)
- User navigates to app
- App reads token from localStorage
- Token expired → attempt refresh using refresh token
- Refresh successful → store new token, render app
- Refresh failed → redirect to login
4.3 Implementation
GCPAuthProvider handles all JWT operations:
export class GCPAuthProvider implements AuthProvider {
private config: GCPAuthConfig;
private tokenCache: string | null = null;
private userCache: User | null = null;
private refreshTimer: number | null = null;
private authStateListeners: Array<(user: User | null) => void> = [];
constructor(config: GCPAuthConfig) {
this.config = config;
}
async initialize(): Promise<void> {
// Extract token from URL if present (OAuth redirect)
const urlToken = this.extractTokenFromUrl();
if (urlToken) {
await this.storeToken(urlToken.accessToken, urlToken.refreshToken);
this.cleanUrlTokens(); // Remove from URL
}
// Load token from storage
const token = this.getStoredToken();
if (token) {
const isValid = await this.validateToken(token);
if (isValid) {
this.tokenCache = token;
this.userCache = this.decodeToken(token);
this.scheduleRefresh(token);
this.notifyAuthStateChange(this.userCache);
} else {
// Token invalid, attempt refresh
await this.refreshToken();
}
}
}
async getToken(): Promise<string | null> {
if (!this.tokenCache) {
return null;
}
// Check if token needs refresh
const decoded = this.decodeToken(this.tokenCache);
const expiresIn = decoded.exp * 1000 - Date.now();
const refreshMargin = this.config.refreshMarginSeconds * 1000;
if (expiresIn < refreshMargin) {
await this.refreshToken();
}
return this.tokenCache;
}
async validateToken(token: string): Promise<boolean> {
try {
// Decode without verification first (to check expiry)
const decoded = this.decodeTokenUnsafe(token);
// Check expiry
if (Date.now() >= decoded.exp * 1000) {
return false;
}
// Verify signature using JWKS
await this.verifySignature(token);
// Validate claims
if (decoded.iss !== this.config.issuer) {
return false;
}
if (decoded.aud !== this.config.audience) {
return false;
}
return true;
} catch (error) {
console.error("Token validation failed:", error);
return false;
}
}
async refreshToken(): Promise<string | null> {
const refreshToken = this.getStoredRefreshToken();
if (!refreshToken) {
return null;
}
try {
const response = await fetch(`${this.config.issuer}/token/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refresh_token: refreshToken,
}),
});
if (!response.ok) {
throw new Error(`Refresh failed: ${response.status}`);
}
const data = await response.json();
await this.storeToken(data.access_token, data.refresh_token);
this.tokenCache = data.access_token;
this.userCache = this.decodeToken(data.access_token);
this.scheduleRefresh(data.access_token);
this.notifyAuthStateChange(this.userCache);
return data.access_token;
} catch (error) {
console.error("Token refresh failed:", error);
// Clear invalid tokens
this.clearTokens();
return null;
}
}
async login(returnUrl?: string): Promise<void> {
const url = new URL(this.config.loginUrl);
url.searchParams.set("return_to", returnUrl || window.location.href);
window.location.href = url.toString();
}
async logout(): Promise<void> {
// Invalidate tokens with auth service
const token = this.tokenCache;
if (token) {
try {
await fetch(`${this.config.issuer}/logout`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
} catch (error) {
console.error("Logout request failed:", error);
}
}
// Clear local state
this.clearTokens();
this.tokenCache = null;
this.userCache = null;
this.notifyAuthStateChange(null);
// Cancel refresh timer
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
isAuthenticated(): boolean {
return this.tokenCache !== null && this.userCache !== null;
}
getUser(): User | null {
return this.userCache;
}
onAuthStateChange(callback: (user: User | null) => void): () => void {
this.authStateListeners.push(callback);
// Return unsubscribe function
return () => {
const index = this.authStateListeners.indexOf(callback);
if (index > -1) {
this.authStateListeners.splice(index, 1);
}
};
}
// Private methods
private decodeToken(token: string): User {
const parts = token.split(".");
const payload = JSON.parse(atob(parts[1]));
return {
id: payload.sub,
email: payload.email,
name: payload.name || payload.email,
orgId: payload.org_id,
ndaStatus: payload.nda_status,
};
}
private decodeTokenUnsafe(token: string): any {
const parts = token.split(".");
return JSON.parse(atob(parts[1]));
}
private async verifySignature(token: string): Promise<void> {
// Fetch JWKS (with caching)
const jwks = await this.fetchJwks();
// Extract key ID from token header
const header = JSON.parse(atob(token.split(".")[0]));
const kid = header.kid;
// Find matching key
const key = jwks.keys.find((k: any) => k.kid === kid);
if (!key) {
throw new Error(`Key with ID ${kid} not found in JWKS`);
}
// Verify signature using Web Crypto API
const publicKey = await this.importJwk(key);
const signatureValid = await this.verifyJwtSignature(token, publicKey);
if (!signatureValid) {
throw new Error("JWT signature verification failed");
}
}
private async fetchJwks(): Promise<any> {
// TODO: Implement JWKS caching (cache for 1 hour)
const response = await fetch(this.config.jwksUri);
return response.json();
}
private async importJwk(jwk: any): Promise<CryptoKey> {
return crypto.subtle.importKey(
"jwk",
jwk,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
false,
["verify"]
);
}
private async verifyJwtSignature(
token: string,
publicKey: CryptoKey
): Promise<boolean> {
const parts = token.split(".");
const data = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
const signature = this.base64UrlDecode(parts[2]);
return crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
publicKey,
signature,
data
);
}
private base64UrlDecode(str: string): Uint8Array {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
private scheduleRefresh(token: string): void {
const decoded = this.decodeTokenUnsafe(token);
const expiresIn = decoded.exp * 1000 - Date.now();
const refreshMargin = this.config.refreshMarginSeconds * 1000;
const refreshDelay = Math.max(0, expiresIn - refreshMargin);
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
this.refreshToken();
}, refreshDelay);
}
private extractTokenFromUrl(): { 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 { accessToken, refreshToken };
}
return null;
}
private cleanUrlTokens(): void {
const url = new URL(window.location.href);
url.searchParams.delete("token");
url.searchParams.delete("refresh_token");
window.history.replaceState({}, "", url.toString());
}
private getStoredToken(): string | null {
return localStorage.getItem(this.config.tokenStorageKey);
}
private getStoredRefreshToken(): string | null {
return localStorage.getItem(this.config.refreshTokenKey);
}
private async storeToken(accessToken: string, refreshToken: string): Promise<void> {
localStorage.setItem(this.config.tokenStorageKey, accessToken);
localStorage.setItem(this.config.refreshTokenKey, refreshToken);
}
private clearTokens(): void {
localStorage.removeItem(this.config.tokenStorageKey);
localStorage.removeItem(this.config.refreshTokenKey);
}
private notifyAuthStateChange(user: User | null): void {
this.authStateListeners.forEach((listener) => listener(user));
}
}
5. Build-Time Configuration Injection
5.1 Vite Environment Variables
Vite automatically exposes environment variables prefixed with VITE_ to the client bundle.
Configuration in .env.development:
VITE_AUTH_MODE=none
Configuration in .env.production:
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
5.2 Type-Safe Configuration Access
Create a centralized config module with type safety:
// 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;
}
export function getAuthConfig(): AuthConfig {
const mode = import.meta.env.VITE_AUTH_MODE as "none" | "gcp";
if (!mode) {
throw new Error("VITE_AUTH_MODE is required");
}
if (!["none", "gcp"].includes(mode)) {
throw new Error(`Invalid VITE_AUTH_MODE: ${mode}`);
}
const config: AuthConfig = {
mode,
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;
config.refreshMarginSeconds = parseInt(
import.meta.env.VITE_AUTH_REFRESH_MARGIN_SECONDS || "300"
);
// 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;
}
5.3 Build-Time Validation
Add validation script to CI/CD pipeline:
#!/bin/bash
# scripts/validate-auth-config.sh
set -e
ENV_FILE="${1:-.env.production}"
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: Environment file not found: $ENV_FILE"
exit 1
fi
source "$ENV_FILE"
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
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
# 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
echo "✓ Auth configuration valid for $ENV_FILE"
6. Runtime Auth Provider Abstraction
6.1 AuthProvider Interface
Defines common contract for all auth implementations:
// src/auth/AuthProvider.interface.ts
export interface User {
id: string;
email: string;
name: string;
orgId: string;
ndaStatus: "pending" | "accepted" | "declined";
}
export interface AuthProvider {
/**
* Initialize the auth provider (load tokens, validate, schedule refresh)
*/
initialize(): Promise<void>;
/**
* Get current valid access token
* Returns null if not authenticated or token refresh fails
*/
getToken(): Promise<string | null>;
/**
* Validate a token (signature, expiry, claims)
*/
validateToken(token: string): Promise<boolean>;
/**
* Refresh access token using refresh token
* Returns new access token or null if refresh fails
*/
refreshToken(): Promise<string | null>;
/**
* Initiate login flow (redirect to auth service)
*/
login(returnUrl?: string): Promise<void>;
/**
* Logout (invalidate tokens, clear storage)
*/
logout(): Promise<void>;
/**
* Check if user is currently authenticated
*/
isAuthenticated(): boolean;
/**
* Get current user information
*/
getUser(): User | null;
/**
* Subscribe to authentication state changes
* Returns unsubscribe function
*/
onAuthStateChange(callback: (user: User | null) => void): () => void;
}
6.2 Provider Factory
Create auth provider based on runtime mode:
// src/auth/createAuthProvider.ts
import { AuthProvider } from "./AuthProvider.interface";
import { NullAuthProvider } from "./NullAuthProvider";
import { GCPAuthProvider } from "./GCPAuthProvider";
import { getAuthConfig } from "../config/auth.config";
export function createAuthProvider(): AuthProvider {
const config = getAuthConfig();
switch (config.mode) {
case "none":
return new NullAuthProvider();
case "gcp":
return new GCPAuthProvider({
issuer: config.issuer!,
audience: config.audience!,
jwksUri: config.jwksUri!,
loginUrl: config.loginUrl!,
refreshMarginSeconds: config.refreshMarginSeconds || 300,
tokenStorageKey: config.tokenStorageKey,
refreshTokenKey: config.refreshTokenKey,
});
default:
throw new Error(`Unknown auth mode: ${config.mode}`);
}
}
6.3 Provider Lifecycle
// src/main.tsx
import { createAuthProvider } from "./auth/createAuthProvider";
async function initializeApp() {
const authProvider = createAuthProvider();
try {
await authProvider.initialize();
// Render React app with auth provider
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AuthProvider provider={authProvider}>
<App />
</AuthProvider>
</React.StrictMode>
);
} catch (error) {
console.error("Failed to initialize auth provider:", error);
// Show error UI
document.getElementById("root")!.innerHTML = `
<div class="error-container">
<h1>Authentication Error</h1>
<p>Failed to initialize authentication system.</p>
<pre>${error.message}</pre>
</div>
`;
}
}
initializeApp();
7. JWT Token Specification
7.1 Token Structure
JWTs consist of three Base64URL-encoded parts separated by dots:
<header>.<payload>.<signature>
7.2 Header Claims
{
"alg": "RS256",
"typ": "JWT",
"kid": "auth.coditect.ai-2026-02-16"
}
| Claim | Description |
|---|---|
alg | Signature algorithm (always RS256 for GCP auth) |
typ | Token type (always JWT) |
kid | Key ID for signature verification (references JWKS) |
7.3 Payload Claims
Standard Claims
{
"iss": "auth.coditect.ai",
"aud": "bio-qms.docs.coditect.ai",
"sub": "user_2nX9kP4mQ7zL8vN1cT6wR5sY3hJ",
"iat": 1708099200,
"exp": 1708102800,
"nbf": 1708099200
}
| Claim | Type | Required | Description |
|---|---|---|---|
iss | string | Yes | Issuer (must match VITE_AUTH_ISSUER) |
aud | string | Yes | Audience (must match VITE_AUTH_AUDIENCE) |
sub | string | Yes | Subject (user ID) |
iat | number | Yes | Issued at (Unix timestamp) |
exp | number | Yes | Expiration time (Unix timestamp) |
nbf | number | No | Not valid before (Unix timestamp) |
Custom Claims
{
"email": "researcher@biotech.example",
"name": "Dr. Jane Smith",
"org_id": "org_biotech_example",
"org_name": "BioTech Example Inc.",
"nda_status": "accepted",
"roles": ["viewer", "researcher"],
"permissions": ["documents:read", "search:query"]
}
| Claim | Type | Required | Description |
|---|---|---|---|
email | string | Yes | User email address |
name | string | No | User display name |
org_id | string | Yes | Organization identifier |
org_name | string | No | Organization display name |
nda_status | enum | Yes | NDA acceptance status (pending, accepted, declined) |
roles | string[] | No | User roles within organization |
permissions | string[] | No | Specific permissions granted |
7.4 Token Lifetime
- Access Token: 1 hour (3600 seconds)
- Refresh Token: 30 days (2592000 seconds)
- Refresh Window: 5 minutes before expiry (configurable via
VITE_AUTH_REFRESH_MARGIN_SECONDS)
7.5 Example Token
Encoded:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImF1dGguY29kaXRlY3QuYWktMjAyNi0wMi0xNiJ9.eyJpc3MiOiJhdXRoLmNvZGl0ZWN0LmFpIiwiYXVkIjoiYmlvLXFtcy5kb2NzLmNvZGl0ZWN0LmFpIiwic3ViIjoidXNlcl8yblg5a1A0bVE3ekw4dk4xY1Q2d1I1c1kzaEoiLCJpYXQiOjE3MDgwOTkyMDAsImV4cCI6MTcwODEwMjgwMCwiZW1haWwiOiJyZXNlYXJjaGVyQGJpb3RlY2guZXhhbXBsZSIsIm5hbWUiOiJEci4gSmFuZSBTbWl0aCIsIm9yZ19pZCI6Im9yZ19iaW90ZWNoX2V4YW1wbGUiLCJuZGFfc3RhdHVzIjoiYWNjZXB0ZWQifQ.signature_here
Decoded Payload:
{
"iss": "auth.coditect.ai",
"aud": "bio-qms.docs.coditect.ai",
"sub": "user_2nX9kP4mQ7zL8vN1cT6wR5sY3hJ",
"iat": 1708099200,
"exp": 1708102800,
"email": "researcher@biotech.example",
"name": "Dr. Jane Smith",
"org_id": "org_biotech_example",
"nda_status": "accepted"
}
8. Token Validation Flow
8.1 Validation Steps
┌─────────────────┐
│ Receive JWT │
└────────┬────────┘
│
┌────────▼─────────┐
│ 1. Decode Header │ (extract kid, alg)
└────────┬─────────┘
│
┌────────▼─────────┐
│ 2. Decode Payload│ (do NOT verify yet)
└────────┬─────────┘
│
┌────────▼─────────┐
│ 3. Check Expiry │ exp > Date.now()
└────────┬─────────┘
│ Valid
┌────────▼─────────┐
│ 4. Fetch JWKS │ (with 1hr cache)
└────────┬─────────┘
│
┌────────▼─────────┐
│ 5. Find Key │ (match kid from header)
└────────┬─────────┘
│ Found
┌────────▼─────────┐
│ 6. Import Key │ (RSA public key)
└────────┬─────────┘
│
┌────────▼─────────┐
│ 7. Verify Sig │ (Web Crypto API)
└────────┬─────────┘
│ Valid
┌────────▼─────────┐
│ 8. Validate iss │ (must match config)
└────────┬─────────┘
│ Valid
┌────────▼─────────┐
│ 9. Validate aud │ (must match config)
└────────┬─────────┘
│ Valid
┌────────▼─────────┐
│ 10. Check nbf │ (if present)
└────────┬─────────┘
│ Valid
┌────────▼─────────┐
│ Token Valid ✓ │
└──────────────────┘
8.2 Validation Implementation
async function validateJWT(token: string, config: GCPAuthConfig): Promise<boolean> {
try {
// Step 1-2: Decode header and payload
const [headerB64, payloadB64, signatureB64] = token.split(".");
const header = JSON.parse(atob(headerB64));
const payload = JSON.parse(atob(payloadB64));
// Step 3: Check expiry
const now = Math.floor(Date.now() / 1000);
if (payload.exp <= now) {
console.warn("Token expired:", { exp: payload.exp, now });
return false;
}
// Step 4-5: Fetch JWKS and find key
const jwks = await fetchJwksCached(config.jwksUri);
const jwk = jwks.keys.find((k: any) => k.kid === header.kid);
if (!jwk) {
console.error("Key not found in JWKS:", header.kid);
return false;
}
// Step 6: Import public key
const publicKey = await crypto.subtle.importKey(
"jwk",
jwk,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"]
);
// Step 7: Verify signature
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlToUint8Array(signatureB64);
const signatureValid = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
publicKey,
signature,
data
);
if (!signatureValid) {
console.error("Signature verification failed");
return false;
}
// Step 8: Validate issuer
if (payload.iss !== config.issuer) {
console.error("Invalid issuer:", { expected: config.issuer, actual: payload.iss });
return false;
}
// Step 9: Validate audience
if (payload.aud !== config.audience) {
console.error("Invalid audience:", { expected: config.audience, actual: payload.aud });
return false;
}
// Step 10: Check nbf (not before)
if (payload.nbf && payload.nbf > now) {
console.warn("Token not yet valid:", { nbf: payload.nbf, now });
return false;
}
return true;
} catch (error) {
console.error("Token validation error:", error);
return false;
}
}
8.3 JWKS Caching
To avoid repeated network requests, cache JWKS for 1 hour:
interface JwksCache {
jwks: any;
fetchedAt: number;
ttl: number; // milliseconds
}
const jwksCache = new Map<string, JwksCache>();
async function fetchJwksCached(jwksUri: string): Promise<any> {
const cached = jwksCache.get(jwksUri);
const now = Date.now();
if (cached && now - cached.fetchedAt < cached.ttl) {
return cached.jwks;
}
const response = await fetch(jwksUri);
const jwks = await response.json();
jwksCache.set(jwksUri, {
jwks,
fetchedAt: now,
ttl: 3600000, // 1 hour
});
return jwks;
}
9. Token Refresh Strategy
9.1 Refresh Trigger Conditions
Token refresh is triggered when:
- Scheduled refresh:
exp - refreshMarginSecondselapsed (default: 5 minutes before expiry) - On-demand refresh: User action requires fresh token, current token < 5 minutes to expiry
- Failed validation: Stored token invalid (expired, signature failed), attempt refresh before redirect
9.2 Refresh Flow
┌───────────────────┐
│ Token Needs │
│ Refresh? │
└────────┬──────────┘
│ Yes
┌────────▼──────────┐
│ Get Refresh Token │
│ from Storage │
└────────┬──────────┘
│ Found
┌────────▼──────────┐
│ POST /token/ │
│ refresh │
│ {refresh_token} │
└────────┬──────────┘
│
┌────▼────┐
│ Success?│
└─┬────┬──┘
│Yes │No
│ │
│ └──────────┐
│ │
┌─────▼──────┐ ┌────▼────────┐
│ Store New │ │ Clear Tokens│
│ Tokens │ │ Redirect to │
│ │ │ Login │
└─────┬──────┘ └─────────────┘
│
┌─────▼──────┐
│ Update │
│ Auth State │
└─────┬──────┘
│
┌─────▼──────┐
│ Schedule │
│ Next │
│ Refresh │
└────────────┘
9.3 Refresh Implementation
async function refreshAccessToken(
refreshToken: string,
config: GCPAuthConfig
): Promise<{ accessToken: string; refreshToken: string } | null> {
try {
const response = await fetch(`${config.issuer}/token/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const error = await response.text();
console.error("Token refresh failed:", response.status, error);
return null;
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token, // New refresh token (rotation)
};
} catch (error) {
console.error("Token refresh request failed:", error);
return null;
}
}
9.4 Refresh Token Rotation
For enhanced security, refresh tokens are single-use with automatic rotation:
- Client sends
refresh_tokento/token/refresh - Server validates refresh token (signature, expiry, revocation status)
- Server generates NEW access token + NEW refresh token
- Server invalidates OLD refresh token
- Client stores NEW tokens, discards OLD
Security Benefit: If a refresh token is compromised, it can only be used once. Legitimate user's next refresh will fail (old token invalidated), triggering security alert.
9.5 Automatic Refresh Scheduling
function scheduleTokenRefresh(token: string, config: GCPAuthConfig): number {
const payload = JSON.parse(atob(token.split(".")[1]));
const expiresInMs = payload.exp * 1000 - Date.now();
const refreshMarginMs = config.refreshMarginSeconds * 1000;
// Refresh 5 minutes before expiry (or immediately if < 5 min remaining)
const refreshDelayMs = Math.max(0, expiresInMs - refreshMarginMs);
return setTimeout(async () => {
console.log("Automatic token refresh triggered");
await refreshToken();
}, refreshDelayMs);
}
10. Auth State Management in React
10.1 AuthContext Provider
Provides auth state to entire React component tree:
// src/contexts/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState } from "react";
import { AuthProvider, User } from "../auth/AuthProvider.interface";
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (returnUrl?: string) => Promise<void>;
logout: () => Promise<void>;
getToken: () => Promise<string | null>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
interface AuthProviderProps {
provider: AuthProvider;
children: React.ReactNode;
}
export function AuthProvider({ provider, children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Subscribe to auth state changes
const unsubscribe = provider.onAuthStateChange((newUser) => {
setUser(newUser);
setIsLoading(false);
});
// Trigger initial auth state check
setUser(provider.getUser());
setIsLoading(false);
return unsubscribe;
}, [provider]);
const value: AuthContextValue = {
user,
isAuthenticated: provider.isAuthenticated(),
isLoading,
login: (returnUrl) => provider.login(returnUrl),
logout: () => provider.logout(),
getToken: () => provider.getToken(),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
10.2 Usage in Components
// src/components/UserProfile.tsx
import { useAuth } from "../contexts/AuthContext";
export function UserProfile() {
const { user, isAuthenticated, logout } = useAuth();
if (!isAuthenticated || !user) {
return null;
}
return (
<div className="user-profile">
<img src={`https://api.dicebear.com/7.x/initials/svg?seed=${user.name}`} alt={user.name} />
<div>
<p className="user-name">{user.name}</p>
<p className="user-email">{user.email}</p>
</div>
<button onClick={logout}>Logout</button>
</div>
);
}
11. Protected Route Component
11.1 Implementation
// src/components/ProtectedRoute.tsx
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
interface ProtectedRouteProps {
children: React.ReactNode;
requireNda?: boolean;
}
export function ProtectedRoute({ children, requireNda = false }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="loading-spinner">
<p>Checking authentication...</p>
</div>
);
}
if (!isAuthenticated) {
// Redirect to login, preserving intended destination
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requireNda && user?.ndaStatus !== "accepted") {
// User authenticated but NDA not accepted
return <Navigate to="/nda-required" replace />;
}
return <>{children}</>;
}
11.2 Usage in Router
// src/App.tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { DocumentViewer } from "./pages/DocumentViewer";
import { LoginPage } from "./pages/LoginPage";
import { NdaRequiredPage } from "./pages/NdaRequiredPage";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/nda-required" element={<NdaRequiredPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<DocumentViewer />
</ProtectedRoute>
}
/>
<Route
path="/confidential/*"
element={
<ProtectedRoute requireNda={true}>
<DocumentViewer />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}
12. Login and Redirect Flow
12.1 Login Page Component
// src/pages/LoginPage.tsx
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export function LoginPage() {
const { isAuthenticated, login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
// Extract intended destination from location state
const from = location.state?.from?.pathname || "/";
useEffect(() => {
// If already authenticated, redirect to intended destination
if (isAuthenticated) {
navigate(from, { replace: true });
}
}, [isAuthenticated, navigate, from]);
const handleLogin = () => {
// Redirect to auth service, preserving return URL
login(window.location.origin + from);
};
return (
<div className="login-page">
<div className="login-card">
<h1>BIO-QMS Documentation</h1>
<p>Sign in to access documentation</p>
<button onClick={handleLogin} className="login-button">
Sign In with CODITECT
</button>
</div>
</div>
);
}
12.2 OAuth Callback Handling
After successful login, auth service redirects to:
https://bio-qms.docs.coditect.ai/?token=<JWT>&refresh_token=<REFRESH>
The GCPAuthProvider.initialize() method extracts tokens from URL, validates, stores, and cleans URL.
12.3 Return URL Preservation
// When redirecting to login
function initiateLogin(intendedPath: string) {
const returnUrl = `${window.location.origin}${intendedPath}`;
const loginUrl = new URL(config.loginUrl);
loginUrl.searchParams.set("return_to", returnUrl);
window.location.href = loginUrl.toString();
}
// Auth service redirects back to
// https://bio-qms.docs.coditect.ai/?token=...&refresh_token=...
// React Router preserves path if tokens are in query params
13. Error Handling
13.1 Error Types
| Error Type | Description | User Action |
|---|---|---|
TokenExpired | Access token expired, refresh token missing/invalid | Redirect to login |
InvalidToken | Token signature verification failed | Clear tokens, redirect to login |
NetworkError | Cannot reach auth service (JWKS, refresh endpoint) | Show offline banner, retry |
AuthServiceDown | Auth service returns 5xx errors | Show error message, retry with backoff |
InsufficientPermissions | User authenticated but lacks NDA acceptance | Redirect to NDA acceptance page |
ConfigurationError | Missing/invalid auth configuration | Show config error, contact admin |
13.2 Error Handling Strategy
// src/auth/errors.ts
export class AuthError extends Error {
constructor(
message: string,
public code: string,
public userMessage: string,
public recoverable: boolean = true
) {
super(message);
this.name = "AuthError";
}
}
export class TokenExpiredError extends AuthError {
constructor() {
super(
"Access token expired",
"TOKEN_EXPIRED",
"Your session has expired. Please log in again.",
true
);
}
}
export class InvalidTokenError extends AuthError {
constructor(reason: string) {
super(
`Token validation failed: ${reason}`,
"INVALID_TOKEN",
"Authentication failed. Please log in again.",
true
);
}
}
export class NetworkError extends AuthError {
constructor(endpoint: string) {
super(
`Network request failed: ${endpoint}`,
"NETWORK_ERROR",
"Cannot connect to authentication service. Please check your connection.",
true
);
}
}
export class AuthServiceDownError extends AuthError {
constructor(statusCode: number) {
super(
`Auth service returned ${statusCode}`,
"AUTH_SERVICE_DOWN",
"Authentication service is temporarily unavailable. Please try again later.",
true
);
}
}
export class InsufficientPermissionsError extends AuthError {
constructor(required: string) {
super(
`User lacks required permission: ${required}`,
"INSUFFICIENT_PERMISSIONS",
"You do not have permission to access this resource.",
false
);
}
}
export class ConfigurationError extends AuthError {
constructor(details: string) {
super(
`Auth configuration error: ${details}`,
"CONFIGURATION_ERROR",
"Authentication system is misconfigured. Please contact support.",
false
);
}
}
13.3 Error Boundary Component
// src/components/AuthErrorBoundary.tsx
import React, { Component, ReactNode } from "react";
import { AuthError } from "../auth/errors";
interface Props {
children: ReactNode;
onError?: (error: AuthError) => void;
}
interface State {
hasError: boolean;
error: AuthError | null;
}
export class AuthErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
if (error instanceof AuthError) {
return { hasError: true, error };
}
return { hasError: false, error: null };
}
componentDidCatch(error: Error) {
if (error instanceof AuthError && this.props.onError) {
this.props.onError(error);
}
}
render() {
if (this.state.hasError && this.state.error) {
const { error } = this.state;
return (
<div className="auth-error-container">
<h1>Authentication Error</h1>
<p>{error.userMessage}</p>
{error.recoverable && (
<button onClick={() => window.location.reload()}>
Retry
</button>
)}
<details>
<summary>Technical Details</summary>
<pre>
{error.name}: {error.message}
{"\n"}Code: {error.code}
</pre>
</details>
</div>
);
}
return this.props.children;
}
}
13.4 Retry Logic with Exponential Backoff
async function fetchWithRetry<T>(
fetchFn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fetchFn();
} catch (error) {
lastError = error as Error;
// Don't retry on client errors (4xx)
if (error instanceof Response && error.status >= 400 && error.status < 500) {
throw error;
}
// Wait with exponential backoff
const delay = baseDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new AuthServiceDownError(lastError?.message || "Max retries exceeded");
}
14. Security Considerations
14.1 XSS Protection
Threat: Malicious scripts injected into app can steal tokens from localStorage.
Mitigations:
- Content Security Policy (CSP): Strict CSP header to prevent inline script execution
- DOMPurify: Sanitize all user-generated content before rendering
- HttpOnly Cookies (future): Migrate tokens from localStorage to httpOnly cookies (prevents JS access)
- Token Binding: Bind tokens to specific browser fingerprint (optional, UX impact)
CSP Header:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://auth.coditect.ai;
frame-ancestors 'none';
14.2 CSRF Protection
Threat: Attacker tricks user into making authenticated requests from malicious site.
Mitigations:
- SameSite Cookies: Set
SameSite=Stricton auth cookies (future) - CORS Configuration: Restrict API access to known origins
- State Parameter: Include random state in OAuth flow to prevent replay attacks
14.3 Token Storage
Current: localStorage (accessible via JavaScript, XSS risk)
Future Migration: httpOnly cookies
| Storage | XSS Risk | CSRF Risk | Cross-Tab | Pros | Cons |
|---|---|---|---|---|---|
| localStorage | High | Low | Yes | Simple, cross-tab sync | Vulnerable to XSS |
| sessionStorage | High | Low | No | Cleared on tab close | No cross-tab sync |
| httpOnly Cookie | Low | Medium | Yes | Protected from JS | Requires CSRF protection |
| Memory only | Low | Low | No | Most secure | Lost on refresh |
Recommendation: Migrate to httpOnly cookies with CSRF tokens for production.
14.4 Token Transmission
- HTTPS Only: All auth endpoints MUST use HTTPS (enforced via CSP
upgrade-insecure-requests) - URL Cleanup: Tokens in URL (OAuth redirect) immediately extracted and removed from history
- No Logging: Never log tokens (access or refresh) in browser console or analytics
14.5 Secrets Management
- No Hardcoded Secrets: All auth config via environment variables
- JWKS Public Key: Fetched from public endpoint, no secrets in client
- Private Key: Never sent to client (server-side only for signing)
14.6 Rate Limiting
- Login Attempts: Auth service rate limits login attempts (e.g., 5 attempts/minute/IP)
- Token Refresh: Rate limit refresh requests (e.g., 10 refresh/hour/user)
- Anomaly Detection: Monitor for unusual token refresh patterns (potential compromise)
14.7 Audit Trail
All auth events logged for security monitoring:
- Login success/failure (IP, user agent, timestamp)
- Token refresh (user ID, timestamp)
- Logout (user ID, timestamp)
- Token validation failures (reason, IP, timestamp)
- Configuration errors (details, timestamp)
15. Testing Strategy
15.1 Unit Tests
AuthProvider Implementations:
// src/auth/__tests__/NullAuthProvider.test.ts
import { NullAuthProvider } from "../NullAuthProvider";
describe("NullAuthProvider", () => {
let provider: NullAuthProvider;
beforeEach(() => {
provider = new NullAuthProvider();
});
it("should always return authenticated state", () => {
expect(provider.isAuthenticated()).toBe(true);
});
it("should return mock user", () => {
const user = provider.getUser();
expect(user).toEqual({
id: "local-dev-user",
email: "dev@localhost",
name: "Local Development User",
orgId: "local-org",
ndaStatus: "accepted",
});
});
it("should return null token", async () => {
const token = await provider.getToken();
expect(token).toBeNull();
});
it("should invoke auth state callback immediately", () => {
const callback = jest.fn();
provider.onAuthStateChange(callback);
expect(callback).toHaveBeenCalledWith(provider.getUser());
});
});
JWT Validation:
// src/auth/__tests__/jwtValidation.test.ts
import { validateJWT } from "../jwtValidation";
describe("JWT Validation", () => {
const config = {
issuer: "auth.coditect.ai",
audience: "bio-qms.docs.coditect.ai",
jwksUri: "https://auth.coditect.ai/.well-known/jwks.json",
};
it("should reject expired token", async () => {
const expiredToken = createMockJWT({ exp: Math.floor(Date.now() / 1000) - 3600 });
const isValid = await validateJWT(expiredToken, config);
expect(isValid).toBe(false);
});
it("should reject token with invalid issuer", async () => {
const token = createMockJWT({ iss: "malicious.com" });
const isValid = await validateJWT(token, config);
expect(isValid).toBe(false);
});
it("should reject token with invalid audience", async () => {
const token = createMockJWT({ aud: "other-service.com" });
const isValid = await validateJWT(token, config);
expect(isValid).toBe(false);
});
it("should accept valid token", async () => {
const token = createMockJWT({
iss: config.issuer,
aud: config.audience,
exp: Math.floor(Date.now() / 1000) + 3600,
});
const isValid = await validateJWT(token, config);
expect(isValid).toBe(true);
});
});
15.2 Integration Tests
Auth Flow:
// src/auth/__tests__/authFlow.integration.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { useAuth } from "../contexts/AuthContext";
import { AuthProvider } from "../components/AuthProvider";
import { GCPAuthProvider } from "../GCPAuthProvider";
describe("Auth Flow Integration", () => {
it("should handle full login flow", async () => {
// Mock fetch for token refresh
global.fetch = jest.fn((url) => {
if (url.includes("/token/refresh")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
access_token: "new_access_token",
refresh_token: "new_refresh_token",
}),
});
}
return Promise.reject(new Error("Unknown endpoint"));
});
const provider = new GCPAuthProvider({
issuer: "auth.coditect.ai",
audience: "bio-qms.docs.coditect.ai",
jwksUri: "https://auth.coditect.ai/.well-known/jwks.json",
loginUrl: "https://auth.coditect.ai/login",
refreshMarginSeconds: 300,
tokenStorageKey: "test_token",
refreshTokenKey: "test_refresh",
});
// Store initial tokens
localStorage.setItem("test_token", createMockJWT({ exp: Date.now() / 1000 + 60 }));
localStorage.setItem("test_refresh", "mock_refresh_token");
await provider.initialize();
const { result } = renderHook(() => useAuth(), {
wrapper: ({ children }) => (
<AuthProvider provider={provider}>{children}</AuthProvider>
),
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
expect(result.current.user).toMatchObject({
id: expect.any(String),
email: expect.any(String),
});
});
});
15.3 E2E Tests
Cypress Test:
// cypress/e2e/auth.cy.ts
describe("Authentication Flow", () => {
it("should redirect unauthenticated user to login", () => {
cy.visit("/");
cy.url().should("include", "/login");
});
it("should allow access with valid token", () => {
// Set mock token in localStorage
cy.window().then((win) => {
win.localStorage.setItem("bio_qms_auth_token", createMockJWT({
iss: "auth.coditect.ai",
aud: "bio-qms.docs.coditect.ai",
exp: Math.floor(Date.now() / 1000) + 3600,
email: "test@example.com",
nda_status: "accepted",
}));
});
cy.visit("/");
cy.contains("Documentation Viewer").should("be.visible");
});
it("should redirect to NDA page if NDA not accepted", () => {
cy.window().then((win) => {
win.localStorage.setItem("bio_qms_auth_token", createMockJWT({
nda_status: "pending",
}));
});
cy.visit("/confidential/document.md");
cy.url().should("include", "/nda-required");
});
it("should handle logout", () => {
cy.window().then((win) => {
win.localStorage.setItem("bio_qms_auth_token", "valid_token");
});
cy.visit("/");
cy.contains("Logout").click();
cy.url().should("include", "/login");
cy.window().then((win) => {
expect(win.localStorage.getItem("bio_qms_auth_token")).to.be.null;
});
});
});
15.4 Security Testing
Penetration Testing Checklist:
- XSS: Attempt to inject
<script>tags in document content - CSRF: Attempt to make authenticated requests from external origin
- Token Replay: Use expired token, verify rejection
- Signature Tampering: Modify token payload, verify signature validation failure
- Issuer Spoofing: Create token with different
iss, verify rejection - Audience Mismatch: Create token with different
aud, verify rejection - Token Extraction: Verify tokens removed from URL after OAuth redirect
- Storage Inspection: Verify no sensitive data (passwords, secrets) in localStorage
- Network Interception: Verify all auth requests over HTTPS
- Refresh Token Rotation: Verify old refresh tokens invalidated after use
16. Graceful Degradation
16.1 Auth Service Unreachable
When auth service is down (cannot fetch JWKS, cannot refresh token):
Behavior:
- If valid cached token: Continue using cached token until expiry
- If token expired: Show "Auth service unavailable" banner, allow limited offline access
- Retry with backoff: Attempt to reconnect every 30s → 1m → 5m → 10m
- Cache JWKS: Use last-known-good JWKS from cache for validation (with expiry warning)
Implementation:
async function fetchJwksWithFallback(jwksUri: string): Promise<any> {
try {
return await fetchWithRetry(() => fetch(jwksUri).then((r) => r.json()));
} catch (error) {
console.error("JWKS fetch failed, using cached fallback:", error);
const cached = localStorage.getItem("jwks_cache");
if (cached) {
const { jwks, fetchedAt } = JSON.parse(cached);
const age = Date.now() - fetchedAt;
if (age < 86400000) { // 24 hours
console.warn("Using cached JWKS (age: ${age}ms)");
return jwks;
}
}
throw new NetworkError(jwksUri);
}
}
16.2 Network Disconnection
User Experience:
┌────────────────────────────────────────────┐
│ ⚠️ Network Connection Lost │
│ │
│ You are currently offline. Authentication │
│ services are unavailable. Your session │
│ will remain valid until: │
│ │
│ Expires: Feb 16, 2026 14:35:00 UTC │
│ │
│ Reconnecting... [Retry Now] │
└────────────────────────────────────────────┘
16.3 Partial Functionality
When auth service is unreachable but token is valid:
| Feature | Available | Limitation |
|---|---|---|
| View cached documents | ✅ Yes | Only previously loaded documents |
| Search | ✅ Yes | Local index only, no server queries |
| Navigation | ✅ Yes | Full navigation within cached content |
| Download | ❌ No | Requires server connection |
| Analytics | ❌ No | Events queued, sent when online |
| Token Refresh | ❌ No | Session will expire at scheduled time |
17. Audit Logging
17.1 Events to Log
All authentication and authorization events are logged for compliance and security monitoring.
| Event | Data Logged | Severity |
|---|---|---|
| Login Success | User ID, email, IP, user agent, timestamp | INFO |
| Login Failure | Email (if provided), IP, user agent, failure reason, timestamp | WARN |
| Logout | User ID, timestamp | INFO |
| Token Refresh | User ID, old token expiry, new token expiry, timestamp | INFO |
| Token Validation Failure | User ID (if decodable), failure reason, IP, timestamp | WARN |
| NDA Gate Blocked | User ID, document ID, NDA status, timestamp | INFO |
| Auth Service Down | Endpoint, status code, error, timestamp | ERROR |
| Config Error | Error details, timestamp | CRITICAL |
17.2 Logging Implementation
// src/logging/authLogger.ts
export enum AuthEventType {
LOGIN_SUCCESS = "AUTH_LOGIN_SUCCESS",
LOGIN_FAILURE = "AUTH_LOGIN_FAILURE",
LOGOUT = "AUTH_LOGOUT",
TOKEN_REFRESH = "AUTH_TOKEN_REFRESH",
TOKEN_VALIDATION_FAILURE = "AUTH_TOKEN_VALIDATION_FAILURE",
NDA_GATE_BLOCKED = "AUTH_NDA_GATE_BLOCKED",
AUTH_SERVICE_DOWN = "AUTH_SERVICE_DOWN",
CONFIG_ERROR = "AUTH_CONFIG_ERROR",
}
export enum LogSeverity {
INFO = "INFO",
WARN = "WARN",
ERROR = "ERROR",
CRITICAL = "CRITICAL",
}
interface AuthLogEntry {
event: AuthEventType;
severity: LogSeverity;
timestamp: string;
userId?: string;
email?: string;
ip?: string;
userAgent?: string;
details: Record<string, any>;
}
class AuthLogger {
private buffer: AuthLogEntry[] = [];
private flushInterval = 60000; // 1 minute
constructor() {
// Flush logs every minute
setInterval(() => this.flush(), this.flushInterval);
}
log(
event: AuthEventType,
severity: LogSeverity,
details: Record<string, any>,
userId?: string,
email?: string
): void {
const entry: AuthLogEntry = {
event,
severity,
timestamp: new Date().toISOString(),
userId,
email,
ip: this.getClientIp(),
userAgent: navigator.userAgent,
details,
};
this.buffer.push(entry);
// Console log for development
if (import.meta.env.DEV) {
console.log(`[${severity}] ${event}:`, details);
}
// Flush immediately for critical events
if (severity === LogSeverity.CRITICAL || severity === LogSeverity.ERROR) {
this.flush();
}
}
private async flush(): Promise<void> {
if (this.buffer.length === 0) return;
const entries = [...this.buffer];
this.buffer = [];
try {
// Send to logging service (e.g., Cloud Logging, Datadog)
await fetch("/api/logs/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entries }),
});
} catch (error) {
console.error("Failed to flush auth logs:", error);
// Re-add to buffer for retry
this.buffer.unshift(...entries);
}
}
private getClientIp(): string | undefined {
// In production, extract from X-Forwarded-For header (server-side)
// Client-side: not reliably available
return undefined;
}
}
export const authLogger = new AuthLogger();
17.3 Usage Examples
// Login success
authLogger.log(
AuthEventType.LOGIN_SUCCESS,
LogSeverity.INFO,
{ method: "email/password" },
user.id,
user.email
);
// Token validation failure
authLogger.log(
AuthEventType.TOKEN_VALIDATION_FAILURE,
LogSeverity.WARN,
{ reason: "Signature verification failed", tokenIss: payload.iss },
payload.sub,
payload.email
);
// NDA gate blocked
authLogger.log(
AuthEventType.NDA_GATE_BLOCKED,
LogSeverity.INFO,
{ documentId: "internal/confidential/doc.md", ndaStatus: user.ndaStatus },
user.id,
user.email
);
17.4 Log Retention
- Production: 90 days in hot storage, 1 year in cold storage (Cloud Logging Archive)
- Development: 7 days
- Compliance: Logs required for SOC2, HIPAA audits
18. Configuration Examples
18.1 Development Environment
.env.development:
# Auth Mode: No authentication required
VITE_AUTH_MODE=none
# Build Info (for watermark)
VITE_BUILD_ENV=development
VITE_BUILD_TIMESTAMP=2026-02-16T10:30:00Z
VITE_GIT_COMMIT=abc123def
Vite Dev Server: npm run dev
Behavior:
- No login required
- All documents accessible
- "DEV MODE" watermark visible
- Mock user:
dev@localhost
18.2 Staging Environment
.env.staging:
# Auth Mode: GCP authentication
VITE_AUTH_MODE=gcp
# GCP Auth Configuration
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
# Storage Keys (different from prod to avoid conflicts)
VITE_AUTH_TOKEN_STORAGE_KEY=bio_qms_staging_auth_token
VITE_AUTH_REFRESH_TOKEN_KEY=bio_qms_staging_refresh_token
# Build Info
VITE_BUILD_ENV=staging
VITE_BUILD_TIMESTAMP=2026-02-16T10:30:00Z
VITE_GIT_COMMIT=abc123def
Cloud Build: gcloud builds submit --config=cloudbuild-staging.yaml
Behavior:
- Login required via auth-staging.coditect.ai
- Test users only
- NDA gating enabled
- Full production behavior simulation
18.3 Production Environment
.env.production:
# Auth Mode: GCP authentication (REQUIRED)
VITE_AUTH_MODE=gcp
# GCP Auth Configuration (Production)
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
# Storage Keys (production)
VITE_AUTH_TOKEN_STORAGE_KEY=bio_qms_auth_token
VITE_AUTH_REFRESH_TOKEN_KEY=bio_qms_refresh_token
# Build Info
VITE_BUILD_ENV=production
VITE_BUILD_TIMESTAMP=2026-02-16T10:30:00Z
VITE_GIT_COMMIT=abc123def
Cloud Build: gcloud builds submit --config=cloudbuild.yaml
Behavior:
- Login required via auth.coditect.ai
- Production users
- NDA gating enforced
- Audit logging to Cloud Logging
- Zero-tolerance for configuration errors
18.4 CI/CD Testing Environment
.env.test:
# Auth Mode: No authentication for tests
VITE_AUTH_MODE=none
# Mock Configuration (ignored in none mode, but defined for completeness)
VITE_AUTH_ISSUER=http://localhost:8080
VITE_AUTH_AUDIENCE=test
VITE_AUTH_JWKS_URI=http://localhost:8080/.well-known/jwks.json
VITE_AUTH_LOGIN_URL=http://localhost:8080/login
# Build Info
VITE_BUILD_ENV=test
VITE_BUILD_TIMESTAMP=2026-02-16T10:30:00Z
VITE_GIT_COMMIT=test-commit
Test Command: npm run test
Behavior:
NullAuthProviderused- No network requests to auth service
- All tests pass without auth dependency
- Mock user always authenticated
19. Implementation Checklist
19.1 Phase 1: Core Infrastructure
- Create
AuthProviderinterface with all methods - Implement
NullAuthProviderfornonemode - Implement
GCPAuthProviderforgcpmode - Create
createAuthProvider()factory function - Add environment variable validation in
getAuthConfig() - Write unit tests for both providers (15+ tests)
- Document auth provider API in code comments
19.2 Phase 2: JWT Validation
- Implement JWT decoding (header, payload, signature)
- Implement JWKS fetching with 1-hour cache
- Implement signature verification using Web Crypto API
- Implement claim validation (iss, aud, exp, nbf)
- Add token expiry checking
- Write unit tests for validation (10+ tests)
- Test with real JWTs from auth-staging.coditect.ai
19.3 Phase 3: Token Refresh
- Implement token refresh API call
- Implement refresh token rotation
- Implement automatic refresh scheduling
- Add retry logic with exponential backoff
- Handle refresh failures (redirect to login)
- Write integration tests for refresh flow
- Test token refresh before expiry (5min margin)
19.4 Phase 4: React Integration
- Create
AuthContextwithuseAuthhook - Implement
AuthProviderwrapper component - Create
ProtectedRoutecomponent - Add NDA gating logic to
ProtectedRoute - Create
LoginPagecomponent - Handle OAuth redirect (extract tokens from URL)
- Clean tokens from URL after extraction
- Write component tests with
@testing-library/react
19.5 Phase 5: Error Handling
- Define all error classes (TokenExpired, InvalidToken, etc.)
- Implement
AuthErrorBoundarycomponent - Add retry logic for network errors
- Implement graceful degradation (offline mode)
- Add user-friendly error messages
- Create error UI components
- Write error scenario tests
19.6 Phase 6: Security Hardening
- Add Content Security Policy headers
- Implement token cleanup from URL
- Verify HTTPS enforcement in production
- Add rate limiting checks (client-side warnings)
- Remove all token logging from code
- Implement JWKS caching with staleness checks
- Conduct security review with security-specialist
19.7 Phase 7: Audit Logging
- Implement
AuthLoggerclass - Add logging for all auth events
- Implement log buffering and flushing
- Create audit log API endpoint (backend)
- Test log delivery to Cloud Logging
- Verify log retention policy
- Document audit log schema
19.8 Phase 8: Testing & Validation
- Write unit tests (target: 80%+ coverage)
- Write integration tests for full auth flow
- Write E2E tests with Cypress (5+ scenarios)
- Conduct security penetration testing
- Test with real auth-staging.coditect.ai
- Validate all error scenarios
- Performance test token validation (<50ms)
19.9 Phase 9: Documentation
- Document auth mode switching in README
- Create runbook for auth troubleshooting
- Document environment variables
- Create deployment guide (staging + production)
- Write security best practices guide
- Update API documentation
- Create user-facing auth FAQ
19.10 Phase 10: Deployment
- Configure
.env.developmentfor local dev - Configure
.env.stagingfor staging - Configure
.env.productionfor production - Add build-time validation script
- Update CI/CD pipeline (validate before deploy)
- Deploy to staging and validate
- Deploy to production with rollback plan
- Monitor auth metrics post-deployment
20. References
20.1 Standards & Specifications
- JWT (RFC 7519): https://tools.ietf.org/html/rfc7519
- JWS (RFC 7515): https://tools.ietf.org/html/rfc7515
- JWK (RFC 7517): https://tools.ietf.org/html/rfc7517
- OAuth 2.0 (RFC 6749): https://tools.ietf.org/html/rfc6749
- PKCE (RFC 7636): https://tools.ietf.org/html/rfc7636
20.2 Security Guidelines
- OWASP JWT Security: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
- OWASP XSS Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- OWASP CSRF Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
20.3 Related Documentation
- TRACK-A-PRESENTATION-PUBLISHING.md: Parent track file defining A.4.4 requirements
- Cloud Run Deployment Configuration: A.4.3 (deployment infrastructure)
- NDA Access Control: Document metadata schema for
nda_requiredfield - BIO-QMS Security Architecture: Overall security posture and compliance requirements
20.4 Implementation Files
| File | Description |
|---|---|
src/auth/AuthProvider.interface.ts | Core auth provider interface |
src/auth/NullAuthProvider.ts | No-auth implementation for none mode |
src/auth/GCPAuthProvider.ts | JWT auth implementation for gcp mode |
src/auth/createAuthProvider.ts | Factory function for provider creation |
src/config/auth.config.ts | Type-safe configuration loader |
src/contexts/AuthContext.tsx | React context and useAuth hook |
src/components/ProtectedRoute.tsx | Route protection component |
src/pages/LoginPage.tsx | Login redirect page |
src/logging/authLogger.ts | Audit logging for auth events |
src/auth/errors.ts | Auth error classes |
Document Status
Version: 1.0.0 Status: Active Last Updated: 2026-02-16 Next Review: 2026-03-16
Change Log:
| Date | Version | Changes | Author |
|---|---|---|---|
| 2026-02-16 | 1.0.0 | Initial specification | security-specialist |
End of Document