Files
Job-Info/NewApproach/auth/UNIFIED_AUTH_GUIDE.md
T
2026-01-10 19:23:47 +00:00

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:

  1. PocketBase User (OAuth via Microsoft) - "pb-user"
  2. PocketBase Service Account (email/password) - "pb-agent"
  3. Microsoft Graph Delegated (on behalf of user) - "graph-user"
  4. 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:

  1. User clicks "Sign in with Microsoft"
  2. Code calls: pb.collection('users').authWithOAuth2({ provider: 'microsoft' })
  3. PocketBase redirects to Microsoft OAuth consent page
  4. User consents and is redirected back
  5. OAuth provider returns:
    • PocketBase auth token (for pb-user)
    • Microsoft access token (for graph-user, if configured in PocketBase)
  6. 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:

  1. Code calls: backendAuth.getPocketBaseServiceToken()
  2. Checks environment variables:
    • POCKETBASE_URL
    • POCKETBASE_SERVICE_EMAIL
    • POCKETBASE_SERVICE_PASSWORD
  3. Makes POST request to PocketBase API: POST /api/collections/users/auth-with-password Body: { identity: email, password: password }
  4. PocketBase returns auth token
  5. 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:

  1. Already acquired during OAuth login (Scenario 1)
  2. Returned in authData.meta by PocketBase
  3. Frontend extracts: extractGraphToken(authData.meta)
  4. Stored in localStorage['auth:graph-user']
  5. 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:

  1. Code calls: backendAuth.getGraphServicePrincipalToken()
  2. Checks environment variables:
    • GRAPH_TENANT_ID
    • GRAPH_CLIENT_ID
    • GRAPH_CLIENT_SECRET
  3. 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' }
  4. Azure AD returns access token
  5. 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:

  1. Use HTTP-only cookies (backend sets them)
  2. Implement CSRF protection
  3. Validate tokens server-side
  4. Implement token refresh rotation (new refresh token each time)
  5. 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):

  1. Checks if token exists and is not expired
  2. If expired: calls Auth.refreshToken(type)
  3. If refresh fails: calls Auth.acquireToken(type) to re-login
  4. 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: ────────────────────

  1. Start backend: bun run dev
  2. Open public/index.html in browser
  3. Should see "Sign in with Microsoft" button
  4. Click it, authenticate
  5. Check localStorage → tokens should be stored
  6. Make API call → should include Authorization headers
  7. 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:

  1. Compile TypeScript: bun run build
  2. Output goes to dist/
  3. Serve from static file server
  4. Ensure PocketBase script is loaded before auth module

BACKEND DEPLOYMENT:

  1. Set all environment variables:
    • POCKETBASE_URL
    • POCKETBASE_SERVICE_EMAIL
    • POCKETBASE_SERVICE_PASSWORD
    • GRAPH_TENANT_ID
    • GRAPH_CLIENT_ID
    • GRAPH_CLIENT_SECRET
  2. Deploy server (e.g., to Docker, Vercel, etc.)
  3. Test token endpoints

MONITORING:

  1. Log all token acquisitions and errors
  2. Monitor token refresh failures
  3. Alert on repeated auth failures
  4. Track token expiration and refresh rates

════════════════════════════════════════════════════════════════════════════ END OF UNIFIED AUTH SYSTEM DOCUMENTATION ════════════════════════════════════════════════════════════════════════════