> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://cla-demo.postmansolutions.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://cla-demo.postmansolutions.com/_mcp/server.

# SaaS IDP Migration from Entra to PING LLD

## Document Control

| Version | Date       | Author           | Changes                                |
| ------- | ---------- | ---------------- | -------------------------------------- |
| 1.0     | 2026-03-05 | Development Team | Initial low-level design               |
| 1.1     | 2026-03-05 | Development Team | Updated for dual provider architecture |

---

## Executive Summary

This Low-Level Design (LLD) document provides detailed implementation specifications for adding PING Identity authentication support to the Documents AI application **alongside** existing Microsoft Entra ID authentication. The application will support **dual authentication providers**:

* **Microsoft Entra ID (Azure AD)**: For enterprise environments (dev, qa, uat, prd, sbx)
* **PING Identity**: For SaaS environments (saas-npd, saas-prd)

The implementation uses a **provider factory pattern** with runtime configuration to conditionally initialize the appropriate authentication provider based on the `AUTH_PROVIDER` environment variable. This approach maintains backward compatibility with existing Entra environments while enabling PING authentication for new SaaS customers.

**Key Architectural Principles:**

1. **Single Codebase**: One deployment supports both providers via configuration
2. **Backward Compatible**: Existing Entra environments unchanged
3. **Provider Agnostic**: Application code uses abstracted useAuth() hook
4. **Environment Specific**: Provider selection via AUTH\_PROVIDER env var

---

## Table of Contents

