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:
- Single Codebase: One deployment supports both providers via configuration
- Backward Compatible: Existing Entra environments unchanged
- Provider Agnostic: Application code uses abstracted useAuth() hook
- Environment Specific: Provider selection via AUTH_PROVIDER env var
Table of Contents
- Code Implementation Details
- Helm Chart Specifications
- Terraform Modules
- Application Authorization
- API Contracts
- Configuration Management
- Error Handling
- Security Implementation
- Monitoring and Logging
- 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):
{"dependencies": {"@azure/msal-browser": "3.5.0","@azure/msal-react": "2.0.7"}}
Add (new PING dependencies):
{"dependencies": {"oidc-client-ts": "^3.0.1","react-oidc-context": "^3.1.0"}}
Keep (existing - for token handling):
{"dependencies": {"jose": "^5.2.0"}}
Final package.json dependencies section:
{"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:
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:
- Single Entry Point: All components use the same
useAuth()hook - Provider Abstraction: Application code is unaware of which provider is active
- Runtime Selection: Provider determined by
AUTH_PROVIDERenvironment variable
Environment Variable: AUTH_PROVIDER
# 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:
interface IAuthProvider {// User stateisAuthenticated: boolean;user: UserProfile | null;isLoading: boolean;// Actionslogin: () => Promise<void>;logout: () => Promise<void>;getAccessToken: () => Promise<string>;// User profilegetUserProfile: () => UserProfile;}interface UserProfile {id: string;email: string;name: string;roles: string[];client?: Client; // Optional: for role-based access}
High-Level Provider Component Structure
// 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 compatibilityreturn <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
// @ts-nocheckimport { 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 variablesif (!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 configurationauthority: window.env.PING_AUTHORITY,client_id: window.env.PING_CLIENT_ID,// Redirect URIsredirect_uri: `${baseUrl}/callback`,post_logout_redirect_uri: `${baseUrl}/login`,silent_redirect_uri: `${baseUrl}/silent-renew.html`,// OAuth 2.0 / OIDC settingsresponse_type: 'code',response_mode: 'query',// Scopes - request access to user info and API resourcesscope: window.env.PING_SCOPE || 'openid profile email',// Token managementautomaticSilentRenew: true,monitorSession: true,checkSessionInterval: 10000, // Check session every 10 seconds// User info endpointloadUserInfo: true,// PKCE (Proof Key for Code Exchange) - required for SPAs// This is enabled by default in oidc-client-ts// Token storagestoreAuthStateInCookie: false,userStore: undefined, // Use default WebStorageStateStore (sessionStorage)// MetadatametadataUrl: `${window.env.PING_AUTHORITY}/.well-known/openid-configuration`,// Additional settingsfilterProtocolClaims: true,includeIdTokenInSilentRenew: false,revokeTokensOnSignout: true,// Clock skew - allow 5 minute differenceclockSkew: 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
// @ts-nocheckimport 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 remainingreturn 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 tokenconst 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 scopesconst 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 objectconst 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 namesconst profile = user.profile as any;// Try common role claim namesconst roleClaims = [profile.roles, // Direct roles arrayprofile.role, // Single roleprofile.groups, // Groups as rolesprofile.group, // Single groupprofile['custom:roles'], // Custom claimprofile['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 modeconst isDevelopment = window.env.NODE_ENV === 'development' &&new URLSearchParams(window.location.search).has('mockAuth');if (isDevelopment) {// Inject development tokensawait injectLocalDevelopmentTokens();setRoles(['Administrator', 'User', 'Developer']);setLoading(false);return;}if (oidcAuth.isLoading) {setLoading(true);return;}if (oidcAuth.user && !oidcAuth.user.expired) {// User is authenticatedconst userRoles = extractRoles(oidcAuth.user);setRoles(userRoles);// Fetch clientsconst email = oidcAuth.user.profile?.email as string | undefined;const clientList = await fetchClients(userRoles, email);setClients(clientList);// Restore selected client from localStorageconst 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 foundsetSelectedClient(clientList[0].id);localStorage.setItem('selectedClient', clientList[0].id.toString());}} else if (clientList.length > 0) {// No stored client, default to firstsetSelectedClient(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 datasessionStorage.removeItem('invoicesColumnFilters');sessionStorage.removeItem('starredInvoices');// Navigate to dashboardif (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 initiatedlocalStorage.setItem('logout-initiated', Date.now().toString());// Clear all local dataremoveTokens();// Sign out from PINGoidcAuth.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 destinationconst 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 compatibilityexport default AuthProvider;
1.4 OIDC Callback Component
File: /src/auth/OidcCallback.tsx
// @ts-nocheckimport 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 navigateif (auth.user && !auth.isLoading) {// Authentication successful, navigate to home or stored return URLconst returnUrl = sessionStorage.getItem('auth-return-url') || '/';sessionStorage.removeItem('auth-return-url');navigate(returnUrl, { replace: true });} else if (auth.error) {// Authentication failedconsole.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><buttononClick={() => 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
<!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
// @ts-nocheckimport axios from 'axios';import { useAuth } from 'react-oidc-context';let api: any;// Create axios instanceif (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 loginconsole.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 tokenif (error.response?.status === 401 && !originalRequest._retry) {originalRequest._retry = true;try {// Trigger silent token renewalawait refreshToken();// Retry original request with new tokenconst token = await getToken();originalRequest.headers.Authorization = `Bearer ${token}`;return api(originalRequest);} catch (refreshError) {// Refresh failed, redirect to loginconsole.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 storageconst 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 expiredconst 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 userconst 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 directlyconst { 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 remainingreturn timeUntilExpiry < 300;} catch (error) {console.error('Error decoding token:', error);return true;}};export { api };
1.7 Login Page Update
File: /src/routes/Login.tsx
// @ts-nocheckimport 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 homeif (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 pageif (window.location.pathname !== '/login') {sessionStorage.setItem('auth-return-url', window.location.pathname + window.location.search);}// Initiate OIDC loginauth.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>)}<ButtononClick={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)
// Find the logout handler and update it:const handleLogout = () => {// Use auth context logoutauth.handleSignout();};
1.9 App Router Update
File: /src/index.jsx or /src/App.jsx
// Add new route for OIDC callbackimport { OidcCallback } from '@/auth/OidcCallback';// In your routes:<Route path="/callback" element={<OidcCallback />} />
1.10 Environment Configuration File Update
File: /public/env.js
window.env = {// PING Identity ConfigurationPING_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 ConfigurationREACT_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
app:name: web-documents-aiversion: 1.0.0image:registry: cladevops.azurecr.iorepository: helm/web-documents-aitag: latestpullPolicy: IfNotPresentcontainer:port: 3002readinessProbe:httpGet:path: /port: 3002initialDelaySeconds: 10periodSeconds: 10livenessProbe:httpGet:path: /port: 3002initialDelaySeconds: 60periodSeconds: 30timeoutSeconds: 30failureThreshold: 10service:type: ClusterIPport: 80targetPort: 3002name: web-documents-aiingress:enabled: falseistio:enabled: truehosts: ["*"]gateways: [documents-ai-web-ingress-gateway]pathPrefix: "/"timeout: 30sgateway:enabled: false# Optional: Enable request authentication at gateway levelrequestAuthentication:enabled: falsejwtRules:- issuer: "" # Set in environment-specific valuesjwksUri: "" # Set in environment-specific valuesaudiences: [] # Set in environment-specific valuesserviceAccount:create: trueannotations: {}name: "web-documents-ai"# PING Identity configuration (set in environment-specific values)oidc:authority: ""clientId: ""issuer: ""jwksUri: ""scope: "openid profile email"# Backend API URLapi:url: ""configMap:enabled: falsesecret:enabled: truedata: {}resources:limits:cpu: 200mmemory: 512Mirequests:cpu: 100mmemory: 256Miautoscaling:enabled: falseminReplicas: 2maxReplicas: 10targetCPUUtilizationPercentage: 70targetMemoryUtilizationPercentage: 80replicaCount: 1podDisruptionBudget:enabled: falsenetworkPolicy:enabled: falsemonitoring:serviceMonitor:enabled: falsevolumeMounts: []volumes: []nodeSelector: {}tolerations: []affinity: {}
2.2 Environment-Specific Values - DEV
File: /helm/values-dev.yaml
app:environment: "dev"replicaCount: 1image:tag: "dev-latest"# PING Identity Configuration for DEVoidc: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 Configurationapi:url: "https://api.dev.claitdev.com/documentsai"# Environment variables injected into containerenv:NODE_ENV: productionPING_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 gatewayrequestAuthentication:enabled: false # Set to true to enable gateway-level authjwtRules:- 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: 150mmemory: 350Mirequests:cpu: 100mmemory: 300Miautoscaling:enabled: trueminReplicas: 1maxReplicas: 10targetCPUUtilizationPercentage: 70targetMemoryUtilizationPercentage: 80
2.3 Environment-Specific Values - PROD
File: /helm/values-prd.yaml
app:environment: "prd"replicaCount: 3image:tag: "prd-v1.0.0"# PING Identity Configuration for PRODoidc: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 Configurationapi:url: "https://api.prod.claitdev.com/documentsai"# Environment variablesenv:NODE_ENV: productionPING_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 productionrequestAuthentication:enabled: truejwtRules:- 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: 500mmemory: 1Girequests:cpu: 250mmemory: 512Miautoscaling:enabled: trueminReplicas: 3maxReplicas: 20targetCPUUtilizationPercentage: 60targetMemoryUtilizationPercentage: 70podDisruptionBudget:enabled: trueminAvailable: 2monitoring:serviceMonitor:enabled: true
2.4 Deployment Template Updates
File: /helm/templates/deployment.yml
apiVersion: apps/v1kind: Deploymentmetadata: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: httpprotocol: TCPenv:{{- 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
{{- if and .Values.istio.enabled .Values.istio.requestAuthentication.enabled }}apiVersion: security.istio.io/v1beta1kind: RequestAuthenticationmetadata:name: {{ .Values.app.name }}-jwt-authnamespace: {{ .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/v1beta1kind: AuthorizationPolicymetadata:name: {{ .Values.app.name }}-require-jwtnamespace: {{ .Release.Namespace }}spec:selector:matchLabels:app: {{ .Values.app.name }}action: ALLOWrules:- from:- source:requestPrincipals: ["*"]{{- end }}
2.6 Secret Template
File: /helm/templates/secret.yaml
{{- if .Values.secret.enabled }}apiVersion: v1kind: Secretmetadata:name: {{ .Values.app.name }}-secretnamespace: {{ .Release.Namespace }}labels:app: {{ .Values.app.name }}type: Opaquedata:{{- range $key, $value := .Values.secret.data }}{{ $key }}: {{ $value | b64enc | quote }}{{- end }}{{- end }}
2.7 Startup Script Update
File: /50-load-env.sh
#!/bin/shset -eecho "Loading environment variables into env.js..."echo "window.env = {" > /usr/share/nginx/html/env.jsecho " PING_AUTHORITY: '$(echo $PING_AUTHORITY)'," >> /usr/share/nginx/html/env.jsecho " PING_CLIENT_ID: '$(echo $PING_CLIENT_ID)'," >> /usr/share/nginx/html/env.jsecho " PING_ISSUER: '$(echo $PING_ISSUER)'," >> /usr/share/nginx/html/env.jsecho " PING_JWKS_URI: '$(echo $PING_JWKS_URI)'," >> /usr/share/nginx/html/env.jsecho " PING_SCOPE: '$(echo $PING_SCOPE)'," >> /usr/share/nginx/html/env.jsecho " REACT_APP_API_URL: '$(echo $REACT_APP_API_URL)'," >> /usr/share/nginx/html/env.jsecho " REACT_APP_VERSION: '$(echo $REACT_APP_VERSION)'," >> /usr/share/nginx/html/env.jsecho " NODE_ENV: '$(echo $NODE_ENV)'," >> /usr/share/nginx/html/env.jsecho "};" >> /usr/share/nginx/html/env.jsecho "Environment variables loaded successfully"cat /usr/share/nginx/html/env.jsexec "$@"
3. Terraform Modules
3.1 PingOne Application Module
File: /terraform/modules/pingone-application/main.tf
terraform {required_providers {pingone = {source = "pingidentity/pingone"version = "~> 0.20.0"}}}# PingOne Application Resourceresource "pingone_application" "web_documents_ai" {environment_id = var.environment_idname = "Web Documents AI - ${var.environment_name}"description = "Document processing application for invoice management"enabled = trueoidc_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 URIsredirect_uris = ["${var.application_url}/callback","${var.application_url}/silent-renew",]# Post logout redirect URIspost_logout_redirect_uris = ["${var.application_url}/login",var.application_url,]# Token settingsrefresh_token_duration = 2592000 # 30 daysrefresh_token_rolling_duration = 15552000 # 180 days}tags = {Environment = var.environment_nameApplication = "documents-ai"ManagedBy = "terraform"}}# Application Role Assignmentsresource "pingone_application_role_assignment" "admin_group" {environment_id = var.environment_idapplication_id = pingone_application.web_documents_ai.idgroup_id = var.admin_group_id# Assign admin rolerole_id = var.admin_role_id}resource "pingone_application_role_assignment" "user_group" {environment_id = var.environment_idapplication_id = pingone_application.web_documents_ai.idgroup_id = var.user_group_id# Assign user rolerole_id = var.user_role_id}# Application Attributes (claims mapping)resource "pingone_application_attribute_mapping" "email" {environment_id = var.environment_idapplication_id = pingone_application.web_documents_ai.idname = "email"value = "$${user.email}"}resource "pingone_application_attribute_mapping" "name" {environment_id = var.environment_idapplication_id = pingone_application.web_documents_ai.idname = "name"value = "$${user.name.formatted}"}resource "pingone_application_attribute_mapping" "groups" {environment_id = var.environment_idapplication_id = pingone_application.web_documents_ai.idname = "groups"value = "$${user.memberOfGroupNames}"}resource "pingone_application_attribute_mapping" "roles" {environment_id = var.environment_idapplication_id = pingone_application.web_documents_ai.idname = "roles"value = "$${user.memberOfGroupNames}" # Map groups to roles}
File: /terraform/modules/pingone-application/variables.tf
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 = stringdefault = ""}variable "user_role_id" {description = "Application role ID for users"type = stringdefault = ""}variable "tags" {description = "Additional tags for resources"type = map(string)default = {}}
File: /terraform/modules/pingone-application/outputs.tf
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_idsensitive = 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
# Define API resource for Documents AIresource "pingone_resource" "documents_ai_api" {environment_id = var.environment_idname = "Documents AI API - ${var.environment_name}"description = "API resource for document processing application"type = "CUSTOM"audience = var.api_audienceintrospection_endpoint_auth_method = "CLIENT_SECRET_BASIC"}# Define custom scopeslocals {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.scopesenvironment_id = var.environment_idresource_id = pingone_resource.documents_ai_api.idname = each.keydescription = each.value}# Grant scopes to applicationresource "pingone_application_resource_grant" "documents_ai_api" {environment_id = var.environment_idapplication_id = var.application_idresource_id = pingone_resource.documents_ai_api.idscopes = [for scope in pingone_resource_scope.scopes : scope.id]}
File: /terraform/modules/pingone-resources/variables.tf
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
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
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_idclient_secret = var.pingone_client_secretenvironment_id = var.pingone_environment_idregion = "NA" # North America}provider "azurerm" {features {}subscription_id = var.azure_subscription_id}# Local variableslocals {environment = "dev"location = "eastus2"common_tags = {Environment = "dev"Application = "documents-ai"ManagedBy = "terraform"Team = "platform-engineering"}}# PingOne Applicationmodule "pingone_application" {source = "../../modules/pingone-application"environment_id = var.pingone_environment_idenvironment_name = local.environmentapplication_url = "https://documentsai.dev.claitdev.com"admin_group_id = var.ping_admin_group_iduser_group_id = var.ping_user_group_idtags = local.common_tags}# API Resource and Scopesmodule "pingone_resources" {source = "../../modules/pingone-resources"environment_id = var.pingone_environment_idenvironment_name = local.environmentapplication_id = module.pingone_application.application_idapi_audience = "https://api.dev.claitdev.com/documentsai"}# Store OIDC configuration in Azure Key Vaultresource "azurerm_key_vault_secret" "ping_client_id" {name = "PING-CLIENT-ID"value = module.pingone_application.client_idkey_vault_id = var.key_vault_idtags = local.common_tags}resource "azurerm_key_vault_secret" "ping_authority" {name = "PING-AUTHORITY"value = module.pingone_application.authoritykey_vault_id = var.key_vault_idtags = local.common_tags}resource "azurerm_key_vault_secret" "ping_issuer" {name = "PING-ISSUER"value = module.pingone_application.issuerkey_vault_id = var.key_vault_idtags = local.common_tags}resource "azurerm_key_vault_secret" "ping_jwks_uri" {name = "PING-JWKS-URI"value = module.pingone_application.jwks_urikey_vault_id = var.key_vault_idtags = local.common_tags}resource "azurerm_key_vault_secret" "ping_scope" {name = "PING-SCOPE"value = module.pingone_resources.scope_stringkey_vault_id = var.key_vault_idtags = local.common_tags}
File: /terraform/environments/dev/variables.tf
# PingOne Variablesvariable "pingone_client_id" {description = "PingOne API client ID for Terraform"type = stringsensitive = true}variable "pingone_client_secret" {description = "PingOne API client secret for Terraform"type = stringsensitive = 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 Variablesvariable "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
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_idsensitive = 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.authorityissuer = module.pingone_application.issuerjwks_uri = module.pingone_application.jwks_uriscope = 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
// File: /src/utilities/authHelpers.tsexport interface UserClaims {sub: string; // User IDname: string; // Full nameemail: string; // Email addressgroups: string[]; // Group membershipsroles: string[]; // Application rolesscope: string; // OAuth scopesiss: string; // Issueraud: string; // Audienceexp: number; // Expirationiat: 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-isconst 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
// File: /src/routes/ProtectedRoute.tsximport 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 loginsessionStorage.setItem('auth-return-url', location.pathname + location.search);return <Navigate to="/login" replace />;}// Check role requirementsif (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
// File: backend/middleware/auth.jsconst { expressjwt: jwt } = require('express-jwt');const jwksRsa = require('jwks-rsa');// JWT validation middlewareconst 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 middlewareconst extractRoles = (req, res, next) => {const user = req.auth;if (!user) {return res.status(401).json({ error: 'Unauthorized' });}// Extract roles from token claimsreq.userRoles = user.roles || user.groups || [];req.userId = user.sub;req.userEmail = user.email;next();};// Role check middleware factoryconst 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 middlewareconst 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
# File: backend/auth.pyfrom typing import List, Optionalfrom fastapi import Depends, HTTPException, status, Securityfrom fastapi.security import HTTPBearer, HTTPAuthorizationCredentialsfrom jose import jwt, JWTErrorimport requestsfrom functools import lru_cachesecurity = 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 groupsself.scope = token.get('scope', '').split(' ')def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)) -> TokenData:token = credentials.credentialstry:# Get JWKSjwks = get_jwks(settings.PING_JWKS_URI)# Decode and verify tokenpayload = 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_datareturn role_checkerdef 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_datareturn 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
// File: /src/utilities/clientAuthorization.tsimport { 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 clientsif (roles.includes('Administrator')) {return true;}// Regular users only have access to their assigned clientsreturn 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 accessif (!canAccessClient(roles, userClients, clientId)) {return false;}// Check action permissionswitch (action) {case 'read':return true; // All users with client access can readcase '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
Authorization: Bearer <PING_ACCESS_TOKEN>
Token Structure
Access Token Claims:
{"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:
{"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:
{"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:
POST /api/v1/upload/1Content-Type: multipart/form-dataAuthorization: Bearer <token>------WebKitFormBoundary...Content-Disposition: form-data; name="file"; filename="invoice.pdf"Content-Type: application/pdf<binary data>------WebKitFormBoundary...
Response:
{"received_invoices": [{"name": "invoice.pdf","documentID": "doc-12345","status": "uploaded","validationProblems": []}]}
5.3 Error Responses
401 Unauthorized
{"error": "Unauthorized","message": "Invalid or expired access token","code": "TOKEN_INVALID"}
403 Forbidden
{"error": "Forbidden","message": "Insufficient permissions to access this resource","code": "INSUFFICIENT_PERMISSIONS","required": {"scopes": ["documents:write"],"roles": ["Administrator", "User"]}}
404 Not Found
{"error": "Not Found","message": "Client with ID 999 not found","code": "RESOURCE_NOT_FOUND"}
6. Configuration Management
6.1 Environment Variables
Frontend Container
# PING Identity ConfigurationPING_AUTHORITY=https://auth.pingone.com/{env-id}/asPING_CLIENT_ID=client-id-12345678PING_ISSUER=https://auth.pingone.com/{env-id}/asPING_JWKS_URI=https://auth.pingone.com/{env-id}/as/jwksPING_SCOPE="openid profile email documents:read documents:write clients:read"# Application ConfigurationREACT_APP_API_URL=https://api.dev.claitdev.com/documentsaiREACT_APP_VERSION=1.0.0NODE_ENV=production
Backend Container
# PING Identity ConfigurationPING_ISSUER=https://auth.pingone.com/{env-id}/asPING_JWKS_URI=https://auth.pingone.com/{env-id}/as/jwksPING_AUDIENCE=client-id-12345678# Application ConfigurationDATABASE_URL=postgresql://user:pass@host:5432/dbnameREDIS_URL=redis://redis:6379/0LOG_LEVEL=INFO
6.2 Secret Management with Azure Key Vault
External Secrets Operator Configuration
# File: kubernetes/external-secret.yamlapiVersion: external-secrets.io/v1beta1kind: ExternalSecretmetadata:name: ping-oidc-confignamespace: documents-aispec:refreshInterval: 1hsecretStoreRef:name: azure-keyvaultkind: ClusterSecretStoretarget:name: ping-oidc-configcreationPolicy: Ownerdata:- secretKey: PING_CLIENT_IDremoteRef:key: PING-CLIENT-ID- secretKey: PING_AUTHORITYremoteRef:key: PING-AUTHORITY- secretKey: PING_ISSUERremoteRef:key: PING-ISSUER- secretKey: PING_JWKS_URIremoteRef:key: PING-JWKS-URI- secretKey: PING_SCOPEremoteRef:key: PING-SCOPE
6.3 ConfigMap for Non-Sensitive Configuration
# File: kubernetes/configmap.yamlapiVersion: v1kind: ConfigMapmetadata:name: web-documents-ai-confignamespace: documents-aidata: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
// File: /src/utilities/errorHandling.tsexport 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 errorsif (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 errorsif (error.message && error.message.includes('Network')) {return new AuthError(AuthErrorCode.NETWORK_ERROR,'Network error. Please check your connection.',error);}// Popup blockedif (error.message && error.message.includes('popup')) {return new AuthError(AuthErrorCode.POPUP_BLOCKED,'Popup blocked by browser. Please allow popups and try again.',error);}// Generic errorreturn new AuthError(AuthErrorCode.AUTH_SERVER_ERROR,error.message || 'An unknown error occurred.',error);};
API Error Handling
// File: /src/utilities/apiErrorHandling.tsexport const handleApiError = (error: any): string => {if (!error) return 'Unknown error occurred';// Network errorsif (error.request && !error.response) {return 'Network error. Please check your connection and try again.';}// Server responded with errorif (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 interceptorreturn '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
# File: backend/exceptions.pyfrom fastapi import HTTPException, statusclass 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:
- Client generates a random
code_verifier - Client creates
code_challenge= SHA256(code_verifier) - Authorization request includes
code_challengeandcode_challenge_method=S256 - Token request includes original
code_verifier - Server validates that
code_verifiermatches storedcode_challenge
8.2 Token Storage Security
// 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)
# File: nginx.conf or added via Helmadd_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
// Frontend: Limit authentication attemptslet authAttempts = 0;const MAX_AUTH_ATTEMPTS = 5;const AUTH_COOLDOWN = 300000; // 5 minutesconst 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};
# Backend: Rate limiting with Redisfrom fastapi_limiter import FastAPILimiterfrom 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:
// File: /src/utilities/metrics.ts (if using Prometheus client)// Counter for authentication attemptsauthAttemptsTotal.inc({ result: 'success' | 'failure' });// Histogram for authentication durationauthDurationSeconds.observe(duration);// Gauge for current authenticated usersauthenticatedUsers.set(count);
Key Metrics to Track:
auth_logins_total{result="success|failure"}- Total login attemptsauth_login_duration_seconds- Time to complete loginauth_token_refresh_total{result="success|failure"}- Token refresh attemptsauth_logout_total- Logout eventsauth_errors_total{type="..."}- Authentication errors by type
9.2 Logging Standards
Frontend Logging
// File: /src/utilities/logger.tsenum 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
# File: backend/logging_config.pyimport loggingimport jsonfrom datetime import datetimeclass 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 presentif record.exc_info:log_data["exception"] = self.formatException(record.exc_info)# Add extra fieldsif hasattr(record, "user_id"):log_data["user_id"] = record.user_idif hasattr(record, "client_id"):log_data["client_id"] = record.client_idif hasattr(record, "request_id"):log_data["request_id"] = record.request_idreturn 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 = Noneif hasattr(request.state, "token_data"):user_id = request.state.token_data.sublogger.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
// File: /src/utilities/appInsights.tsimport { 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 telemetryaddRequestContext: (envelope) => {// Remove sensitive headersif (envelope.headers) {delete envelope.headers['Authorization'];}}}});appInsights.loadAppInsights();appInsights.trackPageView();return appInsights;};// Track custom authentication eventsexport 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:
-
High Authentication Failure Rate
- Condition: Auth failure rate > 10% over 5 minutes
- Action: Email security team
-
Token Validation Errors
- Condition: Token validation failures > 50 in 5 minutes
- Action: Page on-call engineer
-
PING Service Unavailable
- Condition: PING JWKS endpoint unreachable for > 2 minutes
- Action: Page on-call engineer, notify identity team
-
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
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
import { test, expect } from '@playwright/test';test.describe('PING Authentication Flow', () => {test('user can login with PING', async ({ page, context }) => {// Navigate to applicationawait page.goto('https://documentsai.dev.claitdev.com');// Should redirect to loginawait expect(page).toHaveURL(/\/login/);// Click sign in buttonawait page.click('button:has-text("Sign In with PING Identity")');// Should redirect to PINGawait 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 formawait page.click('button[type="submit"]');// Should redirect back to appawait expect(page).toHaveURL('https://documentsai.dev.claitdev.com/');// Verify user is logged inawait expect(page.locator('text=Dashboard')).toBeVisible();// Verify token in sessionStorageconst 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 logoutawait page.click('[aria-label="User menu"]');await page.click('text=Logout');// Should clear sessionStorageconst 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 loginawait expect(page).toHaveURL(/\/login/);});test('expired token triggers renewal', async ({ page }) => {// ... setup with expired token ...// Make API callawait page.click('text=Clients');// Should trigger silent renewalawait 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
import pytestimport jwtfrom datetime import datetime, timedeltadef test_backend_rejects_expired_token(client):"""Test that backend rejects expired tokens"""# Create expired tokenexpired_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 == 401assert "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 issuertoken = create_test_token(issuer="https://evil.com")response = client.get("/api/v1/clients",headers={"Authorization": f"Bearer {token}"})assert response.status_code == 401def 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 == 401def test_backend_enforces_role_requirements(client):"""Test that backend enforces role-based access control"""# Create token without required roletoken = create_test_token(roles=["User"]) # Not Administratorresponse = client.delete("/api/v1/clients/1",headers={"Authorization": f"Bearer {token}"})assert response.status_code == 403assert "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 == 400assert "code_challenge" in response.json()["error_description"].lower()
10.4 Performance Tests
// File: /tests/performance/auth-load.test.tsimport { 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 concurrentlyawait 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 succeededfor (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
# 1. Apply Terraformcd terraform/environments/devterraform plan -out=tfplanterraform apply tfplan# 2. Update Helmcd ../../../helmhelm upgrade --install web-documents-ai . \--namespace documents-ai \--values values.yaml \--values values-dev.yaml \--set image.tag=dev-v1.2.0# 3. Verify deploymentkubectl get pods -n documents-aikubectl logs -n documents-ai deployment/web-documents-ai# 4. Smoke testscurl -I https://documentsai.dev.claitdev.com
Production Environment
# 1. Final verification in UAT# ... run tests ...# 2. Apply Terraformcd terraform/environments/prdterraform plan -out=tfplan# Review plan carefullyterraform 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 deploymentkubectl 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:
# Check environment variables in podkubectl 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:
- Verify JWKS endpoint accessible:
curl https://auth.pingone.com/{env-id}/as/jwks
- Check token issuer matches configuration
- Verify network policies allow outbound to PING
Issue: “User has no roles”
Cause: User not in any PING groups
Solution:
- Verify user group membership in PING
- Check group-to-role mapping in code
- Verify group claims included in token
Issue: “Redirect loop”
Cause: Callback URL misconfigured
Solution:
- Verify redirect URI in PING matches application URL
- Check for trailing slashes (https://app.com/callback vs https://app.com/callback/)
- Ensure CORS configured correctly
12.2 Debug Commands
# View pod logskubectl logs -n documents-ai deployment/web-documents-ai --tail=100 -f# Check env.js in running containerkubectl exec -n documents-ai deployment/web-documents-ai -- cat /usr/share/nginx/html/env.js# Test PING connectivity from podkubectl exec -n documents-ai deployment/web-documents-ai -- curl -I https://auth.pingone.com# Describe pod for eventskubectl describe pod -n documents-ai -l app=web-documents-ai# Check Istio sidecar logskubectl 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:
- Review this low-level design with technical teams
- Create Jira tickets for implementation tasks
- Begin Phase 0 work (prerequisites)
- Schedule kickoff meeting