@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
331 lines • 12.4 kB
JavaScript
/**
* OAuth 2.1 Client Provider for MCP HTTP Transport
* Implements OAuth 2.1 authentication with PKCE support
*/
import { randomBytes, createHash } from "crypto";
import { InMemoryTokenStorage, isTokenExpired, calculateExpiresAt, } from "./tokenStorage.js";
import { logger } from "../../utils/logger.js";
import { withTimeout } from "../../utils/errorHandling.js";
/** Default timeout for OAuth token operations (30 seconds) */
const OAUTH_TOKEN_TIMEOUT_MS = 30000;
/**
* NeuroLink OAuth Provider for MCP HTTP Transport
* Handles OAuth 2.1 authentication flow with optional PKCE support
*/
export class NeuroLinkOAuthProvider {
config;
storage;
pendingChallenges = new Map();
pendingStates = new Set();
constructor(config, storage) {
this.config = {
...config,
usePKCE: config.usePKCE ?? true, // PKCE enabled by default for OAuth 2.1
};
this.storage = storage ?? new InMemoryTokenStorage();
}
/**
* Get stored tokens for a server
* Returns null if tokens are not available or expired (without refresh token)
*/
async tokens(serverId) {
const tokens = await this.storage.getTokens(serverId);
if (!tokens) {
return null;
}
// Check if tokens are expired
if (isTokenExpired(tokens)) {
// Try to refresh if refresh token is available
if (tokens.refreshToken) {
try {
const refreshedTokens = await this.refreshTokens(serverId, tokens.refreshToken);
return refreshedTokens;
}
catch (error) {
logger.warn(`[NeuroLinkOAuthProvider] Token refresh failed: ${error instanceof Error ? error.message : String(error)}`);
// Delete expired tokens if refresh fails
await this.storage.deleteTokens(serverId);
return null;
}
}
// No refresh token, delete expired tokens
await this.storage.deleteTokens(serverId);
return null;
}
return tokens;
}
/**
* Save tokens for a server
*/
async saveTokens(serverId, tokens) {
await this.storage.saveTokens(serverId, tokens);
}
/**
* Delete tokens for a server
*/
async deleteTokens(serverId) {
await this.storage.deleteTokens(serverId);
}
/**
* Get client information for MCP SDK
*/
clientInformation() {
return {
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
redirectUri: this.config.redirectUrl,
};
}
/**
* Generate authorization URL for OAuth flow
* Returns the URL to redirect the user to for authorization
* @param _serverId - Server ID (reserved for future use in state management)
*/
redirectToAuthorization(_serverId) {
// Generate state parameter for CSRF protection
const state = this.generateState();
this.pendingStates.add(state);
// Build authorization URL
const url = new URL(this.config.authorizationUrl);
// Required OAuth 2.1 parameters
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", this.config.clientId);
url.searchParams.set("redirect_uri", this.config.redirectUrl);
url.searchParams.set("state", state);
// Optional scope
if (this.config.scope) {
url.searchParams.set("scope", this.config.scope);
}
// PKCE support
let codeVerifier;
if (this.config.usePKCE) {
const pkce = this.generatePKCE();
codeVerifier = pkce.codeVerifier;
// Store PKCE challenge for later verification
this.pendingChallenges.set(state, pkce);
url.searchParams.set("code_challenge", pkce.codeChallenge);
url.searchParams.set("code_challenge_method", pkce.codeChallengeMethod);
}
// Additional custom parameters
if (this.config.additionalParams) {
for (const [key, value] of Object.entries(this.config.additionalParams)) {
url.searchParams.set(key, value);
}
}
return {
url: url.toString(),
state,
codeVerifier,
};
}
/**
* Exchange authorization code for tokens
*/
async exchangeCode(serverId, request) {
// Validate state
if (!this.pendingStates.has(request.state)) {
throw new Error("Invalid or expired state parameter");
}
this.pendingStates.delete(request.state);
// Get PKCE verifier if applicable
let codeVerifier = request.codeVerifier;
if (this.config.usePKCE && !codeVerifier) {
const pkce = this.pendingChallenges.get(request.state);
if (pkce) {
codeVerifier = pkce.codeVerifier;
this.pendingChallenges.delete(request.state);
}
}
// Build token request
const body = new URLSearchParams();
body.set("grant_type", "authorization_code");
body.set("code", request.code);
body.set("redirect_uri", this.config.redirectUrl);
body.set("client_id", this.config.clientId);
// Include client secret if available (confidential clients)
if (this.config.clientSecret) {
body.set("client_secret", this.config.clientSecret);
}
// Include PKCE verifier if applicable
if (codeVerifier) {
body.set("code_verifier", codeVerifier);
}
// Request tokens with timeout protection
const response = await withTimeout(fetch(this.config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
}), OAUTH_TOKEN_TIMEOUT_MS, new Error(`OAuth token exchange timed out after ${OAUTH_TOKEN_TIMEOUT_MS}ms`));
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`);
}
const tokenResponse = (await response.json());
// Convert to OAuthTokens format
const tokens = {
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
expiresAt: tokenResponse.expires_in
? calculateExpiresAt(tokenResponse.expires_in)
: undefined,
tokenType: tokenResponse.token_type ?? "Bearer",
scope: tokenResponse.scope,
};
// Save tokens
await this.saveTokens(serverId, tokens);
return tokens;
}
/**
* Refresh tokens using refresh token
*/
async refreshTokens(serverId, refreshToken) {
const body = new URLSearchParams();
body.set("grant_type", "refresh_token");
body.set("refresh_token", refreshToken);
body.set("client_id", this.config.clientId);
if (this.config.clientSecret) {
body.set("client_secret", this.config.clientSecret);
}
// Refresh tokens with timeout protection
const response = await withTimeout(fetch(this.config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
}), OAUTH_TOKEN_TIMEOUT_MS, new Error(`OAuth token refresh timed out after ${OAUTH_TOKEN_TIMEOUT_MS}ms`));
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`);
}
const tokenResponse = (await response.json());
const tokens = {
accessToken: tokenResponse.access_token,
// Keep old refresh token if new one not provided
refreshToken: tokenResponse.refresh_token ?? refreshToken,
expiresAt: tokenResponse.expires_in
? calculateExpiresAt(tokenResponse.expires_in)
: undefined,
tokenType: tokenResponse.token_type ?? "Bearer",
scope: tokenResponse.scope,
};
await this.saveTokens(serverId, tokens);
return tokens;
}
/**
* Revoke tokens (if supported by the OAuth server)
*/
async revokeTokens(serverId, revocationUrl) {
const tokens = await this.storage.getTokens(serverId);
if (!tokens) {
return;
}
const body = new URLSearchParams();
body.set("token", tokens.accessToken);
body.set("client_id", this.config.clientId);
if (this.config.clientSecret) {
body.set("client_secret", this.config.clientSecret);
}
try {
// Revoke tokens with timeout protection
await withTimeout(fetch(revocationUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
}), OAUTH_TOKEN_TIMEOUT_MS, new Error(`OAuth token revocation timed out after ${OAUTH_TOKEN_TIMEOUT_MS}ms`));
}
catch (error) {
logger.warn(`[NeuroLinkOAuthProvider] Token revocation failed: ${error instanceof Error ? error.message : String(error)}`);
}
// Always delete local tokens
await this.storage.deleteTokens(serverId);
}
/**
* Get authorization header value for API requests
*/
async getAuthorizationHeader(serverId) {
const tokens = await this.tokens(serverId);
if (!tokens) {
return null;
}
return `${tokens.tokenType} ${tokens.accessToken}`;
}
/**
* Check if a server has valid (non-expired) tokens
*/
async hasValidTokens(serverId) {
const tokens = await this.tokens(serverId);
return tokens !== null;
}
/**
* Generate a cryptographically secure state parameter
*/
generateState() {
return randomBytes(32).toString("base64url");
}
/**
* Generate PKCE code verifier and challenge
* Uses SHA-256 for code challenge method (required by OAuth 2.1)
*/
generatePKCE() {
// Generate code verifier (43-128 characters, URL-safe)
const codeVerifier = randomBytes(32).toString("base64url");
// Generate code challenge using SHA-256
const codeChallenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url");
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: "S256",
};
}
/**
* Get the OAuth configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Get the token storage instance
*/
getStorage() {
return this.storage;
}
/**
* Clean up expired pending states and challenges
* Should be called periodically to prevent memory leaks
*/
cleanupPendingRequests() {
// Clear old pending states (older than 10 minutes)
// Note: In a production system, you'd want to track timestamps
// For now, we just clear all if there are too many
if (this.pendingStates.size > 100) {
this.pendingStates.clear();
}
if (this.pendingChallenges.size > 100) {
this.pendingChallenges.clear();
}
}
}
/**
* Create an OAuth provider from MCP server auth configuration
*/
export function createOAuthProviderFromConfig(authConfig, storage) {
return new NeuroLinkOAuthProvider({
clientId: authConfig.clientId,
clientSecret: authConfig.clientSecret,
authorizationUrl: authConfig.authorizationUrl,
tokenUrl: authConfig.tokenUrl,
redirectUrl: authConfig.redirectUrl,
scope: authConfig.scope,
usePKCE: authConfig.usePKCE ?? true,
}, storage);
}
//# sourceMappingURL=oauthClientProvider.js.map