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
439 lines
14 KiB
Markdown
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
|
|
*/
|