594 lines
25 KiB
Markdown
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
|
|
════════════════════════════════════════════════════════════════════════════
|