@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
JavaScript
"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