Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
oauth-auth-code-flow.ts11 kB
/** * OAuth 2.0 Authorization Code Grant with PKCE * RFC 6749 (OAuth 2.0) + RFC 7636 (PKCE) * * Used for CLI tools that can open a browser and listen on localhost. * More secure than device flow for local environments. */ import { createServer, Server, IncomingMessage, ServerResponse } from 'http'; import { randomBytes, createHash } from 'crypto'; import { logger } from '../utils/logger.js'; export interface AuthCodeFlowConfig { clientId: string; clientSecret?: string; // Optional for public clients authorizationUrl: string; // Authorization endpoint tokenUrl: string; // Token endpoint scopes?: string[]; redirectPort?: number; // Optional specific port (default: auto-select 3000-9000) } export interface TokenResponse { access_token: string; refresh_token?: string; expires_in: number; token_type: string; scope?: string; } export class AuthCodeFlowAuthenticator { private server?: Server; private codeVerifier: string; private codeChallenge: string; private state: string; private redirectUri?: string; constructor(private config: AuthCodeFlowConfig) { // Generate PKCE values this.codeVerifier = this.generateCodeVerifier(); this.codeChallenge = this.generateCodeChallenge(this.codeVerifier); this.state = this.generateState(); } /** * Complete OAuth Authorization Code Flow with PKCE */ async authenticate(): Promise<TokenResponse> { logger.debug('Starting OAuth Authorization Code Flow with PKCE...'); try { // Step 1: Start temporary localhost server const port = await this.startCallbackServer(); this.redirectUri = `http://localhost:${port}/callback`; // Step 2: Build authorization URL and open browser const authUrl = this.buildAuthorizationUrl(); this.displayUserInstructions(authUrl); // Step 3: Wait for callback with authorization code const code = await this.waitForCallback(); // Step 4: Exchange code for token const token = await this.exchangeCodeForToken(code); logger.debug('OAuth Authorization Code Flow completed successfully'); return token; } finally { // Always clean up server this.stopCallbackServer(); } } /** * Generate cryptographically random code verifier */ private generateCodeVerifier(): string { return randomBytes(32) .toString('base64url'); } /** * Generate code challenge from verifier using SHA256 */ private generateCodeChallenge(verifier: string): string { return createHash('sha256') .update(verifier) .digest('base64url'); } /** * Generate random state for CSRF protection */ private generateState(): string { return randomBytes(16).toString('hex'); } /** * Start temporary HTTP server to receive OAuth callback */ private startCallbackServer(): Promise<number> { return new Promise((resolve, reject) => { const startPort = this.config.redirectPort || 3000; const maxPort = this.config.redirectPort || 9000; let currentPort = startPort; const tryPort = () => { this.server = createServer(); this.server.on('error', (err: any) => { if (err.code === 'EADDRINUSE' && currentPort < maxPort) { currentPort++; tryPort(); } else { reject(new Error(`Failed to start callback server: ${err.message}`)); } }); this.server.listen(currentPort, 'localhost', () => { logger.debug(`Callback server listening on http://localhost:${currentPort}`); resolve(currentPort); }); }; tryPort(); }); } /** * Stop the callback server */ private stopCallbackServer(): void { if (this.server) { this.server.close(); this.server = undefined; logger.debug('Callback server stopped'); } } /** * Build authorization URL with PKCE parameters */ private buildAuthorizationUrl(): string { const params = new URLSearchParams({ client_id: this.config.clientId, response_type: 'code', redirect_uri: this.redirectUri!, state: this.state, code_challenge: this.codeChallenge, code_challenge_method: 'S256' }); if (this.config.scopes && this.config.scopes.length > 0) { params.set('scope', this.config.scopes.join(' ')); } return `${this.config.authorizationUrl}?${params.toString()}`; } /** * Display instructions to user */ private displayUserInstructions(authUrl: string): void { console.log('\n┌─────────────────────────────────────────┐'); console.log('│ 🔐 OAuth Authentication Required │'); console.log('└─────────────────────────────────────────┘\n'); console.log('🌐 Opening browser for authentication...\n'); console.log(' If browser doesn\'t open, visit this URL:\n'); console.log(` ${authUrl}\n`); console.log('⏳ Waiting for authorization...'); console.log(' (Press Ctrl+C to cancel)\n'); // Try to open browser this.openBrowser(authUrl); } /** * Open browser to authorization URL */ private openBrowser(url: string): void { const start = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; import('child_process').then(({ exec }) => { exec(`${start} "${url}"`, (error) => { if (error) { logger.debug(`Failed to open browser: ${error.message}`); } }); }); } /** * Wait for OAuth callback with authorization code */ private waitForCallback(): Promise<string> { return new Promise((resolve, reject) => { let cancelled = false; // Set up Ctrl+C handler const stdin = process.stdin; const wasRaw = stdin.isRaw; if (stdin.isTTY) { stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8'); } const onKeypress = (char: string) => { if (char === '\u0003') { // Ctrl+C cancelled = true; cleanup(); reject(new Error('Authentication cancelled by user')); } }; stdin.on('data', onKeypress); const cleanup = () => { stdin.removeListener('data', onKeypress); if (stdin.isTTY) { stdin.setRawMode(wasRaw || false); stdin.pause(); } }; // Handle incoming HTTP requests this.server!.on('request', (req: IncomingMessage, res: ServerResponse) => { const url = new URL(req.url!, `http://localhost`); if (url.pathname === '/callback') { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); const errorDescription = url.searchParams.get('error_description'); // Handle error from OAuth provider if (error) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: sans-serif; text-align: center; padding: 50px;"> <h1>❌ Authentication Failed</h1> <p>${errorDescription || error}</p> <p>You can close this window.</p> </body> </html> `); cleanup(); reject(new Error(`OAuth error: ${error} - ${errorDescription || 'Unknown error'}`)); return; } // Validate state (CSRF protection) if (state !== this.state) { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: sans-serif; text-align: center; padding: 50px;"> <h1>❌ Invalid State</h1> <p>CSRF protection failed. Please try again.</p> <p>You can close this window.</p> </body> </html> `); cleanup(); reject(new Error('Invalid state parameter (CSRF protection)')); return; } // Success if (code) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: sans-serif; text-align: center; padding: 50px;"> <h1>✅ Authentication Successful!</h1> <p>You can close this window and return to the terminal.</p> </body> </html> `); cleanup(); console.log('\n✅ Authorization received, exchanging code for token...\n'); resolve(code); } else { res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(` <html> <body style="font-family: sans-serif; text-align: center; padding: 50px;"> <h1>❌ Missing Code</h1> <p>Authorization code not received. Please try again.</p> <p>You can close this window.</p> </body> </html> `); cleanup(); reject(new Error('Authorization code not received')); } } else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); } }); // Timeout after 5 minutes setTimeout(() => { if (!cancelled) { cleanup(); reject(new Error('Authentication timed out after 5 minutes')); } }, 5 * 60 * 1000); }); } /** * Exchange authorization code for access token */ private async exchangeCodeForToken(code: string): Promise<TokenResponse> { const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: this.redirectUri!, client_id: this.config.clientId, code_verifier: this.codeVerifier }); // Add client secret if provided (for confidential clients) if (this.config.clientSecret) { params.set('client_secret', this.config.clientSecret); } logger.debug(`Exchanging code for token at ${this.config.tokenUrl}`); const response = await fetch(this.config.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }); if (!response.ok) { const error = await response.text(); throw new Error(`Token exchange failed: ${response.status} ${error}`); } const data: TokenResponse = await response.json(); if (!data.access_token) { throw new Error('Token response missing access_token'); } console.log('✅ Authentication successful!\n'); return data; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/portel-dev/ncp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server