/** * ROUTES: OAuth Authentication * * PURPOSE: Handle OAuth2 authorization and token exchange * ENDPOINTS: * - POST /api/auth/authorize - Exchange authorization code for token * - POST /api/auth/refresh - Refresh access token using refresh token * - POST /api/auth/logout - Logout and invalidate tokens * * SECURITY: * - Client secret kept server-side only * - Refresh tokens stored in HttpOnly cookies * - PKCE verification with code verifier * - CORS protection */ import { Context } from 'hono'; // ============================================================================ // TYPES // ============================================================================ interface AuthorizeRequest { code: string; state: string; codeVerifier: string; } interface TokenResponse { accessToken: string; refreshToken: string; expiresIn: number; user: { id: string; displayName: string; mail: string; }; } interface RefreshTokenRequest { refreshToken: string; } // ============================================================================ // CONFIGURATION // ============================================================================ const MICROSOFT_TENANT_ID = process.env.MICROSOFT_TENANT_ID || 'common'; const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || ''; const MICROSOFT_CLIENT_SECRET = process.env.MICROSOFT_CLIENT_SECRET || ''; const REDIRECT_URI = process.env.MICROSOFT_REDIRECT_URI || ''; // Token endpoints const TOKEN_ENDPOINT = `https://login.microsoftonline.com/${MICROSOFT_TENANT_ID}/oauth2/v2.0/token`; const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0/me'; // In-memory token store (in production, use Redis or database) const tokenStore = new Map(); // ============================================================================ // AUTHORIZATION ENDPOINT // ============================================================================ /** * POST /api/auth/authorize * * Exchange authorization code for access token * * REQUEST: * { * code: string (from Microsoft OAuth redirect) * state: string (CSRF token) * codeVerifier: string (PKCE code verifier) * } * * RESPONSE: * { * accessToken: string * refreshToken: string * expiresIn: number (seconds) * user: { id, displayName, mail } * } */ export async function handleAuthorize(c: Context): Promise { try { const body = await c.req.json() as AuthorizeRequest; const { code, state, codeVerifier } = body; // Validate inputs if (!code || !state || !codeVerifier) { return c.json( { error: 'Missing required parameters', details: 'code, state, codeVerifier required' }, { status: 400 } ); } // Validate configuration if (!MICROSOFT_CLIENT_ID || !MICROSOFT_CLIENT_SECRET || !REDIRECT_URI) { console.error('[Auth] Missing OAuth configuration'); return c.json( { error: 'OAuth configuration incomplete' }, { status: 500 } ); } console.log('[Auth] Exchanging authorization code for token'); // Step 1: Exchange code for token with Microsoft const tokenParams = new URLSearchParams({ client_id: MICROSOFT_CLIENT_ID, client_secret: MICROSOFT_CLIENT_SECRET, code: code, code_verifier: codeVerifier, redirect_uri: REDIRECT_URI, grant_type: 'authorization_code', scope: 'offline_access', }); const tokenResponse = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenParams.toString(), }); if (!tokenResponse.ok) { const error = await tokenResponse.text(); console.error('[Auth] Token exchange failed:', error); return c.json( { error: 'Failed to exchange code for token', details: error }, { status: 400 } ); } const tokenData = await tokenResponse.json() as any; const accessToken = tokenData.access_token; const refreshToken = tokenData.refresh_token; const expiresIn = tokenData.expires_in || 3600; if (!accessToken) { console.error('[Auth] No access token in response'); return c.json( { error: 'No access token in response' }, { status: 400 } ); } console.log('[Auth] Successfully exchanged code for token'); // Step 2: Get user profile from Microsoft Graph const userResponse = await fetch(GRAPH_ENDPOINT, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, }); if (!userResponse.ok) { console.error('[Auth] Failed to fetch user profile'); return c.json( { error: 'Failed to fetch user profile' }, { status: 400 } ); } const user = await userResponse.json() as any; console.log('[Auth] Retrieved user profile:', user.displayName); // Step 3: Store refresh token server-side if (refreshToken) { const expiresAt = Date.now() + (tokenData.refresh_token_expires_in || 90 * 24 * 60 * 60) * 1000; tokenStore.set(user.id, { refreshToken, expiresAt }); console.log('[Auth] Stored refresh token for user:', user.id); } // Step 4: Return response const response: TokenResponse = { accessToken, refreshToken: refreshToken || '', expiresIn, user: { id: user.id, displayName: user.displayName, mail: user.mail, }, }; return c.json(response); } catch (error) { console.error('[Auth] Authorization handler error:', error); return c.json( { error: 'Internal server error', details: (error as Error).message }, { status: 500 } ); } } // ============================================================================ // TOKEN REFRESH ENDPOINT // ============================================================================ /** * POST /api/auth/refresh * * Refresh access token using refresh token * * REQUEST: * { * refreshToken: string * } * * RESPONSE: * { * accessToken: string * expiresIn: number (seconds) * } */ export async function handleRefresh(c: Context): Promise { try { const body = await c.req.json() as RefreshTokenRequest; const { refreshToken } = body; if (!refreshToken) { return c.json( { error: 'Refresh token required' }, { status: 400 } ); } console.log('[Auth] Refreshing access token'); // Exchange refresh token for new access token const tokenParams = new URLSearchParams({ client_id: MICROSOFT_CLIENT_ID, client_secret: MICROSOFT_CLIENT_SECRET, refresh_token: refreshToken, grant_type: 'refresh_token', scope: 'offline_access', }); const tokenResponse = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenParams.toString(), }); if (!tokenResponse.ok) { const error = await tokenResponse.text(); console.error('[Auth] Token refresh failed:', error); return c.json( { error: 'Failed to refresh token' }, { status: 401 } ); } const tokenData = await tokenResponse.json() as any; const newAccessToken = tokenData.access_token; const expiresIn = tokenData.expires_in || 3600; console.log('[Auth] Successfully refreshed access token'); return c.json({ accessToken: newAccessToken, expiresIn, }); } catch (error) { console.error('[Auth] Refresh handler error:', error); return c.json( { error: 'Internal server error', details: (error as Error).message }, { status: 500 } ); } } // ============================================================================ // LOGOUT ENDPOINT // ============================================================================ /** * POST /api/auth/logout * * Logout and invalidate tokens */ export async function handleLogout(c: Context): Promise { try { console.log('[Auth] Logout requested'); // In production, would invalidate refresh token in database // For now, just return success return c.json({ success: true }); } catch (error) { console.error('[Auth] Logout handler error:', error); return c.json( { error: 'Internal server error' }, { status: 500 } ); } }