Skip to main content

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:

  • none mode: Unauthenticated access for local development, demos, and presentations
  • gcp mode: 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

  1. Architecture Overview
  2. Environment Variable Specification
  3. Auth Mode: none
  4. Auth Mode: gcp
  5. Build-Time Configuration Injection
  6. Runtime Auth Provider Abstraction
  7. JWT Token Specification
  8. Token Validation Flow
  9. Token Refresh Strategy
  10. Auth State Management in React
  11. Protected Route Component
  12. Login and Redirect Flow
  13. Error Handling
  14. Security Considerations
  15. Testing Strategy
  16. Graceful Degradation
  17. Audit Logging
  18. Configuration Examples
  19. Implementation Checklist
  20. 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 gcp mode, with signature verification and token expiry checks
  • Developer Experience: Zero-friction local development with none mode, 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

ValueDescriptionUse Case
noneNo authentication requiredLocal development, demos, presentations, CI/CD testing
gcpGCP JWT authentication via auth.coditect.aiCloud deployments, production, staging

2.3 Supporting Configuration Variables

For gcp Mode

VariableTypeRequiredDefaultDescription
VITE_AUTH_ISSUERstringYes"auth.coditect.ai"JWT issuer for validation
VITE_AUTH_AUDIENCEstringYes"bio-qms.docs.coditect.ai"Expected JWT audience claim
VITE_AUTH_JWKS_URIstringYes"https://auth.coditect.ai/.well-known/jwks.json"Public key endpoint for JWT signature verification
VITE_AUTH_LOGIN_URLstringYes"https://auth.coditect.ai/login"Login redirect endpoint
VITE_AUTH_REFRESH_MARGIN_SECONDSnumberNo300Seconds before expiry to trigger refresh (5 minutes)
VITE_AUTH_TOKEN_STORAGE_KEYstringNo"bio_qms_auth_token"LocalStorage key for token
VITE_AUTH_REFRESH_TOKEN_KEYstringNo"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

  1. Local Development: Engineers developing features without auth service dependency
  2. Offline Demos: Sales presentations without network connectivity
  3. CI/CD Testing: Automated tests running in isolated environments
  4. Screenshot Generation: Automated screenshot capture for documentation
  5. 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:

  1. Build-time check: CI/CD pipeline MUST validate VITE_AUTH_MODE=gcp before production deployment
  2. Environment detection: If window.location.hostname is a production domain, log warning if mode is none
  3. Watermark indicator: Development builds with none mode show "DEV MODE - UNAUTHENTICATED" watermark
  4. Audit logging: Log all builds with none mode, 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: true hidden unless user has ndaStatus: "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)

  1. User navigates to https://bio-qms.docs.coditect.ai/
  2. App detects no token in storage
  3. App redirects to https://auth.coditect.ai/login?return_to=https://bio-qms.docs.coditect.ai/
  4. User completes login (email/password, OAuth, SSO)
  5. Auth service validates credentials and generates JWT + refresh token
  6. Auth service redirects back to https://bio-qms.docs.coditect.ai/?token=<JWT>&refresh_token=<REFRESH>
  7. App extracts tokens from URL, validates JWT, stores in localStorage
  8. App clears tokens from URL (prevents leak via browser history)
  9. App renders with authenticated state

Returning User (Valid Token)

  1. User navigates to app
  2. App reads token from localStorage
  3. App validates token (expiry, signature, audience)
  4. Token valid → render app immediately
  5. Background: schedule token refresh if < 5 minutes to expiry

Token Expired (Refresh Available)

  1. User navigates to app
  2. App reads token from localStorage
  3. Token expired → attempt refresh using refresh token
  4. Refresh successful → store new token, render app
  5. 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"
}
ClaimDescription
algSignature algorithm (always RS256 for GCP auth)
typToken type (always JWT)
kidKey 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
}
ClaimTypeRequiredDescription
issstringYesIssuer (must match VITE_AUTH_ISSUER)
audstringYesAudience (must match VITE_AUTH_AUDIENCE)
substringYesSubject (user ID)
iatnumberYesIssued at (Unix timestamp)
expnumberYesExpiration time (Unix timestamp)
nbfnumberNoNot 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"]
}
ClaimTypeRequiredDescription
emailstringYesUser email address
namestringNoUser display name
org_idstringYesOrganization identifier
org_namestringNoOrganization display name
nda_statusenumYesNDA acceptance status (pending, accepted, declined)
rolesstring[]NoUser roles within organization
permissionsstring[]NoSpecific 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:

  1. Scheduled refresh: exp - refreshMarginSeconds elapsed (default: 5 minutes before expiry)
  2. On-demand refresh: User action requires fresh token, current token < 5 minutes to expiry
  3. 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:

  1. Client sends refresh_token to /token/refresh
  2. Server validates refresh token (signature, expiry, revocation status)
  3. Server generates NEW access token + NEW refresh token
  4. Server invalidates OLD refresh token
  5. 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 TypeDescriptionUser Action
