@bugroger/lokka 0.3.5
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/README.md +252 -0
- package/build/auth.js +501 -0
- package/build/constants.js +11 -0
- package/build/logger.js +28 -0
- package/build/main.js +587 -0
- package/build/mcp-server.log +148 -0
- package/package.json +54 -0
package/build/auth.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { ClientSecretCredential, ClientCertificateCredential, InteractiveBrowserCredential, DeviceCodeCredential } from "@azure/identity";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
import { LokkaClientId, LokkaDefaultTenantId, LokkaDefaultRedirectUri, LokkaTokenPath } from "./constants.js";
|
|
5
|
+
import { createServer } from "http";
|
|
6
|
+
import { randomBytes, createHash } from "crypto";
|
|
7
|
+
import { exec } from "child_process";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
// Constants
|
|
11
|
+
const ONE_HOUR_IN_MS = 60 * 60 * 1000; // One hour in milliseconds
|
|
12
|
+
// Helper function to parse JWT and extract scopes
|
|
13
|
+
function parseJwtScopes(token) {
|
|
14
|
+
try {
|
|
15
|
+
// Decode JWT without verifying signature (we trust the token from Azure Identity)
|
|
16
|
+
const decoded = jwt.decode(token);
|
|
17
|
+
if (!decoded || typeof decoded !== 'object') {
|
|
18
|
+
logger.info("Failed to decode JWT token");
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
// Extract scopes from the 'scp' claim (space-separated string)
|
|
22
|
+
const scopesString = decoded.scp;
|
|
23
|
+
if (typeof scopesString === 'string') {
|
|
24
|
+
return scopesString.split(' ').filter(scope => scope.length > 0);
|
|
25
|
+
}
|
|
26
|
+
// Some tokens might have roles instead of scopes
|
|
27
|
+
const roles = decoded.roles;
|
|
28
|
+
if (Array.isArray(roles)) {
|
|
29
|
+
return roles;
|
|
30
|
+
}
|
|
31
|
+
logger.info("No scopes found in JWT token");
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
logger.error("Error parsing JWT token for scopes", error);
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Simple authentication provider that works with Azure Identity TokenCredential
|
|
40
|
+
export class TokenCredentialAuthProvider {
|
|
41
|
+
credential;
|
|
42
|
+
constructor(credential) {
|
|
43
|
+
this.credential = credential;
|
|
44
|
+
}
|
|
45
|
+
async getAccessToken() {
|
|
46
|
+
const token = await this.credential.getToken("https://graph.microsoft.com/.default");
|
|
47
|
+
if (!token) {
|
|
48
|
+
throw new Error("Failed to acquire access token");
|
|
49
|
+
}
|
|
50
|
+
return token.token;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class ClientProvidedTokenCredential {
|
|
54
|
+
accessToken;
|
|
55
|
+
expiresOn;
|
|
56
|
+
constructor(accessToken, expiresOn) {
|
|
57
|
+
if (accessToken) {
|
|
58
|
+
this.accessToken = accessToken;
|
|
59
|
+
this.expiresOn = expiresOn || new Date(Date.now() + ONE_HOUR_IN_MS); // Default 1 hour
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
this.expiresOn = new Date(0); // Set to epoch to indicate no valid token
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async getToken(scopes) {
|
|
66
|
+
if (!this.accessToken || !this.expiresOn || this.expiresOn <= new Date()) {
|
|
67
|
+
logger.error("Access token is not available or has expired");
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
token: this.accessToken,
|
|
72
|
+
expiresOnTimestamp: this.expiresOn.getTime()
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
updateToken(accessToken, expiresOn) {
|
|
76
|
+
this.accessToken = accessToken;
|
|
77
|
+
this.expiresOn = expiresOn || new Date(Date.now() + ONE_HOUR_IN_MS);
|
|
78
|
+
logger.info("Access token updated successfully");
|
|
79
|
+
}
|
|
80
|
+
isExpired() {
|
|
81
|
+
return !this.expiresOn || this.expiresOn <= new Date();
|
|
82
|
+
}
|
|
83
|
+
getExpirationTime() {
|
|
84
|
+
return this.expiresOn || new Date(0);
|
|
85
|
+
}
|
|
86
|
+
// Getter for access token (for internal use by AuthManager)
|
|
87
|
+
getAccessToken() {
|
|
88
|
+
return this.accessToken;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export var AuthMode;
|
|
92
|
+
(function (AuthMode) {
|
|
93
|
+
AuthMode["ClientCredentials"] = "client_credentials";
|
|
94
|
+
AuthMode["ClientProvidedToken"] = "client_provided_token";
|
|
95
|
+
AuthMode["Interactive"] = "interactive";
|
|
96
|
+
AuthMode["Certificate"] = "certificate";
|
|
97
|
+
AuthMode["PersistentToken"] = "persistent_token";
|
|
98
|
+
})(AuthMode || (AuthMode = {}));
|
|
99
|
+
const TOKEN_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
|
|
100
|
+
const AUTHORIZE_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
|
|
101
|
+
const REFRESH_BUFFER_SECONDS = 300; // refresh 5 minutes before expiry
|
|
102
|
+
// Default scopes for interactive auth — offline_access is required for refresh tokens
|
|
103
|
+
const DEFAULT_INTERACTIVE_SCOPES = [
|
|
104
|
+
"User.Read",
|
|
105
|
+
"Mail.Read",
|
|
106
|
+
"Mail.ReadWrite",
|
|
107
|
+
"Mail.Send",
|
|
108
|
+
"Calendars.Read",
|
|
109
|
+
"Calendars.ReadWrite",
|
|
110
|
+
"Tasks.Read",
|
|
111
|
+
"Files.Read",
|
|
112
|
+
"Contacts.Read",
|
|
113
|
+
"offline_access",
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* TokenCredential that persists tokens to disk and refreshes via HTTP.
|
|
117
|
+
* First call with no token file triggers interactive OAuth2 auth code flow with PKCE.
|
|
118
|
+
* Subsequent calls refresh silently via refresh_token grant.
|
|
119
|
+
*/
|
|
120
|
+
export class PersistentTokenCredential {
|
|
121
|
+
clientId;
|
|
122
|
+
redirectPort;
|
|
123
|
+
cachedToken = null;
|
|
124
|
+
constructor(clientId = LokkaClientId, redirectPort = 0) {
|
|
125
|
+
this.clientId = clientId;
|
|
126
|
+
this.redirectPort = redirectPort;
|
|
127
|
+
}
|
|
128
|
+
async getToken(scopes) {
|
|
129
|
+
// Try loading cached token
|
|
130
|
+
if (!this.cachedToken) {
|
|
131
|
+
this.cachedToken = this._loadTokenFile();
|
|
132
|
+
}
|
|
133
|
+
if (this.cachedToken) {
|
|
134
|
+
const now = Math.floor(Date.now() / 1000);
|
|
135
|
+
if (this.cachedToken.expires_at > now + REFRESH_BUFFER_SECONDS) {
|
|
136
|
+
// Token still valid
|
|
137
|
+
return {
|
|
138
|
+
token: this.cachedToken.access_token,
|
|
139
|
+
expiresOnTimestamp: this.cachedToken.expires_at * 1000,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Token expired or expiring soon — refresh it
|
|
143
|
+
logger.info("Access token expired or expiring soon, refreshing...");
|
|
144
|
+
const refreshed = await this._refreshToken();
|
|
145
|
+
if (refreshed) {
|
|
146
|
+
return {
|
|
147
|
+
token: refreshed.access_token,
|
|
148
|
+
expiresOnTimestamp: refreshed.expires_at * 1000,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// Refresh failed — fall through to interactive auth
|
|
152
|
+
logger.info("Token refresh failed, falling back to interactive auth");
|
|
153
|
+
}
|
|
154
|
+
// No token file or refresh failed — do interactive auth
|
|
155
|
+
logger.info("No cached token found, starting interactive authentication...");
|
|
156
|
+
const token = await this._interactiveAuth();
|
|
157
|
+
return {
|
|
158
|
+
token: token.access_token,
|
|
159
|
+
expiresOnTimestamp: token.expires_at * 1000,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
_loadTokenFile() {
|
|
163
|
+
try {
|
|
164
|
+
if (!fs.existsSync(LokkaTokenPath)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const data = fs.readFileSync(LokkaTokenPath, "utf-8");
|
|
168
|
+
const parsed = JSON.parse(data);
|
|
169
|
+
if (!parsed.access_token || !parsed.refresh_token) {
|
|
170
|
+
logger.info("Token file missing required fields");
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return parsed;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
logger.error("Failed to load token file", error);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
_saveTokenFile(data) {
|
|
181
|
+
const dir = path.dirname(LokkaTokenPath);
|
|
182
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
183
|
+
fs.writeFileSync(LokkaTokenPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
184
|
+
logger.info("Token saved to disk");
|
|
185
|
+
}
|
|
186
|
+
async _refreshToken() {
|
|
187
|
+
if (!this.cachedToken?.refresh_token) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const body = new URLSearchParams({
|
|
191
|
+
client_id: this.cachedToken.client_id || this.clientId,
|
|
192
|
+
grant_type: "refresh_token",
|
|
193
|
+
refresh_token: this.cachedToken.refresh_token,
|
|
194
|
+
scope: this.cachedToken.scope || DEFAULT_INTERACTIVE_SCOPES.join(" "),
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const response = await fetch(TOKEN_ENDPOINT, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
200
|
+
body: body.toString(),
|
|
201
|
+
});
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
const errorText = await response.text();
|
|
204
|
+
logger.error(`Token refresh failed: ${response.status} - ${errorText}`);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const result = await response.json();
|
|
208
|
+
if (!result.access_token) {
|
|
209
|
+
logger.error("Token refresh response missing access_token");
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const tokenData = {
|
|
213
|
+
access_token: result.access_token,
|
|
214
|
+
refresh_token: result.refresh_token || this.cachedToken.refresh_token,
|
|
215
|
+
expires_at: Math.floor(Date.now() / 1000) + (result.expires_in || 3600),
|
|
216
|
+
scope: result.scope || this.cachedToken.scope,
|
|
217
|
+
client_id: this.cachedToken.client_id || this.clientId,
|
|
218
|
+
};
|
|
219
|
+
this._saveTokenFile(tokenData);
|
|
220
|
+
this.cachedToken = tokenData;
|
|
221
|
+
logger.info("Token refreshed successfully");
|
|
222
|
+
return tokenData;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
logger.error("Token refresh error", error);
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async _interactiveAuth() {
|
|
230
|
+
// Generate PKCE challenge
|
|
231
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
232
|
+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
233
|
+
const scopes = DEFAULT_INTERACTIVE_SCOPES.join(" ");
|
|
234
|
+
// Start local server to capture the redirect
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const server = createServer(async (req, res) => {
|
|
237
|
+
try {
|
|
238
|
+
const url = new URL(req.url || "/", `http://localhost`);
|
|
239
|
+
const code = url.searchParams.get("code");
|
|
240
|
+
const error = url.searchParams.get("error");
|
|
241
|
+
if (error) {
|
|
242
|
+
const errorDesc = url.searchParams.get("error_description") || error;
|
|
243
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
244
|
+
res.end(`<html><body><h2>Authentication Failed</h2><p>${errorDesc}</p></body></html>`);
|
|
245
|
+
server.close();
|
|
246
|
+
reject(new Error(`Authentication failed: ${errorDesc}`));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!code) {
|
|
250
|
+
// Not the redirect callback — ignore (could be favicon.ico etc.)
|
|
251
|
+
res.writeHead(404);
|
|
252
|
+
res.end();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Exchange code for tokens
|
|
256
|
+
const address = server.address();
|
|
257
|
+
const port = typeof address === "object" && address ? address.port : this.redirectPort;
|
|
258
|
+
const redirectUri = `http://localhost:${port}`;
|
|
259
|
+
const body = new URLSearchParams({
|
|
260
|
+
client_id: this.clientId,
|
|
261
|
+
grant_type: "authorization_code",
|
|
262
|
+
code: code,
|
|
263
|
+
redirect_uri: redirectUri,
|
|
264
|
+
code_verifier: codeVerifier,
|
|
265
|
+
});
|
|
266
|
+
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
269
|
+
body: body.toString(),
|
|
270
|
+
});
|
|
271
|
+
if (!tokenResponse.ok) {
|
|
272
|
+
const errorText = await tokenResponse.text();
|
|
273
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
274
|
+
res.end(`<html><body><h2>Token Exchange Failed</h2><pre>${errorText}</pre></body></html>`);
|
|
275
|
+
server.close();
|
|
276
|
+
reject(new Error(`Token exchange failed: ${errorText}`));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const result = await tokenResponse.json();
|
|
280
|
+
const tokenData = {
|
|
281
|
+
access_token: result.access_token,
|
|
282
|
+
refresh_token: result.refresh_token,
|
|
283
|
+
expires_at: Math.floor(Date.now() / 1000) + (result.expires_in || 3600),
|
|
284
|
+
scope: result.scope || scopes,
|
|
285
|
+
client_id: this.clientId,
|
|
286
|
+
};
|
|
287
|
+
this._saveTokenFile(tokenData);
|
|
288
|
+
this.cachedToken = tokenData;
|
|
289
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
290
|
+
res.end(`<html><body><h2>Authentication Successful</h2><p>You can close this window.</p></body></html>`);
|
|
291
|
+
server.close();
|
|
292
|
+
resolve(tokenData);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
296
|
+
res.end(`<html><body><h2>Error</h2><pre>${err}</pre></body></html>`);
|
|
297
|
+
server.close();
|
|
298
|
+
reject(err);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
server.listen(this.redirectPort, "127.0.0.1", async () => {
|
|
302
|
+
const address = server.address();
|
|
303
|
+
const port = typeof address === "object" && address ? address.port : this.redirectPort;
|
|
304
|
+
const redirectUri = `http://localhost:${port}`;
|
|
305
|
+
const authUrl = new URL(AUTHORIZE_ENDPOINT);
|
|
306
|
+
authUrl.searchParams.set("client_id", this.clientId);
|
|
307
|
+
authUrl.searchParams.set("response_type", "code");
|
|
308
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
309
|
+
authUrl.searchParams.set("scope", scopes);
|
|
310
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
311
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
312
|
+
authUrl.searchParams.set("response_mode", "query");
|
|
313
|
+
const authUrlStr = authUrl.toString();
|
|
314
|
+
logger.info(`Opening browser for authentication: ${authUrlStr}`);
|
|
315
|
+
console.error(`\n🔐 Opening browser for authentication...`);
|
|
316
|
+
console.error(`If the browser doesn't open, visit: ${authUrlStr}\n`);
|
|
317
|
+
// Open browser using platform-native command
|
|
318
|
+
const platform = process.platform;
|
|
319
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
320
|
+
exec(`${cmd} "${authUrlStr}"`, (err) => {
|
|
321
|
+
if (err) {
|
|
322
|
+
console.error(`⚠️ Could not open browser automatically. Please open this URL manually:\n${authUrlStr}`);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
// Timeout after 2 minutes
|
|
327
|
+
setTimeout(() => {
|
|
328
|
+
server.close();
|
|
329
|
+
reject(new Error("Authentication timed out after 2 minutes"));
|
|
330
|
+
}, 120_000);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
export class AuthManager {
|
|
335
|
+
credential = null;
|
|
336
|
+
config;
|
|
337
|
+
constructor(config) {
|
|
338
|
+
this.config = config;
|
|
339
|
+
}
|
|
340
|
+
async initialize() {
|
|
341
|
+
switch (this.config.mode) {
|
|
342
|
+
case AuthMode.ClientCredentials:
|
|
343
|
+
if (!this.config.tenantId || !this.config.clientId || !this.config.clientSecret) {
|
|
344
|
+
throw new Error("Client credentials mode requires tenantId, clientId, and clientSecret");
|
|
345
|
+
}
|
|
346
|
+
logger.info("Initializing Client Credentials authentication");
|
|
347
|
+
this.credential = new ClientSecretCredential(this.config.tenantId, this.config.clientId, this.config.clientSecret);
|
|
348
|
+
break;
|
|
349
|
+
case AuthMode.ClientProvidedToken:
|
|
350
|
+
logger.info("Initializing Client Provided Token authentication");
|
|
351
|
+
this.credential = new ClientProvidedTokenCredential(this.config.accessToken, this.config.expiresOn);
|
|
352
|
+
break;
|
|
353
|
+
case AuthMode.Certificate:
|
|
354
|
+
if (!this.config.tenantId || !this.config.clientId || !this.config.certificatePath) {
|
|
355
|
+
throw new Error("Certificate mode requires tenantId, clientId, and certificatePath");
|
|
356
|
+
}
|
|
357
|
+
logger.info("Initializing Certificate authentication");
|
|
358
|
+
this.credential = new ClientCertificateCredential(this.config.tenantId, this.config.clientId, {
|
|
359
|
+
certificatePath: this.config.certificatePath,
|
|
360
|
+
certificatePassword: this.config.certificatePassword
|
|
361
|
+
});
|
|
362
|
+
break;
|
|
363
|
+
case AuthMode.Interactive:
|
|
364
|
+
// Use defaults if not provided
|
|
365
|
+
const tenantId = this.config.tenantId || LokkaDefaultTenantId;
|
|
366
|
+
const clientId = this.config.clientId || LokkaClientId;
|
|
367
|
+
logger.info(`Initializing Interactive authentication with tenant ID: ${tenantId}, client ID: ${clientId}`);
|
|
368
|
+
try {
|
|
369
|
+
// Try Interactive Browser first
|
|
370
|
+
this.credential = new InteractiveBrowserCredential({
|
|
371
|
+
tenantId: tenantId,
|
|
372
|
+
clientId: clientId,
|
|
373
|
+
redirectUri: this.config.redirectUri || LokkaDefaultRedirectUri,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
// Fallback to Device Code flow
|
|
378
|
+
logger.info("Interactive browser failed, falling back to device code flow");
|
|
379
|
+
this.credential = new DeviceCodeCredential({
|
|
380
|
+
tenantId: tenantId,
|
|
381
|
+
clientId: clientId,
|
|
382
|
+
userPromptCallback: (info) => {
|
|
383
|
+
console.log(`\n🔐 Authentication Required:`);
|
|
384
|
+
console.log(`Please visit: ${info.verificationUri}`);
|
|
385
|
+
console.log(`And enter code: ${info.userCode}\n`);
|
|
386
|
+
return Promise.resolve();
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
case AuthMode.PersistentToken:
|
|
392
|
+
logger.info("Initializing Persistent Token authentication (cached token with silent refresh)");
|
|
393
|
+
this.credential = new PersistentTokenCredential(this.config.clientId || LokkaClientId);
|
|
394
|
+
break;
|
|
395
|
+
default:
|
|
396
|
+
throw new Error(`Unsupported authentication mode: ${this.config.mode}`);
|
|
397
|
+
}
|
|
398
|
+
// Test the credential
|
|
399
|
+
await this.testCredential();
|
|
400
|
+
}
|
|
401
|
+
updateAccessToken(accessToken, expiresOn) {
|
|
402
|
+
if (this.config.mode === AuthMode.ClientProvidedToken && this.credential instanceof ClientProvidedTokenCredential) {
|
|
403
|
+
this.credential.updateToken(accessToken, expiresOn);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
throw new Error("Token update only supported in client provided token mode");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async testCredential() {
|
|
410
|
+
if (!this.credential) {
|
|
411
|
+
throw new Error("Credential not initialized");
|
|
412
|
+
}
|
|
413
|
+
// Skip testing if ClientProvidedToken mode has no initial token
|
|
414
|
+
if (this.config.mode === AuthMode.ClientProvidedToken && !this.config.accessToken) {
|
|
415
|
+
logger.info("Skipping initial credential test as no token was provided at startup.");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const token = await this.credential.getToken("https://graph.microsoft.com/.default");
|
|
420
|
+
if (!token) {
|
|
421
|
+
throw new Error("Failed to acquire token");
|
|
422
|
+
}
|
|
423
|
+
logger.info("Authentication successful");
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
logger.error("Authentication test failed", error);
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
getGraphAuthProvider() {
|
|
431
|
+
if (!this.credential) {
|
|
432
|
+
throw new Error("Authentication not initialized");
|
|
433
|
+
}
|
|
434
|
+
return new TokenCredentialAuthProvider(this.credential);
|
|
435
|
+
}
|
|
436
|
+
getAzureCredential() {
|
|
437
|
+
if (!this.credential) {
|
|
438
|
+
throw new Error("Authentication not initialized");
|
|
439
|
+
}
|
|
440
|
+
return this.credential;
|
|
441
|
+
}
|
|
442
|
+
getAuthMode() {
|
|
443
|
+
return this.config.mode;
|
|
444
|
+
}
|
|
445
|
+
isClientCredentials() {
|
|
446
|
+
return this.config.mode === AuthMode.ClientCredentials;
|
|
447
|
+
}
|
|
448
|
+
isClientProvidedToken() {
|
|
449
|
+
return this.config.mode === AuthMode.ClientProvidedToken;
|
|
450
|
+
}
|
|
451
|
+
isInteractive() {
|
|
452
|
+
return this.config.mode === AuthMode.Interactive;
|
|
453
|
+
}
|
|
454
|
+
isPersistentToken() {
|
|
455
|
+
return this.config.mode === AuthMode.PersistentToken;
|
|
456
|
+
}
|
|
457
|
+
async getTokenStatus() {
|
|
458
|
+
if (this.credential instanceof ClientProvidedTokenCredential) {
|
|
459
|
+
const tokenStatus = {
|
|
460
|
+
isExpired: this.credential.isExpired(),
|
|
461
|
+
expiresOn: this.credential.getExpirationTime()
|
|
462
|
+
};
|
|
463
|
+
// If we have a valid token, parse it to extract scopes
|
|
464
|
+
if (!tokenStatus.isExpired) {
|
|
465
|
+
const accessToken = this.credential.getAccessToken();
|
|
466
|
+
if (accessToken) {
|
|
467
|
+
try {
|
|
468
|
+
const scopes = parseJwtScopes(accessToken);
|
|
469
|
+
return {
|
|
470
|
+
...tokenStatus,
|
|
471
|
+
scopes: scopes
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
logger.error("Error parsing token scopes in getTokenStatus", error);
|
|
476
|
+
return tokenStatus;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return tokenStatus;
|
|
481
|
+
}
|
|
482
|
+
else if (this.credential) {
|
|
483
|
+
// For other credential types, try to get a fresh token and parse it
|
|
484
|
+
try {
|
|
485
|
+
const accessToken = await this.credential.getToken("https://graph.microsoft.com/.default");
|
|
486
|
+
if (accessToken && accessToken.token) {
|
|
487
|
+
const scopes = parseJwtScopes(accessToken.token);
|
|
488
|
+
return {
|
|
489
|
+
isExpired: false,
|
|
490
|
+
expiresOn: new Date(accessToken.expiresOnTimestamp),
|
|
491
|
+
scopes: scopes
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
logger.error("Error getting token for scope parsing", error);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return { isExpired: false };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Shared constants for the Lokka MCP Server
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
export const LokkaClientId = "a9bac4c3-af0d-4292-9453-9da89e390140";
|
|
5
|
+
export const LokkaDefaultTenantId = "common";
|
|
6
|
+
export const LokkaDefaultRedirectUri = "http://localhost:3000";
|
|
7
|
+
export const LokkaTokenPath = path.join(os.homedir(), ".claude", "tokens", "lokka-token.json");
|
|
8
|
+
// Default Graph API version based on USE_GRAPH_BETA environment variable
|
|
9
|
+
export const getDefaultGraphApiVersion = () => {
|
|
10
|
+
return process.env.USE_GRAPH_BETA !== 'false' ? "beta" : "v1.0";
|
|
11
|
+
};
|
package/build/logger.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { appendFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
const LOG_FILE = join(import.meta.dirname, "mcp-server.log");
|
|
4
|
+
function formatMessage(level, message, data) {
|
|
5
|
+
const timestamp = new Date().toISOString();
|
|
6
|
+
const dataStr = data
|
|
7
|
+
? `\n${JSON.stringify(data, null, 2)}`
|
|
8
|
+
: "";
|
|
9
|
+
return `[${timestamp}] [${level}] ${message}${dataStr}\n`;
|
|
10
|
+
}
|
|
11
|
+
export const logger = {
|
|
12
|
+
info(message, data) {
|
|
13
|
+
const logMessage = formatMessage("INFO", message, data);
|
|
14
|
+
appendFileSync(LOG_FILE, logMessage);
|
|
15
|
+
},
|
|
16
|
+
error(message, error) {
|
|
17
|
+
const logMessage = formatMessage("ERROR", message, error);
|
|
18
|
+
appendFileSync(LOG_FILE, logMessage);
|
|
19
|
+
},
|
|
20
|
+
// debug(message: string, data?: unknown) {
|
|
21
|
+
// const logMessage = formatMessage(
|
|
22
|
+
// "DEBUG",
|
|
23
|
+
// message,
|
|
24
|
+
// data,
|
|
25
|
+
// );
|
|
26
|
+
// appendFileSync(LOG_FILE, logMessage);
|
|
27
|
+
// },
|
|
28
|
+
};
|