Files
Job-Info/OAUTH2_REFERENCE_GUIDE.md
T
aewing 42ff3e8f33
Build & Deploy / Test & Lint (push) Has been cancelled
Build & Deploy / Security Scan (push) Has been cancelled
Code Quality / Code Quality Checks (push) Has been cancelled
Code Quality / Dependency Vulnerabilities (push) Has been cancelled
Code Quality / Performance Testing (push) Has been cancelled
Build & Deploy / Build Docker Images (push) Has been cancelled
Build & Deploy / Deploy to Kubernetes (push) Has been cancelled
Add Mode6Test: copy of Mode5Test running on port 3000
2026-01-20 13:43:29 +00:00

439 lines
14 KiB
Markdown

/**
* 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
*/