/** * REFERENCE TOOL: OAuth2 + Microsoft Graph Implementation Guide * * This document provides exact patterns and code structures to follow when * building OAuth2 and Microsoft Graph integration. */ // ============================================================================ // PART 1: BACKEND OAUTH ROUTES (Hono + TypeScript) // ============================================================================ /** * Route: POST /api/auth/authorize * Purpose: Exchange authorization code for tokens using service worker credentials * * CRITICAL: This route uses CLIENT_SECRET on backend only. Never expose to frontend. * * Request body: * { * code: string; // Authorization code from Microsoft * state: string; // State value for CSRF protection * codeVerifier: string; // PKCE code verifier (for SPA security) * } * * Response: * { * accessToken: string; // Short-lived token for Graph API (1 hour) * refreshToken: string; // Long-lived refresh token (24 hours for SPA) * expiresIn: number; // Token expiration in seconds * user: { // User info from Microsoft * id: string; * displayName: string; * mail: string; * } * } * * Error responses: * { * error: "invalid_code" | "expired_code" | "invalid_request"; * message: string; * } */ // Pseudo-code (actual implementation will be TypeScript): async function authorizeRoute(c: Context) { // 1. EXTRACT & VALIDATE request const { code, state, codeVerifier } = c.req.json(); // VALIDATION CHECKLIST: if (!code || !state || !codeVerifier) { return c.json({ error: 'Missing required parameters' }, 400); } // Verify state matches session state (CSRF protection) // This requires storing state in session/Redis during login initiation // 2. EXCHANGE CODE for tokens using CLIENT_SECRET const tokenResponse = await fetch('https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: process.env.MICROSOFT_CLIENT_ID, client_secret: process.env.MICROSOFT_CLIENT_SECRET, // BACKEND ONLY code: code, redirect_uri: process.env.MICROSOFT_REDIRECT_URI, grant_type: 'authorization_code', code_verifier: codeVerifier, // Required for PKCE }).toString(), }); if (!tokenResponse.ok) { const error = await tokenResponse.json(); console.error('Microsoft token error:', error); return c.json({ error: error.error, message: error.error_description }, 400); } const tokens = await tokenResponse.json(); // tokens contains: { access_token, refresh_token, expires_in, token_type, ... } // 3. GET USER INFO from Microsoft Graph const userResponse = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${tokens.access_token}` }, }); if (!userResponse.ok) { return c.json({ error: 'Failed to fetch user info' }, 500); } const userInfo = await userResponse.json(); // userInfo contains: { id, displayName, mail, ... } // 4. STORE REFRESH TOKEN in PocketBase (secured backend) // Pattern: // - Create/update user record in PocketBase with refresh_token field // - Key field: Microsoft ID or email // - Store encrypted refresh token // - NEVER return refresh token to frontend await pb.collection('users').upsert({ microsoftId: userInfo.id, email: userInfo.mail, displayName: userInfo.displayName, refreshToken: tokens.refresh_token, // Store securely refreshTokenExpiry: Date.now() + (24 * 60 * 60 * 1000), // 24 hours }); // 5. RETURN ONLY ACCESS TOKEN to frontend return c.json({ accessToken: tokens.access_token, expiresIn: tokens.expires_in, // Usually 3600 seconds (1 hour) user: { id: userInfo.id, displayName: userInfo.displayName, mail: userInfo.mail, }, }); } // ============================================================================ // PART 2: TOKEN REFRESH ROUTE // ============================================================================ /** * Route: POST /api/auth/refresh * Purpose: Get new access token using refresh token from PocketBase * * Headers required: * - Authorization: Bearer {userId} (user ID from frontend, used to lookup refresh token) * * Response: * { * accessToken: string; * expiresIn: number; * } */ async function refreshRoute(c: Context) { // 1. EXTRACT user ID from auth header const auth = c.req.header('Authorization'); if (!auth || !auth.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' }, 401); } const userId = auth.replace('Bearer ', ''); // 2. RETRIEVE refresh token from PocketBase const user = await pb.collection('users').getOne(userId); if (!user || !user.refreshToken) { return c.json({ error: 'No refresh token found' }, 401); } // 3. EXCHANGE refresh token for new access token const tokenResponse = await fetch('https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: process.env.MICROSOFT_CLIENT_ID, client_secret: process.env.MICROSOFT_CLIENT_SECRET, refresh_token: user.refreshToken, grant_type: 'refresh_token', scope: 'https://graph.microsoft.com/.default', }).toString(), }); if (!tokenResponse.ok) { const error = await tokenResponse.json(); // If refresh token invalid, user must re-authenticate return c.json({ error: 'Token refresh failed', needsReauth: true }, 401); } const tokens = await tokenResponse.json(); // 4. UPDATE refresh token in PocketBase (tokens may have new refresh token) if (tokens.refresh_token) { await pb.collection('users').update(userId, { refreshToken: tokens.refresh_token, }); } // 5. RETURN new access token (never refresh token) return c.json({ accessToken: tokens.access_token, expiresIn: tokens.expires_in, }); } // ============================================================================ // PART 3: FRONTEND OAUTH FLOW (Svelte + PKCE) // ============================================================================ /** * PKCE Pattern (Proof Key for Code Exchange) * * Why: SPAs can't securely store client secrets, so PKCE adds extra layer. * Flow: * 1. Generate random code_verifier (43-128 chars) * 2. Create code_challenge = BASE64_URL(SHA256(code_verifier)) * 3. Send code_challenge to Microsoft * 4. Microsoft verifies code_verifier when exchanging code * * This prevents authorization code interception attacks. */ // Generate PKCE code verifier and challenge function generatePKCE() { // Step 1: Generate random string 43-128 chars, base64url encoded const codeVerifier = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map(b => String.fromCharCode(b)) .join(''); // Actually encode as base64url: const encoded = btoa(codeVerifier) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); // Step 2: Create code challenge = SHA256(verifier) -> base64url const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(encoded)); const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); return { codeVerifier: encoded, codeChallenge }; } // Initiate login function initiateLogin() { const { codeVerifier, codeChallenge } = generatePKCE(); // STORE code_verifier in sessionStorage (lost on page refresh, safe) sessionStorage.setItem('oauth_code_verifier', codeVerifier); // Generate state for CSRF protection const state = crypto.getRandomValues(new Uint8Array(16)).toString(); sessionStorage.setItem('oauth_state', state); // Redirect to Microsoft login const params = new URLSearchParams({ client_id: process.env.PUBLIC_MICROSOFT_CLIENT_ID, response_type: 'code', scope: 'https://graph.microsoft.com/User.Read https://graph.microsoft.com/Files.Read.All', redirect_uri: 'http://localhost:5173/auth/callback', code_challenge: codeChallenge, code_challenge_method: 'S256', state: state, prompt: 'select_account', // Let user choose account }); window.location.href = `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?${params.toString()}`; } // Handle callback (in /auth/callback route) async function handleOAuthCallback() { // Step 1: Extract code and state from URL const params = new URLSearchParams(window.location.search); const code = params.get('code'); const returnedState = params.get('state'); if (!code || !returnedState) { throw new Error('Missing code or state in callback'); } // Step 2: Verify state (CSRF protection) const storedState = sessionStorage.getItem('oauth_state'); if (returnedState !== storedState) { throw new Error('State mismatch - possible CSRF attack'); } // Step 3: Retrieve code verifier const codeVerifier = sessionStorage.getItem('oauth_code_verifier'); if (!codeVerifier) { throw new Error('Code verifier not found'); } // Step 4: Send to backend to exchange code for tokens const response = await fetch('/api/auth/authorize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, state: returnedState, codeVerifier }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message); } const { accessToken, expiresIn, user } = await response.json(); // Step 5: Store in Svelte stores and sessionStorage authStore.set({ user, accessToken, expiresIn }); sessionStorage.setItem('ms_access_token', accessToken); sessionStorage.setItem('ms_token_expiry', (Date.now() + expiresIn * 1000).toString()); // Clear OAuth temporary values sessionStorage.removeItem('oauth_code_verifier'); sessionStorage.removeItem('oauth_state'); // Redirect to home goto('/'); } // ============================================================================ // PART 4: SERVICE WORKER TOKEN REFRESH LOGIC // ============================================================================ /** * Service Worker: Background token refresh * * Pattern: Service Worker periodically checks token expiry and refreshes * before expiration, updating the frontend via postMessage. * * Why: Ensures frontend always has valid token without user interaction */ // In service-worker.ts let tokenExpiry = 0; let userId = ''; // Main loop: Check and refresh every minute setInterval(async () => { if (!userId || Date.now() < tokenExpiry - 5 * 60 * 1000) { // Token still valid (5 minute buffer), skip return; } try { const response = await fetch('/api/auth/refresh', { method: 'POST', headers: { Authorization: `Bearer ${userId}` }, }); if (!response.ok) { // Refresh failed, notify frontend const clients = await self.clients.matchAll(); clients.forEach(client => { client.postMessage({ type: 'TOKEN_REFRESH_FAILED', needsReauth: true, }); }); return; } const { accessToken, expiresIn } = await response.json(); // Update token expiry tokenExpiry = Date.now() + expiresIn * 1000; // Notify all clients of new token const clients = await self.clients.matchAll(); clients.forEach(client => { client.postMessage({ type: 'TOKEN_REFRESHED', accessToken, expiresIn, }); }); } catch (error) { console.error('Token refresh error:', error); } }, 60 * 1000); // Every 60 seconds // ============================================================================ // PART 5: CONSTANTS & CONFIGURATION // ============================================================================ // Environment variables required (in .env and secrets/.env) const OAUTH_CONFIG = { MICROSOFT_CLIENT_ID: 'your-client-id', MICROSOFT_CLIENT_SECRET: 'your-secret', // Backend only MICROSOFT_TENANT_ID: 'common', // Or specific tenant MICROSOFT_REDIRECT_URI: 'http://localhost:5173/auth/callback', }; // Graph API scopes needed const GRAPH_SCOPES = [ 'https://graph.microsoft.com/User.Read', // Read user profile 'https://graph.microsoft.com/Files.Read.All', // Read files in OneDrive 'offline_access', // Required for refresh token ]; // ============================================================================ // PART 6: ERROR HANDLING CHECKLIST // ============================================================================ /** * Common OAuth errors and how to handle them: * * invalid_code / expired_code * → User took too long to complete auth, or code was reused * → Solution: Restart login flow * * invalid_request * → Missing required parameters (client_id, redirect_uri, code, etc) * → Solution: Check all required params are sent correctly * * unauthorized_client * → Client not registered in Azure or not configured * → Solution: Verify app registration in Azure portal * * access_denied * → User declined consent or permissions * → Solution: Retry with proper scopes, or user chose to not authorize * * invalid_scope * → Requested scope not allowed for this app * → Solution: Check scopes in app registration, match exactly * * invalid_client * → Client secret is wrong * → Solution: Verify MICROSOFT_CLIENT_SECRET is correct * * token_expired * → Refresh token expired (24 hours for SPA) * → Solution: User must re-authenticate * * AADSTS_error_codes * → Azure-specific errors (look up AADSTS number) * → Solution: Check error_description, consult Azure docs */ // ============================================================================ // SUMMARY // ============================================================================ /** * GOLDEN RULES for OAuth2 + Microsoft: * * 1. NEVER expose CLIENT_SECRET to frontend * 2. ALWAYS use PKCE for SPA (code challenge/verifier) * 3. ALWAYS validate state parameter (CSRF protection) * 4. ALWAYS store refresh token on backend only * 5. STORE access token in memory or sessionStorage, NEVER localStorage * 6. ACCESS token expires in 1 hour, refresh BEFORE expiry * 7. REFRESH token expires in 24 hours, user must re-auth after * 8. Use SERVICE WORKER to refresh tokens in background * 9. SCOPE must include offline_access for refresh token * 10. Test with actual Microsoft account, not demo account */