14 KiB
/**
- 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:
-
- Generate random code_verifier (43-128 chars)
-
- Create code_challenge = BASE64_URL(SHA256(code_verifier))
-
- Send code_challenge to Microsoft
-
- 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:
-
- NEVER expose CLIENT_SECRET to frontend
-
- ALWAYS use PKCE for SPA (code challenge/verifier)
-
- ALWAYS validate state parameter (CSRF protection)
-
- ALWAYS store refresh token on backend only
-
- STORE access token in memory or sessionStorage, NEVER localStorage
-
- ACCESS token expires in 1 hour, refresh BEFORE expiry
-
- REFRESH token expires in 24 hours, user must re-auth after
-
- Use SERVICE WORKER to refresh tokens in background
-
- SCOPE must include offline_access for refresh token
-
- Test with actual Microsoft account, not demo account */