# UI Client Authentication Guide This document provides technical instructions for frontend (UI) developers integrating with the Query Orchestration API. It covers how to authenticate requests, discover user permissions, handle errors, and configure CORS for cross-origin deployments. ## Table of Contents 1. [Authentication Overview](#authentication-overview) 2. [Sending Authenticated Requests](#sending-authenticated-requests) 3. [Discovering User Permissions](#discovering-user-permissions) 4. [Error Handling](#error-handling) 5. [Token Refresh Strategy](#token-refresh-strategy) 6. [CORS Configuration](#cors-configuration) 7. [Security Best Practices](#security-best-practices) 8. [Quick Reference](#quick-reference) --- ## Authentication Overview The Query Orchestration API uses **JWT (JSON Web Token) authentication** with tokens issued by **AWS Cognito**. The authentication flow assumes that the UI application handles user login directly with Cognito and obtains the necessary tokens. ### Token Types When a user authenticates with Cognito, your UI receives three tokens: | Token | Purpose | Typical Lifetime | |-------|---------|------------------| | `access_token` | **Used for API requests** - Contains user identity and scopes | 1 hour | | `id_token` | Contains user profile claims (email, name, etc.) | 1 hour | | `refresh_token` | Used to obtain new access/id tokens without re-authentication | 30 days | **Important**: The Query Orchestration API validates the `access_token`. Always use this token (not the `id_token`) in the `Authorization` header. ### Authentication Flow ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐ │ UI App │ │ Cognito │ │ Query Orchestration│ │ Permit.io │ │ (Frontend) │ │ (IDP) │ │ API │ │ (AuthZ) │ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ └──────┬──────┘ │ │ │ │ │ 1. Login request │ │ │ │───────────────────>│ │ │ │ │ │ │ │ 2. Tokens │ │ │ │<───────────────────│ │ │ │ │ │ │ │ 3. API Request + Bearer Token │ │ │────────────────────────────────────────────>│ │ │ │ │ │ │ │ │ 4. Check Permission │ │ │ │───────────────────────>│ │ │ │ │ │ │ │ 5. Allowed/Denied │ │ │ │<───────────────────────│ │ │ │ │ │ 6. API Response │ │ │<────────────────────────────────────────────│ │ ``` --- ## Sending Authenticated Requests ### Authorization Header Format All authenticated API requests must include the JWT access token in the `Authorization` header using the Bearer scheme: ``` Authorization: Bearer ``` ### Accepted Token Locations The API accepts tokens from exactly two locations: | Location | Format | Notes | |----------|--------|-------| | `Authorization` header | `Bearer ` | **Recommended for cross-origin requests** | | `auth_token` cookie | JWT string | Used by cookie-based auth flow | **Not supported**: - Query parameters (e.g., `?token=...`) - Other header names - Request body ### Cookie-Based Authentication (Alternative) If your deployment uses cookie-based authentication, the server uses these HTTP-only cookies: | Cookie Name | Purpose | |-------------|---------| | `auth_token` | Access token (JWT) | | `refresh_token` | Refresh token for obtaining new access tokens | When cookies are present, the server middleware automatically copies the `auth_token` cookie value to the `Authorization` header internally. The server can also automatically refresh expired tokens using the `refresh_token` cookie. **Note**: Cookie-based auth requires CORS configuration for cross-origin deployments. See [CORS Configuration](#cors-configuration). ### No CSRF Token Required The API does not require CSRF tokens. JWT bearer token authentication provides protection against CSRF attacks because: - The token must be explicitly included in the `Authorization` header - Browsers do not automatically attach custom headers to cross-origin requests ### JavaScript/TypeScript Examples #### Using Fetch API ```javascript const API_BASE_URL = 'https://api.yourdomain.com'; async function makeAuthenticatedRequest(endpoint, options = {}) { const accessToken = getAccessToken(); // Retrieve from your token storage const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', ...options.headers, }, }); if (!response.ok) { await handleErrorResponse(response); } return response.json(); } // Example: Get client details async function getClient(clientId) { return makeAuthenticatedRequest(`/client/${clientId}`); } // Example: Upload a document async function uploadDocument(clientId, file, folderId = null) { const accessToken = getAccessToken(); const formData = new FormData(); formData.append('file', file); if (folderId) { formData.append('folderId', folderId); } const response = await fetch(`${API_BASE_URL}/client/${clientId}/document`, { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, // Note: Do NOT set Content-Type for FormData - browser sets it automatically }, body: formData, }); if (!response.ok) { await handleErrorResponse(response); } return response.json(); } ``` #### Using Axios ```javascript import axios from 'axios'; const apiClient = axios.create({ baseURL: 'https://api.yourdomain.com', }); // Add auth token to every request apiClient.interceptors.request.use((config) => { const accessToken = getAccessToken(); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }); // Handle 401 errors globally apiClient.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { // Token expired or invalid - attempt refresh or redirect to login const refreshed = await attemptTokenRefresh(); if (refreshed) { // Retry the original request with new token error.config.headers.Authorization = `Bearer ${getAccessToken()}`; return apiClient.request(error.config); } // Refresh failed - redirect to login redirectToLogin(); } return Promise.reject(error); } ); // Example usage const client = await apiClient.get('/client/AAA'); const documents = await apiClient.get('/client/AAA/document'); ``` #### Using React Query ```typescript import { useQuery, useMutation, QueryClient } from '@tanstack/react-query'; const queryClient = new QueryClient(); // Fetch wrapper with auth async function authFetch(endpoint: string, options?: RequestInit): Promise { const accessToken = getAccessToken(); const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', ...options?.headers, }, }); if (response.status === 401) { // Trigger re-authentication throw new AuthenticationError('Token expired'); } if (!response.ok) { throw new ApiError(response.status, await response.json()); } return response.json(); } // Hook for fetching client data function useClient(clientId: string) { return useQuery({ queryKey: ['client', clientId], queryFn: () => authFetch(`/client/${clientId}`), retry: (failureCount, error) => { // Don't retry on auth errors if (error instanceof AuthenticationError) return false; return failureCount < 3; }, }); } // Hook for fetching user identity/permissions function useIdentity() { return useQuery({ queryKey: ['identity'], queryFn: () => authFetch('/identity'), staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); } ``` --- ## Discovering User Permissions ### The `/identity` Endpoint After authentication, use the `/identity` endpoint to discover what the current user is authorized to do. This endpoint returns the user's roles and associated permissions from Permit.io. **Endpoint**: `GET /identity` **Authentication**: Required (Bearer token) **Response** (200 OK): ```json { "roles": [ { "key": "auditor", "name": "Auditor", "description": "Read-only access to view documents and exports", "permissions": [ "client:get", "document:get", "folders:get", "export:get" ] }, { "key": "editor", "name": "Editor", "description": "Can create and modify documents", "permissions": [ "client:get", "client:post", "document:get", "document:post", "folders:get", "folders:post", "folders:patch" ] } ] } ``` ### Permission Format Permissions follow the format `resource:action`: | Resource | Actions | Description | |----------|---------|-------------| | `client` | `get`, `post`, `patch`, `delete` | Client management | | `clients` | `get` | List all clients | | `document` | `get`, `post`, `patch`, `delete` | Document operations | | `folders` | `get`, `post`, `patch`, `delete` | Folder management | | `export` | `get`, `post` | Export operations | | `admin` | `get`, `post`, `patch`, `delete` | User and EULA administration | | `eula` | `get`, `post` | EULA status and agreement | ### Using Permissions in UI ```typescript interface IdentityRole { key: string; name: string; description?: string; permissions: string[]; } interface IdentityResponse { roles: IdentityRole[]; } // Fetch and cache user permissions let cachedPermissions: Set | null = null; async function loadUserPermissions(): Promise> { if (cachedPermissions) { return cachedPermissions; } const response = await authFetch('/identity'); // Flatten all permissions from all roles const permissions = new Set(); for (const role of response.roles) { for (const permission of role.permissions) { permissions.add(permission); } } cachedPermissions = permissions; return permissions; } // Check if user has a specific permission async function hasPermission(resource: string, action: string): Promise { const permissions = await loadUserPermissions(); return permissions.has(`${resource}:${action}`); } // Check if user can perform an action async function canUploadDocuments(): Promise { return hasPermission('client', 'post'); // Upload uses POST /client/{id}/document } async function canManageUsers(): Promise { return hasPermission('admin', 'post'); } // React hook example function usePermission(resource: string, action: string) { const { data: identity, isLoading } = useIdentity(); const hasPermission = useMemo(() => { if (!identity) return false; const permission = `${resource}:${action}`; return identity.roles.some(role => role.permissions.includes(permission) ); }, [identity, resource, action]); return { hasPermission, isLoading }; } // Usage in component function UploadButton() { const { hasPermission, isLoading } = usePermission('client', 'post'); if (isLoading) return ; if (!hasPermission) return null; // Hide button if not authorized return ; } ``` ### Role-Based UI Rendering ```typescript // Define UI feature permissions const UI_FEATURES = { viewDocuments: ['client:get', 'document:get'], uploadDocuments: ['client:post'], manageClients: ['client:post', 'client:patch'], viewExports: ['export:get'], createExports: ['export:post'], adminPanel: ['admin:get'], } as const; function canAccessFeature( userPermissions: Set, feature: keyof typeof UI_FEATURES ): boolean { const requiredPermissions = UI_FEATURES[feature]; return requiredPermissions.every(p => userPermissions.has(p)); } ``` --- ## Error Handling ### HTTP Status Codes | Status | Meaning | UI Action | |--------|---------|-----------| | `200` | Success | Process response | | `201` | Created | Resource created successfully | | `204` | No Content | Operation successful (no body) | | `400` | Bad Request | Show validation error to user | | `401` | Unauthorized | Token invalid/expired - refresh or re-login | | `403` | Forbidden | User lacks permission - show access denied | | `404` | Not Found | Resource doesn't exist | | `429` | Too Many Requests | Rate limited - implement backoff | | `500` | Server Error | Show generic error, retry later | | `502` | Bad Gateway | Authorization service unavailable | ### Error Response Format Error responses typically include a `message` field, but the structure varies by endpoint and error type: ```json // Standard error format { "message": "Description of what went wrong" } // Authentication error format (401) { "error": "Authorization required", "message": "This endpoint requires authentication. Please obtain a valid JWT token.", "login_url": "/login" } // Authorization error format (403) { "error": "Access denied" } ``` **Recommended error handling**: Check for both `error` and `message` fields when parsing error responses. ### Handling Authentication Errors (401) ```typescript // Helper to extract error message from varying response formats function getErrorMessage(body: any, fallback: string): string { return body.message || body.error || fallback; } async function handleErrorResponse(response: Response) { const body = await response.json().catch(() => ({})); switch (response.status) { case 401: // Token expired or invalid console.log('Authentication required:', getErrorMessage(body, 'Authentication required')); // Attempt to refresh the token (only if using header-based auth) const refreshed = await attemptTokenRefresh(); if (!refreshed) { // Clear stored tokens clearTokens(); // Redirect to login redirectToLogin(); } throw new AuthenticationError(getErrorMessage(body, 'Authentication required')); case 403: // User authenticated but not authorized console.log('Access denied:', getErrorMessage(body, 'Access denied')); throw new AuthorizationError(getErrorMessage(body, 'Access denied')); case 429: // Rate limited - check RateLimit header const rateLimitInfo = response.headers.get('RateLimit'); console.log('Rate limited:', rateLimitInfo); throw new RateLimitError(getErrorMessage(body, 'Rate limit exceeded'), rateLimitInfo); default: throw new ApiError(response.status, getErrorMessage(body, 'Request failed')); } } ``` ### Rate Limiting The API includes rate limiting. Monitor the `RateLimit` response header: ``` RateLimit: 100;window=60 ``` This indicates 100 requests allowed per 60-second window. ```typescript // Implement exponential backoff for rate limits async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { const response = await fetch(url, options); if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, attempt) * 1000; console.log(`Rate limited. Retrying in ${waitTime}ms`); await sleep(waitTime); continue; } return response; } throw new Error('Max retries exceeded'); } ``` --- ## Token Refresh Strategy ### Server-Side vs Client-Side Refresh The refresh strategy depends on how your UI sends authentication: | Authentication Method | Who Handles Refresh | Notes | |-----------------------|---------------------|-------| | `Authorization` header | **UI must refresh** | Server has no access to refresh token | | Cookie-based | **Server auto-refreshes** | Server reads `refresh_token` cookie | **If using `Authorization: Bearer` header** (recommended for cross-origin): - The UI must store and manage the refresh token - The UI must call Cognito directly to refresh tokens - The server cannot refresh for you (it doesn't have your refresh token) **If using cookie-based authentication**: - The server automatically refreshes expired tokens - New tokens are returned as updated cookies - No action required from the UI ### When to Refresh (Header-Based Auth) The access token has a limited lifetime (typically 1 hour). Implement proactive refresh to avoid authentication errors: 1. **Proactive refresh**: Check token expiration before each request 2. **Reactive refresh**: Refresh when receiving a 401 response ### Checking Token Expiration JWT tokens contain an `exp` claim with the expiration timestamp: ```typescript function isTokenExpired(token: string, bufferSeconds = 60): boolean { try { // Decode JWT payload (base64) const payload = JSON.parse(atob(token.split('.')[1])); const expiresAt = payload.exp * 1000; // Convert to milliseconds const now = Date.now(); // Consider expired if within buffer period return now >= (expiresAt - bufferSeconds * 1000); } catch { return true; // Treat decode errors as expired } } function getAccessToken(): string | null { const token = localStorage.getItem('access_token'); if (!token || isTokenExpired(token)) { return null; } return token; } ``` ### Refreshing Tokens with Cognito Use the Cognito SDK or direct API call to refresh tokens: ```typescript import { CognitoIdentityProviderClient, InitiateAuthCommand } from '@aws-sdk/client-cognito-identity-provider'; const cognitoClient = new CognitoIdentityProviderClient({ region: 'us-east-2', // Your Cognito region }); async function refreshTokens(): Promise { const refreshToken = localStorage.getItem('refresh_token'); if (!refreshToken) { return false; } try { const command = new InitiateAuthCommand({ AuthFlow: 'REFRESH_TOKEN_AUTH', ClientId: 'your-cognito-client-id', AuthParameters: { REFRESH_TOKEN: refreshToken, }, }); const response = await cognitoClient.send(command); if (response.AuthenticationResult) { // Store new tokens localStorage.setItem('access_token', response.AuthenticationResult.AccessToken!); localStorage.setItem('id_token', response.AuthenticationResult.IdToken!); // Note: Refresh token may not be returned - keep existing one return true; } return false; } catch (error) { console.error('Token refresh failed:', error); return false; } } ``` ### Request Interceptor with Auto-Refresh ```typescript async function authFetchWithRefresh( endpoint: string, options?: RequestInit ): Promise { // Check if token needs refresh before request let accessToken = localStorage.getItem('access_token'); if (!accessToken || isTokenExpired(accessToken)) { const refreshed = await refreshTokens(); if (!refreshed) { throw new AuthenticationError('Session expired. Please log in again.'); } accessToken = localStorage.getItem('access_token'); } const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', ...options?.headers, }, }); // Handle 401 in case token expired between check and request if (response.status === 401) { const refreshed = await refreshTokens(); if (refreshed) { // Retry with new token return authFetchWithRefresh(endpoint, options); } throw new AuthenticationError('Session expired. Please log in again.'); } if (!response.ok) { throw new ApiError(response.status, await response.json()); } return response.json(); } ``` --- ## CORS Configuration ### Overview Since the UI and API are hosted on different domains, Cross-Origin Resource Sharing (CORS) must be configured. The API uses Echo framework's CORS middleware with the following **default** configuration: - **Allowed Origins**: `*` (all origins) - **Allowed Methods**: `GET, HEAD, PUT, PATCH, POST, DELETE` - **Allowed Headers**: All headers - **Credentials**: **NOT enabled by default** (`AllowCredentials: false`) **Important**: The default CORS configuration does **not** support credentials (cookies). This means: - Cross-origin requests with `credentials: 'include'` will fail - Cookie-based authentication will not work cross-origin without server configuration changes - **Use `Authorization: Bearer` header for cross-origin authentication** (recommended) ### UI Configuration for Cross-Origin Requests For cross-origin requests, use the `Authorization` header (not cookies): ```typescript // Recommended: Use Authorization header for cross-origin requests const response = await fetch(`${API_BASE_URL}/client/AAA`, { method: 'GET', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, }); ``` **Cookie-based authentication cross-origin** requires server-side CORS configuration changes: 1. `AllowCredentials: true` must be set on the server 2. `AllowOrigins` must list specific origins (cannot use wildcard `*`) 3. UI must use `credentials: 'include'` in fetch calls ```typescript // Cookie-based auth (ONLY works if server is configured for credentials) const response = await fetch(`${API_BASE_URL}/client/AAA`, { method: 'GET', credentials: 'include', // Sends cookies cross-origin headers: { 'Content-Type': 'application/json', // Authorization header optional if using cookies }, }); ``` ### Preflight Requests For non-simple requests (e.g., with custom headers like `Authorization`), the browser sends a preflight `OPTIONS` request. The API automatically handles these. **Preflight triggers include**: - Custom headers (e.g., `Authorization`) - Content-Type other than `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain` - HTTP methods other than `GET`, `HEAD`, `POST` ### Production CORS Configuration For production deployments with specific UI domains, the API CORS configuration can be customized. Contact the backend team to configure: ```go // Example production CORS config (server-side) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{ "https://app.yourdomain.com", "https://admin.yourdomain.com", }, AllowMethods: []string{ http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete, }, AllowHeaders: []string{ "Authorization", "Content-Type", "Accept", "X-Requested-With", }, AllowCredentials: true, MaxAge: 86400, // Preflight cache: 24 hours })) ``` ### Troubleshooting CORS Issues | Error | Cause | Solution | |-------|-------|----------| | `No 'Access-Control-Allow-Origin' header` | Origin not allowed | Add UI origin to server config | | `Preflight response is not successful` | OPTIONS blocked | Ensure server handles OPTIONS | | `Credentials flag is true, but...` | Wildcard origin with credentials | Use specific origins, not `*` | | `Request header not allowed` | Custom header blocked | Add header to `AllowHeaders` | --- ## Security Best Practices ### Token Storage | Storage Option | Security | Recommendation | |----------------|----------|----------------| | `localStorage` | Vulnerable to XSS | Use with caution | | `sessionStorage` | Cleared on tab close, vulnerable to XSS | Better than localStorage | | Memory (variable) | Lost on refresh, most secure | Best for high-security apps | | HTTP-only cookies | Protected from XSS | Requires server-side support | **Recommended approach for SPAs**: ```typescript // Store tokens in memory with sessionStorage backup class TokenManager { private accessToken: string | null = null; private idToken: string | null = null; private refreshToken: string | null = null; constructor() { // Restore from sessionStorage on page load this.accessToken = sessionStorage.getItem('access_token'); this.idToken = sessionStorage.getItem('id_token'); this.refreshToken = sessionStorage.getItem('refresh_token'); } setTokens(tokens: { accessToken: string; idToken: string; refreshToken: string }) { this.accessToken = tokens.accessToken; this.idToken = tokens.idToken; this.refreshToken = tokens.refreshToken; // Backup to sessionStorage (cleared on tab close) sessionStorage.setItem('access_token', tokens.accessToken); sessionStorage.setItem('id_token', tokens.idToken); sessionStorage.setItem('refresh_token', tokens.refreshToken); } getAccessToken(): string | null { return this.accessToken; } clearTokens() { this.accessToken = null; this.idToken = null; this.refreshToken = null; sessionStorage.clear(); } } export const tokenManager = new TokenManager(); ``` ### Content Security Policy Configure CSP headers in your UI application to prevent XSS: ```html ``` ### Additional Security Measures 1. **Always use HTTPS** for both UI and API 2. **Validate token on the client** before making requests 3. **Implement logout** that clears all stored tokens 4. **Set reasonable token lifetimes** in Cognito configuration 5. **Monitor for suspicious activity** via API logs --- ## Quick Reference ### Required Headers ``` Authorization: Bearer Content-Type: application/json ``` ### Common Endpoints | Endpoint | Method | Description | |----------|--------|-------------| | `/identity` | GET | Get user roles and permissions | | `/health` | GET | API health check (no auth required) | | `/clients` | GET | List all clients | | `/client/{id}` | GET | Get client details | | `/client/{id}/document` | GET | List client documents | | `/client/{id}/document` | POST | Upload document (multipart/form-data) | | `/client/{id}/document/batch` | POST | Upload ZIP batch | | `/document/{id}` | GET | Get document details | | `/clients/{clientId}/folders` | GET | List folders | | `/eula` | GET | Get current EULA (no auth required) | | `/eula/status` | GET | Check user's EULA agreement status | | `/eula/agree` | POST | Record EULA agreement | ### Public Endpoints (No Authentication Required) | Endpoint | Description | |----------|-------------| | `/health` | Health check and version info | | `/swagger/*` | API documentation | | `/metrics` | Prometheus metrics | | `/eula` | Current EULA version (public) | ### TypeScript Type Definitions ```typescript // Identity types interface IdentityRole { key: string; name: string; description?: string; permissions: string[]; } interface IdentityResponse { roles: IdentityRole[]; } // Error types interface ErrorResponse { message: string; } // Client types interface Client { client_id: string; name: string; can_sync: boolean; } // Document types interface Document { id: string; client_id: string; hash: string; filename?: string; folder_id?: string; fields?: Record; } ``` --- ## Support For API issues or questions: - Check the Swagger documentation at `/swagger/index.html` - Review API logs for detailed error information - Contact the backend team for CORS or authentication configuration changes