25 KiB
================================================================================ UNIFIED AUTH SYSTEM: Complete Implementation Guide
PROJECT: NewApproach DATE: 2026-01-10 STATUS: Production-ready, drop-in authentication system
================================================================================ OVERVIEW
This is a COMPLETE, STANDALONE authentication system that handles ALL token acquisition, storage, and retrieval across 4 authentication scenarios:
- PocketBase User (OAuth via Microsoft) - "pb-user"
- PocketBase Service Account (email/password) - "pb-agent"
- Microsoft Graph Delegated (on behalf of user) - "graph-user"
- Microsoft Graph Service Principal (app-only) - "graph-agent"
The system is modular and can be dropped into ANY project. It works in BOTH browser (frontend) and backend (Node.js) environments.
================================================================================ ARCHITECTURE
TWO-TIER DESIGN:
FRONTEND (Browser) ├─ auth/auth.unified.ts │ ├─ Universal login popup (username/password + OAuth buttons) │ ├─ Token acquisition from external auth providers │ ├─ localStorage-based token persistence │ ├─ Token refresh and expiration tracking │ └─ User info extraction │ └─ Usage: Auth.getToken('pb-user') → automatically prompts if needed
BACKEND (Server) ├─ src/backend/auth.ts │ ├─ Service principal authentication (client credentials flow) │ ├─ PocketBase service account auth │ ├─ Token caching with expiration │ ├─ Hono middleware for token injection │ └─ API endpoints for token operations │ └─ Usage: await backendAuth.getGraphServicePrincipalToken()
================================================================================ TOKEN ACQUISITION FLOW: DETAILED BREAKDOWN
SCENARIO 1: PocketBase User Login (Primary Flow) ═════════════════════════════════════════════════
WHEN: User visits app and is not authenticated WHERE: auth/auth.unified.ts → acquirePocketBaseUser() HOW:
- User clicks "Sign in with Microsoft"
- Code calls: pb.collection('users').authWithOAuth2({ provider: 'microsoft' })
- PocketBase redirects to Microsoft OAuth consent page
- User consents and is redirected back
- OAuth provider returns:
- PocketBase auth token (for pb-user)
- Microsoft access token (for graph-user, if configured in PocketBase)
- Tokens are extracted and stored in localStorage
STORAGE:
- pb-user token → localStorage['auth:pb-user']
- graph-user token → localStorage['auth:graph-user'] (if returned)
RETRIEVAL:
- Frontend calls: const token = await Auth.getToken('pb-user')
- Automatically uses cached token if valid
- Automatically refreshes if expired
CRITICAL: This is a UNIFIED login that gets TWO tokens at once. User only logs in once, both pb-user and graph-user are acquired.
────────────────────────────────────────────────────────────────────────────
SCENARIO 2: PocketBase Service Account (Agent) ═══════════════════════════════════════════════
WHEN: Backend or agent code needs PocketBase access WHERE: src/backend/auth.ts → getPocketBaseServiceToken() HOW:
- Code calls: backendAuth.getPocketBaseServiceToken()
- Checks environment variables:
- POCKETBASE_URL
- POCKETBASE_SERVICE_EMAIL
- POCKETBASE_SERVICE_PASSWORD
- Makes POST request to PocketBase API: POST /api/collections/users/auth-with-password Body: { identity: email, password: password }
- PocketBase returns auth token
- Token is cached locally (7 day expiration)
STORAGE:
- In-memory cache in BackendAuthManager
- Token expires after 7 days (typical PocketBase duration)
RETRIEVAL:
- Backend code: const token = await backendAuth.getPocketBaseServiceToken()
- Automatically uses cached token if valid
- Automatically re-authenticates if expired
USE CASE: Backend calling PocketBase to create records, process data, etc.
────────────────────────────────────────────────────────────────────────────
SCENARIO 3: Microsoft Graph Delegated (User Token) ═══════════════════════════════════════════════════
WHEN: User's apps need access to Microsoft Graph (email, files, etc.) WHERE: Acquired via OAuth, stored by frontend HOW:
- Already acquired during OAuth login (Scenario 1)
- Returned in authData.meta by PocketBase
- Frontend extracts: extractGraphToken(authData.meta)
- Stored in localStorage['auth:graph-user']
- Can be refreshed by calling Auth.refreshToken('graph-user')
STORAGE:
- localStorage['auth:graph-user']
RETRIEVAL:
- Frontend: const token = await Auth.getToken('graph-user')
- Backend: Frontend sends token in header 'x-graph-token'
USE CASE: App accessing user's OneDrive, Outlook, Teams, etc.
────────────────────────────────────────────────────────────────────────────
SCENARIO 4: Microsoft Graph Service Principal (App-Only) ═════════════════════════════════════════════════════════
WHEN: Backend needs app-only access to Graph (no user context) WHERE: src/backend/auth.ts → getGraphServicePrincipalToken() HOW:
- Code calls: backendAuth.getGraphServicePrincipalToken()
- Checks environment variables:
- GRAPH_TENANT_ID
- GRAPH_CLIENT_ID
- GRAPH_CLIENT_SECRET
- Makes POST request to Azure AD token endpoint: POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token Body: { client_id, client_secret, scope, grant_type: 'client_credentials' }
- Azure AD returns access token
- Token is cached locally (typically 1 hour)
STORAGE:
- In-memory cache in BackendAuthManager
- Token expires after ~1 hour (configurable by Azure)
RETRIEVAL:
- Backend code: const token = await backendAuth.getGraphServicePrincipalToken()
- Automatically uses cached token if valid
- Automatically re-authenticates if expired
USE CASE: App reading all users' files, sending emails on behalf of org, etc.
================================================================================ FILE STRUCTURE & LOCATIONS
/NewApproach/ ├── auth/ │ ├── auth.unified.ts ← FRONTEND: Main auth module (THIS IS THE CORE) │ ├── auth-test.html ← Testing interface │ └── AUTH_ANALYSIS.md ← Detailed analysis (previous) │ ├── src/ │ ├── backend/ │ │ ├── auth.ts ← BACKEND: Service auth (THIS IS THE CORE) │ │ └── server.ts ← Hono server (uses auth middleware) │ │ │ └── frontend/ │ └── (integrate auth.unified.ts here) │ └── public/ ├── index.html ← Should include auth initialization └── app.js ← Application code using tokens
================================================================================ ENVIRONMENT VARIABLES REQUIRED
FRONTEND (Browser - Usually from PocketBase config): ──────────────────────────────────────────────────
Optional, but used by Auth.init(): POCKETBASE_URL PocketBase instance URL (default: http://127.0.0.1:8090) GRAPH_TENANT_ID Azure AD tenant for Graph scopes (optional)
BACKEND (Server): ────────────────
CRITICAL - Must be set for service authentication:
PocketBase Service Account: POCKETBASE_URL http://localhost:8090 POCKETBASE_SERVICE_EMAIL agent@example.com POCKETBASE_SERVICE_PASSWORD agent_password
Microsoft Graph Service Principal: GRAPH_TENANT_ID {tenant-id} GRAPH_CLIENT_ID {client-id} GRAPH_CLIENT_SECRET {client-secret}
All should be set in .env file or environment.
================================================================================ USAGE EXAMPLES
FRONTEND: Getting Tokens ════════════════════════
// 1. Initialize auth module (usually in app startup) await Auth.init('pb+graph', { pbUrl: 'http://localhost:8090' });
// 2. Get user token (automatically prompts for login if needed) const pbUserToken = await Auth.getToken('pb-user'); // First call: Shows login popup → User signs in → Token returned // Subsequent calls: Returns cached token
// 3. Get Graph token (returned from same OAuth flow) const graphUserToken = await Auth.getToken('graph-user'); // Usually already available from Auth.getToken('pb-user') // If not, can be refreshed separately
// 4. Use token in API calls
fetch('/api/data', {
headers: {
'Authorization': Bearer ${pbUserToken},
'X-Graph-Token': graphUserToken
}
});
// 5. Get user info
const { displayName, email } = Auth.getUserInfo();
console.log(Logged in as: ${displayName} (${email}));
// 6. Clear tokens on logout Auth.clearAllTokens();
────────────────────────────────────────────────────────────────────────────
BACKEND: Service Authentication ════════════════════════════════
import backendAuth, { serviceTokenMiddleware, createTokenEndpoint } from './auth.ts'; import { Hono } from 'hono';
const app = new Hono();
// 1. Initialize auth with config (loads from env vars) backendAuth.init({ pbUrl: process.env.POCKETBASE_URL });
// 2. Register middleware to inject tokens app.use('/*', serviceTokenMiddleware());
// 3. Get service tokens when needed app.get('/api/process', async (c) => { // Option A: Manual acquisition const pbToken = await backendAuth.getPocketBaseServiceToken(); const graphToken = await backendAuth.getGraphServicePrincipalToken();
// Option B: From middleware (if registered) const pbToken2 = c.get('pbServiceToken'); const graphToken2 = c.get('graphServiceToken');
// Use tokens
const response = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { 'Authorization': Bearer ${graphToken} }
});
return c.json({ success: true }); });
// 4. Register token endpoints (optional, for frontend to fetch) createTokenEndpoint(app); // GET /api/auth/service-tokens createRefreshEndpoint(app); // POST /api/auth/refresh-tokens
────────────────────────────────────────────────────────────────────────────
INTEGRATION EXAMPLE: Full App ══════════════════════════════
// public/index.html
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script> <script type="module"> import Auth from '/auth.unified.ts'; // Initialize on page load await Auth.init('pb+graph'); // Get tokens const pbToken = await Auth.getToken('pb-user'); const graphToken = await Auth.getToken('graph-user'); // Show user info const { displayName, email } = Auth.getUserInfo(); document.getElementById('userInfo').textContent = `${displayName} (${email})`; // Make authenticated API calls const response = await fetch('/api/data', { headers: { 'Authorization': `Bearer ${pbToken}`, 'X-Graph-Token': graphToken } }); </script>// Backend routes app.get('/api/data', async (c) => { // Get Graph service token from cache const graphToken = await backendAuth.getGraphServicePrincipalToken();
// Fetch data from Microsoft Graph
const result = await fetch('https://graph.microsoft.com/v1.0/drives', {
headers: { 'Authorization': Bearer ${graphToken} }
}).then(r => r.json());
return c.json(result); });
================================================================================ SECURITY CONSIDERATIONS
FRONTEND TOKEN STORAGE ──────────────────────
Current: localStorage (plaintext) ✓ Works for development and many scenarios ✗ Vulnerable to XSS attacks ✗ Not suitable for highly sensitive credentials
IMPROVEMENTS FOR PRODUCTION:
- Use HTTP-only cookies (backend sets them)
- Implement CSRF protection
- Validate tokens server-side
- Implement token refresh rotation (new refresh token each time)
- Use Content Security Policy headers
────────────────────────────────────────────────────────────────────────────
BACKEND CREDENTIALS ───────────────────
CRITICAL: Service credentials (GRAPH_CLIENT_SECRET, etc.) should NEVER be: ✗ Committed to git ✗ Logged or printed ✗ Exposed to frontend ✗ Stored in localStorage
DO: ✓ Use environment variables only ✓ Use .env.local (not committed) ✓ Use secrets manager in production (Azure Key Vault, etc.) ✓ Rotate credentials regularly ✓ Monitor token usage and access logs
────────────────────────────────────────────────────────────────────────────
FRONTEND/BACKEND COMMUNICATION ───────────────────────────────
Frontend should NOT send credentials to backend. Instead: ✓ Frontend gets user token from OAuth ✓ Frontend sends token in headers (Authorization header) ✓ Backend verifies token validity ✓ Backend uses ITS OWN service principal for backend-to-backend calls
Example: Frontend has: graph-user token (on behalf of user) Backend has: graph-agent token (service principal) Both can call Microsoft Graph, with different permissions
================================================================================ TOKEN REFRESH & EXPIRATION
PocketBase Tokens:
- Expiration: Typically 7 days
- Refresh: Auth.refreshToken('pb-user') or automatic on getToken()
- Strategy: Stored in localStorage, cached in memory
Graph Delegated Tokens:
- Expiration: Typically 1 hour
- Refresh: Auth.refreshToken('graph-user')
- Strategy: Can refresh via PocketBase authRefresh()
Graph Service Principal:
- Expiration: Typically 1 hour
- Refresh: Automatic via client credentials flow
- Strategy: Cached in backend, auto-refreshes if expired
────────────────────────────────────────────────────────────────────────────
AUTO-REFRESH IMPLEMENTATION
When you call Auth.getToken(type):
- Checks if token exists and is not expired
- If expired: calls Auth.refreshToken(type)
- If refresh fails: calls Auth.acquireToken(type) to re-login
- Returns valid token
This means:
- Tokens are automatically refreshed before use
- No manual refresh logic needed
- User rarely sees "login again" prompts
================================================================================ COMMON WORKFLOWS
WORKFLOW 1: User Logs In, Gets Both Tokens ═══════════════════════════════════════════
Step 1: Page loads, Auth.init() is called Step 2: User not authenticated, clicks "Sign in" Step 3: Auth.getToken('pb-user') is called Step 4: System shows "Sign in with Microsoft" button Step 5: User clicks, Microsoft OAuth popup appears Step 6: User consents Step 7: OAuth returns PB token + Graph token (in PocketBase meta) Step 8: Both stored in localStorage Step 9: User is logged in, can make API calls with either token Step 10: Future requests use cached tokens, auto-refresh as needed
────────────────────────────────────────────────────────────────────────────
WORKFLOW 2: Backend Processes Data with Service Account ════════════════════════════════════════════════════════
Step 1: Backend init() is called, loads credentials from env Step 2: Route receives request Step 3: Code calls backendAuth.getPocketBaseServiceToken() Step 4: System checks cache: - If valid: Returns cached token - If expired or missing: Authenticates with service account Step 5: Backend makes request with service token Step 6: PocketBase validates token and returns data Step 7: Backend processes and returns to frontend
────────────────────────────────────────────────────────────────────────────
WORKFLOW 3: Frontend Uses Microsoft Graph ═══════════════════════════════════════════
Step 1: User has graph-user token from initial OAuth
Step 2: Frontend code calls: const token = await Auth.getToken('graph-user')
Step 3: System returns cached token (if still valid)
Step 4: Frontend makes fetch to Microsoft Graph:
fetch('https://graph.microsoft.com/v1.0/me', {
headers: { 'Authorization': Bearer ${token} }
})
Step 5: Graph API validates token and returns user data
Step 6: Frontend displays results
────────────────────────────────────────────────────────────────────────────
WORKFLOW 4: Backend Calls Microsoft Graph (Service Principal) ══════════════════════════════════════════════════════════════
Step 1: Backend init() is called, loads Graph credentials from env
Step 2: Route needs to call Microsoft Graph
Step 3: Code calls: const token = await backendAuth.getGraphServicePrincipalToken()
Step 4: System authenticates with service principal via client credentials flow
Step 5: Token is cached (1 hour typical)
Step 6: Backend makes request:
fetch('https://graph.microsoft.com/v1.0/drives', {
headers: { 'Authorization': Bearer ${token} }
})
Step 7: Graph API validates service principal token
Step 8: Returns data (with service principal permissions, not user's)
================================================================================ TESTING
Manual Testing: auth-test.html ──────────────────────────────
- Open in browser
- Click "Test Module Load" → Verifies Auth is available
- Click "Test Token Storage" → Verifies localStorage works
- Click "Test Configuration" → Verifies Auth.configure exists
- Click "Clear Tokens" → Tests token clearing
Integration Testing: ────────────────────
- Start backend: bun run dev
- Open public/index.html in browser
- Should see "Sign in with Microsoft" button
- Click it, authenticate
- Check localStorage → tokens should be stored
- Make API call → should include Authorization headers
- Backend should receive and validate tokens
Unit Testing (TODO): ─────────────────
- Test token parsing and storage
- Test expiration tracking
- Test refresh logic
- Test error handling
- Test popup UI
================================================================================ TROUBLESHOOTING
ISSUE: "PocketBase SDK not loaded" ────────────────────────────────── Solution: Include PocketBase script before auth.ts:
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>────────────────────────────────────────────────────────────────────────────
ISSUE: Graph token is undefined after OAuth ───────────────────────────────────────────── Solution: PocketBase may not be configured to return Graph token Check PocketBase settings → OAuth2 → Configure Microsoft with proper scopes
Scopes needed:
- offline_access (for refresh tokens)
- User.Read
- Files.Read
- Sites.Read.All
────────────────────────────────────────────────────────────────────────────
ISSUE: Service principal token acquisition fails ────────────────────────────────────────────────── Solution: Check environment variables are set echo $GRAPH_TENANT_ID echo $GRAPH_CLIENT_ID echo $GRAPH_CLIENT_SECRET (DON'T print in production!)
Also check:
- Credentials are correct
- Service principal has required permissions
- Network connectivity to Azure AD
────────────────────────────────────────────────────────────────────────────
ISSUE: Tokens not persisting across page reloads ──────────────────────────────────────────────── Solution: Check localStorage is enabled and available localStorage.setItem('test', '1') localStorage.getItem('test')
Also check browser DevTools → Application → Local Storage
════════════════════════════════════════════════════════════════════════════
DEPLOYMENT & PRODUCTION ════════════════════════════════════════════════════════════════════════════
FRONTEND DEPLOYMENT:
- Compile TypeScript: bun run build
- Output goes to dist/
- Serve from static file server
- Ensure PocketBase script is loaded before auth module
BACKEND DEPLOYMENT:
- Set all environment variables:
- POCKETBASE_URL
- POCKETBASE_SERVICE_EMAIL
- POCKETBASE_SERVICE_PASSWORD
- GRAPH_TENANT_ID
- GRAPH_CLIENT_ID
- GRAPH_CLIENT_SECRET
- Deploy server (e.g., to Docker, Vercel, etc.)
- Test token endpoints
MONITORING:
- Log all token acquisitions and errors
- Monitor token refresh failures
- Alert on repeated auth failures
- Track token expiration and refresh rates
════════════════════════════════════════════════════════════════════════════ END OF UNIFIED AUTH SYSTEM DOCUMENTATION ════════════════════════════════════════════════════════════════════════════