@houmak/minerva-mcp-server
Version:
Minerva Model Context Protocol (MCP) Server for Microsoft 365 and Azure integrations
259 lines (258 loc) • 10.7 kB
JavaScript
import { ClientSecretCredential, ClientCertificateCredential, InteractiveBrowserCredential, DeviceCodeCredential, ManagedIdentityCredential } from "@azure/identity";
import jwt from "jsonwebtoken";
import { logger } from "./logger.js";
import { LokkaClientId, LokkaDefaultTenantId, LokkaDefaultRedirectUri } from "./constants.js";
// Constants
const ONE_HOUR_IN_MS = 60 * 60 * 1000; // One hour in milliseconds
// Helper function to parse JWT and extract scopes
function parseJwtScopes(token) {
try {
// Decode JWT without verifying signature (we trust the token from Azure Identity)
const decoded = jwt.decode(token);
if (!decoded || typeof decoded !== 'object') {
logger.info("Failed to decode JWT token");
return [];
}
// Extract scopes from the 'scp' claim (space-separated string)
const scopesString = decoded.scp;
if (typeof scopesString === 'string') {
return scopesString.split(' ').filter(scope => scope.length > 0);
}
// Some tokens might have roles instead of scopes
const roles = decoded.roles;
if (Array.isArray(roles)) {
return roles;
}
logger.info("No scopes found in JWT token");
return [];
}
catch (error) {
logger.error("Error parsing JWT token for scopes", error);
return [];
}
}
// Simple authentication provider that works with Azure Identity TokenCredential
export class TokenCredentialAuthProvider {
credential;
constructor(credential) {
this.credential = credential;
}
async getAccessToken() {
const token = await this.credential.getToken("https://graph.microsoft.com/.default");
if (!token) {
throw new Error("Failed to acquire access token");
}
return token.token;
}
}
export class ClientProvidedTokenCredential {
accessToken;
expiresOn;
constructor(accessToken, expiresOn) {
if (accessToken) {
this.accessToken = accessToken;
this.expiresOn = expiresOn || new Date(Date.now() + ONE_HOUR_IN_MS); // Default 1 hour
}
else {
this.expiresOn = new Date(0); // Set to epoch to indicate no valid token
}
}
async getToken(scopes) {
if (!this.accessToken || !this.expiresOn || this.expiresOn <= new Date()) {
logger.error("Access token is not available or has expired");
return null;
}
return {
token: this.accessToken,
expiresOnTimestamp: this.expiresOn.getTime()
};
}
updateToken(accessToken, expiresOn) {
this.accessToken = accessToken;
this.expiresOn = expiresOn || new Date(Date.now() + ONE_HOUR_IN_MS);
logger.info("Access token updated successfully");
}
isExpired() {
return !this.expiresOn || this.expiresOn <= new Date();
}
getExpirationTime() {
return this.expiresOn || new Date(0);
}
// Getter for access token (for internal use by AuthManager)
getAccessToken() {
return this.accessToken;
}
}
export var AuthMode;
(function (AuthMode) {
AuthMode["ClientCredentials"] = "client_credentials";
AuthMode["ClientProvidedToken"] = "client_provided_token";
AuthMode["Interactive"] = "interactive";
AuthMode["Certificate"] = "certificate";
AuthMode["ManagedIdentity"] = "managed_identity";
})(AuthMode || (AuthMode = {}));
export class AuthManager {
credential = null;
config;
constructor(config) {
this.config = config;
}
async initialize() {
switch (this.config.mode) {
case AuthMode.ManagedIdentity:
logger.info("Initializing Managed Identity authentication");
this.credential = new ManagedIdentityCredential();
break;
case AuthMode.ClientCredentials:
if (!this.config.tenantId || !this.config.clientId || !this.config.clientSecret) {
throw new Error("Client credentials mode requires tenantId, clientId, and clientSecret");
}
logger.info("Initializing Client Credentials authentication");
this.credential = new ClientSecretCredential(this.config.tenantId, this.config.clientId, this.config.clientSecret);
break;
case AuthMode.ClientProvidedToken:
logger.info("Initializing Client Provided Token authentication");
this.credential = new ClientProvidedTokenCredential(this.config.accessToken, this.config.expiresOn);
break;
case AuthMode.Certificate:
if (!this.config.tenantId || !this.config.clientId || !this.config.certificatePath) {
throw new Error("Certificate mode requires tenantId, clientId, and certificatePath");
}
logger.info("Initializing Certificate authentication");
this.credential = new ClientCertificateCredential(this.config.tenantId, this.config.clientId, {
certificatePath: this.config.certificatePath,
certificatePassword: this.config.certificatePassword
});
break;
case AuthMode.Interactive:
// Use defaults if not provided
const tenantId = this.config.tenantId || LokkaDefaultTenantId;
const clientId = this.config.clientId || LokkaClientId;
logger.info(`Initializing Interactive authentication with tenant ID: ${tenantId}, client ID: ${clientId}`);
try {
// Try Interactive Browser first
this.credential = new InteractiveBrowserCredential({
tenantId: tenantId,
clientId: clientId,
redirectUri: this.config.redirectUri || LokkaDefaultRedirectUri,
});
}
catch (error) {
// Fallback to Device Code flow
logger.info("Interactive browser failed, falling back to device code flow");
this.credential = new DeviceCodeCredential({
tenantId: tenantId,
clientId: clientId,
userPromptCallback: (info) => {
console.error(`Authentication Required:`);
console.error(`Please visit: ${info.verificationUri}`);
console.error(`And enter code: ${info.userCode}`);
return Promise.resolve();
},
});
}
break;
default:
throw new Error(`Unsupported authentication mode: ${this.config.mode}`);
}
// Test the credential
await this.testCredential();
}
updateAccessToken(accessToken, expiresOn) {
if (this.config.mode === AuthMode.ClientProvidedToken && this.credential instanceof ClientProvidedTokenCredential) {
this.credential.updateToken(accessToken, expiresOn);
}
else {
throw new Error("Token update only supported in client provided token mode");
}
}
async testCredential() {
if (!this.credential) {
throw new Error("Credential not initialized");
}
// Skip testing if ClientProvidedToken mode has no initial token
if (this.config.mode === AuthMode.ClientProvidedToken && !this.config.accessToken) {
logger.info("Skipping initial credential test as no token was provided at startup.");
return;
}
try {
const token = await this.credential.getToken("https://graph.microsoft.com/.default");
if (!token) {
throw new Error("Failed to acquire token");
}
logger.info("Authentication successful");
}
catch (error) {
logger.error("Authentication test failed", error);
throw error;
}
}
getGraphAuthProvider() {
if (!this.credential) {
throw new Error("Authentication not initialized");
}
return new TokenCredentialAuthProvider(this.credential);
}
getAzureCredential() {
if (!this.credential) {
throw new Error("Authentication not initialized");
}
return this.credential;
}
getAuthMode() {
return this.config.mode;
}
isClientCredentials() {
return this.config.mode === AuthMode.ClientCredentials;
}
isClientProvidedToken() {
return this.config.mode === AuthMode.ClientProvidedToken;
}
isInteractive() {
return this.config.mode === AuthMode.Interactive;
}
async getTokenStatus() {
if (this.credential instanceof ClientProvidedTokenCredential) {
const tokenStatus = {
isExpired: this.credential.isExpired(),
expiresOn: this.credential.getExpirationTime()
};
// If we have a valid token, parse it to extract scopes
if (!tokenStatus.isExpired) {
const accessToken = this.credential.getAccessToken();
if (accessToken) {
try {
const scopes = parseJwtScopes(accessToken);
return {
...tokenStatus,
scopes: scopes
};
}
catch (error) {
logger.error("Error parsing token scopes in getTokenStatus", error);
return tokenStatus;
}
}
}
return tokenStatus;
}
else if (this.credential) {
// For other credential types, try to get a fresh token and parse it
try {
const accessToken = await this.credential.getToken("https://graph.microsoft.com/.default");
if (accessToken && accessToken.token) {
const scopes = parseJwtScopes(accessToken.token);
return {
isExpired: false,
expiresOn: new Date(accessToken.expiresOnTimestamp),
scopes: scopes
};
}
}
catch (error) {
logger.error("Error getting token for scope parsing", error);
}
}
return { isExpired: false };
}
}