/** * Microsoft OAuth Module * * Handles Microsoft OAuth flow to authenticate users and retrieve: * - User's email * - User's display name * - PocketBase auth token (user must already exist) * * Environment Variables Used: * - MICROSOFT_CLIENT_ID * - MICROSOFT_CLIENT_SECRET * - MICROSOFT_TENANT_ID * - MICROSOFT_REDIRECT_URI * - MICROSOFT_SCOPES * - PB_URL */ interface MicrosoftTokenResponse { access_token: string; token_type: string; expires_in: number; scope: string; refresh_token?: string; } interface MicrosoftUserInfo { id: string; displayName: string; mail: string; userPrincipalName: string; } export interface AuthResult { email: string; displayName: string; pocketbaseToken: string; } export interface OAuthConfig { clientId: string; clientSecret: string; tenantId: string; redirectUri: string; scopes: string; pbUrl: string; } /** * Build config from process.env */ export function getConfigFromEnv(): OAuthConfig { const clientId = process.env.MICROSOFT_CLIENT_ID; const clientSecret = process.env.MICROSOFT_CLIENT_SECRET; const tenantId = process.env.MICROSOFT_TENANT_ID; const redirectUri = process.env.MICROSOFT_REDIRECT_URI; const scopes = process.env.MICROSOFT_SCOPES; const pbUrl = process.env.PB_URL; if (!clientId || !clientSecret || !tenantId || !redirectUri || !scopes || !pbUrl) { throw new Error('Missing required OAuth environment variables'); } return { clientId, clientSecret, tenantId, redirectUri, scopes, pbUrl }; } /** * Generate the Microsoft OAuth authorization URL */ export function getAuthUrl(config: OAuthConfig): string { const params = new URLSearchParams({ client_id: config.clientId, response_type: 'code', redirect_uri: config.redirectUri, response_mode: 'query', scope: config.scopes, state: crypto.randomUUID(), }); return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/authorize?${params}`; } /** * Exchange authorization code for access token */ export async function exchangeCodeForToken( code: string, config: OAuthConfig ): Promise { const response = await fetch( `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ client_id: config.clientId, client_secret: config.clientSecret, code, redirect_uri: config.redirectUri, grant_type: 'authorization_code', scope: config.scopes, }), } ); if (!response.ok) { const error = await response.text(); throw new Error(`Token exchange failed: ${error}`); } return response.json(); } /** * Get user info from Microsoft Graph API */ export async function getMicrosoftUserInfo( accessToken: string ): Promise { const response = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to get user info: ${error}`); } return response.json(); } /** * Authenticate existing user with PocketBase * User MUST already exist - no user creation */ export async function getPocketBaseToken( email: string, pbUrl: string ): Promise { // Look up user by email - user must exist const searchResponse = await fetch( `${pbUrl}/api/collections/users/records?filter=(email='${encodeURIComponent(email)}')`, { headers: { 'Content-Type': 'application/json', }, } ); if (!searchResponse.ok) { throw new Error('Failed to query PocketBase'); } const searchData = await searchResponse.json(); if (!searchData.items || searchData.items.length === 0) { throw new Error('Access denied: User does not exist'); } // User exists - authenticate via OAuth const authResponse = await fetch(`${pbUrl}/api/collections/users/auth-with-oauth2`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ provider: 'microsoft', }), }); if (!authResponse.ok) { const error = await authResponse.text(); throw new Error(`PocketBase auth failed: ${error}`); } const data = await authResponse.json(); return data.token; } /** * Complete OAuth flow: code -> user info + PocketBase token * Denies access if user doesn't exist in PocketBase */ export async function handleCallback( code: string, config: OAuthConfig ): Promise { // 1. Exchange code for Microsoft access token const tokenResponse = await exchangeCodeForToken(code, config); // 2. Get user info from Microsoft Graph const userInfo = await getMicrosoftUserInfo(tokenResponse.access_token); const email = userInfo.mail || userInfo.userPrincipalName; const displayName = userInfo.displayName; // 3. Get PocketBase auth token (user must exist) const pocketbaseToken = await getPocketBaseToken(email, config.pbUrl); return { email, displayName, pocketbaseToken, }; }