Advertisement

← vibesdk

worker/services/oauth/base.ts

6.7 KB Download file
/**
 * Base OAuth Provider
 * Abstract base class for OAuth provider implementations
 */

import { OAuthUserInfo } from '../../types/auth-types';
import { createLogger } from '../../logger';
import { base64url } from '../../utils/cryptoUtils';

const logger = createLogger('OAuthProvider');


/**
 * OAuth tokens returned from providers
 */
export interface OAuthTokens {
    accessToken: string;
    refreshToken?: string;
    expiresIn?: number;
    tokenType: string;
}

/**
 * How client credentials are presented when calling the token endpoint.
 * - `body`: include `client_id`/`client_secret` in the form body (RFC 6749 default).
 * - `basic`: send credentials via HTTP Basic Authorization header
 *   (required by some providers, e.g. Cloudflare's OAuth).
 */
export type OAuthClientAuthMethod = 'body' | 'basic';

/**
 * Raw token endpoint response shape shared by code-exchange and refresh.
 */
interface RawTokenResponse {
    access_token: string;
    refresh_token?: string;
    expires_in?: number;
    token_type?: string;
}

/**
 * Base OAuth Provider class
 */
export abstract class BaseOAuthProvider {
    protected abstract readonly provider: string;
    protected abstract readonly authorizationUrl: string;
    protected abstract readonly tokenUrl: string;
    protected abstract readonly userInfoUrl: string;
    protected abstract readonly scopes: string[];
    /** Providers may override to use HTTP Basic auth for token requests. */
    protected readonly clientAuthMethod: OAuthClientAuthMethod = 'body';

    constructor(
        protected clientId: string,
        protected clientSecret: string,
        protected redirectUri: string
    ) {}
    
    /**
     * Get authorization URL
     */
    async getAuthorizationUrl(state: string, codeVerifier?: string): Promise<string> {
        const params = new URLSearchParams({
            client_id: this.clientId,
            redirect_uri: this.redirectUri,
            response_type: 'code',
            scope: this.scopes.join(' '),
            state,
            access_type: 'offline', // Request refresh token
            prompt: 'consent' // Force consent to get refresh token
        });
        
        // Add PKCE challenge if provided
        if (codeVerifier) {
            const challenge = await this.generateCodeChallenge(codeVerifier);
            params.append('code_challenge', challenge);
            params.append('code_challenge_method', 'S256');
        }
        
        return `${this.authorizationUrl}?${params.toString()}`;
    }
    
    /**
     * POST to the token endpoint with the configured client-auth method and parse
     * the standard OAuth token response.
     */
    protected async postTokenRequest(
        params: URLSearchParams,
        context: 'exchange' | 'refresh'
    ): Promise<RawTokenResponse> {
        const headers: Record<string, string> = {
            'Content-Type': 'application/x-www-form-urlencoded',
            Accept: 'application/json',
        };

        if (this.clientAuthMethod === 'basic') {
            headers.Authorization = `Basic ${btoa(`${this.clientId}:${this.clientSecret}`)}`;
        } else {
            params.set('client_id', this.clientId);
            params.set('client_secret', this.clientSecret);
        }

        const response = await fetch(this.tokenUrl, {
            method: 'POST',
            headers,
            body: params.toString(),
        });

        if (!response.ok) {
            const error = await response.text();
            logger.error(`Token ${context} failed`, { provider: this.provider, error });
            throw new Error(`Token ${context} failed: ${error}`);
        }

        return (await response.json()) as RawTokenResponse;
    }

    /**
     * Exchange authorization code for tokens
     */
    async exchangeCodeForTokens(
        code: string,
        codeVerifier?: string
    ): Promise<OAuthTokens> {
        try {
            const params = new URLSearchParams({
                grant_type: 'authorization_code',
                code,
                redirect_uri: this.redirectUri,
            });
            if (codeVerifier) params.append('code_verifier', codeVerifier);

            const data = await this.postTokenRequest(params, 'exchange');
            return {
                accessToken: data.access_token,
                refreshToken: data.refresh_token,
                expiresIn: data.expires_in,
                tokenType: data.token_type || 'Bearer',
            };
        } catch (error) {
            logger.error('Error exchanging code for tokens', error);
            throw error;
        }
    }

    /**
     * Get user info from provider
     */
    abstract getUserInfo(accessToken: string): Promise<OAuthUserInfo>;

    /**
     * Refresh access token
     */
    async refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
        try {
            const params = new URLSearchParams({
                grant_type: 'refresh_token',
                refresh_token: refreshToken,
            });
            const data = await this.postTokenRequest(params, 'refresh');
            return {
                accessToken: data.access_token,
                // Some providers don't return a new refresh token on rotation.
                refreshToken: data.refresh_token || refreshToken,
                expiresIn: data.expires_in,
                tokenType: data.token_type || 'Bearer',
            };
        } catch (error) {
            logger.error('Error refreshing access token', error);
            throw error;
        }
    }
    
    /**
     * Generate PKCE code challenge
     */
    protected async generateCodeChallenge(verifier: string): Promise<string> {
        const encoder = new TextEncoder();
        const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(verifier));
        return base64url(new Uint8Array(hashBuffer));
    }
    
    /**
     * Generate PKCE code verifier (RFC 7636: ALPHA / DIGIT / "-" / "." / "_" / "~").
     * Uses rejection sampling to avoid modulo bias.
     */
    static generateCodeVerifier(): string {
        const length = 64;
        const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
        const maxUnbiased = Math.floor(256 / charset.length) * charset.length; // 198 for 66 chars
        const out = new Array<string>(length);
        const buf = new Uint8Array(1);
        for (let i = 0; i < length; i++) {
            let v: number;
            do {
                crypto.getRandomValues(buf);
                v = buf[0];
            } while (v >= maxUnbiased);
            out[i] = charset[v % charset.length];
        }
        return out.join('');
    }
}