@littlebearapps/outlook-assistant 3.3.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/.env.example +22 -0
- package/LICENSE +21 -0
- package/README.md +422 -0
- package/advanced/index.js +652 -0
- package/auth/index.js +32 -0
- package/auth/oauth-server.js +233 -0
- package/auth/token-manager.js +105 -0
- package/auth/token-storage.js +359 -0
- package/auth/tools.js +159 -0
- package/calendar/accept.js +72 -0
- package/calendar/cancel.js +72 -0
- package/calendar/create.js +115 -0
- package/calendar/decline.js +72 -0
- package/calendar/delete.js +67 -0
- package/calendar/index.js +130 -0
- package/calendar/list.js +108 -0
- package/categories/index.js +955 -0
- package/config.js +95 -0
- package/contacts/index.js +754 -0
- package/email/attachments.js +365 -0
- package/email/conversations.js +666 -0
- package/email/delta.js +210 -0
- package/email/export.js +572 -0
- package/email/folder-utils.js +192 -0
- package/email/headers.js +344 -0
- package/email/index.js +537 -0
- package/email/list.js +136 -0
- package/email/mark-as-read.js +114 -0
- package/email/mime.js +286 -0
- package/email/read.js +161 -0
- package/email/search.js +628 -0
- package/email/send.js +169 -0
- package/folder/create.js +137 -0
- package/folder/delete.js +108 -0
- package/folder/index.js +112 -0
- package/folder/list.js +289 -0
- package/folder/move.js +186 -0
- package/folder/stats.js +322 -0
- package/index.js +162 -0
- package/llms.txt +76 -0
- package/outlook-auth-server.js +384 -0
- package/package.json +97 -0
- package/rules/create.js +273 -0
- package/rules/index.js +276 -0
- package/rules/list.js +216 -0
- package/settings/index.js +678 -0
- package/utils/field-presets.js +311 -0
- package/utils/graph-api.js +268 -0
- package/utils/mock-data.js +154 -0
- package/utils/odata-helpers.js +33 -0
- package/utils/response-formatter.js +457 -0
- package/utils/safety.js +123 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
const _express = require('express'); // Reserved for future HTTPS support
|
|
2
|
+
const querystring = require('querystring');
|
|
3
|
+
const _https = require('https'); // Reserved for future HTTPS support
|
|
4
|
+
const _fs = require('fs'); // Reserved for future HTTPS support
|
|
5
|
+
const crypto = require('crypto'); // Added for generating random string
|
|
6
|
+
const TokenStorage = require('./token-storage'); // Assuming TokenStorage is in the same directory
|
|
7
|
+
|
|
8
|
+
// HTML templates
|
|
9
|
+
function escapeHtml(unsafe) {
|
|
10
|
+
return unsafe
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const templates = {
|
|
19
|
+
authError: (error, errorDescription) => `
|
|
20
|
+
<html>
|
|
21
|
+
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
22
|
+
<h1 style="color: #e74c3c;">❌ Authorization Failed</h1>
|
|
23
|
+
<p><strong>Error:</strong> ${escapeHtml(error)}</p>
|
|
24
|
+
${errorDescription ? `<p><strong>Description:</strong> ${escapeHtml(errorDescription)}</p>` : ''}
|
|
25
|
+
<p>You can close this window and try again.</p>
|
|
26
|
+
</body>
|
|
27
|
+
</html>`,
|
|
28
|
+
authSuccess: `
|
|
29
|
+
<html>
|
|
30
|
+
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
31
|
+
<h1 style="color: #2ecc71;">✓ Authentication Successful</h1>
|
|
32
|
+
<p>You have successfully authenticated with Microsoft Graph API.</p>
|
|
33
|
+
<p>You can close this window and return to your AI assistant.</p>
|
|
34
|
+
</body>
|
|
35
|
+
</html>`,
|
|
36
|
+
tokenExchangeError: (error) => `
|
|
37
|
+
<html>
|
|
38
|
+
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
39
|
+
<h1 style="color: #e74c3c;">❌ Token Exchange Failed</h1>
|
|
40
|
+
<p>Failed to exchange authorization code for access token.</p>
|
|
41
|
+
<p><strong>Error:</strong> ${escapeHtml(error instanceof Error ? error.message : String(error))}</p>
|
|
42
|
+
<p>You can close this window and try again.</p>
|
|
43
|
+
</body>
|
|
44
|
+
</html>`,
|
|
45
|
+
tokenStatus: (status) => `
|
|
46
|
+
<html>
|
|
47
|
+
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
48
|
+
<h1>🔐 Token Status</h1>
|
|
49
|
+
<p>${escapeHtml(status)}</p>
|
|
50
|
+
</body>
|
|
51
|
+
</html>`,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function createAuthConfig(envPrefix = 'OUTLOOK_') {
|
|
55
|
+
return {
|
|
56
|
+
clientId:
|
|
57
|
+
process.env[`${envPrefix}CLIENT_ID`] || process.env.MS_CLIENT_ID || '',
|
|
58
|
+
clientSecret:
|
|
59
|
+
process.env[`${envPrefix}CLIENT_SECRET`] ||
|
|
60
|
+
process.env.MS_CLIENT_SECRET ||
|
|
61
|
+
'',
|
|
62
|
+
redirectUri:
|
|
63
|
+
process.env[`${envPrefix}REDIRECT_URI`] ||
|
|
64
|
+
'http://localhost:3333/auth/callback',
|
|
65
|
+
scopes: (
|
|
66
|
+
process.env[`${envPrefix}SCOPES`] || 'offline_access User.Read Mail.Read'
|
|
67
|
+
).split(' '),
|
|
68
|
+
tokenEndpoint:
|
|
69
|
+
process.env[`${envPrefix}TOKEN_ENDPOINT`] ||
|
|
70
|
+
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
71
|
+
authEndpoint:
|
|
72
|
+
process.env[`${envPrefix}AUTH_ENDPOINT`] ||
|
|
73
|
+
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function setupOAuthRoutes(
|
|
78
|
+
app,
|
|
79
|
+
tokenStorage,
|
|
80
|
+
authConfig,
|
|
81
|
+
envPrefix = 'OUTLOOK_'
|
|
82
|
+
) {
|
|
83
|
+
if (!authConfig) {
|
|
84
|
+
authConfig = createAuthConfig(envPrefix);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!(tokenStorage instanceof TokenStorage)) {
|
|
88
|
+
console.error(
|
|
89
|
+
'Error: tokenStorage is not an instance of TokenStorage. OAuth routes will not function correctly.'
|
|
90
|
+
);
|
|
91
|
+
// Optionally, you could throw an error here or disable the routes
|
|
92
|
+
// throw new Error("Invalid tokenStorage provided to setupOAuthRoutes");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
app.get('/auth', (req, res) => {
|
|
96
|
+
if (!authConfig.clientId) {
|
|
97
|
+
return res
|
|
98
|
+
.status(500)
|
|
99
|
+
.send(
|
|
100
|
+
templates.authError(
|
|
101
|
+
'Configuration Error',
|
|
102
|
+
'Client ID is not configured.'
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const state = crypto.randomBytes(16).toString('hex'); // Generate a random 16-byte string
|
|
107
|
+
// Store state in session or similar mechanism if available.
|
|
108
|
+
// For a server without sessions, this state would need to be passed through and verified differently,
|
|
109
|
+
// or a temporary server-side storage (like a short-lived cache) would be needed.
|
|
110
|
+
// For this example, we'll assume session middleware is configured elsewhere if this were a full app.
|
|
111
|
+
// If using express-session: req.session.oauthState = state;
|
|
112
|
+
// Since this is a module, actual session handling is outside its direct scope,
|
|
113
|
+
// but it's crucial for the consuming application to handle state verification.
|
|
114
|
+
|
|
115
|
+
const authorizationUrl =
|
|
116
|
+
`${authConfig.authEndpoint}?` +
|
|
117
|
+
querystring.stringify({
|
|
118
|
+
client_id: authConfig.clientId,
|
|
119
|
+
response_type: 'code',
|
|
120
|
+
redirect_uri: authConfig.redirectUri,
|
|
121
|
+
scope: authConfig.scopes.join(' '),
|
|
122
|
+
response_mode: 'query',
|
|
123
|
+
state: state,
|
|
124
|
+
});
|
|
125
|
+
res.redirect(authorizationUrl);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
app.get('/auth/callback', async (req, res) => {
|
|
129
|
+
const { code, error, error_description, state } = req.query;
|
|
130
|
+
|
|
131
|
+
// IMPORTANT: State validation is crucial for CSRF protection.
|
|
132
|
+
// The application using this module MUST implement a way to store the 'state' generated in /auth
|
|
133
|
+
// (e.g., in a user session if using express-session, or a short-lived cache)
|
|
134
|
+
// and then verify it here against the 'state' received from the OAuth provider.
|
|
135
|
+
// For example, if using express-session:
|
|
136
|
+
// const savedState = req.session.oauthState;
|
|
137
|
+
// if (!state || state !== savedState) {
|
|
138
|
+
// console.error("OAuth callback state mismatch. Potential CSRF attack.");
|
|
139
|
+
// return res.status(400).send(templates.authError('Invalid State', 'CSRF token mismatch. Please try authenticating again.'));
|
|
140
|
+
// }
|
|
141
|
+
// delete req.session.oauthState; // Clean up session state
|
|
142
|
+
|
|
143
|
+
// Since this module itself doesn't manage sessions, we'll log a warning if state is missing,
|
|
144
|
+
// but actual enforcement must be done by the consuming application.
|
|
145
|
+
// The Gemini review recommended uncommenting the rejection.
|
|
146
|
+
// However, the consuming app (CLI or server) is responsible for session/state storage.
|
|
147
|
+
// This module *cannot* validate state if it wasn't involved in storing it.
|
|
148
|
+
// The PR author (ranxian) needs to implement state storage & validation in the calling server (sse-server.js or outlook-auth-server.js).
|
|
149
|
+
// For now, enforcing a missing state here would break flows where state *is* passed but not validated by *this specific module*.
|
|
150
|
+
// The best this module can do is check for presence and rely on the consumer to validate the actual value.
|
|
151
|
+
// The original PR #10's outlook-auth-server.js used Date.now() and didn't store/validate it beyond this.
|
|
152
|
+
// The new sse-server.js also doesn't show session management for state.
|
|
153
|
+
// So, we will make the check for presence mandatory as per Gemini's suggestion.
|
|
154
|
+
if (!state) {
|
|
155
|
+
console.error(
|
|
156
|
+
"OAuth callback received without a 'state' parameter. Rejecting request to prevent potential CSRF attack."
|
|
157
|
+
);
|
|
158
|
+
return res
|
|
159
|
+
.status(400)
|
|
160
|
+
.send(
|
|
161
|
+
templates.authError(
|
|
162
|
+
'Missing State Parameter',
|
|
163
|
+
'The state parameter was missing from the OAuth callback. This is a security risk. Please try authenticating again.'
|
|
164
|
+
)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
// Further validation of the state's VALUE (e.g., req.session.oauthState === state) is the responsibility
|
|
168
|
+
// of the application integrating this module, as session management is outside this module's scope.
|
|
169
|
+
// if (req.session && req.session.oauthState !== state) {
|
|
170
|
+
// return res.status(400).send(templates.authError('Invalid State Parameter', 'CSRF detected. State mismatch.'));
|
|
171
|
+
// }
|
|
172
|
+
// if (req.session) delete req.session.oauthState;
|
|
173
|
+
|
|
174
|
+
if (error) {
|
|
175
|
+
return res
|
|
176
|
+
.status(400)
|
|
177
|
+
.send(templates.authError(error, error_description));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!code) {
|
|
181
|
+
return res
|
|
182
|
+
.status(400)
|
|
183
|
+
.send(
|
|
184
|
+
templates.authError(
|
|
185
|
+
'Missing Authorization Code',
|
|
186
|
+
'No authorization code was provided in the callback.'
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await tokenStorage.exchangeCodeForTokens(code);
|
|
193
|
+
res.send(templates.authSuccess);
|
|
194
|
+
} catch (exchangeError) {
|
|
195
|
+
console.error('Token exchange error:', exchangeError);
|
|
196
|
+
res.status(500).send(templates.tokenExchangeError(exchangeError));
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.get('/token-status', async (req, res) => {
|
|
201
|
+
try {
|
|
202
|
+
const token = await tokenStorage.getValidAccessToken();
|
|
203
|
+
if (token) {
|
|
204
|
+
const expiryDate = new Date(tokenStorage.getExpiryTime());
|
|
205
|
+
res.send(
|
|
206
|
+
templates.tokenStatus(
|
|
207
|
+
`Access token is valid. Expires at: ${expiryDate.toLocaleString()}`
|
|
208
|
+
)
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
res.send(
|
|
212
|
+
templates.tokenStatus(
|
|
213
|
+
'No valid access token found. Please authenticate.'
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
res
|
|
219
|
+
.status(500)
|
|
220
|
+
.send(
|
|
221
|
+
templates.tokenStatus(`Error checking token status: ${err.message}`)
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
setupOAuthRoutes,
|
|
229
|
+
createAuthConfig,
|
|
230
|
+
// Exporting templates for potential direct use or testing, though not typical
|
|
231
|
+
// templates
|
|
232
|
+
};
|
|
233
|
+
// Adding a newline at the end of the file as requested by Gemini Code Assist
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token management for Microsoft Graph API authentication
|
|
3
|
+
*/
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
|
|
7
|
+
// Global variable to store tokens
|
|
8
|
+
let cachedTokens = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Loads authentication tokens from the token file
|
|
12
|
+
* @returns {object|null} - The loaded tokens or null if not available
|
|
13
|
+
*/
|
|
14
|
+
function loadTokenCache() {
|
|
15
|
+
try {
|
|
16
|
+
const tokenPath = config.AUTH_CONFIG.tokenStorePath;
|
|
17
|
+
|
|
18
|
+
if (!fs.existsSync(tokenPath)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const tokenData = fs.readFileSync(tokenPath, 'utf8');
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const tokens = JSON.parse(tokenData);
|
|
26
|
+
|
|
27
|
+
if (!tokens.access_token) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const expiresAt = tokens.expires_at || 0;
|
|
33
|
+
|
|
34
|
+
if (now > expiresAt) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
cachedTokens = tokens;
|
|
39
|
+
return tokens;
|
|
40
|
+
} catch (parseError) {
|
|
41
|
+
console.error('Error parsing token file:', parseError.message);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('Error loading token cache:', error.message);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Saves authentication tokens to the token file
|
|
52
|
+
* @param {object} tokens - The tokens to save
|
|
53
|
+
* @returns {boolean} - Whether the save was successful
|
|
54
|
+
*/
|
|
55
|
+
function saveTokenCache(tokens) {
|
|
56
|
+
try {
|
|
57
|
+
const tokenPath = config.AUTH_CONFIG.tokenStorePath;
|
|
58
|
+
|
|
59
|
+
fs.writeFileSync(tokenPath, JSON.stringify(tokens, null, 2), {
|
|
60
|
+
mode: 0o600,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Update the cache
|
|
64
|
+
cachedTokens = tokens;
|
|
65
|
+
return true;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Error saving token cache:', error);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Gets the current access token, loading from cache if necessary
|
|
74
|
+
* @returns {string|null} - The access token or null if not available
|
|
75
|
+
*/
|
|
76
|
+
function getAccessToken() {
|
|
77
|
+
if (cachedTokens && cachedTokens.access_token) {
|
|
78
|
+
return cachedTokens.access_token;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tokens = loadTokenCache();
|
|
82
|
+
return tokens ? tokens.access_token : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Creates a test access token for use in test mode
|
|
87
|
+
* @returns {object} - The test tokens
|
|
88
|
+
*/
|
|
89
|
+
function createTestTokens() {
|
|
90
|
+
const testTokens = {
|
|
91
|
+
access_token: 'test_access_token_' + Date.now(),
|
|
92
|
+
refresh_token: 'test_refresh_token_' + Date.now(),
|
|
93
|
+
expires_at: Date.now() + 3600 * 1000, // 1 hour
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
saveTokenCache(testTokens);
|
|
97
|
+
return testTokens;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
loadTokenCache,
|
|
102
|
+
saveTokenCache,
|
|
103
|
+
getAccessToken,
|
|
104
|
+
createTestTokens,
|
|
105
|
+
};
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const fsSync = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const querystring = require('querystring');
|
|
6
|
+
|
|
7
|
+
class TokenStorage {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = {
|
|
10
|
+
tokenStorePath: path.join(
|
|
11
|
+
process.env.HOME || process.env.USERPROFILE,
|
|
12
|
+
'.outlook-assistant-tokens.json'
|
|
13
|
+
),
|
|
14
|
+
clientId: process.env.OUTLOOK_CLIENT_ID || process.env.MS_CLIENT_ID,
|
|
15
|
+
clientSecret:
|
|
16
|
+
process.env.OUTLOOK_CLIENT_SECRET || process.env.MS_CLIENT_SECRET,
|
|
17
|
+
redirectUri:
|
|
18
|
+
process.env.MS_REDIRECT_URI || 'http://localhost:3333/auth/callback',
|
|
19
|
+
scopes: (
|
|
20
|
+
process.env.MS_SCOPES || 'offline_access User.Read Mail.Read'
|
|
21
|
+
).split(' '),
|
|
22
|
+
tokenEndpoint:
|
|
23
|
+
process.env.MS_TOKEN_ENDPOINT ||
|
|
24
|
+
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
25
|
+
refreshTokenBuffer: 5 * 60 * 1000, // 5 minutes buffer for token refresh
|
|
26
|
+
...config, // Allow overriding default config
|
|
27
|
+
};
|
|
28
|
+
this.tokens = null;
|
|
29
|
+
this._loadPromise = null;
|
|
30
|
+
this._refreshPromise = null;
|
|
31
|
+
|
|
32
|
+
// Migrate tokens from old path (.outlook-mcp-tokens.json) if present
|
|
33
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE;
|
|
34
|
+
if (homeDir) {
|
|
35
|
+
const oldTokenPath = path.join(homeDir, '.outlook-mcp-tokens.json');
|
|
36
|
+
if (
|
|
37
|
+
oldTokenPath !== this.config.tokenStorePath &&
|
|
38
|
+
fsSync.existsSync(oldTokenPath) &&
|
|
39
|
+
!fsSync.existsSync(this.config.tokenStorePath)
|
|
40
|
+
) {
|
|
41
|
+
try {
|
|
42
|
+
fsSync.renameSync(oldTokenPath, this.config.tokenStorePath);
|
|
43
|
+
console.error(
|
|
44
|
+
'[auth] Migrated tokens: .outlook-mcp-tokens.json → .outlook-assistant-tokens.json'
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
// Ignore migration errors — user can re-authenticate
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!this.config.clientId || !this.config.clientSecret) {
|
|
53
|
+
console.warn(
|
|
54
|
+
'TokenStorage: OUTLOOK_CLIENT_ID or OUTLOOK_CLIENT_SECRET is not configured. Token operations might fail.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async _loadTokensFromFile() {
|
|
60
|
+
try {
|
|
61
|
+
const tokenData = await fs.readFile(this.config.tokenStorePath, 'utf8');
|
|
62
|
+
this.tokens = JSON.parse(tokenData);
|
|
63
|
+
return this.tokens;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code !== 'ENOENT') {
|
|
66
|
+
console.error('Error loading token cache:', error.message);
|
|
67
|
+
}
|
|
68
|
+
this.tokens = null;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async _saveTokensToFile() {
|
|
74
|
+
if (!this.tokens) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await fs.writeFile(
|
|
79
|
+
this.config.tokenStorePath,
|
|
80
|
+
JSON.stringify(this.tokens, null, 2),
|
|
81
|
+
{ mode: 0o600 }
|
|
82
|
+
);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('Error saving token cache:', error.message);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getTokens() {
|
|
90
|
+
if (this.tokens) {
|
|
91
|
+
return this.tokens;
|
|
92
|
+
}
|
|
93
|
+
if (!this._loadPromise) {
|
|
94
|
+
this._loadPromise = this._loadTokensFromFile().finally(() => {
|
|
95
|
+
this._loadPromise = null; // Reset promise once completed
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return this._loadPromise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getExpiryTime() {
|
|
102
|
+
return this.tokens && this.tokens.expires_at ? this.tokens.expires_at : 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
isTokenExpired() {
|
|
106
|
+
if (!this.tokens || !this.tokens.expires_at) {
|
|
107
|
+
return true; // No token or no expiry means it's effectively expired or invalid
|
|
108
|
+
}
|
|
109
|
+
// Check if current time is past expiry time, considering a buffer
|
|
110
|
+
return (
|
|
111
|
+
Date.now() >= this.tokens.expires_at - this.config.refreshTokenBuffer
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async getValidAccessToken() {
|
|
116
|
+
await this.getTokens(); // Ensure tokens are loaded
|
|
117
|
+
|
|
118
|
+
if (!this.tokens || !this.tokens.access_token) {
|
|
119
|
+
console.log('No access token available.');
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (this.isTokenExpired()) {
|
|
124
|
+
console.log(
|
|
125
|
+
'Access token expired or nearing expiration. Attempting refresh.'
|
|
126
|
+
);
|
|
127
|
+
if (this.tokens.refresh_token) {
|
|
128
|
+
try {
|
|
129
|
+
return await this.refreshAccessToken();
|
|
130
|
+
} catch (refreshError) {
|
|
131
|
+
console.error('Failed to refresh access token:', refreshError);
|
|
132
|
+
this.tokens = null; // Invalidate tokens on refresh failure
|
|
133
|
+
await this._saveTokensToFile(); // Persist invalidation
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
console.warn(
|
|
138
|
+
'No refresh token available. Cannot refresh access token.'
|
|
139
|
+
);
|
|
140
|
+
this.tokens = null; // Invalidate tokens as they are expired and cannot be refreshed
|
|
141
|
+
await this._saveTokensToFile(); // Persist invalidation
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return this.tokens.access_token;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async refreshAccessToken() {
|
|
149
|
+
if (!this.tokens || !this.tokens.refresh_token) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
'No refresh token available to refresh the access token.'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Prevent multiple concurrent refresh attempts
|
|
156
|
+
if (this._refreshPromise) {
|
|
157
|
+
console.log('Refresh already in progress, returning existing promise.');
|
|
158
|
+
return this._refreshPromise.then((tokens) => tokens.access_token);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log('Attempting to refresh access token...');
|
|
162
|
+
const postData = querystring.stringify({
|
|
163
|
+
client_id: this.config.clientId,
|
|
164
|
+
client_secret: this.config.clientSecret,
|
|
165
|
+
grant_type: 'refresh_token',
|
|
166
|
+
refresh_token: this.tokens.refresh_token,
|
|
167
|
+
scope: this.config.scopes.join(' '),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const requestOptions = {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
174
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
this._refreshPromise = new Promise((resolve, reject) => {
|
|
179
|
+
const req = https.request(
|
|
180
|
+
this.config.tokenEndpoint,
|
|
181
|
+
requestOptions,
|
|
182
|
+
(res) => {
|
|
183
|
+
let data = '';
|
|
184
|
+
res.on('data', (chunk) => (data += chunk));
|
|
185
|
+
res.on('end', async () => {
|
|
186
|
+
try {
|
|
187
|
+
const responseBody = JSON.parse(data);
|
|
188
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
189
|
+
this.tokens.access_token = responseBody.access_token;
|
|
190
|
+
// Microsoft Graph API refresh tokens may or may not return a new refresh_token
|
|
191
|
+
if (responseBody.refresh_token) {
|
|
192
|
+
this.tokens.refresh_token = responseBody.refresh_token;
|
|
193
|
+
}
|
|
194
|
+
this.tokens.expires_in = responseBody.expires_in;
|
|
195
|
+
this.tokens.expires_at =
|
|
196
|
+
Date.now() + responseBody.expires_in * 1000;
|
|
197
|
+
try {
|
|
198
|
+
await this._saveTokensToFile();
|
|
199
|
+
console.log('Access token refreshed and saved successfully.');
|
|
200
|
+
resolve(this.tokens);
|
|
201
|
+
} catch (saveError) {
|
|
202
|
+
console.error('Failed to save refreshed tokens:', saveError);
|
|
203
|
+
// Even if save fails, tokens are updated in memory.
|
|
204
|
+
// Depending on desired strictness, could reject here.
|
|
205
|
+
// For now, resolve with in-memory tokens but log critical error.
|
|
206
|
+
// Or, to be stricter and align with re-throwing:
|
|
207
|
+
reject(
|
|
208
|
+
new Error(
|
|
209
|
+
`Access token refreshed but failed to save: ${saveError.message}`
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
console.error('Error refreshing token:', responseBody);
|
|
215
|
+
reject(
|
|
216
|
+
new Error(
|
|
217
|
+
responseBody.error_description ||
|
|
218
|
+
`Token refresh failed with status ${res.statusCode}`
|
|
219
|
+
)
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
} catch (e) {
|
|
223
|
+
// Catch any error during parsing or saving
|
|
224
|
+
console.error(
|
|
225
|
+
'Error processing refresh token response or saving tokens:',
|
|
226
|
+
e
|
|
227
|
+
);
|
|
228
|
+
reject(e);
|
|
229
|
+
} finally {
|
|
230
|
+
this._refreshPromise = null; // Clear promise after completion
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
req.on('error', (error) => {
|
|
236
|
+
console.error('HTTP error during token refresh:', error);
|
|
237
|
+
reject(error);
|
|
238
|
+
this._refreshPromise = null; // Clear promise on error
|
|
239
|
+
});
|
|
240
|
+
req.write(postData);
|
|
241
|
+
req.end();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return this._refreshPromise.then((tokens) => tokens.access_token);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async exchangeCodeForTokens(authCode) {
|
|
248
|
+
if (!this.config.clientId || !this.config.clientSecret) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
'Client ID or Client Secret is not configured. Cannot exchange code for tokens.'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
console.log('Exchanging authorization code for tokens...');
|
|
254
|
+
const postData = querystring.stringify({
|
|
255
|
+
client_id: this.config.clientId,
|
|
256
|
+
client_secret: this.config.clientSecret,
|
|
257
|
+
grant_type: 'authorization_code',
|
|
258
|
+
code: authCode,
|
|
259
|
+
redirect_uri: this.config.redirectUri,
|
|
260
|
+
scope: this.config.scopes.join(' '),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const requestOptions = {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: {
|
|
266
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
267
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
const req = https.request(
|
|
273
|
+
this.config.tokenEndpoint,
|
|
274
|
+
requestOptions,
|
|
275
|
+
(res) => {
|
|
276
|
+
let data = '';
|
|
277
|
+
res.on('data', (chunk) => (data += chunk));
|
|
278
|
+
res.on('end', async () => {
|
|
279
|
+
try {
|
|
280
|
+
const responseBody = JSON.parse(data);
|
|
281
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
282
|
+
this.tokens = {
|
|
283
|
+
access_token: responseBody.access_token,
|
|
284
|
+
refresh_token: responseBody.refresh_token,
|
|
285
|
+
expires_in: responseBody.expires_in,
|
|
286
|
+
expires_at: Date.now() + responseBody.expires_in * 1000,
|
|
287
|
+
scope: responseBody.scope,
|
|
288
|
+
token_type: responseBody.token_type,
|
|
289
|
+
};
|
|
290
|
+
try {
|
|
291
|
+
await this._saveTokensToFile();
|
|
292
|
+
console.log('Tokens exchanged and saved successfully.');
|
|
293
|
+
resolve(this.tokens);
|
|
294
|
+
} catch (saveError) {
|
|
295
|
+
console.error('Failed to save exchanged tokens:', saveError);
|
|
296
|
+
// Similar to refresh, tokens are in memory but not persisted.
|
|
297
|
+
// Rejecting to indicate the operation wasn't fully successful.
|
|
298
|
+
reject(
|
|
299
|
+
new Error(
|
|
300
|
+
`Tokens exchanged but failed to save: ${saveError.message}`
|
|
301
|
+
)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
console.error(
|
|
306
|
+
'Error exchanging code for tokens:',
|
|
307
|
+
responseBody
|
|
308
|
+
);
|
|
309
|
+
reject(
|
|
310
|
+
new Error(
|
|
311
|
+
responseBody.error_description ||
|
|
312
|
+
`Token exchange failed with status ${res.statusCode}`
|
|
313
|
+
)
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
} catch (e) {
|
|
317
|
+
// Catch any error during parsing or saving
|
|
318
|
+
console.error(
|
|
319
|
+
'Error processing token exchange response or saving tokens:',
|
|
320
|
+
e,
|
|
321
|
+
'Raw data:',
|
|
322
|
+
data
|
|
323
|
+
);
|
|
324
|
+
reject(
|
|
325
|
+
new Error(
|
|
326
|
+
`Error processing token response: ${e.message}. Response data: ${data}`
|
|
327
|
+
)
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
req.on('error', (error) => {
|
|
334
|
+
console.error('HTTP error during code exchange:', error);
|
|
335
|
+
reject(error);
|
|
336
|
+
});
|
|
337
|
+
req.write(postData);
|
|
338
|
+
req.end();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Utility to clear tokens, e.g., for logout or forcing re-auth
|
|
343
|
+
async clearTokens() {
|
|
344
|
+
this.tokens = null;
|
|
345
|
+
try {
|
|
346
|
+
await fs.unlink(this.config.tokenStorePath);
|
|
347
|
+
console.log('Token file deleted successfully.');
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (error.code === 'ENOENT') {
|
|
350
|
+
console.log('Token file not found, nothing to delete.');
|
|
351
|
+
} else {
|
|
352
|
+
console.error('Error deleting token file:', error);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = TokenStorage;
|
|
359
|
+
// Adding a newline at the end of the file as requested by Gemini Code Assist
|