Files
2026-01-10 19:23:47 +00:00

594 lines
25 KiB
Markdown

================================================================================
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
════════════════════════════════════════════════════════════════════════════