318 lines
10 KiB
JavaScript
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
|