tasq/node_modules/@claude-flow/mcp/dist/oauth.js

318 lines
10 KiB
JavaScript

/**
* @claude-flow/mcp - OAuth 2.1 Authentication
*
* MCP 2025-11-25 compliant OAuth 2.1 with PKCE
*/
import { EventEmitter } from 'events';
import * as crypto from 'crypto';
/**
* In-memory token storage (for development)
*/
export class InMemoryTokenStorage {
tokens = new Map();
async save(key, tokens) {
this.tokens.set(key, tokens);
}
async load(key) {
return this.tokens.get(key) || null;
}
async delete(key) {
this.tokens.delete(key);
}
}
/**
* OAuth 2.1 Manager
*/
export class OAuthManager extends EventEmitter {
logger;
config;
tokenStorage;
pendingRequests = new Map();
cleanupTimer;
constructor(logger, config) {
super();
this.logger = logger;
this.config = {
usePKCE: true,
scopes: [],
stateGenerator: () => this.generateRandomString(32),
...config,
};
this.tokenStorage = config.tokenStorage || new InMemoryTokenStorage();
this.startCleanup();
}
/**
* Generate authorization URL for OAuth flow
*/
createAuthorizationRequest() {
const state = this.config.stateGenerator();
let codeVerifier;
let codeChallenge;
if (this.config.usePKCE) {
codeVerifier = this.generateCodeVerifier();
codeChallenge = this.generateCodeChallenge(codeVerifier);
}
const params = new URLSearchParams({
response_type: 'code',
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
state,
});
if (this.config.scopes && this.config.scopes.length > 0) {
params.set('scope', this.config.scopes.join(' '));
}
if (codeChallenge) {
params.set('code_challenge', codeChallenge);
params.set('code_challenge_method', 'S256');
}
// Store pending request for validation
this.pendingRequests.set(state, {
codeVerifier,
timestamp: Date.now(),
});
const url = `${this.config.authorizationEndpoint}?${params.toString()}`;
this.logger.debug('Created authorization request', { state, usePKCE: !!codeVerifier });
this.emit('authorization:created', { state });
return { url, state, codeVerifier };
}
/**
* Exchange authorization code for tokens
*/
async exchangeCode(code, state) {
const pending = this.pendingRequests.get(state);
if (!pending) {
throw new Error('Invalid or expired state parameter');
}
this.pendingRequests.delete(state);
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.config.redirectUri,
client_id: this.config.clientId,
});
if (this.config.clientSecret) {
params.set('client_secret', this.config.clientSecret);
}
if (pending.codeVerifier) {
params.set('code_verifier', pending.codeVerifier);
}
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const error = await response.text();
this.logger.error('Token exchange failed', { status: response.status, error });
throw new Error(`Token exchange failed: ${response.status}`);
}
const data = (await response.json());
const tokens = this.parseTokenResponse(data);
await this.tokenStorage.save('default', tokens);
this.logger.info('Token exchange successful');
this.emit('tokens:received', { expiresIn: tokens.expiresIn });
return tokens;
}
/**
* Refresh access token using refresh token
*/
async refreshTokens(storageKey = 'default') {
const existing = await this.tokenStorage.load(storageKey);
if (!existing?.refreshToken) {
throw new Error('No refresh token available');
}
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: existing.refreshToken,
client_id: this.config.clientId,
});
if (this.config.clientSecret) {
params.set('client_secret', this.config.clientSecret);
}
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const error = await response.text();
this.logger.error('Token refresh failed', { status: response.status, error });
// Clear invalid tokens
await this.tokenStorage.delete(storageKey);
throw new Error(`Token refresh failed: ${response.status}`);
}
const data = (await response.json());
const tokens = this.parseTokenResponse(data);
// Preserve refresh token if not returned in response
if (!tokens.refreshToken && existing.refreshToken) {
tokens.refreshToken = existing.refreshToken;
}
await this.tokenStorage.save(storageKey, tokens);
this.logger.info('Token refresh successful');
this.emit('tokens:refreshed', { expiresIn: tokens.expiresIn });
return tokens;
}
/**
* Get valid access token (auto-refresh if expired)
*/
async getAccessToken(storageKey = 'default') {
const tokens = await this.tokenStorage.load(storageKey);
if (!tokens) {
return null;
}
// Check if token is expired (with 60 second buffer)
if (tokens.expiresAt && Date.now() >= tokens.expiresAt - 60000) {
if (tokens.refreshToken) {
try {
const refreshed = await this.refreshTokens(storageKey);
return refreshed.accessToken;
}
catch {
return null;
}
}
return null;
}
return tokens.accessToken;
}
/**
* Revoke tokens
*/
async revokeTokens(storageKey = 'default') {
await this.tokenStorage.delete(storageKey);
this.logger.info('Tokens revoked');
this.emit('tokens:revoked', { storageKey });
}
/**
* Check if authenticated
*/
async isAuthenticated(storageKey = 'default') {
const token = await this.getAccessToken(storageKey);
return token !== null;
}
/**
* Destroy manager and cleanup
*/
destroy() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = undefined;
}
this.pendingRequests.clear();
this.removeAllListeners();
}
/**
* Parse token response
*/
parseTokenResponse(data) {
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
tokenType: data.token_type,
expiresIn: data.expires_in,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
scope: data.scope,
};
}
/**
* Generate PKCE code verifier
*/
generateCodeVerifier() {
return this.generateRandomString(64);
}
/**
* Generate PKCE code challenge (S256)
*/
generateCodeChallenge(verifier) {
const hash = crypto.createHash('sha256').update(verifier).digest();
return this.base64UrlEncode(hash);
}
/**
* Generate random string
*/
generateRandomString(length) {
const bytes = crypto.randomBytes(length);
return this.base64UrlEncode(bytes).substring(0, length);
}
/**
* Base64 URL encode
*/
base64UrlEncode(buffer) {
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
/**
* Start cleanup of expired pending requests
*/
startCleanup() {
this.cleanupTimer = setInterval(() => {
const now = Date.now();
const expireTime = 10 * 60 * 1000; // 10 minutes
for (const [state, request] of this.pendingRequests) {
if (now - request.timestamp > expireTime) {
this.pendingRequests.delete(state);
this.logger.debug('Expired pending OAuth request', { state });
}
}
}, 60000);
}
}
/**
* Create OAuth manager
*/
export function createOAuthManager(logger, config) {
return new OAuthManager(logger, config);
}
/**
* OAuth middleware for Express/Connect
*/
export function oauthMiddleware(oauthManager, storageKey = 'default') {
return async (req, res, next) => {
const token = await oauthManager.getAccessToken(storageKey);
if (!token) {
res.status(401).json({
jsonrpc: '2.0',
id: null,
error: {
code: -32000,
message: 'Unauthorized - OAuth authentication required',
},
});
return;
}
req.oauthToken = token;
next();
};
}
/**
* Create GitHub OAuth provider config
*/
export function createGitHubOAuthConfig(clientId, clientSecret, redirectUri, scopes = ['read:user']) {
return {
clientId,
clientSecret,
redirectUri,
scopes,
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
usePKCE: false, // GitHub doesn't support PKCE for OAuth apps
};
}
/**
* Create Google OAuth provider config
*/
export function createGoogleOAuthConfig(clientId, clientSecret, redirectUri, scopes = ['openid', 'profile', 'email']) {
return {
clientId,
clientSecret,
redirectUri,
scopes,
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
usePKCE: true,
};
}
//# sourceMappingURL=oauth.js.map