outlook-cli 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +89 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +104 -0
- package/README.md +994 -0
- package/auth/index.js +36 -0
- package/auth/oauth-server.js +139 -0
- package/auth/token-manager.js +199 -0
- package/auth/token-storage.js +282 -0
- package/auth/tools.js +127 -0
- package/calendar/accept.js +64 -0
- package/calendar/cancel.js +64 -0
- package/calendar/create.js +69 -0
- package/calendar/decline.js +64 -0
- package/calendar/delete.js +59 -0
- package/calendar/index.js +123 -0
- package/calendar/list.js +77 -0
- package/cli.js +1349 -0
- package/config.js +84 -0
- package/docs/PROJECT-STRUCTURE.md +52 -0
- package/docs/PUBLISHING.md +86 -0
- package/docs/REFERENCE.md +679 -0
- package/email/folder-utils.js +171 -0
- package/email/index.js +157 -0
- package/email/list.js +89 -0
- package/email/mark-as-read.js +101 -0
- package/email/read.js +128 -0
- package/email/search.js +282 -0
- package/email/send.js +120 -0
- package/folder/create.js +124 -0
- package/folder/index.js +78 -0
- package/folder/list.js +264 -0
- package/folder/move.js +163 -0
- package/index.js +136 -0
- package/outlook-auth-server.js +305 -0
- package/package.json +76 -0
- package/rules/create.js +248 -0
- package/rules/index.js +177 -0
- package/rules/list.js +202 -0
- package/tool-registry.js +54 -0
- package/utils/graph-api.js +120 -0
- package/utils/mock-data.js +145 -0
- package/utils/odata-helpers.js +40 -0
package/auth/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication module for Outlook MCP server
|
|
3
|
+
*/
|
|
4
|
+
const tokenManager = require('./token-manager');
|
|
5
|
+
const { authTools } = require('./tools');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ensures the user is authenticated and returns an access token
|
|
9
|
+
* @param {boolean} forceNew - Whether to force a new authentication
|
|
10
|
+
* @returns {Promise<string>} - Access token
|
|
11
|
+
* @throws {Error} - If authentication fails
|
|
12
|
+
*/
|
|
13
|
+
async function ensureAuthenticated(forceNew = false) {
|
|
14
|
+
if (forceNew) {
|
|
15
|
+
// Force re-authentication
|
|
16
|
+
throw new Error('Authentication required');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check for existing token and try refresh flow when needed.
|
|
20
|
+
let accessToken = tokenManager.getAccessToken();
|
|
21
|
+
if (!accessToken) {
|
|
22
|
+
accessToken = await tokenManager.getValidAccessToken();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!accessToken) {
|
|
26
|
+
throw new Error('Authentication required');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return accessToken;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
tokenManager,
|
|
34
|
+
authTools,
|
|
35
|
+
ensureAuthenticated
|
|
36
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const querystring = require('querystring');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const TokenStorage = require('./token-storage');
|
|
5
|
+
|
|
6
|
+
// HTML templates
|
|
7
|
+
function escapeHtml(unsafe) {
|
|
8
|
+
return unsafe
|
|
9
|
+
.replace(/&/g, "&")
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, """)
|
|
13
|
+
.replace(/'/g, "'");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const templates = {
|
|
17
|
+
authError: (error, errorDescription) => `
|
|
18
|
+
<html>
|
|
19
|
+
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
20
|
+
<h1 style="color: #e74c3c;">❌ Authorization Failed</h1>
|
|
21
|
+
<p><strong>Error:</strong> ${escapeHtml(error)}</p>
|
|
22
|
+
${errorDescription ? `<p><strong>Description:</strong> ${escapeHtml(errorDescription)}</p>` : ''}
|
|
23
|
+
<p>You can close this window and try again.</p>
|
|
24
|
+
</body>
|
|
25
|
+
</html>`,
|
|
26
|
+
authSuccess: `
|
|
27
|
+
<html>
|
|
28
|
+
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
29
|
+
<h1 style="color: #2ecc71;">✅ Authentication Successful</h1>
|
|
30
|
+
<p>You have successfully authenticated with Microsoft Graph API.</p>
|
|
31
|
+
<p>You can close this window.</p>
|
|
32
|
+
</body>
|
|
33
|
+
</html>`,
|
|
34
|
+
tokenExchangeError: (error) => `
|
|
35
|
+
<html>
|
|
36
|
+
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
37
|
+
<h1 style="color: #e74c3c;">❌ Token Exchange Failed</h1>
|
|
38
|
+
<p>Failed to exchange authorization code for access token.</p>
|
|
39
|
+
<p><strong>Error:</strong> ${escapeHtml(error instanceof Error ? error.message : String(error))}</p>
|
|
40
|
+
<p>You can close this window and try again.</p>
|
|
41
|
+
</body>
|
|
42
|
+
</html>`,
|
|
43
|
+
tokenStatus: (status) => `
|
|
44
|
+
<html>
|
|
45
|
+
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
46
|
+
<h1>🔐 Token Status</h1>
|
|
47
|
+
<p>${escapeHtml(status)}</p>
|
|
48
|
+
</body>
|
|
49
|
+
</html>`
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function createAuthConfig(envPrefix = 'MS_') {
|
|
53
|
+
return {
|
|
54
|
+
clientId: process.env[`${envPrefix}CLIENT_ID`] || '',
|
|
55
|
+
clientSecret: process.env[`${envPrefix}CLIENT_SECRET`] || '',
|
|
56
|
+
redirectUri: process.env[`${envPrefix}REDIRECT_URI`] || 'http://localhost:3333/auth/callback',
|
|
57
|
+
scopes: (process.env[`${envPrefix}SCOPES`] || 'offline_access User.Read Mail.Read').split(' '),
|
|
58
|
+
tokenEndpoint: process.env[`${envPrefix}TOKEN_ENDPOINT`] || 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
|
|
59
|
+
authEndpoint: process.env[`${envPrefix}AUTH_ENDPOINT`] || 'https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setupOAuthRoutes(app, tokenStorage, authConfig, envPrefix = 'MS_') {
|
|
64
|
+
if (!authConfig) {
|
|
65
|
+
authConfig = createAuthConfig(envPrefix);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!(tokenStorage instanceof TokenStorage)) {
|
|
69
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
70
|
+
console.error("Error: tokenStorage is not an instance of TokenStorage. OAuth routes will not function correctly.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
app.get('/auth', (req, res) => {
|
|
76
|
+
if (!authConfig.clientId) {
|
|
77
|
+
return res.status(500).send(templates.authError('Configuration Error', 'Client ID is not configured.'));
|
|
78
|
+
}
|
|
79
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
80
|
+
|
|
81
|
+
const authorizationUrl = `${authConfig.authEndpoint}?` +
|
|
82
|
+
querystring.stringify({
|
|
83
|
+
client_id: authConfig.clientId,
|
|
84
|
+
response_type: 'code',
|
|
85
|
+
redirect_uri: authConfig.redirectUri,
|
|
86
|
+
scope: authConfig.scopes.join(' '),
|
|
87
|
+
response_mode: 'query',
|
|
88
|
+
state: state
|
|
89
|
+
});
|
|
90
|
+
res.redirect(authorizationUrl);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
app.get('/auth/callback', async (req, res) => {
|
|
94
|
+
const { code, error, error_description, state } = req.query;
|
|
95
|
+
|
|
96
|
+
// Require a state value to reduce CSRF risk. The integrating app should also
|
|
97
|
+
// validate state value matching using a session or short-lived server store.
|
|
98
|
+
if (!state) {
|
|
99
|
+
console.error("OAuth callback received without a 'state' parameter. Rejecting request to prevent potential CSRF attack.");
|
|
100
|
+
return res.status(400).send(templates.authError('Missing State Parameter', 'The state parameter was missing from the OAuth callback. This is a security risk. Please try authenticating again.'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if (error) {
|
|
105
|
+
return res.status(400).send(templates.authError(error, error_description));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!code) {
|
|
109
|
+
return res.status(400).send(templates.authError('Missing Authorization Code', 'No authorization code was provided in the callback.'));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await tokenStorage.exchangeCodeForTokens(code);
|
|
114
|
+
res.send(templates.authSuccess);
|
|
115
|
+
} catch (exchangeError) {
|
|
116
|
+
console.error('Token exchange error:', exchangeError);
|
|
117
|
+
res.status(500).send(templates.tokenExchangeError(exchangeError));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
app.get('/token-status', async (req, res) => {
|
|
122
|
+
try {
|
|
123
|
+
const token = await tokenStorage.getValidAccessToken();
|
|
124
|
+
if (token) {
|
|
125
|
+
const expiryDate = new Date(tokenStorage.getExpiryTime());
|
|
126
|
+
res.send(templates.tokenStatus(`Access token is valid. Expires at: ${expiryDate.toLocaleString()}`));
|
|
127
|
+
} else {
|
|
128
|
+
res.send(templates.tokenStatus('No valid access token found. Please authenticate.'));
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
res.status(500).send(templates.tokenStatus(`Error checking token status: ${err.message}`));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
setupOAuthRoutes,
|
|
138
|
+
createAuthConfig
|
|
139
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token management for Microsoft Graph API authentication
|
|
3
|
+
*/
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
const TokenStorage = require('./token-storage');
|
|
7
|
+
|
|
8
|
+
// Global variable to store tokens
|
|
9
|
+
let cachedTokens = null;
|
|
10
|
+
let tokenStorage = null;
|
|
11
|
+
|
|
12
|
+
const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
|
|
13
|
+
|
|
14
|
+
function debugLog(message) {
|
|
15
|
+
if (config.DEBUG_LOGS) {
|
|
16
|
+
console.error(`[TOKEN-MANAGER] ${message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isTokenUsable(tokens) {
|
|
21
|
+
if (!tokens || !tokens.access_token) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!tokens.expires_at) {
|
|
26
|
+
// Some token responses may not include expires_at. Accept for backward compatibility.
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const expiry = Number(tokens.expires_at);
|
|
31
|
+
if (!Number.isFinite(expiry)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Date.now() < (expiry - TOKEN_EXPIRY_BUFFER_MS);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getTokenStorage() {
|
|
39
|
+
if (!tokenStorage) {
|
|
40
|
+
tokenStorage = new TokenStorage({
|
|
41
|
+
tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
|
|
42
|
+
clientId: config.AUTH_CONFIG.clientId,
|
|
43
|
+
clientSecret: config.AUTH_CONFIG.clientSecret,
|
|
44
|
+
redirectUri: config.AUTH_CONFIG.redirectUri,
|
|
45
|
+
scopes: config.AUTH_CONFIG.scopes,
|
|
46
|
+
tokenEndpoint: config.AUTH_CONFIG.tokenEndpoint
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return tokenStorage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loads authentication tokens from the token file
|
|
55
|
+
* @returns {object|null} - The loaded tokens or null if not available
|
|
56
|
+
*/
|
|
57
|
+
function loadTokenCache() {
|
|
58
|
+
try {
|
|
59
|
+
const tokenPath = config.AUTH_CONFIG.tokenStorePath;
|
|
60
|
+
debugLog(`Attempting to load tokens from ${tokenPath}`);
|
|
61
|
+
|
|
62
|
+
if (!fs.existsSync(tokenPath)) {
|
|
63
|
+
debugLog('Token file does not exist');
|
|
64
|
+
cachedTokens = null;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tokenData = fs.readFileSync(tokenPath, 'utf8');
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const tokens = JSON.parse(tokenData);
|
|
72
|
+
|
|
73
|
+
if (!isTokenUsable(tokens)) {
|
|
74
|
+
debugLog('Token cache exists but is unusable or expired');
|
|
75
|
+
cachedTokens = null;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
cachedTokens = tokens;
|
|
80
|
+
return tokens;
|
|
81
|
+
} catch (parseError) {
|
|
82
|
+
console.error('Error parsing token cache JSON:', parseError.message);
|
|
83
|
+
cachedTokens = null;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error('Error loading token cache:', error.message);
|
|
88
|
+
cachedTokens = null;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Saves authentication tokens to the token file
|
|
95
|
+
* @param {object} tokens - The tokens to save
|
|
96
|
+
* @returns {boolean} - Whether the save was successful
|
|
97
|
+
*/
|
|
98
|
+
function saveTokenCache(tokens) {
|
|
99
|
+
try {
|
|
100
|
+
const tokenPath = config.AUTH_CONFIG.tokenStorePath;
|
|
101
|
+
|
|
102
|
+
fs.writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
|
|
103
|
+
|
|
104
|
+
cachedTokens = tokens;
|
|
105
|
+
if (tokenStorage) {
|
|
106
|
+
tokenStorage.tokens = tokens;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return true;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Error saving token cache:', error.message);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Gets the current access token, loading from cache if necessary
|
|
118
|
+
* @returns {string|null} - The access token or null if not available
|
|
119
|
+
*/
|
|
120
|
+
function getAccessToken() {
|
|
121
|
+
if (isTokenUsable(cachedTokens)) {
|
|
122
|
+
return cachedTokens.access_token;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tokens = loadTokenCache();
|
|
126
|
+
return tokens ? tokens.access_token : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Gets a valid access token and attempts refresh when possible.
|
|
131
|
+
* @returns {Promise<string|null>} - Valid access token or null
|
|
132
|
+
*/
|
|
133
|
+
async function getValidAccessToken() {
|
|
134
|
+
const directToken = getAccessToken();
|
|
135
|
+
if (directToken) {
|
|
136
|
+
return directToken;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const storage = getTokenStorage();
|
|
141
|
+
const refreshedToken = await storage.getValidAccessToken();
|
|
142
|
+
|
|
143
|
+
if (refreshedToken && storage.tokens) {
|
|
144
|
+
cachedTokens = storage.tokens;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return refreshedToken || null;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
debugLog(`Unable to get valid access token via refresh flow: ${error.message}`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Creates a test access token for use in test mode
|
|
156
|
+
* @returns {object} - The test tokens
|
|
157
|
+
*/
|
|
158
|
+
function createTestTokens() {
|
|
159
|
+
const testTokens = {
|
|
160
|
+
access_token: "test_access_token_" + Date.now(),
|
|
161
|
+
refresh_token: "test_refresh_token_" + Date.now(),
|
|
162
|
+
expires_at: Date.now() + (3600 * 1000) // 1 hour
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
saveTokenCache(testTokens);
|
|
166
|
+
return testTokens;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Clears in-memory and on-disk token cache.
|
|
171
|
+
*/
|
|
172
|
+
function clearTokenCache() {
|
|
173
|
+
cachedTokens = null;
|
|
174
|
+
|
|
175
|
+
if (tokenStorage) {
|
|
176
|
+
tokenStorage.tokens = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const tokenPath = config.AUTH_CONFIG.tokenStorePath;
|
|
181
|
+
if (fs.existsSync(tokenPath)) {
|
|
182
|
+
fs.unlinkSync(tokenPath);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return true;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error(`Failed to clear token cache: ${error.message}`);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
loadTokenCache,
|
|
194
|
+
saveTokenCache,
|
|
195
|
+
getAccessToken,
|
|
196
|
+
getValidAccessToken,
|
|
197
|
+
createTestTokens,
|
|
198
|
+
clearTokenCache
|
|
199
|
+
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const querystring = require('querystring');
|
|
5
|
+
|
|
6
|
+
class TokenStorage {
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = {
|
|
9
|
+
tokenStorePath: path.join(process.env.HOME || process.env.USERPROFILE, '.outlook-mcp-tokens.json'),
|
|
10
|
+
clientId: process.env.OUTLOOK_CLIENT_ID || process.env.MS_CLIENT_ID,
|
|
11
|
+
clientSecret: process.env.OUTLOOK_CLIENT_SECRET || process.env.MS_CLIENT_SECRET,
|
|
12
|
+
redirectUri: process.env.OUTLOOK_REDIRECT_URI || process.env.MS_REDIRECT_URI || 'http://localhost:3333/auth/callback',
|
|
13
|
+
scopes: (process.env.OUTLOOK_SCOPES || process.env.MS_SCOPES || 'offline_access User.Read Mail.Read').split(' '),
|
|
14
|
+
tokenEndpoint: process.env.OUTLOOK_TOKEN_ENDPOINT || process.env.MS_TOKEN_ENDPOINT || 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
|
|
15
|
+
refreshTokenBuffer: 5 * 60 * 1000, // 5 minutes buffer for token refresh
|
|
16
|
+
debug: process.env.OUTLOOK_DEBUG === 'true' || process.env.MCP_DEBUG === 'true',
|
|
17
|
+
...config // Allow overriding default config
|
|
18
|
+
};
|
|
19
|
+
this.tokens = null;
|
|
20
|
+
this._loadPromise = null;
|
|
21
|
+
this._refreshPromise = null;
|
|
22
|
+
|
|
23
|
+
if (!this.config.clientId || !this.config.clientSecret) {
|
|
24
|
+
console.warn("TokenStorage: client ID/secret is not configured. Set OUTLOOK_CLIENT_ID/OUTLOOK_CLIENT_SECRET or MS_CLIENT_ID/MS_CLIENT_SECRET.");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_debugLog(...args) {
|
|
29
|
+
if (this.config.debug) {
|
|
30
|
+
console.error('[TokenStorage]', ...args);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async _loadTokensFromFile() {
|
|
35
|
+
try {
|
|
36
|
+
const tokenData = await fs.readFile(this.config.tokenStorePath, 'utf8');
|
|
37
|
+
this.tokens = JSON.parse(tokenData);
|
|
38
|
+
this._debugLog('Tokens loaded from file.');
|
|
39
|
+
return this.tokens;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code === 'ENOENT') {
|
|
42
|
+
this._debugLog('Token file not found. No tokens loaded.');
|
|
43
|
+
} else {
|
|
44
|
+
this._debugLog('Error loading token cache:', error.message);
|
|
45
|
+
}
|
|
46
|
+
this.tokens = null;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async _saveTokensToFile() {
|
|
52
|
+
if (!this.tokens) {
|
|
53
|
+
this._debugLog('No tokens to save.');
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await fs.writeFile(this.config.tokenStorePath, JSON.stringify(this.tokens, null, 2));
|
|
58
|
+
this._debugLog('Tokens saved successfully.');
|
|
59
|
+
// return true; // No longer returning boolean, will throw on error.
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this._debugLog('Error saving token cache:', error.message);
|
|
62
|
+
throw error; // Propagate the error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getTokens() {
|
|
67
|
+
if (this.tokens) {
|
|
68
|
+
return this.tokens;
|
|
69
|
+
}
|
|
70
|
+
if (!this._loadPromise) {
|
|
71
|
+
this._loadPromise = this._loadTokensFromFile().finally(() => {
|
|
72
|
+
this._loadPromise = null; // Reset promise once completed
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return this._loadPromise;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getExpiryTime() {
|
|
79
|
+
return this.tokens && this.tokens.expires_at ? this.tokens.expires_at : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
isTokenExpired() {
|
|
83
|
+
if (!this.tokens || !this.tokens.expires_at) {
|
|
84
|
+
return true; // No token or no expiry means it's effectively expired or invalid
|
|
85
|
+
}
|
|
86
|
+
// Check if current time is past expiry time, considering a buffer
|
|
87
|
+
return Date.now() >= (this.tokens.expires_at - this.config.refreshTokenBuffer);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getValidAccessToken() {
|
|
91
|
+
await this.getTokens(); // Ensure tokens are loaded
|
|
92
|
+
|
|
93
|
+
if (!this.tokens || !this.tokens.access_token) {
|
|
94
|
+
this._debugLog('No access token available.');
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.isTokenExpired()) {
|
|
99
|
+
this._debugLog('Access token expired or nearing expiration. Attempting refresh.');
|
|
100
|
+
if (this.tokens.refresh_token) {
|
|
101
|
+
try {
|
|
102
|
+
return await this.refreshAccessToken();
|
|
103
|
+
} catch (refreshError) {
|
|
104
|
+
this._debugLog('Failed to refresh access token:', refreshError.message);
|
|
105
|
+
this.tokens = null; // Invalidate tokens on refresh failure
|
|
106
|
+
await this._saveTokensToFile(); // Persist invalidation
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
console.warn('No refresh token available. Cannot refresh access token.');
|
|
111
|
+
this.tokens = null; // Invalidate tokens as they are expired and cannot be refreshed
|
|
112
|
+
await this._saveTokensToFile(); // Persist invalidation
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return this.tokens.access_token;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async refreshAccessToken() {
|
|
120
|
+
if (!this.tokens || !this.tokens.refresh_token) {
|
|
121
|
+
throw new Error('No refresh token available to refresh the access token.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Prevent multiple concurrent refresh attempts
|
|
125
|
+
if (this._refreshPromise) {
|
|
126
|
+
this._debugLog("Refresh already in progress, returning existing promise.");
|
|
127
|
+
return this._refreshPromise.then(tokens => tokens.access_token);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this._debugLog('Attempting to refresh access token...');
|
|
131
|
+
const postData = querystring.stringify({
|
|
132
|
+
client_id: this.config.clientId,
|
|
133
|
+
client_secret: this.config.clientSecret,
|
|
134
|
+
grant_type: 'refresh_token',
|
|
135
|
+
refresh_token: this.tokens.refresh_token,
|
|
136
|
+
scope: this.config.scopes.join(' ')
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const requestOptions = {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
143
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
this._refreshPromise = new Promise((resolve, reject) => {
|
|
148
|
+
const req = https.request(this.config.tokenEndpoint, requestOptions, (res) => {
|
|
149
|
+
let data = '';
|
|
150
|
+
res.on('data', (chunk) => data += chunk);
|
|
151
|
+
res.on('end', async () => {
|
|
152
|
+
try {
|
|
153
|
+
const responseBody = JSON.parse(data);
|
|
154
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
155
|
+
this.tokens.access_token = responseBody.access_token;
|
|
156
|
+
// Microsoft Graph API refresh tokens may or may not return a new refresh_token
|
|
157
|
+
if (responseBody.refresh_token) {
|
|
158
|
+
this.tokens.refresh_token = responseBody.refresh_token;
|
|
159
|
+
}
|
|
160
|
+
this.tokens.expires_in = responseBody.expires_in;
|
|
161
|
+
this.tokens.expires_at = Date.now() + (responseBody.expires_in * 1000);
|
|
162
|
+
try {
|
|
163
|
+
await this._saveTokensToFile();
|
|
164
|
+
this._debugLog('Access token refreshed and saved successfully.');
|
|
165
|
+
resolve(this.tokens);
|
|
166
|
+
} catch (saveError) {
|
|
167
|
+
this._debugLog('Failed to save refreshed tokens:', saveError.message);
|
|
168
|
+
// Even if save fails, tokens are updated in memory.
|
|
169
|
+
// Depending on desired strictness, could reject here.
|
|
170
|
+
// For now, resolve with in-memory tokens but log critical error.
|
|
171
|
+
// Or, to be stricter and align with re-throwing:
|
|
172
|
+
reject(new Error(`Access token refreshed but failed to save: ${saveError.message}`));
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
this._debugLog('Error refreshing token response:', responseBody);
|
|
176
|
+
reject(new Error(responseBody.error_description || `Token refresh failed with status ${res.statusCode}`));
|
|
177
|
+
}
|
|
178
|
+
} catch (e) { // Catch any error during parsing or saving
|
|
179
|
+
this._debugLog('Error processing refresh token response or saving tokens:', e.message);
|
|
180
|
+
reject(e);
|
|
181
|
+
} finally {
|
|
182
|
+
this._refreshPromise = null; // Clear promise after completion
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
req.on('error', (error) => {
|
|
187
|
+
this._debugLog('HTTP error during token refresh:', error.message);
|
|
188
|
+
reject(error);
|
|
189
|
+
this._refreshPromise = null; // Clear promise on error
|
|
190
|
+
});
|
|
191
|
+
req.write(postData);
|
|
192
|
+
req.end();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return this._refreshPromise.then(tokens => tokens.access_token);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async exchangeCodeForTokens(authCode) {
|
|
200
|
+
if (!this.config.clientId || !this.config.clientSecret) {
|
|
201
|
+
throw new Error("Client ID or Client Secret is not configured. Cannot exchange code for tokens.");
|
|
202
|
+
}
|
|
203
|
+
this._debugLog('Exchanging authorization code for tokens...');
|
|
204
|
+
const postData = querystring.stringify({
|
|
205
|
+
client_id: this.config.clientId,
|
|
206
|
+
client_secret: this.config.clientSecret,
|
|
207
|
+
grant_type: 'authorization_code',
|
|
208
|
+
code: authCode,
|
|
209
|
+
redirect_uri: this.config.redirectUri,
|
|
210
|
+
scope: this.config.scopes.join(' ')
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const requestOptions = {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: {
|
|
216
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
217
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const req = https.request(this.config.tokenEndpoint, requestOptions, (res) => {
|
|
223
|
+
let data = '';
|
|
224
|
+
res.on('data', (chunk) => data += chunk);
|
|
225
|
+
res.on('end', async () => {
|
|
226
|
+
try {
|
|
227
|
+
const responseBody = JSON.parse(data);
|
|
228
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
229
|
+
this.tokens = {
|
|
230
|
+
access_token: responseBody.access_token,
|
|
231
|
+
refresh_token: responseBody.refresh_token,
|
|
232
|
+
expires_in: responseBody.expires_in,
|
|
233
|
+
expires_at: Date.now() + (responseBody.expires_in * 1000),
|
|
234
|
+
scope: responseBody.scope,
|
|
235
|
+
token_type: responseBody.token_type
|
|
236
|
+
};
|
|
237
|
+
try {
|
|
238
|
+
await this._saveTokensToFile();
|
|
239
|
+
this._debugLog('Tokens exchanged and saved successfully.');
|
|
240
|
+
resolve(this.tokens);
|
|
241
|
+
} catch (saveError) {
|
|
242
|
+
this._debugLog('Failed to save exchanged tokens:', saveError.message);
|
|
243
|
+
// Similar to refresh, tokens are in memory but not persisted.
|
|
244
|
+
// Rejecting to indicate the operation wasn't fully successful.
|
|
245
|
+
reject(new Error(`Tokens exchanged but failed to save: ${saveError.message}`));
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
this._debugLog('Error exchanging code for tokens response:', responseBody);
|
|
249
|
+
reject(new Error(responseBody.error_description || `Token exchange failed with status ${res.statusCode}`));
|
|
250
|
+
}
|
|
251
|
+
} catch (e) { // Catch any error during parsing or saving
|
|
252
|
+
this._debugLog('Error processing token exchange response:', e.message);
|
|
253
|
+
reject(new Error(`Error processing token response: ${e.message}. Response data: ${data}`));
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
req.on('error', (error) => {
|
|
258
|
+
this._debugLog('HTTP error during code exchange:', error.message);
|
|
259
|
+
reject(error);
|
|
260
|
+
});
|
|
261
|
+
req.write(postData);
|
|
262
|
+
req.end();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Utility to clear tokens, e.g., for logout or forcing re-auth
|
|
267
|
+
async clearTokens() {
|
|
268
|
+
this.tokens = null;
|
|
269
|
+
try {
|
|
270
|
+
await fs.unlink(this.config.tokenStorePath);
|
|
271
|
+
this._debugLog('Token file deleted successfully.');
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error.code === 'ENOENT') {
|
|
274
|
+
this._debugLog('Token file not found, nothing to delete.');
|
|
275
|
+
} else {
|
|
276
|
+
this._debugLog('Error deleting token file:', error.message);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = TokenStorage;
|