1. [Code Implementation Details](#1-code-implementation-details)
2. [Helm Chart Specifications](#2-helm-chart-specifications)
3. [Terraform Modules](#3-terraform-modules)
4. [Application Authorization](#4-application-authorization)
5. [API Contracts](#5-api-contracts)
6. [Configuration Management](#6-configuration-management)
7. [Error Handling](#7-error-handling)
8. [Security Implementation](#8-security-implementation)
9. [Monitoring and Logging](#9-monitoring-and-logging)
10. [Testing Specifications](#10-testing-specifications)

---

## 1. Code Implementation Details

### 1.1 Frontend Package Dependencies

#### package.json Changes

**IMPORTANT**: We are **adding** PING support, not replacing Entra. Both authentication libraries must be present.

**Keep (existing Entra dependencies):**

```json
{
  "dependencies": {
    "@azure/msal-browser": "3.5.0",
    "@azure/msal-react": "2.0.7"
  }
}
```

**Add (new PING dependencies):**

```json
{
  "dependencies": {
    "oidc-client-ts": "^3.0.1",
    "react-oidc-context": "^3.1.0"
  }
}
```

**Keep (existing - for token handling):**

```json
{
  "dependencies": {
    "jose": "^5.2.0"
  }
}
```

**Final package.json dependencies section:**

```json
{
  "dependencies": {
    "@azure/msal-browser": "3.5.0",
    "@azure/msal-react": "2.0.7",
    "oidc-client-ts": "^3.0.1",
    "react-oidc-context": "^3.1.0",
    "jose": "^5.2.0",
    // ... other dependencies
  }
}
```

**Installation Command:**

```bash
npm install oidc-client-ts@^3.0.1 react-oidc-context@^3.1.0
```

**Bundle Size Impact:**

* `@azure/msal-browser`: \~150KB (existing)
* `@azure/msal-react`: \~20KB (existing)
* `oidc-client-ts`: \~80KB (new)
* `react-oidc-context`: \~15KB (new)
* **Total Additional Size**: \~95KB (gzipped: \~30KB)

This is acceptable as the provider factory will tree-shake unused provider code at build time if configured properly.

### 1.2 Dual Provider Architecture Overview

#### Authentication Provider Factory Pattern

The application uses a **factory pattern** to instantiate the correct authentication provider based on runtime configuration. This ensures:

1. **Single Entry Point**: All components use the same `useAuth()` hook
2. **Provider Abstraction**: Application code is unaware of which provider is active
3. **Runtime Selection**: Provider determined by `AUTH_PROVIDER` environment variable

#### Environment Variable: AUTH\_PROVIDER

```bash
# Entra ID environments (existing)
AUTH_PROVIDER=entra  # dev, qa, uat, prd, sbx

# PING Identity environments (new)
AUTH_PROVIDER=ping   # saas-npd, saas-prd
```

**Default Behavior**: If `AUTH_PROVIDER` is not set, default to `entra` for backward compatibility.

#### Provider Interface Contract

Both providers must implement this interface:

```typescript
interface IAuthProvider {
  // User state
  isAuthenticated: boolean;
  user: UserProfile | null;
  isLoading: boolean;
  
  // Actions
  login: () => Promise<void>;
  logout: () => Promise<void>;
  getAccessToken: () => Promise<string>;
  
  // User profile
  getUserProfile: () => UserProfile;
}

interface UserProfile {
  id: string;
  email: string;
  name: string;
  roles: string[];
  client?: Client; // Optional: for role-based access
}
```

#### High-Level Provider Component Structure

```typescript
// src/components/AuthProvider.tsx (high-level structure)

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const authProvider = window.env.AUTH_PROVIDER || 'entra';
  
  if (authProvider === 'ping') {
    return <PingAuthProvider>{children}</PingAuthProvider>;
  }
  
  // Default to Entra for backward compatibility
  return <EntraAuthProvider>{children}</EntraAuthProvider>;
};
```

#### Provider-Specific Implementations

* **EntraAuthProvider**: Wraps `@azure/msal-react` → Used by dev, qa, uat, prd, sbx
* **PingAuthProvider**: Wraps `react-oidc-context` → Used by saas-npd, saas-prd

Both expose the same `useAuth()` hook interface, ensuring application code remains provider-agnostic.

---

### 1.3 OIDC Configuration Module (PING Only)

**File:** `/src/auth/OidcConfig.ts`

```typescript
// @ts-nocheck
import { UserManagerSettings } from 'oidc-client-ts';

/**
 * OIDC Configuration for PING Identity
 * This configuration is used to initialize the OIDC client
 */
export interface PingOidcConfig extends UserManagerSettings {
  authority: string;
  client_id: string;
  redirect_uri: string;
  post_logout_redirect_uri: string;
  response_type: string;
  scope: string;
  automaticSilentRenew: boolean;
  loadUserInfo: boolean;
  silent_redirect_uri?: string;
}

/**
 * Creates OIDC configuration from environment variables
 */
export const createOidcConfig = (): PingOidcConfig => {
  const baseUrl = window.location.origin;
  
  // Validate required environment variables
  if (!window.env.PING_AUTHORITY) {
    throw new Error('PING_AUTHORITY environment variable is not set');
  }
  
  if (!window.env.PING_CLIENT_ID) {
    throw new Error('PING_CLIENT_ID environment variable is not set');
  }

  const config: PingOidcConfig = {
    // PING Identity server configuration
    authority: window.env.PING_AUTHORITY,
    client_id: window.env.PING_CLIENT_ID,
    
    // Redirect URIs
    redirect_uri: `${baseUrl}/callback`,
    post_logout_redirect_uri: `${baseUrl}/login`,
    silent_redirect_uri: `${baseUrl}/silent-renew.html`,
    
    // OAuth 2.0 / OIDC settings
    response_type: 'code',
    response_mode: 'query',
    
    // Scopes - request access to user info and API resources
    scope: window.env.PING_SCOPE || 'openid profile email',
    
    // Token management
    automaticSilentRenew: true,
    monitorSession: true,
    checkSessionInterval: 10000, // Check session every 10 seconds
    
    // User info endpoint
    loadUserInfo: true,
    
    // PKCE (Proof Key for Code Exchange) - required for SPAs
    // This is enabled by default in oidc-client-ts
    
    // Token storage
    storeAuthStateInCookie: false,
    userStore: undefined, // Use default WebStorageStateStore (sessionStorage)
    
    // Metadata
    metadataUrl: `${window.env.PING_AUTHORITY}/.well-known/openid-configuration`,
    
    // Additional settings
    filterProtocolClaims: true,
    includeIdTokenInSilentRenew: false,
    revokeTokensOnSignout: true,
    
    // Clock skew - allow 5 minute difference
    clockSkew: 300,
  };

  return config;
};

/**
 * Validate OIDC configuration
 */
export const validateOidcConfig = (config: PingOidcConfig): boolean => {
  const requiredFields = [
    'authority',
    'client_id',
    'redirect_uri',
    'post_logout_redirect_uri',
    'response_type',
    'scope',
  ];

  for (const field of requiredFields) {
    if (!config[field]) {
      console.error(`OIDC configuration missing required field: ${field}`);
      return false;
    }
  }

  return true;
};

/**
 * Get scope list as array
 */
export const getScopeArray = (): string[] => {
  const scope = window.env.PING_SCOPE || 'openid profile email';
  return scope.split(' ').filter(s => s.length > 0);
};

/**
 * Check if a scope is included in the config
 */
export const hasScope = (scopeName: string): boolean => {
  return getScopeArray().includes(scopeName);
};
```

### 1.3 AuthProvider Rewrite

**File:** `/src/components/AuthProvider.tsx`

```typescript
// @ts-nocheck
import React, { createContext, useContext, useEffect, useState } from 'react';
import { AuthProvider as OidcAuthProvider, useAuth as useOidcAuth } from 'react-oidc-context';
import { User, UserManager } from 'oidc-client-ts';
import { useNavigate } from 'react-router-dom';
import { createOidcConfig, validateOidcConfig } from '@/auth/OidcConfig';
import { api } from '@/utilities/api';
import { Client } from '@/hooks/useClients';
import * as jose from 'jose';

/**
 * Custom error class for authentication errors
 */
export class NotLoggedInError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NotLoggedInError';
  }
}

/**
 * Auth context interface
 */
interface AuthContextType {
  tokens: {
    accessToken: string | null;
    idToken: string | null;
  };
  roles: string[];
  selectedClient: number | null;
  clients: Client[];
  loading: boolean;
  user: User | null;
  switchClient: (clientId: number, navigate: any) => void;
  addClient: (client: Client) => void;
  handleSignin: () => void;
  handleSignout: () => void;
}

/**
 * Create auth context
 */
const AuthContext = createContext<AuthContextType>({
  tokens: { accessToken: null, idToken: null },
  roles: [],
  selectedClient: null,
  clients: [],
  loading: false,
  user: null,
  switchClient: () => {},
  addClient: () => {},
  handleSignin: () => {},
  handleSignout: () => {},
});

/**
 * Hook to access auth context
 */
export const useAuth = () => {
  return useContext(AuthContext);
};

/**
 * Check if token is expiring soon (within 5 minutes)
 */
export const isTokenExpiring = (token: string | null): boolean => {
  if (!token) return true;

  try {
    const decoded = jose.decodeJwt(token);
    const exp = decoded.exp;
    
    if (!exp) return true;
    
    const now = Math.floor(Date.now() / 1000);
    const timeUntilExpiry = exp - now;
    
    // Consider token expiring if less than 5 minutes remaining
    return timeUntilExpiry < 300;
  } catch (error) {
    console.error('Error decoding token:', error);
    return true;
  }
};

/**
 * Remove tokens from storage (used on logout)
 */
export const removeTokens = () => {
  sessionStorage.removeItem(`oidc.user:${window.env.PING_AUTHORITY}:${window.env.PING_CLIENT_ID}`);
  localStorage.removeItem('logout-initiated');
  localStorage.removeItem('selectedClient');
  sessionStorage.removeItem('starredInvoices');
};

/**
 * Check if user is authenticated
 */
export const authenticated = (user: User | null | undefined): boolean => {
  return user != null && !user.expired;
};

/**
 * Development mode token injection for local testing
 */
export const injectLocalDevelopmentTokens = async (): Promise<[string, string]> => {
  const scopes = 'openid profile email documents:read documents:write clients:read clients:write users:read users:write';
  const roles = ['Administrator', 'User', 'Developer'];

  // Secret key for signing (development only)
  const secretKey = '00000000-0000-0000-0000-000000000000';
  const encoder = new TextEncoder();
  const secretKeyUint8Array = encoder.encode(secretKey);

  const expireTime = Math.floor(Date.now() / 1000) + 7200; // 2 hours from now

  // Create mock ID token
  const mockIdToken = await new jose.SignJWT({
    sub: '00000000-0000-0000-0000-000000000000',
    name: 'CLA Developer',
    given_name: 'CLA',
    family_name: 'Developer',
    email: 'developer@claconnect.invalid',
    email_verified: true,
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setIssuer('http://localhost:4200')
    .setAudience(window.env.PING_CLIENT_ID || 'local-dev')
    .setExpirationTime('2h')
    .sign(secretKeyUint8Array);

  // Create mock access token with roles and scopes
  const mockAccessToken = await new jose.SignJWT({
    sub: '00000000-0000-0000-0000-000000000000',
    oid: '00000000-0000-0000-0000-000000000000',
    azp: window.env.PING_CLIENT_ID || 'local-dev',
    scope: scopes,
    roles: roles,
    groups: roles, // Some PING configs use groups instead of roles
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setIssuer('http://localhost:4200')
    .setAudience('api://documents-ai')
    .setExpirationTime('2h')
    .sign(secretKeyUint8Array);

  // Create mock user object
  const mockUser = {
    id_token: mockIdToken,
    session_state: null,
    access_token: mockAccessToken,
    refresh_token: null,
    token_type: 'Bearer',
    scope: scopes,
    profile: {
      sub: '00000000-0000-0000-0000-000000000000',
      name: 'CLA Developer',
      given_name: 'CLA',
      family_name: 'Developer',
      email: 'developer@claconnect.invalid',
      email_verified: true,
    },
    expires_at: expireTime,
    expires_in: 7200,
  };

  // Store in sessionStorage (mimicking OIDC client behavior)
  const storageKey = `oidc.user:${window.env.PING_AUTHORITY || 'http://localhost:4200'}:${window.env.PING_CLIENT_ID || 'local-dev'}`;
  sessionStorage.setItem(storageKey, JSON.stringify(mockUser));

  console.log('[DEV MODE] Mock tokens injected. User can access all features.');

  return [mockAccessToken, mockIdToken];
};

/**
 * Inner auth provider that wraps OIDC logic
 */
const InnerAuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const navigate = useNavigate();
  const oidcAuth = useOidcAuth();
  
  const [roles, setRoles] = useState<string[]>([]);
  const [selectedClient, setSelectedClient] = useState<number | null>(null);
  const [clients, setClients] = useState<Client[]>([]);
  const [loading, setLoading] = useState<boolean>(true);

  /**
   * Extract roles from OIDC user token
   */
  const extractRoles = (user: User | null | undefined): string[] => {
    if (!user || !user.profile) return [];

    // PING can provide roles in different claim names
    const profile = user.profile as any;
    
    // Try common role claim names
    const roleClaims = [
      profile.roles,           // Direct roles array
      profile.role,            // Single role
      profile.groups,          // Groups as roles
      profile.group,           // Single group
      profile['custom:roles'], // Custom claim
      profile['app_roles'],    // Application roles
    ];

    for (const claim of roleClaims) {
      if (Array.isArray(claim)) {
        return claim;
      } else if (typeof claim === 'string') {
        return [claim];
      }
    }

    return [];
  };

  /**
   * Fetch user's clients from API
   */
  const fetchClients = async (userRoles: string[], email?: string): Promise<Client[]> => {
    try {
      let endpoint = userRoles.includes('Administrator') ? '/clients' : '/clients/me';
      
      if (endpoint === '/clients/me' && email) {
        endpoint = `${endpoint}?email=${encodeURIComponent(email)}`;
      }

      const response = await api.get(endpoint);
      const raw = response.data;
      const items: any[] = Array.isArray(raw) ? raw : raw?.items ?? raw?.data ?? [];

      return items.map((c: any) => ({
        id: c.id,
        clientId: c.clientID,
        clientName: c.clientName,
        displayName: c.displayName,
        contactName: c.contactName,
        contactEmail: c.contactEmail,
        enabled: c.enabled,
        createdOn: c.createdOn,
        modifiedOn: c.modifiedOn,
        config: c.config,
      }));
    } catch (error) {
      console.error('Failed to fetch clients:', error);
      return [];
    }
  };

  /**
   * Initialize auth state from OIDC user
   */
  useEffect(() => {
    const initializeAuth = async () => {
      // Check if development mode
      const isDevelopment = window.env.NODE_ENV === 'development' && 
                           new URLSearchParams(window.location.search).has('mockAuth');

      if (isDevelopment) {
        // Inject development tokens
        await injectLocalDevelopmentTokens();
        setRoles(['Administrator', 'User', 'Developer']);
        setLoading(false);
        return;
      }

      if (oidcAuth.isLoading) {
        setLoading(true);
        return;
      }

      if (oidcAuth.user && !oidcAuth.user.expired) {
        // User is authenticated
        const userRoles = extractRoles(oidcAuth.user);
        setRoles(userRoles);

        // Fetch clients
        const email = oidcAuth.user.profile?.email as string | undefined;
        const clientList = await fetchClients(userRoles, email);
        setClients(clientList);

        // Restore selected client from localStorage
        const storedClientId = localStorage.getItem('selectedClient');
        if (storedClientId) {
          const clientId = parseInt(storedClientId, 10);
          if (clientList.some(c => c.id === clientId)) {
            setSelectedClient(clientId);
          } else if (clientList.length > 0) {
            // Default to first client if stored client not found
            setSelectedClient(clientList[0].id);
            localStorage.setItem('selectedClient', clientList[0].id.toString());
          }
        } else if (clientList.length > 0) {
          // No stored client, default to first
          setSelectedClient(clientList[0].id);
          localStorage.setItem('selectedClient', clientList[0].id.toString());
        }
      } else if (oidcAuth.error) {
        console.error('OIDC authentication error:', oidcAuth.error);
      }

      setLoading(false);
    };

    initializeAuth();
  }, [oidcAuth.isLoading, oidcAuth.user, oidcAuth.error]);

  /**
   * Handle client switching
   */
  const switchClient = (clientId: number, navigate: any) => {
    setSelectedClient(clientId);
    localStorage.setItem('selectedClient', clientId.toString());
    
    // Clear session-specific data
    sessionStorage.removeItem('invoicesColumnFilters');
    sessionStorage.removeItem('starredInvoices');
    
    // Navigate to dashboard
    if (navigate) {
      navigate('/');
    }
  };

  /**
   * Add a new client to the list
   */
  const addClient = (client: Client) => {
    setClients(prev => [...prev, client]);
  };

  /**
   * Handle sign in
   */
  const handleSignin = () => {
    oidcAuth.signinRedirect();
  };

  /**
   * Handle sign out
   */
  const handleSignout = () => {
    // Mark logout as initiated
    localStorage.setItem('logout-initiated', Date.now().toString());
    
    // Clear all local data
    removeTokens();
    
    // Sign out from PING
    oidcAuth.signoutRedirect();
  };

  const tokens = {
    accessToken: oidcAuth.user?.access_token || null,
    idToken: oidcAuth.user?.id_token || null,
  };

  const contextValue: AuthContextType = {
    tokens,
    roles,
    selectedClient,
    clients,
    loading: loading || oidcAuth.isLoading,
    user: oidcAuth.user || null,
    switchClient,
    addClient,
    handleSignin,
    handleSignout,
  };

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

/**
 * Main AuthProvider component
 * Wraps application with OIDC provider
 */
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [oidcConfig, setOidcConfig] = useState<any>(null);
  const [configError, setConfigError] = useState<string | null>(null);

  useEffect(() => {
    try {
      const config = createOidcConfig();
      
      if (!validateOidcConfig(config)) {
        throw new Error('Invalid OIDC configuration');
      }

      setOidcConfig(config);
    } catch (error) {
      console.error('Failed to create OIDC configuration:', error);
      setConfigError(error.message);
    }
  }, []);

  if (configError) {
    return (
      <div style={{ padding: '20px', color: 'red' }}>
        <h1>Configuration Error</h1>
        <p>{configError}</p>
        <p>Please check your environment variables.</p>
      </div>
    );
  }

  if (!oidcConfig) {
    return <div>Loading authentication...</div>;
  }

  const onSigninCallback = () => {
    // After successful auth, navigate to home or original destination
    const returnUrl = sessionStorage.getItem('auth-return-url') || '/';
    sessionStorage.removeItem('auth-return-url');
    window.history.replaceState({}, document.title, returnUrl);
  };

  return (
    <OidcAuthProvider
      {...oidcConfig}
      onSigninCallback={onSigninCallback}
      onRemoveUser={() => {
        console.log('User removed from session');
      }}
    >
      <InnerAuthProvider>{children}</InnerAuthProvider>
    </OidcAuthProvider>
  );
};

// Export for backwards compatibility
export default AuthProvider;
```

### 1.4 OIDC Callback Component

**File:** `/src/auth/OidcCallback.tsx`

```typescript
// @ts-nocheck
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from 'react-oidc-context';
import { Loading } from '@/components/Loading';

/**
 * OIDC callback page
 * This page handles the redirect from PING after authentication
 */
export const OidcCallback: React.FC = () => {
  const navigate = useNavigate();
  const auth = useAuth();

  useEffect(() => {
    // The OIDC client automatically handles the callback
    // We just need to wait for it to complete and then navigate
    
    if (auth.user && !auth.isLoading) {
      // Authentication successful, navigate to home or stored return URL
      const returnUrl = sessionStorage.getItem('auth-return-url') || '/';
      sessionStorage.removeItem('auth-return-url');
      navigate(returnUrl, { replace: true });
    } else if (auth.error) {
      // Authentication failed
      console.error('Authentication error:', auth.error);
      navigate('/login?error=auth_failed', { replace: true });
    }
  }, [auth.user, auth.isLoading, auth.error, navigate]);

  if (auth.error) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <div className="text-center">
          <h1 className="text-2xl font-bold mb-4">Authentication Error</h1>
          <p className="text-red-600 mb-4">{auth.error.message}</p>
          <button
            onClick={() => navigate('/login', { replace: true })}
            className="px-4 py-2 bg-primary text-white rounded"
          >
            Return to Login
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="text-center">
        <Loading />
        <p className="mt-4 text-muted-foreground">Completing authentication...</p>
      </div>
    </div>
  );
};

export default OidcCallback;
```

### 1.5 Silent Renew HTML

**File:** `/public/silent-renew.html`

```html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Silent Renew</title>
</head>
<body>
    <script src="https://unpkg.com/oidc-client-ts@3.0.1/dist/browser/oidc-client-ts.min.js"></script>
    <script>
        new OidcClientTs.UserManager({
            response_mode: 'query',
            userStore: new OidcClientTs.WebStorageStateStore({ store: window.sessionStorage })
        }).signinSilentCallback().catch(function (e) {
            console.error('Silent renew error:', e);
        });
    </script>
</body>
</html>
```

### 1.6 API Token Interceptor Update

**File:** `/src/utilities/api.ts`

```typescript
// @ts-nocheck
import axios from 'axios';
import { useAuth } from 'react-oidc-context';

let api: any;

// Create axios instance
if (new URLSearchParams(window.location.search).has('mockAxios')) {
  const module = await import('../__mocks__/axios');
  api = module.default;
} else {
  console.log('Window Env API URL:', (window as any).env?.REACT_APP_API_URL);
  let API_BASE_URL = (window as any).env?.REACT_APP_API_URL;
  
  api = axios.create({
    baseURL: `${API_BASE_URL}/v1`,
    timeout: 30000,
    headers: {
      Accept: 'application/json',
    },
  });

  /**
   * Request interceptor to add authentication token
   */
  api.interceptors.request.use(
    async (config: any) => {
      const token = await getToken();

      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
        return config;
      } else {
        // No token available - user needs to login
        console.error('No authentication token available');
        window.location.assign('/login');
        return Promise.reject(new Error('No authentication token'));
      }
    },
    (error: any) => {
      return Promise.reject(error);
    }
  );

  /**
   * Response interceptor to handle 401 errors
   */
  api.interceptors.response.use(
    (response: any) => response,
    async (error: any) => {
      const originalRequest = error.config;

      // If 401 and we haven't retried yet, try to refresh token
      if (error.response?.status === 401 && !originalRequest._retry) {
        originalRequest._retry = true;

        try {
          // Trigger silent token renewal
          await refreshToken();
          
          // Retry original request with new token
          const token = await getToken();
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return api(originalRequest);
        } catch (refreshError) {
          // Refresh failed, redirect to login
          console.error('Token refresh failed:', refreshError);
          window.location.assign('/login');
          return Promise.reject(refreshError);
        }
      }

      return Promise.reject(error);
    }
  );
}

/**
 * Get access token from OIDC user
 */
const getToken = async (): Promise<string | null> => {
  // Get OIDC user from session storage
  const storageKey = `oidc.user:${window.env.PING_AUTHORITY}:${window.env.PING_CLIENT_ID}`;
  const userJson = sessionStorage.getItem(storageKey);
  
  if (!userJson) {
    return null;
  }

  try {
    const user = JSON.parse(userJson);
    
    // Check if token is expired
    const now = Math.floor(Date.now() / 1000);
    if (user.expires_at && user.expires_at < now) {
      console.warn('Access token expired, attempting renewal...');
      await refreshToken();
      
      // Get updated user
      const updatedUserJson = sessionStorage.getItem(storageKey);
      if (updatedUserJson) {
        const updatedUser = JSON.parse(updatedUserJson);
        return updatedUser.access_token;
      }
      
      return null;
    }

    return user.access_token;
  } catch (error) {
    console.error('Error parsing user from storage:', error);
    return null;
  }
};

/**
 * Refresh access token using OIDC client
 */
const refreshToken = async (): Promise<void> => {
  // We need access to the OIDC auth context, but we're in a utility file
  // The OIDC client handles automatic token renewal if automaticSilentRenew is true
  // If manual renewal is needed, we can use the UserManager directly
  
  const { UserManager } = await import('oidc-client-ts');
  const { createOidcConfig } = await import('@/auth/OidcConfig');
  
  const config = createOidcConfig();
  const userManager = new UserManager(config);
  
  try {
    const user = await userManager.signinSilent();
    console.log('Token refreshed successfully');
  } catch (error) {
    console.error('Failed to refresh token:', error);
    throw error;
  }
};

/**
 * Check if token is expiring soon (within 5 minutes)
 */
export const isTokenExpiring = (token: string | null): boolean => {
  if (!token) return true;

  try {
    // Decode JWT (without verification, just to read claims)
    const base64Url = token.split('.')[1];
    const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
    const jsonPayload = decodeURIComponent(
      atob(base64)
        .split('')
        .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
        .join('')
    );

    const payload = JSON.parse(jsonPayload);
    const exp = payload.exp;
    
    if (!exp) return true;
    
    const now = Math.floor(Date.now() / 1000);
    const timeUntilExpiry = exp - now;
    
    // Consider token expiring if less than 5 minutes remaining
    return timeUntilExpiry < 300;
  } catch (error) {
    console.error('Error decoding token:', error);
    return true;
  }
};

export { api };
```

### 1.7 Login Page Update

**File:** `/src/routes/Login.tsx`

```typescript
// @ts-nocheck
import React, { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/components/AuthProvider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';

export const Login: React.FC = () => {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const auth = useAuth();

  const error = searchParams.get('error');

  useEffect(() => {
    // If user is already authenticated, redirect to home
    if (auth.user && !auth.loading) {
      const returnUrl = sessionStorage.getItem('auth-return-url') || '/';
      sessionStorage.removeItem('auth-return-url');
      navigate(returnUrl, { replace: true });
    }
  }, [auth.user, auth.loading, navigate]);

  const handleLogin = () => {
    // Store current URL as return URL if not login page
    if (window.location.pathname !== '/login') {
      sessionStorage.setItem('auth-return-url', window.location.pathname + window.location.search);
    }
    
    // Initiate OIDC login
    auth.handleSignin();
  };

  const getErrorMessage = (errorCode: string | null): string => {
    switch (errorCode) {
      case 'auth_failed':
        return 'Authentication failed. Please try again.';
      case 'timeout':
        return 'Authentication request timed out. Please try again.';
      case 'cancelled':
        return 'Authentication was cancelled.';
      case 'access_denied':
        return 'Access denied. You may not have permission to access this application.';
      default:
        return 'An error occurred during authentication.';
    }
  };

  return (
    <div className="flex items-center justify-center min-h-screen bg-background">
      <Card className="w-full max-w-md">
        <CardHeader className="space-y-1">
          <CardTitle className="text-2xl font-bold">Welcome to Documents AI</CardTitle>
          <CardDescription>
            Sign in with your organization credentials to continue
          </CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          {error && (
            <Alert variant="destructive">
              <AlertDescription>{getErrorMessage(error)}</AlertDescription>
            </Alert>
          )}
          
          <Button
            onClick={handleLogin}
            disabled={auth.loading}
            className="w-full"
            size="lg"
          >
            {auth.loading ? 'Loading...' : 'Sign In with PING Identity'}
          </Button>

          <div className="text-center text-sm text-muted-foreground">
            <p>By signing in, you agree to our Terms of Service and Privacy Policy.</p>
          </div>
        </CardContent>
      </Card>
    </div>
  );
};

export default Login;
```

### 1.8 Navigation Component Update

**File:** `/src/components/Navigation.tsx` (partial update)

```typescript
// Find the logout handler and update it:

const handleLogout = () => {
  // Use auth context logout
  auth.handleSignout();
};
```

### 1.9 App Router Update

**File:** `/src/index.jsx` or `/src/App.jsx`

```jsx
// Add new route for OIDC callback
import { OidcCallback } from '@/auth/OidcCallback';

// In your routes:
<Route path="/callback" element={<OidcCallback />} />
```

### 1.10 Environment Configuration File Update

**File:** `/public/env.js`

```javascript
window.env = {
  // PING Identity Configuration
  PING_AUTHORITY: "https://auth.pingone.com/{environment-id}/as",
  PING_CLIENT_ID: "00000000-0000-0000-0000-000000000000",
  PING_ISSUER: "https://auth.pingone.com/{environment-id}/as",
  PING_JWKS_URI: "https://auth.pingone.com/{environment-id}/as/jwks",
  PING_SCOPE: "openid profile email documents:read documents:write",
  
  // API Configuration
  REACT_APP_API_URL: "http://localhost:4200",
  REACT_APP_VERSION: "1.0.0",
  NODE_ENV: "development",
};
```

---

## 2. Helm Chart Specifications

### 2.1 Values Structure

**File:** `/helm/values.yaml`

```yaml
app:
  name: web-documents-ai
  version: 1.0.0

image:
  registry: cladevops.azurecr.io
  repository: helm/web-documents-ai
  tag: latest
  pullPolicy: IfNotPresent

container:
  port: 3002
  readinessProbe:
    httpGet:
      path: /
      port: 3002
    initialDelaySeconds: 10
    periodSeconds: 10
  livenessProbe:
    httpGet:
      path: /
      port: 3002
    initialDelaySeconds: 60
    periodSeconds: 30
    timeoutSeconds: 30
    failureThreshold: 10

service:
  type: ClusterIP
  port: 80
  targetPort: 3002
  name: web-documents-ai

ingress:
  enabled: false

istio:
  enabled: true
  hosts: ["*"]
  gateways: [documents-ai-web-ingress-gateway]
  pathPrefix: "/"
  timeout: 30s
  gateway:
    enabled: false
  # Optional: Enable request authentication at gateway level
  requestAuthentication:
    enabled: false
    jwtRules:
      - issuer: ""  # Set in environment-specific values
        jwksUri: ""  # Set in environment-specific values
        audiences: []  # Set in environment-specific values

serviceAccount:
  create: true
  annotations: {}
  name: "web-documents-ai"

# PING Identity configuration (set in environment-specific values)
oidc:
  authority: ""
  clientId: ""
  issuer: ""
  jwksUri: ""
  scope: "openid profile email"

# Backend API URL
api:
  url: ""

configMap:
  enabled: false

secret:
  enabled: true
  data: {}

resources:
  limits:
    cpu: 200m
    memory: 512Mi
  requests:
    cpu: 100m
    memory: 256Mi

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80

replicaCount: 1

podDisruptionBudget:
  enabled: false

networkPolicy:
  enabled: false

monitoring:
  serviceMonitor:
    enabled: false

volumeMounts: []
volumes: []

nodeSelector: {}
tolerations: []
affinity: {}
```

### 2.2 Environment-Specific Values - DEV

**File:** `/helm/values-dev.yaml`

```yaml
app:
  environment: "dev"

replicaCount: 1

image:
  tag: "dev-latest"

# PING Identity Configuration for DEV
oidc:
  authority: "https://auth.pingone.com/1a2b3c4d-5e6f-7890-abcd-ef1234567890/as"
  clientId: "dev-client-id-12345678-1234-1234-1234-123456789012"
  issuer: "https://auth.pingone.com/1a2b3c4d-5e6f-7890-abcd-ef1234567890/as"
  jwksUri: "https://auth.pingone.com/1a2b3c4d-5e6f-7890-abcd-ef1234567890/as/jwks"
  scope: "openid profile email documents:read documents:write clients:read clients:write users:read users:write"

# API Configuration
api:
  url: "https://api.dev.claitdev.com/documentsai"

# Environment variables injected into container
env:
  NODE_ENV: production
  PING_AUTHORITY: "https://auth.pingone.com/1a2b3c4d-5e6f-7890-abcd-ef1234567890/as"
  PING_CLIENT_ID: "dev-client-id-12345678-1234-1234-1234-123456789012"
  PING_ISSUER: "https://auth.pingone.com/1a2b3c4d-5e6f-7890-abcd-ef1234567890/as"
  PING_JWKS_URI: "https://auth.pingone.com/1a2b3c4d-5e6f-7890-abcd-ef1234567890/as/jwks"
  PING_SCOPE: "openid profile email documents:read documents:write clients:read clients:write users:read users:write"
  REACT_APP_API_URL: "https://api.dev.claitdev.com/documentsai"
  REACT_APP_VERSION: "1.0.0"

istio:
  hosts: ["documentsai.dev.claitdev.com"]
  # Optional: Enable JWT validation at Istio gateway
  requestAuthentication:
    enabled: false  # Set to true to enable gateway-level auth
    jwtRules:
      - issuer: "https://auth.pingone.com/1a2b3c4d-5e6f-7890-abcd-ef1234567890/as"
        jwksUri: "https://auth.pingone.com/1a2b3c4d-5e6f-7890-abcd-ef1234567890/as/jwks"
        audiences:
          - "dev-client-id-12345678-1234-1234-1234-123456789012"

serviceAccount:
  annotations:
    azure.workload.identity/client-id: "31f10a9c-d3c7-47d6-9cfb-536a1d9008f4"

resources:
  limits:
    cpu: 150m
    memory: 350Mi
  requests:
    cpu: 100m
    memory: 300Mi

autoscaling:
  enabled: true
  minReplicas: 1
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80
```

### 2.3 Environment-Specific Values - PROD

**File:** `/helm/values-prd.yaml`

```yaml
app:
  environment: "prd"

replicaCount: 3

image:
  tag: "prd-v1.0.0"

# PING Identity Configuration for PROD
oidc:
  authority: "https://auth.pingone.com/9z8y7x6w-5v4u-3t2s-1r0q-ponmlkjihgfe/as"
  clientId: "prd-client-id-87654321-4321-4321-4321-210987654321"
  issuer: "https://auth.pingone.com/9z8y7x6w-5v4u-3t2s-1r0q-ponmlkjihgfe/as"
  jwksUri: "https://auth.pingone.com/9z8y7x6w-5v4u-3t2s-1r0q-ponmlkjihgfe/as/jwks"
  scope: "openid profile email documents:read documents:write clients:read clients:write users:read users:write"

# API Configuration
api:
  url: "https://api.prod.claitdev.com/documentsai"

# Environment variables
env:
  NODE_ENV: production
  PING_AUTHORITY: "https://auth.pingone.com/9z8y7x6w-5v4u-3t2s-1r0q-ponmlkjihgfe/as"
  PING_CLIENT_ID: "prd-client-id-87654321-4321-4321-4321-210987654321"
  PING_ISSUER: "https://auth.pingone.com/9z8y7x6w-5v4u-3t2s-1r0q-ponmlkjihgfe/as"
  PING_JWKS_URI: "https://auth.pingone.com/9z8y7x6w-5v4u-3t2s-1r0q-ponmlkjihgfe/as/jwks"
  PING_SCOPE: "openid profile email documents:read documents:write clients:read clients:write users:read users:write"
  REACT_APP_API_URL: "https://api.prod.claitdev.com/documentsai"
  REACT_APP_VERSION: "1.0.0"

istio:
  hosts: ["documentsai.prod.claitdev.com"]
  # Enable JWT validation at gateway for production
  requestAuthentication:
    enabled: true
    jwtRules:
      - issuer: "https://auth.pingone.com/9z8y7x6w-5v4u-3t2s-1r0q-ponmlkjihgfe/as"
        jwksUri: "https://auth.pingone.com/9z8y7x6w-5v4u-3t2s-1r0q-ponmlkjihgfe/as/jwks"
        audiences:
          - "prd-client-id-87654321-4321-4321-4321-210987654321"

serviceAccount:
  annotations:
    azure.workload.identity/client-id: "prod-uami-client-id"

resources:
  limits:
    cpu: 500m
    memory: 1Gi
  requests:
    cpu: 250m
    memory: 512Mi

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20
  targetCPUUtilizationPercentage: 60
  targetMemoryUtilizationPercentage: 70

podDisruptionBudget:
  enabled: true
  minAvailable: 2

monitoring:
  serviceMonitor:
    enabled: true
```

### 2.4 Deployment Template Updates

**File:** `/helm/templates/deployment.yml`

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Values.app.name }}
  namespace: {{ .Release.Namespace }}
  labels:
    app: {{ .Values.app.name }}
    environment: {{ .Values.app.environment }}
    version: {{ .Values.app.version }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: {{ .Values.app.name }}
  template:
    metadata:
      labels:
        app: {{ .Values.app.name }}
        azure.workload.identity/use: "true"
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "{{ .Values.container.port }}"
    spec:
      serviceAccountName: {{ .Values.serviceAccount.name }}
      containers:
      - name: {{ .Values.app.name }}
        image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        imagePullPolicy: {{ .Values.image.pullPolicy }}
        ports:
        - containerPort: {{ .Values.container.port }}
          name: http
          protocol: TCP
        env:
        {{- range $key, $value := .Values.env }}
        - name: {{ $key }}
          value: {{ $value | quote }}
        {{- end }}
        {{- if .Values.secret.enabled }}
        envFrom:
        - secretRef:
            name: {{ .Values.app.name }}-secret
        {{- end }}
        readinessProbe:
{{ toYaml .Values.container.readinessProbe | indent 10 }}
        livenessProbe:
{{ toYaml .Values.container.livenessProbe | indent 10 }}
        resources:
{{ toYaml .Values.resources | indent 10 }}
        {{- if .Values.volumeMounts }}
        volumeMounts:
{{ toYaml .Values.volumeMounts | indent 8 }}
        {{- end }}
      {{- if .Values.volumes }}
      volumes:
{{ toYaml .Values.volumes | indent 6 }}
      {{- end }}
```

### 2.5 Istio RequestAuthentication Template

**File:** `/helm/templates/istio-request-authentication.yaml`

```yaml
{{- if and .Values.istio.enabled .Values.istio.requestAuthentication.enabled }}
apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: {{ .Values.app.name }}-jwt-auth
  namespace: {{ .Release.Namespace }}
spec:
  selector:
    matchLabels:
      app: {{ .Values.app.name }}
  jwtRules:
  {{- range .Values.istio.requestAuthentication.jwtRules }}
  - issuer: {{ .issuer | quote }}
    jwksUri: {{ .jwksUri | quote }}
    {{- if .audiences }}
    audiences:
    {{- range .audiences }}
    - {{ . | quote }}
    {{- end }}
    {{- end }}
    forwardOriginalToken: true
  {{- end }}
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: {{ .Values.app.name }}-require-jwt
  namespace: {{ .Release.Namespace }}
spec:
  selector:
    matchLabels:
      app: {{ .Values.app.name }}
  action: ALLOW
  rules:
  - from:
    - source:
        requestPrincipals: ["*"]
{{- end }}
```

### 2.6 Secret Template

**File:** `/helm/templates/secret.yaml`

```yaml
{{- if .Values.secret.enabled }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ .Values.app.name }}-secret
  namespace: {{ .Release.Namespace }}
  labels:
    app: {{ .Values.app.name }}
type: Opaque
data:
  {{- range $key, $value := .Values.secret.data }}
  {{ $key }}: {{ $value | b64enc | quote }}
  {{- end }}
{{- end }}
```

### 2.7 Startup Script Update

**File:** `/50-load-env.sh`

```bash
#!/bin/sh
set -e

echo "Loading environment variables into env.js..."

echo "window.env = {" > /usr/share/nginx/html/env.js
echo "  PING_AUTHORITY: '$(echo $PING_AUTHORITY)'," >> /usr/share/nginx/html/env.js
echo "  PING_CLIENT_ID: '$(echo $PING_CLIENT_ID)'," >> /usr/share/nginx/html/env.js
echo "  PING_ISSUER: '$(echo $PING_ISSUER)'," >> /usr/share/nginx/html/env.js
echo "  PING_JWKS_URI: '$(echo $PING_JWKS_URI)'," >> /usr/share/nginx/html/env.js
echo "  PING_SCOPE: '$(echo $PING_SCOPE)'," >> /usr/share/nginx/html/env.js
echo "  REACT_APP_API_URL: '$(echo $REACT_APP_API_URL)'," >> /usr/share/nginx/html/env.js
echo "  REACT_APP_VERSION: '$(echo $REACT_APP_VERSION)'," >> /usr/share/nginx/html/env.js
echo "  NODE_ENV: '$(echo $NODE_ENV)'," >> /usr/share/nginx/html/env.js
echo "};" >> /usr/share/nginx/html/env.js

echo "Environment variables loaded successfully"
cat /usr/share/nginx/html/env.js

exec "$@"
```

---

## 3. Terraform Modules

### 3.1 PingOne Application Module

**File:** `/terraform/modules/pingone-application/main.tf`

```hcl
terraform {
  required_providers {
    pingone = {
      source  = "pingidentity/pingone"
      version = "~> 0.20.0"
    }
  }
}

# PingOne Application Resource
resource "pingone_application" "web_documents_ai" {
  environment_id = var.environment_id
  name           = "Web Documents AI - ${var.environment_name}"
  description    = "Document processing application for invoice management"
  enabled        = true

  oidc_options {
    type                        = "SINGLE_PAGE_APP"
    grant_types                 = ["AUTHORIZATION_CODE"]
    response_types              = ["CODE"]
    pkce_enforcement            = "S256_REQUIRED"
    token_endpoint_authn_method = "NONE"  # SPA doesn't use client secret
    
    # Redirect URIs
    redirect_uris = [
      "${var.application_url}/callback",
      "${var.application_url}/silent-renew",
    ]
    
    # Post logout redirect URIs
    post_logout_redirect_uris = [
      "${var.application_url}/login",
      var.application_url,
    ]
    
    # Token settings
    refresh_token_duration         = 2592000  # 30 days
    refresh_token_rolling_duration = 15552000 # 180 days
  }

  tags = {
    Environment = var.environment_name
    Application = "documents-ai"
    ManagedBy   = "terraform"
  }
}

# Application Role Assignments
resource "pingone_application_role_assignment" "admin_group" {
  environment_id = var.environment_id
  application_id = pingone_application.web_documents_ai.id
  group_id       = var.admin_group_id
  
  # Assign admin role
  role_id = var.admin_role_id
}

resource "pingone_application_role_assignment" "user_group" {
  environment_id = var.environment_id
  application_id = pingone_application.web_documents_ai.id
  group_id       = var.user_group_id
  
  # Assign user role
  role_id = var.user_role_id
}

# Application Attributes (claims mapping)
resource "pingone_application_attribute_mapping" "email" {
  environment_id = var.environment_id
  application_id = pingone_application.web_documents_ai.id
  
  name  = "email"
  value = "$${user.email}"
}

resource "pingone_application_attribute_mapping" "name" {
  environment_id = var.environment_id
  application_id = pingone_application.web_documents_ai.id
  
  name  = "name"
  value = "$${user.name.formatted}"
}

resource "pingone_application_attribute_mapping" "groups" {
  environment_id = var.environment_id
  application_id = pingone_application.web_documents_ai.id
  
  name  = "groups"
  value = "$${user.memberOfGroupNames}"
}

resource "pingone_application_attribute_mapping" "roles" {
  environment_id = var.environment_id
  application_id = pingone_application.web_documents_ai.id
  
  name  = "roles"
  value = "$${user.memberOfGroupNames}"  # Map groups to roles
}
```

**File:** `/terraform/modules/pingone-application/variables.tf`

```hcl
variable "environment_id" {
  description = "PingOne environment ID"
  type        = string
}

variable "environment_name" {
  description = "Environment name (dev, qa, uat, prd)"
  type        = string
}

variable "application_url" {
  description = "Application base URL"
  type        = string
}

variable "admin_group_id" {
  description = "PingOne group ID for administrators"
  type        = string
}

variable "user_group_id" {
  description = "PingOne group ID for regular users"
  type        = string
}

variable "admin_role_id" {
  description = "Application role ID for administrators"
  type        = string
  default     = ""
}

variable "user_role_id" {
  description = "Application role ID for users"
  type        = string
  default     = ""
}

variable "tags" {
  description = "Additional tags for resources"
  type        = map(string)
  default     = {}
}
```

**File:** `/terraform/modules/pingone-application/outputs.tf`

```hcl
output "application_id" {
  description = "PingOne application ID"
  value       = pingone_application.web_documents_ai.id
}

output "client_id" {
  description = "OIDC client ID"
  value       = pingone_application.web_documents_ai.oidc_options[0].client_id
  sensitive   = true
}

output "authority" {
  description = "OIDC authority URL"
  value       = "https://auth.pingone.com/${var.environment_id}/as"
}

output "issuer" {
  description = "OIDC issuer URL"
  value       = "https://auth.pingone.com/${var.environment_id}/as"
}

output "jwks_uri" {
  description = "JWKS URI for token validation"
  value       = "https://auth.pingone.com/${var.environment_id}/as/jwks"
}

output "authorization_endpoint" {
  description = "OAuth 2.0 authorization endpoint"
  value       = "https://auth.pingone.com/${var.environment_id}/as/authorize"
}

output "token_endpoint" {
  description = "OAuth 2.0 token endpoint"
  value       = "https://auth.pingone.com/${var.environment_id}/as/token"
}

output "userinfo_endpoint" {
  description = "OIDC userinfo endpoint"
  value       = "https://auth.pingone.com/${var.environment_id}/as/userinfo"
}
```

### 3.2 Resource Scopes Module

**File:** `/terraform/modules/pingone-resources/main.tf`

```hcl
# Define API resource for Documents AI
resource "pingone_resource" "documents_ai_api" {
  environment_id = var.environment_id
  name           = "Documents AI API - ${var.environment_name}"
  description    = "API resource for document processing application"
  type           = "CUSTOM"
  
  audience = var.api_audience
  
  introspection_endpoint_auth_method = "CLIENT_SECRET_BASIC"
}

# Define custom scopes
locals {
  scopes = {
    "documents:read"    = "Read documents and invoices"
    "documents:write"   = "Create and update documents"
    "clients:read"      = "Read client information"
    "clients:write"     = "Manage clients"
    "users:read"        = "Read user information"
    "users:write"       = "Manage users"
    "admin"             = "Administrative access"
  }
}

resource "pingone_resource_scope" "scopes" {
  for_each = local.scopes

  environment_id = var.environment_id
  resource_id    = pingone_resource.documents_ai_api.id
  
  name        = each.key
  description = each.value
}

# Grant scopes to application
resource "pingone_application_resource_grant" "documents_ai_api" {
  environment_id = var.environment_id
  application_id = var.application_id
  resource_id    = pingone_resource.documents_ai_api.id
  
  scopes = [
    for scope in pingone_resource_scope.scopes : scope.id
  ]
}
```

**File:** `/terraform/modules/pingone-resources/variables.tf`

```hcl
variable "environment_id" {
  description = "PingOne environment ID"
  type        = string
}

variable "environment_name" {
  description = "Environment name"
  type        = string
}

variable "application_id" {
  description = "PingOne application ID to grant scopes to"
  type        = string
}

variable "api_audience" {
  description = "API audience identifier (typically API URL)"
  type        = string
}
```

**File:** `/terraform/modules/pingone-resources/outputs.tf`

```hcl
output "resource_id" {
  description = "Documents AI API resource ID"
  value       = pingone_resource.documents_ai_api.id
}

output "scopes" {
  description = "Map of scope names to IDs"
  value = {
    for name, scope in pingone_resource_scope.scopes : name => scope.id
  }
}

output "scope_string" {
  description = "Space-separated scope string for OIDC configuration"
  value       = join(" ", ["openid", "profile", "email", keys(local.scopes)...])
}
```

### 3.3 Environment Configuration

**File:** `/terraform/environments/dev/main.tf`

```hcl
terraform {
  required_version = ">= 1.5.0"
  
  required_providers {
    pingone = {
      source  = "pingidentity/pingone"
      version = "~> 0.20.0"
    }
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
  
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "stterraformstate"
    container_name       = "tfstate"
    key                  = "documents-ai-dev.tfstate"
  }
}

provider "pingone" {
  client_id      = var.pingone_client_id
  client_secret  = var.pingone_client_secret
  environment_id = var.pingone_environment_id
  region         = "NA"  # North America
}

provider "azurerm" {
  features {}
  subscription_id = var.azure_subscription_id
}

# Local variables
locals {
  environment = "dev"
  location    = "eastus2"
  
  common_tags = {
    Environment = "dev"
    Application = "documents-ai"
    ManagedBy   = "terraform"
    Team        = "platform-engineering"
  }
}

# PingOne Application
module "pingone_application" {
  source = "../../modules/pingone-application"
  
  environment_id   = var.pingone_environment_id
  environment_name = local.environment
  application_url  = "https://documentsai.dev.claitdev.com"
  
  admin_group_id = var.ping_admin_group_id
  user_group_id  = var.ping_user_group_id
  
  tags = local.common_tags
}

# API Resource and Scopes
module "pingone_resources" {
  source = "../../modules/pingone-resources"
  
  environment_id   = var.pingone_environment_id
  environment_name = local.environment
  application_id   = module.pingone_application.application_id
  api_audience     = "https://api.dev.claitdev.com/documentsai"
}

# Store OIDC configuration in Azure Key Vault
resource "azurerm_key_vault_secret" "ping_client_id" {
  name         = "PING-CLIENT-ID"
  value        = module.pingone_application.client_id
  key_vault_id = var.key_vault_id
  
  tags = local.common_tags
}

resource "azurerm_key_vault_secret" "ping_authority" {
  name         = "PING-AUTHORITY"
  value        = module.pingone_application.authority
  key_vault_id = var.key_vault_id
  
  tags = local.common_tags
}

resource "azurerm_key_vault_secret" "ping_issuer" {
  name         = "PING-ISSUER"
  value        = module.pingone_application.issuer
  key_vault_id = var.key_vault_id
  
  tags = local.common_tags
}

resource "azurerm_key_vault_secret" "ping_jwks_uri" {
  name         = "PING-JWKS-URI"
  value        = module.pingone_application.jwks_uri
  key_vault_id = var.key_vault_id
  
  tags = local.common_tags
}

resource "azurerm_key_vault_secret" "ping_scope" {
  name         = "PING-SCOPE"
  value        = module.pingone_resources.scope_string
  key_vault_id = var.key_vault_id
  
  tags = local.common_tags
}
```

**File:** `/terraform/environments/dev/variables.tf`

```hcl
# PingOne Variables
variable "pingone_client_id" {
  description = "PingOne API client ID for Terraform"
  type        = string
  sensitive   = true
}

variable "pingone_client_secret" {
  description = "PingOne API client secret for Terraform"
  type        = string
  sensitive   = true
}

variable "pingone_environment_id" {
  description = "PingOne environment ID for DEV"
  type        = string
}

variable "ping_admin_group_id" {
  description = "PingOne group ID for administrators"
  type        = string
}

variable "ping_user_group_id" {
  description = "PingOne group ID for users"
  type        = string
}

# Azure Variables
variable "azure_subscription_id" {
  description = "Azure subscription ID"
  type        = string
}

variable "key_vault_id" {
  description = "Azure Key Vault ID for storing secrets"
  type        = string
}
```

**File:** `/terraform/environments/dev/outputs.tf`

```hcl
output "ping_application_id" {
  description = "PingOne application ID"
  value       = module.pingone_application.application_id
}

output "ping_client_id" {
  description = "OIDC client ID (sensitive)"
  value       = module.pingone_application.client_id
  sensitive   = true
}

output "ping_authority" {
  description = "OIDC authority URL"
  value       = module.pingone_application.authority
}

output "ping_scope" {
  description = "OIDC scope string"
  value       = module.pingone_resources.scope_string
}

output "configuration_summary" {
  description = "Configuration summary for Helm deployment"
  value = {
    authority = module.pingone_application.authority
    issuer    = module.pingone_application.issuer
    jwks_uri  = module.pingone_application.jwks_uri
    scope     = module.pingone_resources.scope_string
  }
}
```

---

## 4. Application Authorization

### 4.1 Role-Based Access Control (RBAC)

#### Role Definitions

| Role              | PING Group                            | Permissions                                            | Scopes                                                                                                        |
| ----------------- | ------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| **Administrator** | `cn=DocumentsAI-Admins,ou=Groups`     | Full system access, user management, all client access | `openid profile email documents:read documents:write clients:read clients:write users:read users:write admin` |
| **User**          | `cn=DocumentsAI-Users,ou=Groups`      | Read/write documents for assigned clients              | `openid profile email documents:read documents:write clients:read`                                            |
| **Developer**     | `cn=DocumentsAI-Developers,ou=Groups` | Development features, debugging tools                  | `openid profile email documents:read documents:write clients:read admin`                                      |
| **Auditor**       | `cn=DocumentsAI-Auditors,ou=Groups`   | Read-only access for compliance review                 | `openid profile email documents:read clients:read`                                                            |

### 4.2 Claims Mapping

#### PING Token Claims → Application Roles

```typescript
// File: /src/utilities/authHelpers.ts

export interface UserClaims {
  sub: string;              // User ID
  name: string;             // Full name
  email: string;            // Email address
  groups: string[];         // Group memberships
  roles: string[];          // Application roles
  scope: string;            // OAuth scopes
  iss: string;              // Issuer
  aud: string;              // Audience
  exp: number;              // Expiration
  iat: number;              // Issued at
}

/**
 * Map PING groups to application roles
 */
export const mapGroupsToRoles = (groups: string[]): string[] => {
  const roleMapping: Record<string, string> = {
    'DocumentsAI-Admins': 'Administrator',
    'DocumentsAI-Users': 'User',
    'DocumentsAI-Developers': 'Developer',
    'DocumentsAI-Auditors': 'Auditor',
  };

  const roles: string[] = [];
  
  for (const group of groups) {
    // Extract group name from DN or use as-is
    const groupName = group.includes('cn=') 
      ? group.split(',')[0].replace('cn=', '')
      : group;
    
    if (roleMapping[groupName]) {
      roles.push(roleMapping[groupName]);
    }
  }

  return roles;
};

/**
 * Check if user has required role
 */
export const hasRole = (roles: string[], requiredRole: string): boolean => {
  return roles.includes(requiredRole);
};

/**
 * Check if user has any of the required roles
 */
export const hasAnyRole = (roles: string[], requiredRoles: string[]): boolean => {
  return requiredRoles.some(role => roles.includes(role));
};

/**
 * Check if user has all required roles
 */
export const hasAllRoles = (roles: string[], requiredRoles: string[]): boolean => {
  return requiredRoles.every(role => roles.includes(role));
};
```

### 4.3 Frontend Route Protection

```typescript
// File: /src/routes/ProtectedRoute.tsx

import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/components/AuthProvider';
import { Loading } from '@/components/Loading';

interface ProtectedRouteProps {
  children: React.ReactNode;
  requiredRoles?: string[];
  requireAll?: boolean;  // If true, user must have all roles; if false, any role
}

export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
  children,
  requiredRoles = [],
  requireAll = false,
}) => {
  const auth = useAuth();
  const location = useLocation();

  if (auth.loading) {
    return <Loading />;
  }

  if (!auth.user || !auth.tokens.accessToken) {
    // Store return URL and redirect to login
    sessionStorage.setItem('auth-return-url', location.pathname + location.search);
    return <Navigate to="/login" replace />;
  }

  // Check role requirements
  if (requiredRoles.length > 0) {
    const hasAccess = requireAll
      ? requiredRoles.every(role => auth.roles.includes(role))
      : requiredRoles.some(role => auth.roles.includes(role));

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

  return <>{children}</>;
};

// Usage in router:
// <Route path="/admin" element={
//   <ProtectedRoute requiredRoles={['Administrator']}>
//     <AdminPage />
//   </ProtectedRoute>
// } />
```

### 4.4 Backend Authorization Middleware

#### Node.js/Express Example

```javascript
// File: backend/middleware/auth.js

const { expressjwt: jwt } = require('express-jwt');
const jwksRsa = require('jwks-rsa');

// JWT validation middleware
const jwtCheck = jwt({
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: process.env.PING_JWKS_URI
  }),
  audience: process.env.PING_CLIENT_ID,
  issuer: process.env.PING_ISSUER,
  algorithms: ['RS256'],
  credentialsRequired: true,
});

// Role extraction middleware
const extractRoles = (req, res, next) => {
  const user = req.auth;
  
  if (!user) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Extract roles from token claims
  req.userRoles = user.roles || user.groups || [];
  req.userId = user.sub;
  req.userEmail = user.email;

  next();
};

// Role check middleware factory
const requireRole = (...roles) => {
  return (req, res, next) => {
    if (!req.userRoles) {
      return res.status(401).json({ error: 'Unauthorized' });
    }

    const hasRole = roles.some(role => req.userRoles.includes(role));
    
    if (!hasRole) {
      return res.status(403).json({
        error: 'Forbidden',
        message: `Required roles: ${roles.join(', ')}`
      });
    }

    next();
  };
};

// Scope check middleware
const requireScope = (...scopes) => {
  return (req, res, next) => {
    const tokenScopes = req.auth.scope ? req.auth.scope.split(' ') : [];
    
    const hasScope = scopes.some(scope => tokenScopes.includes(scope));
    
    if (!hasScope) {
      return res.status(403).json({
        error: 'Forbidden',
        message: `Required scopes: ${scopes.join(', ')}`
      });
    }

    next();
  };
};

module.exports = {
  jwtCheck,
  extractRoles,
  requireRole,
  requireScope,
};

// Usage:
// app.get('/api/v1/clients',
//   jwtCheck,
//   extractRoles,
//   requireRole('Administrator', 'User'),
//   requireScope('clients:read'),
//   clientsController.getClients
// );
```

#### Python/FastAPI Example

```python
# File: backend/auth.py

from typing import List, Optional
from fastapi import Depends, HTTPException, status, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
import requests
from functools import lru_cache

security = HTTPBearer()

# Cache JWKS for 1 hour
@lru_cache(maxsize=1)
def get_jwks(jwks_uri: str):
    response = requests.get(jwks_uri)
    return response.json()

class TokenData:
    def __init__(self, token: dict):
        self.sub = token.get('sub')
        self.email = token.get('email')
        self.name = token.get('name')
        self.groups = token.get('groups', [])
        self.roles = token.get('roles', self.groups)  # Fallback to groups
        self.scope = token.get('scope', '').split(' ')

def verify_token(
    credentials: HTTPAuthorizationCredentials = Security(security)
) -> TokenData:
    token = credentials.credentials
    
    try:
        # Get JWKS
        jwks = get_jwks(settings.PING_JWKS_URI)
        
        # Decode and verify token
        payload = jwt.decode(
            token,
            jwks,
            algorithms=['RS256'],
            audience=settings.PING_CLIENT_ID,
            issuer=settings.PING_ISSUER,
        )
        
        return TokenData(payload)
    
    except JWTError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Invalid authentication credentials: {str(e)}",
            headers={"WWW-Authenticate": "Bearer"},
        )

def require_roles(required_roles: List[str]):
    def role_checker(token_data: TokenData = Depends(verify_token)):
        if not any(role in token_data.roles for role in required_roles):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Required roles: {', '.join(required_roles)}"
            )
        return token_data
    return role_checker

def require_scopes(required_scopes: List[str]):
    def scope_checker(token_data: TokenData = Depends(verify_token)):
        if not any(scope in token_data.scope for scope in required_scopes):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Required scopes: {', '.join(required_scopes)}"
            )
        return token_data
    return scope_checker

# Usage:
# @app.get("/api/v1/clients")
# def get_clients(
#     token_data: TokenData = Depends(require_roles(["Administrator", "User"])),
#     _: TokenData = Depends(require_scopes(["clients:read"]))
# ):
#     return {"clients": [...]}
```

### 4.5 Client-Level Authorization

```typescript
// File: /src/utilities/clientAuthorization.ts

import { Client } from '@/hooks/useClients';

/**
 * Check if user has access to specific client
 */
export const canAccessClient = (
  roles: string[],
  userClients: Client[],
  clientId: number
): boolean => {
  // Administrators have access to all clients
  if (roles.includes('Administrator')) {
    return true;
  }

  // Regular users only have access to their assigned clients
  return userClients.some(client => client.id === clientId);
};

/**
 * Filter clients based on user access
 */
export const filterAccessibleClients = (
  roles: string[],
  allClients: Client[],
  userClients: Client[]
): Client[] => {
  if (roles.includes('Administrator')) {
    return allClients;
  }

  return userClients;
};

/**
 * Check if user can perform action on client
 */
export const canPerformAction = (
  roles: string[],
  action: 'read' | 'write' | 'delete',
  clientId: number,
  userClients: Client[]
): boolean => {
  // Check client access
  if (!canAccessClient(roles, userClients, clientId)) {
    return false;
  }

  // Check action permission
  switch (action) {
    case 'read':
      return true;  // All users with client access can read
    case 'write':
      return roles.includes('Administrator') || roles.includes('User');
    case 'delete':
      return roles.includes('Administrator');
    default:
      return false;
  }
};
```

---

## 5. API Contracts

### 5.1 Token Validation API

#### Request Headers

```http
Authorization: Bearer <PING_ACCESS_TOKEN>
```

#### Token Structure

**Access Token Claims:**

```json
{
  "sub": "00000000-0000-0000-0000-000000000000",
  "iss": "https://auth.pingone.com/{env-id}/as",
  "aud": "client-id-12345678",
  "exp": 1709740800,
  "iat": 1709737200,
  "azp": "client-id-12345678",
  "scope": "openid profile email documents:read documents:write clients:read",
  "email": "user@example.com",
  "email_verified": true,
  "name": "John Doe",
  "given_name": "John",
  "family_name": "Doe",
  "groups": ["DocumentsAI-Users", "DocumentsAI-Developers"],
  "roles": ["User", "Developer"]
}
```

**ID Token Claims:**

```json
{
  "sub": "00000000-0000-0000-0000-000000000000",
  "iss": "https://auth.pingone.com/{env-id}/as",
  "aud": "client-id-12345678",
  "exp": 1709740800,
  "iat": 1709737200,
  "auth_time": 1709737200,
  "nonce": "n-0S6_WzA2Mj",
  "at_hash": "aaaaaaaaaaaaaaaaaa",
  "email": "user@example.com",
  "email_verified": true,
  "name": "John Doe",
  "given_name": "John",
  "family_name": "Doe"
}
```

### 5.2 Backend API Endpoints

#### GET /api/v1/clients

**Authorization:** Bearer token with `clients:read` scope

**Response:**

```json
{
  "items": [
    {
      "id": 1,
      "clientID": "CLIENT001",
      "clientName": "Acme Corporation",
      "displayName": "Acme Corp",
      "contactName": "Jane Smith",
      "contactEmail": "jane@acme.com",
      "enabled": true,
      "createdOn": "2024-01-15T10:00:00Z",
      "modifiedOn": "2024-03-01T14:30:00Z",
      "config": {}
    }
  ],
  "total": 1
}
```

#### POST /api/v1/upload/\{clientId}

**Authorization:** Bearer token with `documents:write` scope and client access

**Request:**

```http
POST /api/v1/upload/1
Content-Type: multipart/form-data
Authorization: Bearer <token>

------WebKitFormBoundary...
Content-Disposition: form-data; name="file"; filename="invoice.pdf"
Content-Type: application/pdf

<binary data>
------WebKitFormBoundary...
```

**Response:**

```json
{
  "received_invoices": [
    {
      "name": "invoice.pdf",
      "documentID": "doc-12345",
      "status": "uploaded",
      "validationProblems": []
    }
  ]
}
```

### 5.3 Error Responses

#### 401 Unauthorized

```json
{
  "error": "Unauthorized",
  "message": "Invalid or expired access token",
  "code": "TOKEN_INVALID"
}
```

#### 403 Forbidden

```json
{
  "error": "Forbidden",
  "message": "Insufficient permissions to access this resource",
  "code": "INSUFFICIENT_PERMISSIONS",
  "required": {
    "scopes": ["documents:write"],
    "roles": ["Administrator", "User"]
  }
}
```

#### 404 Not Found

```json
{
  "error": "Not Found",
  "message": "Client with ID 999 not found",
  "code": "RESOURCE_NOT_FOUND"
}
```

---

## 6. Configuration Management

### 6.1 Environment Variables

#### Frontend Container

```bash
# PING Identity Configuration
PING_AUTHORITY=https://auth.pingone.com/{env-id}/as
PING_CLIENT_ID=client-id-12345678
PING_ISSUER=https://auth.pingone.com/{env-id}/as
PING_JWKS_URI=https://auth.pingone.com/{env-id}/as/jwks
PING_SCOPE="openid profile email documents:read documents:write clients:read"

# Application Configuration
REACT_APP_API_URL=https://api.dev.claitdev.com/documentsai
REACT_APP_VERSION=1.0.0
NODE_ENV=production
```

#### Backend Container

```bash
# PING Identity Configuration
PING_ISSUER=https://auth.pingone.com/{env-id}/as
PING_JWKS_URI=https://auth.pingone.com/{env-id}/as/jwks
PING_AUDIENCE=client-id-12345678

# Application Configuration
DATABASE_URL=postgresql://user:pass@host:5432/dbname
REDIS_URL=redis://redis:6379/0
LOG_LEVEL=INFO
```

### 6.2 Secret Management with Azure Key Vault

#### External Secrets Operator Configuration

```yaml
# File: kubernetes/external-secret.yaml

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: ping-oidc-config
  namespace: documents-ai
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: azure-keyvault
    kind: ClusterSecretStore
  target:
    name: ping-oidc-config
    creationPolicy: Owner
  data:
  - secretKey: PING_CLIENT_ID
    remoteRef:
      key: PING-CLIENT-ID
  - secretKey: PING_AUTHORITY
    remoteRef:
      key: PING-AUTHORITY
  - secretKey: PING_ISSUER
    remoteRef:
      key: PING-ISSUER
  - secretKey: PING_JWKS_URI
    remoteRef:
      key: PING-JWKS-URI
  - secretKey: PING_SCOPE
    remoteRef:
      key: PING-SCOPE
```

### 6.3 ConfigMap for Non-Sensitive Configuration

```yaml
# File: kubernetes/configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: web-documents-ai-config
  namespace: documents-ai
data:
  REACT_APP_API_URL: "https://api.dev.claitdev.com/documentsai"
  REACT_APP_VERSION: "1.0.0"
  NODE_ENV: "production"
```

---

## 7. Error Handling

### 7.1 Frontend Error Scenarios

#### Authentication Errors

```typescript
// File: /src/utilities/errorHandling.ts

export enum AuthErrorCode {
  TOKEN_EXPIRED = 'TOKEN_EXPIRED',
  TOKEN_INVALID = 'TOKEN_INVALID',
  NO_TOKEN = 'NO_TOKEN',
  NETWORK_ERROR = 'NETWORK_ERROR',
  AUTH_SERVER_ERROR = 'AUTH_SERVER_ERROR',
  USER_CANCELLED = 'USER_CANCELLED',
  POPUP_BLOCKED = 'POPUP_BLOCKED',
}

export class AuthError extends Error {
  code: AuthErrorCode;
  originalError?: any;

  constructor(code: AuthErrorCode, message: string, originalError?: any) {
    super(message);
    this.name = 'AuthError';
    this.code = code;
    this.originalError = originalError;
  }
}

export const handleAuthError = (error: any): AuthError => {
  // OIDC client errors
  if (error.error) {
    switch (error.error) {
      case 'login_required':
      case 'interaction_required':
        return new AuthError(
          AuthErrorCode.TOKEN_EXPIRED,
          'Authentication required. Please sign in again.',
          error
        );
      case 'consent_required':
        return new AuthError(
          AuthErrorCode.TOKEN_INVALID,
          'Additional consent required.',
          error
        );
      case 'access_denied':
        return new AuthError(
          AuthErrorCode.TOKEN_INVALID,
          'Access denied by authorization server.',
          error
        );
      default:
        return new AuthError(
          AuthErrorCode.AUTH_SERVER_ERROR,
          'Authentication server error.',
          error
        );
    }
  }

  // Network errors
  if (error.message && error.message.includes('Network')) {
    return new AuthError(
      AuthErrorCode.NETWORK_ERROR,
      'Network error. Please check your connection.',
      error
    );
  }

  // Popup blocked
  if (error.message && error.message.includes('popup')) {
    return new AuthError(
      AuthErrorCode.POPUP_BLOCKED,
      'Popup blocked by browser. Please allow popups and try again.',
      error
    );
  }

  // Generic error
  return new AuthError(
    AuthErrorCode.AUTH_SERVER_ERROR,
    error.message || 'An unknown error occurred.',
    error
  );
};
```

#### API Error Handling

```typescript
// File: /src/utilities/apiErrorHandling.ts

export const handleApiError = (error: any): string => {
  if (!error) return 'Unknown error occurred';

  // Network errors
  if (error.request && !error.response) {
    return 'Network error. Please check your connection and try again.';
  }

  // Server responded with error
  if (error.response) {
    const status = error.response.status;
    const data = error.response.data;

    switch (status) {
      case 400:
        return data?.message || data?.error || 'Invalid request.';
      case 401:
        // Token expired or invalid - handled by interceptor
        return 'Session expired. Redirecting to login...';
      case 403:
        return 'You don\'t have permission to perform this action.';
      case 404:
        return data?.message || 'Resource not found.';
      case 409:
        return data?.message || 'Conflict with existing resource.';
      case 413:
        return 'File size too large.';
      case 422:
        return data?.message || 'Validation error.';
      case 429:
        return 'Too many requests. Please try again later.';
      case 500:
        return 'Server error. Please try again later.';
      case 502:
      case 503:
      case 504:
        return 'Service temporarily unavailable. Please try again later.';
      default:
        return data?.message || `Request failed (${status}).`;
    }
  }

  return error.message || 'An error occurred.';
};
```

### 7.2 Backend Error Handling

```python
# File: backend/exceptions.py

from fastapi import HTTPException, status

class AuthenticationError(HTTPException):
    def __init__(self, detail: str = "Authentication failed"):
        super().__init__(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=detail,
            headers={"WWW-Authenticate": "Bearer"},
        )

class AuthorizationError(HTTPException):
    def __init__(self, detail: str = "Insufficient permissions"):
        super().__init__(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=detail,
        )

class TokenValidationError(HTTPException):
    def __init__(self, detail: str = "Invalid token"):
        super().__init__(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=detail,
            headers={"WWW-Authenticate": "Bearer"},
        )

# Error handler middleware
@app.exception_handler(JWTError)
async def jwt_error_handler(request: Request, exc: JWTError):
    return JSONResponse(
        status_code=401,
        content={
            "error": "Unauthorized",
            "message": "Invalid or expired token",
            "code": "TOKEN_INVALID"
        }
    )
```

---

## 8. Security Implementation

### 8.1 PKCE (Proof Key for Code Exchange)

PKCE is automatically enabled by `oidc-client-ts` for authorization code flow. No additional configuration needed.

**How it works:**

1. Client generates a random `code_verifier`
2. Client creates `code_challenge` = SHA256(code\_verifier)
3. Authorization request includes `code_challenge` and `code_challenge_method=S256`
4. Token request includes original `code_verifier`
5. Server validates that `code_verifier` matches stored `code_challenge`

### 8.2 Token Storage Security

```typescript
// Tokens are stored in sessionStorage (not localStorage) for better security
// sessionStorage is cleared when tab is closed

// OIDC client stores in this format:
// Key: `oidc.user:{authority}:{client_id}`
// Value: JSON string containing user object with tokens

// Additional security measures:
// 1. Tokens never logged to console in production
// 2. Tokens not included in error reports
// 3. Tokens cleared on logout
// 4. Tokens have short lifespan (2 hours typical)
// 5. Refresh tokens rotated on each use
```

### 8.3 Content Security Policy (CSP)

```nginx
# File: nginx.conf or added via Helm

add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self' data:;
  connect-src 'self' https://auth.pingone.com https://api.*.claitdev.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self' https://auth.pingone.com;
" always;

add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
```

### 8.4 Token Validation Checklist

Backend must validate:

* [ ] **Signature**: Token signed by PING's private key (verified with JWKS)
* [ ] **Issuer (iss)**: Matches expected PING issuer URL
* [ ] **Audience (aud)**: Matches application client ID
* [ ] **Expiration (exp)**: Token not expired
* [ ] **Not Before (nbf)**: Token not used before valid time
* [ ] **Issued At (iat)**: Token issued recently (within clock skew)

### 8.5 Rate Limiting

```typescript
// Frontend: Limit authentication attempts
let authAttempts = 0;
const MAX_AUTH_ATTEMPTS = 5;
const AUTH_COOLDOWN = 300000; // 5 minutes

const attemptAuth = async () => {
  if (authAttempts >= MAX_AUTH_ATTEMPTS) {
    throw new Error('Too many authentication attempts. Please try again later.');
  }

  authAttempts++;
  setTimeout(() => authAttempts = Math.max(0, authAttempts - 1), AUTH_COOLDOWN);

  // Proceed with authentication
};
```

```python
# Backend: Rate limiting with Redis
from fastapi_limiter import FastAPILimiter
from fastapi_limiter.depends import RateLimiter

@app.get("/api/v1/clients",
    dependencies=[Depends(RateLimiter(times=100, seconds=60))]
)
```

---

## 9. Monitoring and Logging

### 9.1 Authentication Metrics

**Prometheus Metrics:**

```typescript
// File: /src/utilities/metrics.ts (if using Prometheus client)

// Counter for authentication attempts
authAttemptsTotal.inc({ result: 'success' | 'failure' });

// Histogram for authentication duration
authDurationSeconds.observe(duration);

// Gauge for current authenticated users
authenticatedUsers.set(count);
```

**Key Metrics to Track:**

* `auth_logins_total{result="success|failure"}` - Total login attempts
* `auth_login_duration_seconds` - Time to complete login
* `auth_token_refresh_total{result="success|failure"}` - Token refresh attempts
* `auth_logout_total` - Logout events
* `auth_errors_total{type="..."}` - Authentication errors by type

### 9.2 Logging Standards

#### Frontend Logging

```typescript
// File: /src/utilities/logger.ts

enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  WARN = 2,
  ERROR = 3,
}

class Logger {
  private level: LogLevel;

  constructor() {
    this.level = window.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.DEBUG;
  }

  debug(message: string, ...args: any[]) {
    if (this.level <= LogLevel.DEBUG) {
      console.debug(`[DEBUG] ${message}`, ...args);
    }
  }

  info(message: string, ...args: any[]) {
    if (this.level <= LogLevel.INFO) {
      console.info(`[INFO] ${message}`, ...args);
    }
  }

  warn(message: string, ...args: any[]) {
    if (this.level <= LogLevel.WARN) {
      console.warn(`[WARN] ${message}`, ...args);
    }
  }

  error(message: string, error?: any) {
    if (this.level <= LogLevel.ERROR) {
      console.error(`[ERROR] ${message}`, error);
      
      // Send to logging service (e.g., Application Insights)
      if (window.appInsights) {
        window.appInsights.trackException({
          exception: error,
          properties: { message }
        });
      }
    }
  }

  // Never log tokens!
  sanitizeForLogging(obj: any): any {
    const sensitiveKeys = ['access_token', 'id_token', 'refresh_token', 'password', 'secret'];
    const sanitized = { ...obj };
    
    for (const key of sensitiveKeys) {
      if (sanitized[key]) {
        sanitized[key] = '[REDACTED]';
      }
    }
    
    return sanitized;
  }
}

export const logger = new Logger();
```

#### Backend Logging

```python
# File: backend/logging_config.py

import logging
import json
from datetime import datetime

class JSONFormatter(logging.Formatter):
    def format(self, record):
        log_data = {
            "timestamp": datetime.utcnow().isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "module": record.module,
            "function": record.funcName,
            "line": record.lineno,
        }
        
        # Add exception info if present
        if record.exc_info:
            log_data["exception"] = self.formatException(record.exc_info)
        
        # Add extra fields
        if hasattr(record, "user_id"):
            log_data["user_id"] = record.user_id
        if hasattr(record, "client_id"):
            log_data["client_id"] = record.client_id
        if hasattr(record, "request_id"):
            log_data["request_id"] = record.request_id
        
        return json.dumps(log_data)

# Request logging middleware
@app.middleware("http")
async def log_requests(request: Request, call_next):
    request_id = str(uuid.uuid4())
    
    # Extract user info from token (if present)
    user_id = None
    if hasattr(request.state, "token_data"):
        user_id = request.state.token_data.sub
    
    logger.info(
        f"Request: {request.method} {request.url.path}",
        extra={"request_id": request_id, "user_id": user_id}
    )
    
    response = await call_next(request)
    
    logger.info(
        f"Response: {response.status_code}",
        extra={"request_id": request_id, "user_id": user_id}
    )
    
    return response
```

### 9.3 Azure Application Insights Integration

```typescript
// File: /src/utilities/appInsights.ts

import { ApplicationInsights } from '@microsoft/applicationinsights-web';

export const initializeAppInsights = () => {
  const appInsights = new ApplicationInsights({
    config: {
      instrumentationKey: window.env.APPINSIGHTS_INSTRUMENTATION_KEY,
      enableAutoRouteTracking: true,
      enableRequestHeaderTracking: true,
      enableResponseHeaderTracking: true,
      disableFetchTracking: false,
      // Don't track tokens in telemetry
      addRequestContext: (envelope) => {
        // Remove sensitive headers
        if (envelope.headers) {
          delete envelope.headers['Authorization'];
        }
      }
    }
  });
  
  appInsights.loadAppInsights();
  appInsights.trackPageView();
  
  return appInsights;
};

// Track custom authentication events
export const trackAuthEvent = (eventName: string, properties?: any) => {
  if (window.appInsights) {
    window.appInsights.trackEvent({
      name: eventName,
      properties: {
        ...properties,
        timestamp: new Date().toISOString(),
      }
    });
  }
};

// Usage:
// trackAuthEvent('Login', { method: 'redirect', success: true });
// trackAuthEvent('TokenRefresh', { success: false, error: 'expired' });
```

### 9.4 Alerting Rules

**Azure Monitor Alerts:**

1. **High Authentication Failure Rate**
   * Condition: Auth failure rate > 10% over 5 minutes
   * Action: Email security team

2. **Token Validation Errors**
   * Condition: Token validation failures > 50 in 5 minutes
   * Action: Page on-call engineer

3. **PING Service Unavailable**
   * Condition: PING JWKS endpoint unreachable for > 2 minutes
   * Action: Page on-call engineer, notify identity team

4. **Unusual Login Activity**
   * Condition: Logins from new location or device
   * Action: Email user and security team

---

## 10. Testing Specifications

### 10.1 Unit Tests

#### AuthProvider Tests

**File:** `/src/components/__tests__/AuthProvider.test.tsx`

```typescript
import { render, screen, waitFor } from '@testing-library/react';
import { AuthProvider, useAuth } from '@/components/AuthProvider';
import { useAuth as useOidcAuth } from 'react-oidc-context';

jest.mock('react-oidc-context');

describe('AuthProvider', () => {
  beforeEach(() => {
    (useOidcAuth as jest.Mock).mockReturnValue({
      isLoading: false,
      user: null,
      error: null,
      signinRedirect: jest.fn(),
      signoutRedirect: jest.fn(),
    });
  });

  test('provides auth context', () => {
    const TestComponent = () => {
      const auth =useAuth();
      return <div>Loading: {auth.loading.toString()}</div>;
    };

    render(
      <AuthProvider>
        <TestComponent />
      </AuthProvider>
    );

    expect(screen.getByText(/Loading: false/)).toBeInTheDocument();
  });

  test('handles authenticated user', async () => {
    (useOidcAuthject.Mock).mockReturnValue({
      isLoading: false,
      user: {
        access_token: 'mock-token',
        id_token: 'mock-id-token',
        profile: {
          sub: '123',
          email: 'test@example.com',
          name: 'Test User',
          groups: ['DocumentsAI-Users'],
        },
        expired: false,
      },
      error: null,
      signinRedirect: jest.fn(),
      signoutRedirect: jest.fn(),
    });

    const TestComponent = () => {
      const auth = useAuth();
      return <div>User: {auth.user?.profile.email}</div>;
    };

    render(
      <AuthProvider>
        <TestComponent />
      </AuthProvider>
    );

    await waitFor(() => {
      expect(screen.getByText(/User: test@example.com/)).toBeInTheDocument();
    });
  });

  test('extracts roles from groups', async () => {
    (useOidcAuth as jest.Mock).mockReturnValue({
      isLoading: false,
      user: {
        access_token: 'mock-token',
        profile: {
          groups: ['DocumentsAI-Admins', 'DocumentsAI-Developers'],
        },
        expired: false,
      },
      error: null,
    });

    const TestComponent = () => {
      const auth = useAuth();
      return <div>Roles: {auth.roles.join(', ')}</div>;
    };

    render(
      <AuthProvider>
        <TestComponent />
      </AuthProvider>
    );

    await waitFor(() => {
      expect(screen.getByText(/Roles: Administrator, Developer/)).toBeInTheDocument();
    });
  });
});
```

### 10.2 Integration Tests

**File:** `/tests/integration/auth.test.ts`

```typescript
import { test, expect } from '@playwright/test';

test.describe('PING Authentication Flow', () => {
  test('user can login with PING', async ({ page, context }) => {
    // Navigate to application
    await page.goto('https://documentsai.dev.claitdev.com');

    // Should redirect to login
    await expect(page).toHaveURL(/\/login/);

    // Click sign in button
    await page.click('button:has-text("Sign In with PING Identity")');

    // Should redirect to PING
    await expect(page).toHaveURL(/auth.pingone.com/);

    // Fill in credentials (using test user)
    await page.fill('input[name="username"]', process.env.TEST_USER_EMAIL);
    await page.fill('input[name="password"]', process.env.TEST_USER_PASSWORD);

    // Submit login form
    await page.click('button[type="submit"]');

    // Should redirect back to app
    await expect(page).toHaveURL('https://documentsai.dev.claitdev.com/');

    // Verify user is logged in
    await expect(page.locator('text=Dashboard')).toBeVisible();

    // Verify token in sessionStorage
    const token = await page.evaluate(() => {
      const key = Object.keys(sessionStorage).find(k => k.startsWith('oidc.user:'));
      return key ? sessionStorage.getItem(key) : null;
    });

    expect(token).toBeTruthy();
    const userObj = JSON.parse(token);
    expect(userObj.access_token).toBeTruthy();
  });

  test('user can logout', async ({ page }) => {
    // ... login first ...

    // Click logout
    await page.click('[aria-label="User menu"]');
    await page.click('text=Logout');

    // Should clear sessionStorage
    const token = await page.evaluate(() => {
      const key = Object.keys(sessionStorage).find(k => k.startsWith('oidc.user:'));
      return key ? sessionStorage.getItem(key) : null;
    });

    expect(token).toBeNull();

    // Should redirect to login
    await expect(page).toHaveURL(/\/login/);
  });

  test('expired token triggers renewal', async ({ page }) => {
    // ... setup with expired token ...

    // Make API call
    await page.click('text=Clients');

    // Should trigger silent renewal
    await page.waitForTimeout(2000);

    // Verify API call succeeded (token was renewed)
    await expect(page.locator('table')).toBeVisible();
  });
});
```

### 10.3 Security Tests

**File:** `/tests/security/token-validation.test.py`

```python
import pytest
import jwt
from datetime import datetime, timedelta

def test_backend_rejects_expired_token(client):
    """Test that backend rejects expired tokens"""
    # Create expired token
    expired_token = create_test_token(exp=datetime.utcnow() - timedelta(hours=1))
    
    response = client.get(
        "/api/v1/clients",
        headers={"Authorization": f"Bearer {expired_token}"}
    )
    
    assert response.status_code == 401
    assert "expired" in response.json()["detail"].lower()

def test_backend_rejects_invalid_issuer(client):
    """Test that backend rejects tokens from wrong issuer"""
    # Create token with wrong issuer
    token = create_test_token(issuer="https://evil.com")
    
    response = client.get(
        "/api/v1/clients",
        headers={"Authorization": f"Bearer {token}"}
    )
    
    assert response.status_code == 401

def test_backend_rejects_invalid_audience(client):
    """Test that backend rejects tokens for wrong audience"""
    token = create_test_token(audience="wrong-audience")
    
    response = client.get(
        "/api/v1/clients",
        headers={"Authorization": f"Bearer {token}"}
    )
    
    assert response.status_code == 401

def test_backend_enforces_role_requirements(client):
    """Test that backend enforces role-based access control"""
    # Create token without required role
    token = create_test_token(roles=["User"])  # Not Administrator
    
    response = client.delete(
        "/api/v1/clients/1",
        headers={"Authorization": f"Bearer {token}"}
    )
    
    assert response.status_code == 403
    assert "permission" in response.json()["detail"].lower()

def test_pkce_code_challenge_validation(client):
    """Test that authorization endpoint requires PKCE"""
    response = client.get("/authorize", params={
        "response_type": "code",
        "client_id": "test-client",
        "redirect_uri": "https://app.example.com/callback",
        # Missing code_challenge and code_challenge_method
    })
    
    assert response.status_code == 400
    assert "code_challenge" in response.json()["error_description"].lower()
```

### 10.4 Performance Tests

```typescript
// File: /tests/performance/auth-load.test.ts

import { test } from '@playwright/test';

test('authentication can handle concurrent users', async ({ context }) => {
  const numUsers = 50;
  const pages = [];

  // Create multiple browser pages (simulating concurrent users)
  for (let i = 0; i < numUsers; i++) {
    pages.push(await context.newPage());
  }

  const startTime = Date.now();

  // All users login concurrently
  await Promise.all(
    pages.map(async (page) => {
      await page.goto('https://documentsai.dev.claitdev.com');
      // ... perform login ...
    })
  );

  const endTime = Date.now();
  const duration = endTime - startTime;

  console.log(`${numUsers} concurrent logins completed in ${duration}ms`);
  console.log(`Average: ${duration / numUsers}ms per user`);

  // Verify all succeeded
  for (const page of pages) {
    expect(await page.title()).not.toContain('Error');
  }
});
```

---

## 11. Deployment Checklist

### 11.1 Pre-Deployment

* [ ] PING applications created in all environments
* [ ] User groups configured in PING
* [ ] Terraform applied and outputs verified
* [ ] Secrets stored in Azure Key Vault
* [ ] Helm values files updated with PING configuration
* [ ] Code changes merged to main branch
* [ ] Docker image built with PING integration
* [ ] Integration tests passing in DEV
* [ ] Security scan completed (no high/critical vulnerabilities)
* [ ] Performance tests show no degradation
* [ ] Rollback plan documented and tested
* [ ] Monitoring alerts configured
* [ ] On-call engineer identified
* [ ] Stakeholder approval obtained

### 11.2 Deployment Steps

#### DEV Environment

```bash
# 1. Apply Terraform
cd terraform/environments/dev
terraform plan -out=tfplan
terraform apply tfplan

# 2. Update Helm
cd ../../../helm
helm upgrade --install web-documents-ai . \
  --namespace documents-ai \
  --values values.yaml \
  --values values-dev.yaml \
  --set image.tag=dev-v1.2.0

# 3. Verify deployment
kubectl get pods -n documents-ai
kubectl logs -n documents-ai deployment/web-documents-ai

# 4. Smoke tests
curl -I https://documentsai.dev.claitdev.com
```

#### Production Environment

```bash
# 1. Final verification in UAT
# ... run tests ...

# 2. Apply Terraform
cd terraform/environments/prd
terraform plan -out=tfplan
# Review plan carefully
terraform apply tfplan

# 3. Deploy Helm (with health checks)
helm upgrade --install web-documents-ai . \
  --namespace documents-ai \
  --values values.yaml \
  --values values-prd.yaml \
  --set image.tag=prd-v1.2.0 \
  --wait \
  --timeout 10m

# 4. Monitor deployment
kubectl rollout status deployment/web-documents-ai -n documents-ai

# 5. Smoke tests
./scripts/smoke-tests.sh production
```

### 11.3 Post-Deployment

* [ ] Smoke tests passing
* [ ] User login test successful
* [ ] API calls with PING token successful
* [ ] Token refresh working
* [ ] Logout working
* [ ] No errors in application logs
* [ ] Metrics showing normal behavior
* [ ] User communication sent (if applicable)
* [ ] Documentation updated
* [ ] Runbook verified
* [ ] Azure AD app registration disabled (after grace period)

---

## 12. Troubleshooting Guide

### 12.1 Common Issues

#### Issue: "Invalid OIDC configuration"

**Cause:** Missing or incorrect environment variables

**Solution:**

```bash
# Check environment variables in pod
kubectl exec -n documents-ai deployment/web-documents-ai -- env | grep PING

# Expected output:
# PING_AUTHORITY=https://auth.pingone.com/{env-id}/as
# PING_CLIENT_ID=client-id-...
# PING_ISSUER=https://auth.pingone.com/{env-id}/as
# PING_JWKS_URI=https://auth.pingone.com/{env-id}/as/jwks
# PING_SCOPE=openid profile email ...
```

#### Issue: "Token validation failed"

**Cause:** JWKS endpoint unreachable or token from wrong issuer

**Solution:**

1. Verify JWKS endpoint accessible:

```bash
curl https://auth.pingone.com/{env-id}/as/jwks
```

2. Check token issuer matches configuration
3. Verify network policies allow outbound to PING

#### Issue: "User has no roles"

**Cause:** User not in any PING groups

**Solution:**

1. Verify user group membership in PING
2. Check group-to-role mapping in code
3. Verify group claims included in token

#### Issue: "Redirect loop"

**Cause:** Callback URL misconfigured

**Solution:**

1. Verify redirect URI in PING matches application URL
2. Check for trailing slashes ([https://app.com/callback](https://app.com/callback) vs [https://app.com/callback/](https://app.com/callback/))
3. Ensure CORS configured correctly

### 12.2 Debug Commands

```bash
# View pod logs
kubectl logs -n documents-ai deployment/web-documents-ai --tail=100 -f

# Check env.js in running container
kubectl exec -n documents-ai deployment/web-documents-ai -- cat /usr/share/nginx/html/env.js

# Test PING connectivity from pod
kubectl exec -n documents-ai deployment/web-documents-ai -- curl -I https://auth.pingone.com

# Describe pod for events
kubectl describe pod -n documents-ai -l app=web-documents-ai

# Check Istio sidecar logs
kubectl logs -n documents-ai deployment/web-documents-ai -c istio-proxy
```

---

## Document Approval

**Prepared by:** Development Team\
**Reviewed by:**

* [ ] Backend Team Lead
* [ ] Frontend Team Lead
* [ ] Identity Team Lead
* [ ] Infrastructure Team Lead
* [ ] Security Team Lead

**Approved by:**

* [ ] Engineering Director

**Date:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_

---

**Next Steps:**

1. Review this low-level design with technical teams
2. Create Jira tickets for implementation tasks
3. Begin Phase 0 work (prerequisites)
4. Schedule kickoff meeting