Add Mode6Test: copy of Mode5Test running on port 3000
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
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
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
Reference in New Issue
Block a user