/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import * as http from 'node:http';
import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
import { getErrorMessage } from '../utils/errors.js';
import { OAuthUtils } from './oauth-utils.js';
/**
 * Provider for handling OAuth authentication for MCP servers.
 */
export class MCPOAuthProvider {
    static REDIRECT_PORT = 7777;
    static REDIRECT_PATH = '/oauth/callback';
    static HTTP_OK = 200;
    static HTTP_REDIRECT = 302;
    /**
     * Register a client dynamically with the OAuth server.
     *
     * @param registrationUrl The client registration endpoint URL
     * @param config OAuth configuration
     * @returns The registered client information
     */
    static async registerClient(registrationUrl, config) {
        const redirectUri = config.redirectUri ||
            `http://localhost:${this.REDIRECT_PORT}${this.REDIRECT_PATH}`;
        const registrationRequest = {
            client_name: 'Gemini CLI (Google ADC)',
            redirect_uris: [redirectUri],
            grant_types: ['authorization_code', 'refresh_token'],
            response_types: ['code'],
            token_endpoint_auth_method: 'none', // Public client
            code_challenge_method: ['S256'],
            scope: config.scopes?.join(' ') || '',
        };
        const response = await fetch(registrationUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(registrationRequest),
        });
        if (!response.ok) {
            const errorText = await response.text();
            throw new Error(`Client registration failed: ${response.status} ${response.statusText} - ${errorText}`);
        }
        return (await response.json());
    }
    /**
     * Discover OAuth configuration from an MCP server URL.
     *
     * @param mcpServerUrl The MCP server URL
     * @returns OAuth configuration if discovered, null otherwise
     */
    static async discoverOAuthFromMCPServer(mcpServerUrl) {
        const baseUrl = OAuthUtils.extractBaseUrl(mcpServerUrl);
        return OAuthUtils.discoverOAuthConfig(baseUrl);
    }
    /**
     * Generate PKCE parameters for OAuth flow.
     *
     * @returns PKCE parameters including code verifier, challenge, and state
     */
    static generatePKCEParams() {
        // Generate code verifier (43-128 characters)
        const codeVerifier = crypto.randomBytes(32).toString('base64url');
        // Generate code challenge using SHA256
        const codeChallenge = crypto
            .createHash('sha256')
            .update(codeVerifier)
            .digest('base64url');
        // Generate state for CSRF protection
        const state = crypto.randomBytes(16).toString('base64url');
        return { codeVerifier, codeChallenge, state };
    }
    /**
     * Start a local HTTP server to handle OAuth callback.
     *
     * @param expectedState The state parameter to validate
     * @returns Promise that resolves with the authorization code
     */
    static async startCallbackServer(expectedState) {
        return new Promise((resolve, reject) => {
            const server = http.createServer(async (req, res) => {
                try {
                    const url = new URL(req.url, `http://localhost:${this.REDIRECT_PORT}`);
                    if (url.pathname !== this.REDIRECT_PATH) {
                        res.writeHead(404);
                        res.end('Not found');
                        return;
                    }
                    const code = url.searchParams.get('code');
                    const state = url.searchParams.get('state');
                    const error = url.searchParams.get('error');
                    if (error) {
                        res.writeHead(this.HTTP_OK, { 'Content-Type': 'text/html' });
                        res.end(`
              <html>
                <body>
                  <h1>Authentication Failed</h1>
                  <p>Error: ${error.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
                  <p>${(url.searchParams.get('error_description') || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
                  <p>You can close this window.</p>
                </body>
              </html>
            `);
                        server.close();
                        reject(new Error(`OAuth error: ${error}`));
                        return;
                    }
                    if (!code || !state) {
                        res.writeHead(400);
                        res.end('Missing code or state parameter');
                        return;
                    }
                    if (state !== expectedState) {
                        res.writeHead(400);
                        res.end('Invalid state parameter');
                        server.close();
                        reject(new Error('State mismatch - possible CSRF attack'));
                        return;
                    }
                    // Send success response to browser
                    res.writeHead(this.HTTP_OK, { 'Content-Type': 'text/html' });
                    res.end(`
            <html>
              <body>
                <h1>Authentication Successful!</h1>
                <p>You can close this window and return to Gemini CLI.</p>
                <script>window.close();</script>
              </body>
            </html>
          `);
                    server.close();
                    resolve({ code, state });
                }
                catch (error) {
                    server.close();
                    reject(error);
                }
            });
            server.on('error', reject);
            server.listen(this.REDIRECT_PORT, () => {
                console.log(`OAuth callback server listening on port ${this.REDIRECT_PORT}`);
            });
            // Timeout after 5 minutes
            setTimeout(() => {
                server.close();
                reject(new Error('OAuth callback timeout'));
            }, 5 * 60 * 1000);
        });
    }
    /**
     * Build the authorization URL with PKCE parameters.
     *
     * @param config OAuth configuration
     * @param pkceParams PKCE parameters
     * @param mcpServerUrl The MCP server URL to use as the resource parameter
     * @returns The authorization URL
     */
    static buildAuthorizationUrl(config, pkceParams, mcpServerUrl) {
        const redirectUri = config.redirectUri ||
            `http://localhost:${this.REDIRECT_PORT}${this.REDIRECT_PATH}`;
        const params = new URLSearchParams({
            client_id: config.clientId,
            response_type: 'code',
            redirect_uri: redirectUri,
            state: pkceParams.state,
            code_challenge: pkceParams.codeChallenge,
            code_challenge_method: 'S256',
        });
        if (config.scopes && config.scopes.length > 0) {
            params.append('scope', config.scopes.join(' '));
        }
        if (config.audiences && config.audiences.length > 0) {
            params.append('audience', config.audiences.join(' '));
        }
        // Add resource parameter for MCP OAuth spec compliance
        // Use the MCP server URL if provided, otherwise fall back to authorization URL
        const resourceUrl = mcpServerUrl || config.authorizationUrl;
        try {
            params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl));
        }
        catch (error) {
            throw new Error(`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`);
        }
        const url = new URL(config.authorizationUrl);
        params.forEach((value, key) => {
            url.searchParams.append(key, value);
        });
        return url.toString();
    }
    /**
     * Exchange authorization code for tokens.
     *
     * @param config OAuth configuration
     * @param code Authorization code
     * @param codeVerifier PKCE code verifier
     * @param mcpServerUrl The MCP server URL to use as the resource parameter
     * @returns The token response
     */
    static async exchangeCodeForToken(config, code, codeVerifier, mcpServerUrl) {
        const redirectUri = config.redirectUri ||
            `http://localhost:${this.REDIRECT_PORT}${this.REDIRECT_PATH}`;
        const params = new URLSearchParams({
            grant_type: 'authorization_code',
            code,
            redirect_uri: redirectUri,
            code_verifier: codeVerifier,
            client_id: config.clientId,
        });
        if (config.clientSecret) {
            params.append('client_secret', config.clientSecret);
        }
        if (config.audiences && config.audiences.length > 0) {
            params.append('audience', config.audiences.join(' '));
        }
        // Add resource parameter for MCP OAuth spec compliance
        // Use the MCP server URL if provided, otherwise fall back to token URL
        const resourceUrl = mcpServerUrl || config.tokenUrl;
        try {
            params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl));
        }
        catch (error) {
            throw new Error(`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`);
        }
        const response = await fetch(config.tokenUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: params.toString(),
        });
        if (!response.ok) {
            const errorText = await response.text();
            throw new Error(`Token exchange failed: ${response.status} - ${errorText}`);
        }
        return (await response.json());
    }
    /**
     * Refresh an access token using a refresh token.
     *
     * @param config OAuth configuration
     * @param refreshToken The refresh token
     * @param tokenUrl The token endpoint URL
     * @param mcpServerUrl The MCP server URL to use as the resource parameter
     * @returns The new token response
     */
    static async refreshAccessToken(config, refreshToken, tokenUrl, mcpServerUrl) {
        const params = new URLSearchParams({
            grant_type: 'refresh_token',
            refresh_token: refreshToken,
            client_id: config.clientId,
        });
        if (config.clientSecret) {
            params.append('client_secret', config.clientSecret);
        }
        if (config.scopes && config.scopes.length > 0) {
            params.append('scope', config.scopes.join(' '));
        }
        if (config.audiences && config.audiences.length > 0) {
            params.append('audience', config.audiences.join(' '));
        }
        // Add resource parameter for MCP OAuth spec compliance
        // Use the MCP server URL if provided, otherwise fall back to token URL
        const resourceUrl = mcpServerUrl || tokenUrl;
        try {
            params.append('resource', OAuthUtils.buildResourceParameter(resourceUrl));
        }
        catch (error) {
            throw new Error(`Invalid resource URL: "${resourceUrl}". ${getErrorMessage(error)}`);
        }
        const response = await fetch(tokenUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: params.toString(),
        });
        if (!response.ok) {
            const errorText = await response.text();
            throw new Error(`Token refresh failed: ${response.status} - ${errorText}`);
        }
        return (await response.json());
    }
    /**
     * Perform the full OAuth authorization code flow with PKCE.
     *
     * @param serverName The name of the MCP server
     * @param config OAuth configuration
     * @param mcpServerUrl Optional MCP server URL for OAuth discovery
     * @returns The obtained OAuth token
     */
    static async authenticate(serverName, config, mcpServerUrl) {
        // If no authorization URL is provided, try to discover OAuth configuration
        if (!config.authorizationUrl && mcpServerUrl) {
            console.log('No authorization URL provided, attempting OAuth discovery...');
            // For SSE URLs, first check if authentication is required
            if (OAuthUtils.isSSEEndpoint(mcpServerUrl)) {
                try {
                    const response = await fetch(mcpServerUrl, {
                        method: 'HEAD',
                        headers: {
                            Accept: 'text/event-stream',
                        },
                    });
                    if (response.status === 401 || response.status === 307) {
                        const wwwAuthenticate = response.headers.get('www-authenticate');
                        if (wwwAuthenticate) {
                            const discoveredConfig = await OAuthUtils.discoverOAuthFromWWWAuthenticate(wwwAuthenticate);
                            if (discoveredConfig) {
                                config = {
                                    ...config,
                                    ...discoveredConfig,
                                    scopes: discoveredConfig.scopes || config.scopes || [],
                                };
                            }
                        }
                    }
                }
                catch (error) {
                    console.debug(`Failed to check SSE endpoint for authentication requirements: ${getErrorMessage(error)}`);
                }
            }
            // If we still don't have OAuth config, try the standard discovery
            if (!config.authorizationUrl) {
                const discoveredConfig = await this.discoverOAuthFromMCPServer(mcpServerUrl);
                if (discoveredConfig) {
                    config = { ...config, ...discoveredConfig };
                    console.log('OAuth configuration discovered successfully');
                }
                else {
                    throw new Error('Failed to discover OAuth configuration from MCP server');
                }
            }
        }
        // If no client ID is provided, try dynamic client registration
        if (!config.clientId) {
            // Extract server URL from authorization URL
            if (!config.authorizationUrl) {
                throw new Error('Cannot perform dynamic registration without authorization URL');
            }
            const authUrl = new URL(config.authorizationUrl);
            const serverUrl = `${authUrl.protocol}//${authUrl.host}`;
            console.log('No client ID provided, attempting dynamic client registration...');
            // Get the authorization server metadata for registration
            const authServerMetadataUrl = new URL('/.well-known/oauth-authorization-server', serverUrl).toString();
            const authServerMetadata = await OAuthUtils.fetchAuthorizationServerMetadata(authServerMetadataUrl);
            if (!authServerMetadata) {
                throw new Error('Failed to fetch authorization server metadata for client registration');
            }
            // Register client if registration endpoint is available
            if (authServerMetadata.registration_endpoint) {
                const clientRegistration = await this.registerClient(authServerMetadata.registration_endpoint, config);
                config.clientId = clientRegistration.client_id;
                if (clientRegistration.client_secret) {
                    config.clientSecret = clientRegistration.client_secret;
                }
                console.log('Dynamic client registration successful');
            }
            else {
                throw new Error('No client ID provided and dynamic registration not supported');
            }
        }
        // Validate configuration
        if (!config.clientId || !config.authorizationUrl || !config.tokenUrl) {
            throw new Error('Missing required OAuth configuration after discovery and registration');
        }
        // Generate PKCE parameters
        const pkceParams = this.generatePKCEParams();
        // Build authorization URL
        const authUrl = this.buildAuthorizationUrl(config, pkceParams, mcpServerUrl);
        console.log('\nOpening browser for OAuth authentication...');
        console.log('If the browser does not open, please visit:');
        console.log('');
        // Get terminal width or default to 80
        const terminalWidth = process.stdout.columns || 80;
        const separatorLength = Math.min(terminalWidth - 2, 80);
        const separator = '━'.repeat(separatorLength);
        console.log(separator);
        console.log('COPY THE ENTIRE URL BELOW (select all text between the lines):');
        console.log(separator);
        console.log(authUrl);
        console.log(separator);
        console.log('');
        console.log('💡 TIP: Triple-click to select the entire URL, then copy and paste it into your browser.');
        console.log('⚠️  Make sure to copy the COMPLETE URL - it may wrap across multiple lines.');
        console.log('');
        // Start callback server
        const callbackPromise = this.startCallbackServer(pkceParams.state);
        // Open browser securely
        try {
            await openBrowserSecurely(authUrl);
        }
        catch (error) {
            console.warn('Failed to open browser automatically:', getErrorMessage(error));
        }
        // Wait for callback
        const { code } = await callbackPromise;
        console.log('\nAuthorization code received, exchanging for tokens...');
        // Exchange code for tokens
        const tokenResponse = await this.exchangeCodeForToken(config, code, pkceParams.codeVerifier, mcpServerUrl);
        // Convert to our token format
        const token = {
            accessToken: tokenResponse.access_token,
            tokenType: tokenResponse.token_type,
            refreshToken: tokenResponse.refresh_token,
            scope: tokenResponse.scope,
        };
        if (tokenResponse.expires_in) {
            token.expiresAt = Date.now() + tokenResponse.expires_in * 1000;
        }
        // Save token
        try {
            await MCPOAuthTokenStorage.saveToken(serverName, token, config.clientId, config.tokenUrl, mcpServerUrl);
            console.log('Authentication successful! Token saved.');
            // Verify token was saved
            const savedToken = await MCPOAuthTokenStorage.getToken(serverName);
            if (savedToken) {
                console.log(`Token verification successful: ${savedToken.token.accessToken.substring(0, 20)}...`);
            }
            else {
                console.error('Token verification failed: token not found after save');
            }
        }
        catch (saveError) {
            console.error(`Failed to save token: ${getErrorMessage(saveError)}`);
            throw saveError;
        }
        return token;
    }
    /**
     * Get a valid access token for an MCP server, refreshing if necessary.
     *
     * @param serverName The name of the MCP server
     * @param config OAuth configuration
     * @returns A valid access token or null if not authenticated
     */
    static async getValidToken(serverName, config) {
        console.debug(`Getting valid token for server: ${serverName}`);
        const credentials = await MCPOAuthTokenStorage.getToken(serverName);
        if (!credentials) {
            console.debug(`No credentials found for server: ${serverName}`);
            return null;
        }
        const { token } = credentials;
        console.debug(`Found token for server: ${serverName}, expired: ${MCPOAuthTokenStorage.isTokenExpired(token)}`);
        // Check if token is expired
        if (!MCPOAuthTokenStorage.isTokenExpired(token)) {
            console.debug(`Returning valid token for server: ${serverName}`);
            return token.accessToken;
        }
        // Try to refresh if we have a refresh token
        if (token.refreshToken && config.clientId && credentials.tokenUrl) {
            try {
                console.log(`Refreshing expired token for MCP server: ${serverName}`);
                const newTokenResponse = await this.refreshAccessToken(config, token.refreshToken, credentials.tokenUrl, credentials.mcpServerUrl);
                // Update stored token
                const newToken = {
                    accessToken: newTokenResponse.access_token,
                    tokenType: newTokenResponse.token_type,
                    refreshToken: newTokenResponse.refresh_token || token.refreshToken,
                    scope: newTokenResponse.scope || token.scope,
                };
                if (newTokenResponse.expires_in) {
                    newToken.expiresAt = Date.now() + newTokenResponse.expires_in * 1000;
                }
                await MCPOAuthTokenStorage.saveToken(serverName, newToken, config.clientId, credentials.tokenUrl, credentials.mcpServerUrl);
                return newToken.accessToken;
            }
            catch (error) {
                console.error(`Failed to refresh token: ${getErrorMessage(error)}`);
                // Remove invalid token
                await MCPOAuthTokenStorage.removeToken(serverName);
            }
        }
        return null;
    }
}
//# sourceMappingURL=oauth-provider.js.map