TokenExpiredAccess token expired, refresh token missing/invalidRedirect to login
InvalidTokenToken signature verification failedClear tokens, redirect to login
NetworkErrorCannot reach auth service (JWKS, refresh endpoint)Show offline banner, retry
AuthServiceDownAuth service returns 5xx errorsShow error message, retry with backoff
InsufficientPermissionsUser authenticated but lacks NDA acceptanceRedirect to NDA acceptance page
ConfigurationErrorMissing/invalid auth configurationShow 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:

  1. Content Security Policy (CSP): Strict CSP header to prevent inline script execution
  2. DOMPurify: Sanitize all user-generated content before rendering
  3. HttpOnly Cookies (future): Migrate tokens from localStorage to httpOnly cookies (prevents JS access)
  4. 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:

  1. SameSite Cookies: Set SameSite=Strict on auth cookies (future)
  2. CORS Configuration: Restrict API access to known origins
  3. 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

StorageXSS RiskCSRF RiskCross-TabProsCons
localStorageHighLowYesSimple, cross-tab syncVulnerable to XSS
sessionStorageHighLowNoCleared on tab closeNo cross-tab sync
httpOnly CookieLowMediumYesProtected from JSRequires CSRF protection
Memory onlyLowLowNoMost secureLost 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:

  1. If valid cached token: Continue using cached token until expiry
  2. If token expired: Show "Auth service unavailable" banner, allow limited offline access
  3. Retry with backoff: Attempt to reconnect every 30s → 1m → 5m → 10m
  4. 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:

FeatureAvailableLimitation
View cached documents✅ YesOnly previously loaded documents
Search✅ YesLocal index only, no server queries
Navigation✅ YesFull navigation within cached content
Download❌ NoRequires server connection
Analytics❌ NoEvents queued, sent when online
Token Refresh❌ NoSession 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.

EventData LoggedSeverity
Login SuccessUser ID, email, IP, user agent, timestampINFO
Login FailureEmail (if provided), IP, user agent, failure reason, timestampWARN
LogoutUser ID, timestampINFO
Token RefreshUser ID, old token expiry, new token expiry, timestampINFO
Token Validation FailureUser ID (if decodable), failure reason, IP, timestampWARN
NDA Gate BlockedUser ID, document ID, NDA status, timestampINFO
Auth Service DownEndpoint, status code, error, timestampERROR
Config ErrorError details, timestampCRITICAL

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:

  • NullAuthProvider used
  • 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 AuthProvider interface with all methods
  • Implement NullAuthProvider for none mode
  • Implement GCPAuthProvider for gcp mode
  • 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 AuthContext with useAuth hook
  • Implement AuthProvider wrapper component
  • Create ProtectedRoute component
  • Add NDA gating logic to ProtectedRoute
  • Create LoginPage component
  • 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 AuthErrorBoundary component
  • 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 AuthLogger class
  • 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.development for local dev
  • Configure .env.staging for staging
  • Configure .env.production for 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

20.2 Security Guidelines

  • 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_required field
  • BIO-QMS Security Architecture: Overall security posture and compliance requirements

20.4 Implementation Files

FileDescription
src/auth/AuthProvider.interface.tsCore auth provider interface
src/auth/NullAuthProvider.tsNo-auth implementation for none mode
src/auth/GCPAuthProvider.tsJWT auth implementation for gcp mode
src/auth/createAuthProvider.tsFactory function for provider creation
src/config/auth.config.tsType-safe configuration loader
src/contexts/AuthContext.tsxReact context and useAuth hook
src/components/ProtectedRoute.tsxRoute protection component
src/pages/LoginPage.tsxLogin redirect page
src/logging/authLogger.tsAudit logging for auth events
src/auth/errors.tsAuth error classes

Document Status

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

Change Log:

DateVersionChangesAuthor
2026-02-161.0.0Initial specificationsecurity-specialist

End of Document