UNPKG

@checkfirst/nestjs-outlook

Version:

An opinionated NestJS module for Microsoft Outlook integration that provides easy access to Microsoft Graph API for emails, calendars, and more.

454 lines 23.8 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var MicrosoftAuthService_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.MicrosoftAuthService = void 0; const common_1 = require("@nestjs/common"); const event_emitter_1 = require("@nestjs/event-emitter"); const axios_1 = require("axios"); const calendar_service_1 = require("../calendar/calendar.service"); const email_service_1 = require("../email/email.service"); const constants_1 = require("../../constants"); const event_types_enum_1 = require("../../enums/event-types.enum"); const crypto = require("crypto"); const schedule_1 = require("@nestjs/schedule"); const microsoft_csrf_token_repository_1 = require("../../repositories/microsoft-csrf-token.repository"); const permission_scope_enum_1 = require("../../enums/permission-scope.enum"); const typeorm_1 = require("@nestjs/typeorm"); const typeorm_2 = require("typeorm"); const microsoft_user_entity_1 = require("../../entities/microsoft-user.entity"); const retry_util_1 = require("../../utils/retry.util"); let MicrosoftAuthService = MicrosoftAuthService_1 = class MicrosoftAuthService { constructor(eventEmitter, calendarService, emailService, microsoftConfig, csrfTokenRepository, microsoftUserRepository) { this.eventEmitter = eventEmitter; this.calendarService = calendarService; this.emailService = emailService; this.microsoftConfig = microsoftConfig; this.csrfTokenRepository = csrfTokenRepository; this.microsoftUserRepository = microsoftUserRepository; this.logger = new common_1.Logger(MicrosoftAuthService_1.name); this.tenantId = 'common'; this.requiredScopes = ['offline_access', 'User.Read']; this.defaultScopes = [ permission_scope_enum_1.PermissionScope.CALENDAR_READ, permission_scope_enum_1.PermissionScope.CALENDAR_WRITE, permission_scope_enum_1.PermissionScope.EMAIL_SEND, permission_scope_enum_1.PermissionScope.EMAIL_READ, permission_scope_enum_1.PermissionScope.EMAIL_WRITE, ]; this.CSRF_TOKEN_EXPIRY = 30 * 60 * 1000; this.subscriptionInProgress = new Map(); console.log('MicrosoftAuthService constructor - microsoftConfig:', { clientId: this.microsoftConfig.clientId, redirectUri: this.microsoftConfig.redirectPath, }); this.clientId = this.microsoftConfig.clientId; this.clientSecret = this.microsoftConfig.clientSecret; this.redirectUri = this.buildRedirectUri(this.microsoftConfig); console.log('Redirect URI:', this.redirectUri); this.tokenEndpoint = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'; this.logger.log(`Microsoft OAuth redirect URI set to: ${this.redirectUri}`); } mapToMicrosoftScopes(scopes) { const scopeMapping = { [permission_scope_enum_1.PermissionScope.CALENDAR_READ]: ['Calendars.Read'], [permission_scope_enum_1.PermissionScope.CALENDAR_WRITE]: ['Calendars.ReadWrite'], [permission_scope_enum_1.PermissionScope.EMAIL_READ]: ['Mail.Read'], [permission_scope_enum_1.PermissionScope.EMAIL_WRITE]: ['Mail.ReadWrite'], [permission_scope_enum_1.PermissionScope.EMAIL_SEND]: ['Mail.Send'], }; const microsoftScopes = new Set(); this.requiredScopes.forEach(scope => microsoftScopes.add(scope)); scopes.forEach(scope => { scopeMapping[scope].forEach(mappedScope => microsoftScopes.add(mappedScope)); }); return Array.from(microsoftScopes); } buildRedirectUri(config) { if (config.redirectPath.startsWith('http')) { this.logger.log(`Using complete redirect URI from config: ${config.redirectPath}`); return config.redirectPath; } const baseUrl = config.backendBaseUrl; const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; let path = ''; if (config.basePath) { const cleanBasePath = config.basePath.replace(/^\/+|\/+$/g, ''); path += `/${cleanBasePath}`; } if (config.redirectPath) { const cleanRedirectPath = config.redirectPath.replace(/^\/+/g, ''); path += `/${cleanRedirectPath}`; } path = path.replace(/\/+/g, '/'); const finalUri = `${cleanBaseUrl}${path}`; this.logger.debug(`Constructed redirect URI: ${finalUri}`); this.logger.debug(`Using config: baseUrl=${baseUrl}, basePath=${config.basePath || ''}, redirectPath=${config.redirectPath || ''}`); return finalUri; } async cleanupExpiredTokens() { try { await this.csrfTokenRepository.cleanupExpiredTokens(); this.logger.log('Cleaned up expired CSRF tokens'); } catch (error) { this.logger.error(`Error cleaning up expired tokens: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async generateCsrfToken(externalUserId) { const token = crypto.randomBytes(32).toString('hex'); await this.csrfTokenRepository.saveToken(token, externalUserId, this.CSRF_TOKEN_EXPIRY); return token; } parseState(state) { try { const paddingNeeded = 4 - (state.length % 4); const paddedState = paddingNeeded < 4 ? state + '='.repeat(paddingNeeded) : state; const decoded = Buffer.from(paddedState, 'base64').toString(); return JSON.parse(decoded); } catch (error) { this.logger.error(`Failed to parse state: ${error instanceof Error ? error.message : 'Unknown error'}`); return null; } } async validateCsrfToken(token, timestamp) { if (!token) { return 'Missing CSRF token'; } const csrfToken = await this.csrfTokenRepository.findAndValidateToken(token); if (!csrfToken) { this.logger.warn('CSRF token not found or expired'); return 'Invalid or expired CSRF token'; } if (timestamp && Date.now() - timestamp > this.CSRF_TOKEN_EXPIRY) { this.logger.warn(`Request timestamp expired for user ${csrfToken.userId}`); return 'Authorization request has expired'; } return null; } async getLoginUrl(externalUserId, scopes = this.defaultScopes) { const csrf = await this.generateCsrfToken(externalUserId); const stateObj = { userId: externalUserId, csrf, timestamp: Date.now(), requestedScopes: scopes, }; const stateJson = JSON.stringify(stateObj); const state = Buffer.from(stateJson).toString('base64').replace(/=/g, ''); this.logger.debug(`State object: ${JSON.stringify(stateObj)}`); const scopeString = this.mapToMicrosoftScopes(scopes).join(' '); const encodedScope = encodeURIComponent(scopeString); const encodedRedirectUri = encodeURIComponent(this.redirectUri); this.logger.debug(`Requested generic scopes: ${scopes.join(', ')}`); this.logger.debug(`Mapped to Microsoft scopes: ${scopeString}`); this.logger.debug(`Redirect URI (raw): ${this.redirectUri}`); this.logger.debug(`Redirect URI (encoded): ${encodedRedirectUri}`); const authorizeUrl = `https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/authorize` + `?client_id=${this.clientId}` + `&response_type=code` + `&redirect_uri=${encodedRedirectUri}` + `&response_mode=query` + `&scope=${encodedScope}` + `&state=${state}`; this.logger.debug(`Final Microsoft login URL: ${authorizeUrl}`); return authorizeUrl; } async saveMicrosoftUser(externalUserId, accessToken, refreshToken, expiresIn, scopes) { let user = await this.microsoftUserRepository.findOne({ where: { externalUserId: externalUserId } }); if (!user) { user = new microsoft_user_entity_1.MicrosoftUser(); user.externalUserId = externalUserId; this.logger.log(`Creating new Microsoft user for external user ${externalUserId}`); } else { this.logger.log(`Reusing existing Microsoft user record (id: ${user.id}) for external user ${externalUserId}`); } user.accessToken = accessToken; user.refreshToken = refreshToken; user.tokenExpiry = new Date(Date.now() + expiresIn * 1000); user.scopes = scopes; user.isActive = true; await this.microsoftUserRepository.save(user); } async getMicrosoftUser(params) { const { internalUserId, externalUserId, includeInactive = false, cache = true } = params; if (!internalUserId && !externalUserId) { throw new Error('Either internalUserId or externalUserId must be provided'); } const whereCondition = {}; if (!includeInactive) { whereCondition.isActive = true; } if (internalUserId !== undefined) { whereCondition.id = internalUserId; } if (externalUserId !== undefined) { whereCondition.externalUserId = externalUserId; } return await this.microsoftUserRepository.findOne({ where: whereCondition, cache, }); } async getUserAccessToken(params) { try { const { internalUserId, externalUserId, includeInactive = false, cache = true } = params; if (!internalUserId && !externalUserId) { throw new Error('Either internalUserId or externalUserId must be provided'); } const user = await this.getMicrosoftUser({ internalUserId, externalUserId, includeInactive, cache }); if (!user) { const identifier = internalUserId ? `internal ID ${String(internalUserId)}` : `external ID ${externalUserId}`; throw new Error(`No Microsoft user found with ${identifier}`); } return await this.processTokenInfo({ accessToken: user.accessToken, refreshToken: user.refreshToken, tokenExpiry: user.tokenExpiry, scopes: user.scopes }, user.id); } catch (error) { const identifier = params.internalUserId ? `internal user ID ${String(params.internalUserId)}` : `external user ID ${params.externalUserId}`; this.logger.error(`Error getting access token for ${identifier}:`, error); throw new Error(`Failed to get valid access token: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async processTokenInfo(tokenInfo, internalUserId) { if (!this.isTokenExpired(tokenInfo.tokenExpiry)) { return tokenInfo.accessToken; } this.logger.log(`Access token for user ID ${String(internalUserId)} is expired, refreshing...`); const accessToken = await this.refreshAccessToken(tokenInfo.refreshToken, internalUserId); return accessToken; } async exchangeCodeForToken(code, state) { const stateData = this.parseState(state); if (!(stateData === null || stateData === void 0 ? void 0 : stateData.userId)) { throw new Error('Invalid state parameter - missing user ID'); } const correlationId = `auth-${stateData.userId}-${Date.now()}`; this.logger.log(`[${correlationId}] Starting token exchange for user ${stateData.userId}`); const csrfError = await this.validateCsrfToken(stateData.csrf, stateData.timestamp); if (csrfError) { this.logger.error(`[${correlationId}] CSRF validation failed for user ${String(stateData.userId)}: ${csrfError}`); throw new Error(`CSRF validation failed: ${csrfError}`); } try { this.logger.log(`[${correlationId}] Exchanging code for token with redirect URI: ${this.redirectUri}`); const scopesToUse = stateData.requestedScopes || this.defaultScopes; this.logger.log(`[${correlationId}] Using scopes for token exchange: ${scopesToUse.join(', ')}`); const scopeString = this.mapToMicrosoftScopes(scopesToUse).join(' '); const postData = new URLSearchParams({ client_id: this.clientId, scope: scopeString, code: code, redirect_uri: this.redirectUri, grant_type: 'authorization_code', client_secret: this.clientSecret, }); this.logger.debug(`[${correlationId}] Token request payload: ${postData.toString()}`); const tokenResponse = await axios_1.default.post(this.tokenEndpoint, postData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); this.logger.log(`[${correlationId}] Successfully received token from Microsoft`); const tokenData = { access_token: tokenResponse.data.access_token, refresh_token: tokenResponse.data.refresh_token || '', expires_in: tokenResponse.data.expires_in, }; this.logger.log(`[${correlationId}] Saving Microsoft user to database`); await this.saveMicrosoftUser(stateData.userId, tokenData.access_token, tokenData.refresh_token, tokenData.expires_in, scopeString); this.logger.log(`[${correlationId}] Emitting USER_AUTHENTICATED event`); await Promise.resolve(this.eventEmitter.emit(event_types_enum_1.OutlookEventTypes.USER_AUTHENTICATED, stateData.userId, { externalUserId: stateData.userId, scopes: scopesToUse })); this.logger.log(`[${correlationId}] Starting subscription setup (async)`); await this.setupSubscriptions(stateData.userId, scopesToUse); this.logger.log(`[${correlationId}] Token exchange completed successfully`); return tokenData; } catch (error) { this.logger.error(`[${correlationId}] Error exchanging code for token:`, error); throw new Error('Failed to exchange code for token'); } } async setupSubscriptions(externalUserId, scopes = this.defaultScopes) { if (this.subscriptionInProgress.get(externalUserId)) { this.logger.log(`Subscription setup already in progress for user ${externalUserId}`); return; } const correlationId = `auth-${externalUserId}-${Date.now()}`; this.logger.log(`[${correlationId}] Starting subscription setup for user ${externalUserId}`); try { this.subscriptionInProgress.set(externalUserId, true); const errors = []; if (this.hasCalendarSubscriptionPermission(scopes)) { try { this.logger.log(`[${correlationId}] Creating calendar webhook subscription with retry logic`); await (0, retry_util_1.retryWithBackoff)(() => this.calendarService.createWebhookSubscription(externalUserId), { maxRetries: 3, retryDelayMs: 1000 }); this.logger.log(`[${correlationId}] Successfully created calendar webhook subscription for user ${externalUserId}`); } catch (calendarError) { const errorMsg = `Failed to create calendar webhook subscription after retries: ${calendarError instanceof Error ? calendarError.message : 'Unknown error'}`; this.logger.error(`[${correlationId}] ${errorMsg}`); errors.push(errorMsg); } } if (this.hasEmailSubscriptionPermission(scopes)) { try { this.logger.log(`[${correlationId}] Creating email webhook subscription with retry logic`); await (0, retry_util_1.retryWithBackoff)(() => this.emailService.createWebhookSubscription(externalUserId), { maxRetries: 3, retryDelayMs: 1000 }); this.logger.log(`[${correlationId}] Successfully created email webhook subscription for user ${externalUserId}`); } catch (emailError) { const errorMsg = `Failed to create email webhook subscription after retries: ${emailError instanceof Error ? emailError.message : 'Unknown error'}`; this.logger.error(`[${correlationId}] ${errorMsg}`); errors.push(errorMsg); } } if (errors.length > 0) { this.logger.error(`[${correlationId}] Subscription setup completed with errors for user ${externalUserId}: ${errors.join('; ')}`); } else { this.logger.log(`[${correlationId}] All subscriptions created successfully for user ${externalUserId}`); } } catch (error) { this.logger.error(`[${correlationId}] Unexpected error setting up subscriptions: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { this.subscriptionInProgress.set(externalUserId, false); this.logger.log(`[${correlationId}] Subscription setup process finished for user ${externalUserId}`); } } async refreshAccessToken(refreshToken, internalUserId) { try { const internalUser = await this.microsoftUserRepository.findOne({ where: { id: internalUserId } }); if (!internalUser) { throw new Error(`No user found with ID ${String(internalUserId)}`); } const scopeString = internalUser.scopes; this.logger.debug(`Using saved scopes from database: ${scopeString}`); this.logger.debug(`Refreshing token for user ID ${String(internalUserId)} with scopes: ${scopeString}`); const payload = new URLSearchParams({ client_id: this.clientId, client_secret: this.clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token', scope: scopeString, }); try { const response = await axios_1.default.post(this.tokenEndpoint, payload.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); if (!response.data.access_token || !response.data.expires_in) { throw new Error('Invalid token refresh response from Microsoft'); } const newRefreshToken = response.data.refresh_token || refreshToken; const newAccessToken = response.data.access_token; internalUser.accessToken = newAccessToken; internalUser.refreshToken = newRefreshToken; internalUser.tokenExpiry = new Date(Date.now() + response.data.expires_in * 1000); await this.microsoftUserRepository.save(internalUser); return newAccessToken; } catch (error) { if (axios_1.default.isAxiosError(error) && error.response) { this.logger.error(`Microsoft API error refreshing token for user ID ${String(internalUserId)}: Status: ${String(error.response.status)}, Response: ${JSON.stringify(error.response.data)}`); const errorData = error.response.data; if (errorData.error === 'invalid_grant') { throw new Error('Microsoft refresh token is invalid or expired'); } } throw error; } } catch (error) { this.logger.error(`Error refreshing access token for user ID ${String(internalUserId)}:`, error); throw new Error('Failed to refresh access token from Microsoft'); } } async revokeRefreshToken(refreshToken) { try { if (!refreshToken) { this.logger.warn('⚠️ No refresh token available for revocation'); return; } await axios_1.default.post('https://login.microsoftonline.com/common/oauth2/v2.0/logout', new URLSearchParams({ token: refreshToken, token_type_hint: 'refresh_token', }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); this.logger.log('✅ Microsoft tokens revoked successfully'); } catch (error) { this.logger.warn(`⚠️ Failed to revoke Microsoft tokens: ${error instanceof Error ? error.message : 'Unknown error'}`); } } hasCalendarSubscriptionPermission(scopes) { return scopes.some(scope => scope === permission_scope_enum_1.PermissionScope.CALENDAR_READ); } hasEmailSubscriptionPermission(scopes) { return scopes.some(scope => scope === permission_scope_enum_1.PermissionScope.EMAIL_READ); } isTokenExpired(tokenExpiry, bufferMinutes = 5) { const currentTimeWithBuffer = new Date(Date.now() + bufferMinutes * 60 * 1000); return tokenExpiry < currentTimeWithBuffer; } }; exports.MicrosoftAuthService = MicrosoftAuthService; __decorate([ (0, schedule_1.Cron)(schedule_1.CronExpression.EVERY_DAY_AT_MIDNIGHT), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], MicrosoftAuthService.prototype, "cleanupExpiredTokens", null); exports.MicrosoftAuthService = MicrosoftAuthService = MicrosoftAuthService_1 = __decorate([ (0, common_1.Injectable)(), __param(1, (0, common_1.Inject)((0, common_1.forwardRef)(() => calendar_service_1.CalendarService))), __param(2, (0, common_1.Inject)((0, common_1.forwardRef)(() => email_service_1.EmailService))), __param(3, (0, common_1.Inject)(constants_1.MICROSOFT_CONFIG)), __param(5, (0, typeorm_1.InjectRepository)(microsoft_user_entity_1.MicrosoftUser)), __metadata("design:paramtypes", [event_emitter_1.EventEmitter2, calendar_service_1.CalendarService, email_service_1.EmailService, Object, microsoft_csrf_token_repository_1.MicrosoftCsrfTokenRepository, typeorm_2.Repository]) ], MicrosoftAuthService); //# sourceMappingURL=microsoft-auth.service.js.map