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.

408 lines 20.6 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"); 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(userId) { const token = crypto.randomBytes(32).toString('hex'); await this.csrfTokenRepository.saveToken(token, userId, 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(userId, scopes = this.defaultScopes) { const csrf = await this.generateCsrfToken(userId); const stateObj = { userId, 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, isActive: true } }); if (!user) { user = new microsoft_user_entity_1.MicrosoftUser(); user.externalUserId = 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 getMicrosoftUserTokenInfo(externalUserId) { const user = await this.microsoftUserRepository.findOne({ where: { externalUserId: externalUserId, isActive: true } }); if (!user) { return null; } return { accessToken: user.accessToken, refreshToken: user.refreshToken, tokenExpiry: user.tokenExpiry, scopes: user.scopes, }; } async getUserAccessTokenByExternalUserId(externalUserId) { try { const userInfo = await this.getMicrosoftUserTokenInfo(externalUserId); if (!userInfo) { throw new Error(`No token information found for user ${externalUserId}`); } const user = await this.microsoftUserRepository.findOne({ where: { externalUserId: externalUserId, isActive: true } }); if (!user) { throw new Error(`Could not find user record for ${externalUserId}`); } return await this.processTokenInfo(userInfo, user.id); } catch (error) { this.logger.error(`Error getting access token for user ${externalUserId}:`, error); throw new Error(`Failed to get valid access token: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getUserAccessTokenByUserId(internalUserId) { try { const user = await this.microsoftUserRepository.findOne({ where: { id: typeof internalUserId === 'string' ? parseInt(internalUserId, 10) : internalUserId } }); if (!user) { throw new Error(`No Microsoft user found with internal ID ${String(internalUserId)}`); } return await this.processTokenInfo({ accessToken: user.accessToken, refreshToken: user.refreshToken, tokenExpiry: user.tokenExpiry, scopes: user.scopes }, user.id); } catch (error) { this.logger.error(`Error getting access token for internal user ID ${String(internalUserId)}:`, error); throw new Error(`Failed to get valid access token: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async processTokenInfo(tokenInfo, userId) { if (!this.isTokenExpired(tokenInfo.tokenExpiry)) { return tokenInfo.accessToken; } this.logger.log(`Access token for user ID ${String(userId)} is expired, refreshing...`); const accessToken = await this.refreshAccessToken(tokenInfo.refreshToken, userId); 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 csrfError = await this.validateCsrfToken(stateData.csrf, stateData.timestamp); if (csrfError) { this.logger.error(`CSRF validation failed for user ${String(stateData.userId)}: ${csrfError}`); throw new Error(`CSRF validation failed: ${csrfError}`); } try { this.logger.log(`Exchanging code for token with redirect URI: ${this.redirectUri}`); const scopesToUse = stateData.requestedScopes || this.defaultScopes; this.logger.log(`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(`Token request payload: ${postData.toString()}`); const tokenResponse = await axios_1.default.post(this.tokenEndpoint, postData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); const tokenData = { access_token: tokenResponse.data.access_token, refresh_token: tokenResponse.data.refresh_token || '', expires_in: tokenResponse.data.expires_in, }; await this.saveMicrosoftUser(stateData.userId, tokenData.access_token, tokenData.refresh_token, tokenData.expires_in, scopeString); await Promise.resolve(this.eventEmitter.emit(event_types_enum_1.OutlookEventTypes.USER_AUTHENTICATED, stateData.userId, { externalUserId: stateData.userId, scopes: scopesToUse })); await this.setupSubscriptions(stateData.userId, scopesToUse); return tokenData; } catch (error) { this.logger.error(`Error exchanging code for token:`, error); throw new Error('Failed to exchange code for token'); } } async setupSubscriptions(userId, scopes = this.defaultScopes) { const userIdNum = parseInt(userId, 10); if (this.subscriptionInProgress.get(userIdNum)) { this.logger.log(`Subscription setup already in progress for user ${userId}`); return; } try { this.subscriptionInProgress.set(userIdNum, true); if (this.hasCalendarPermission(scopes)) { try { await this.calendarService.createWebhookSubscription(userId); this.logger.log(`Successfully created calendar webhook subscription for user ${userId}`); } catch (calendarError) { this.logger.error(`Failed to create calendar webhook subscription: ${calendarError instanceof Error ? calendarError.message : 'Unknown error'}`); } } if (this.hasEmailPermission(scopes)) { try { await this.emailService.createWebhookSubscription(userId); this.logger.log(`Successfully created email webhook subscription for user ${userId}`); } catch (emailError) { this.logger.error(`Failed to create email webhook subscription: ${emailError instanceof Error ? emailError.message : 'Unknown error'}`); } } } catch (error) { this.logger.error(`Error setting up subscriptions: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { this.subscriptionInProgress.set(userIdNum, false); } } async refreshAccessToken(refreshToken, userId) { try { const user = await this.microsoftUserRepository.findOne({ where: { id: userId } }); if (!user) { throw new Error(`No user found with ID ${String(userId)}`); } const scopeString = user.scopes; this.logger.debug(`Using saved scopes from database: ${scopeString}`); this.logger.debug(`Refreshing token for user ID ${String(userId)} 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; user.accessToken = newAccessToken; user.refreshToken = newRefreshToken; user.tokenExpiry = new Date(Date.now() + response.data.expires_in * 1000); await this.microsoftUserRepository.save(user); return newAccessToken; } catch (error) { if (axios_1.default.isAxiosError(error) && error.response) { this.logger.error(`Microsoft API error refreshing token for user ID ${String(userId)}: 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(userId)}:`, error); throw new Error('Failed to refresh access token from Microsoft'); } } hasCalendarPermission(scopes) { return scopes.some(scope => scope === permission_scope_enum_1.PermissionScope.CALENDAR_READ || scope === permission_scope_enum_1.PermissionScope.CALENDAR_WRITE); } hasEmailPermission(scopes) { return scopes.some(scope => scope === permission_scope_enum_1.PermissionScope.EMAIL_READ || scope === permission_scope_enum_1.PermissionScope.EMAIL_WRITE || scope === permission_scope_enum_1.PermissionScope.EMAIL_SEND); } 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