diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 16b4993d..9201bac1 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,25 +1,20 @@ import { BrowserWindow } from 'electron'; -import { randomBytes } from 'crypto'; import { createAuthServer } from './auth-server.js'; -import { generateCodeVerifier, generateCodeChallenge } from '@x/core/dist/auth/pkce.js'; -import { OAuthService } from '@x/core/dist/auth/oauth.js'; +import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; +import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js'; -import { discoverAuthorizationServer, createStaticMetadata, AuthorizationServerMetadata } from '@x/core/dist/auth/discovery.js'; import container from '@x/core/dist/di/container.js'; import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; -// Store active OAuth flows (state -> { codeVerifier, provider }) -const activeFlows = new Map(); - -/** - * Generate a random state string for CSRF protection - */ -function generateState(): string { - return randomBytes(32).toString('hex'); -} +// Store active OAuth flows (state -> { codeVerifier, provider, config }) +const activeFlows = new Map(); /** * Get OAuth repository from DI container @@ -36,73 +31,63 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { } /** - * Discover or get provider metadata + * Get or create OAuth configuration for a provider */ -async function getProviderMetadata(provider: string): Promise { +async function getProviderConfiguration(provider: string): Promise { const config = getProviderConfig(provider); if (config.discovery.mode === 'issuer') { - // Discover endpoints from well-known - console.log(`[OAuth] Discovering metadata for ${provider} from issuer: ${config.discovery.issuer}`); - return await discoverAuthorizationServer(config.discovery.issuer); + if (config.client.mode === 'static') { + // Discover endpoints, use static client ID + console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`); + return await oauthClient.discoverConfiguration( + config.discovery.issuer, + config.client.clientId + ); + } else { + // DCR mode - check for existing registration or register new + console.log(`[OAuth] ${provider}: Discovery from issuer with DCR`); + const clientRepo = getClientRegistrationRepo(); + const existingRegistration = await clientRepo.getClientRegistration(provider); + + if (existingRegistration) { + console.log(`[OAuth] ${provider}: Using existing DCR registration`); + return await oauthClient.discoverConfiguration( + config.discovery.issuer, + existingRegistration.client_id + ); + } + + // Register new client + const scopes = config.scopes || []; + const { config: oauthConfig, registration } = await oauthClient.registerClient( + config.discovery.issuer, + [REDIRECT_URI], + scopes + ); + + // Save registration for future use + await clientRepo.saveClientRegistration(provider, registration); + console.log(`[OAuth] ${provider}: DCR registration saved`); + + return oauthConfig; + } } else { - // Use static endpoints - console.log(`[OAuth] Using static metadata for ${provider} (no discovery)`); - return createStaticMetadata( + // Static endpoints mode + if (config.client.mode !== 'static') { + throw new Error('DCR requires discovery mode "issuer", not "static"'); + } + + console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`); + return oauthClient.createStaticConfiguration( config.discovery.authorizationEndpoint, config.discovery.tokenEndpoint, + config.client.clientId, config.discovery.revocationEndpoint ); } } -/** - * Get or register client ID based on provider configuration - */ -async function getOrRegisterClient( - provider: string, - metadata: AuthorizationServerMetadata, - scopes: string[] -): Promise { - const config = getProviderConfig(provider); - const clientRepo = getClientRegistrationRepo(); - - if (config.client.mode === 'static') { - // Use static client ID - if (!config.client.clientId) { - throw new Error('Static client mode requires clientId in provider configuration'); - } - console.log(`[OAuth] Using static client ID for ${provider}`); - return config.client.clientId; - } else { - // DCR mode - check if registration endpoint exists - const registrationEndpoint = config.client.registrationEndpoint || metadata.registration_endpoint; - - if (!registrationEndpoint) { - throw new Error('Provider does not support Dynamic Client Registration (no registration_endpoint found)'); - } - - // Check for existing registered client - const existingRegistration = await clientRepo.getClientRegistration(provider); - if (existingRegistration) { - console.log(`[OAuth] Using existing DCR client registration for ${provider}`); - return existingRegistration.client_id; - } - - // Register new client - create temporary service just for registration - // We need to pass a dummy clientId, but it won't be used for registration - console.log(`[OAuth] Registering new client via DCR for ${provider}...`); - const tempService = new OAuthService(metadata, 'temp', scopes); - const registration = await tempService.registerClient([REDIRECT_URI], scopes); - - // Save registration - await clientRepo.saveClientRegistration(provider, registration); - console.log(`[OAuth] DCR registration successful for ${provider}, client_id: ${registration.client_id}`); - - return registration.client_id; - } -} - /** * Initiate OAuth flow for a provider */ @@ -110,32 +95,20 @@ export async function connectProvider(provider: string): Promise<{ success: bool try { console.log(`[OAuth] Starting connection flow for ${provider}...`); const oauthRepo = getOAuthRepo(); - const config = getProviderConfig(provider); + const providerConfig = getProviderConfig(provider); - // Validate configuration combinations - if (config.discovery.mode === 'static' && config.client.mode === 'dcr') { - throw new Error('DCR requires discovery mode "issuer", not "static"'); - } - - // Get provider metadata (discover or use static) - const metadata = await getProviderMetadata(provider); - - // Get scopes from config or use empty array - const scopes = config.scopes || []; - - // Get or register client ID - const clientId = await getOrRegisterClient(provider, metadata, scopes); - - // Create OAuth service with metadata and client ID - const oauthService = new OAuthService(metadata, clientId, scopes); + // Get or create OAuth configuration + const config = await getProviderConfiguration(provider); // Generate PKCE codes - const codeVerifier = generateCodeVerifier(); - const codeChallenge = generateCodeChallenge(codeVerifier); - const state = generateState(); + const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE(); + const state = oauthClient.generateState(); + + // Get scopes from config + const scopes = providerConfig.scopes || []; // Store flow state - activeFlows.set(state, { codeVerifier, provider }); + activeFlows.set(state, { codeVerifier, provider, config }); // Create callback server const { server } = await createAuthServer(8080, async (code, receivedState) => { @@ -150,12 +123,16 @@ export async function connectProvider(provider: string): Promise<{ success: bool } try { + // Build callback URL for token exchange + const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`); + // Exchange code for tokens console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); - const tokens = await oauthService.exchangeCodeForTokens( - code, + const tokens = await oauthClient.exchangeCodeForTokens( + flow.config, + callbackUrl, flow.codeVerifier, - REDIRECT_URI + state ); // Save tokens @@ -172,7 +149,12 @@ export async function connectProvider(provider: string): Promise<{ success: bool }); // Build authorization URL - const authUrl = oauthService.buildAuthorizationUrl(codeChallenge, state, REDIRECT_URI); + const authUrl = oauthClient.buildAuthorizationUrl(config, { + redirectUri: REDIRECT_URI, + scope: scopes.join(' '), + codeChallenge, + state, + }); // Open browser window const authWindow = new BrowserWindow({ @@ -185,7 +167,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool }, }); - authWindow.loadURL(authUrl); + authWindow.loadURL(authUrl.toString()); // Clean up on window close authWindow.on('closed', () => { @@ -239,34 +221,26 @@ export async function isConnected(provider: string): Promise<{ isConnected: bool export async function getAccessToken(provider: string): Promise { try { const oauthRepo = getOAuthRepo(); - const config = getProviderConfig(provider); let tokens = await oauthRepo.getTokens(provider); if (!tokens) { return null; } - // Get provider metadata - const metadata = await getProviderMetadata(provider); - - // Get client ID (static or registered) - const clientId = await getOrRegisterClient(provider, metadata, config.scopes || []); - - // Create OAuth service - const scopes = config.scopes || []; - const oauthService = new OAuthService(metadata, clientId, scopes); - // Check if token needs refresh - if (oauthService.isTokenExpired(tokens)) { + if (oauthClient.isTokenExpired(tokens)) { if (!tokens.refresh_token) { // No refresh token, need to reconnect return null; } try { + // Get configuration for refresh + const config = await getProviderConfiguration(provider); + // Refresh token, preserving existing scopes const existingScopes = tokens.scopes; - tokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes); + tokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes); await oauthRepo.saveTokens(provider, tokens); } catch (error) { console.error('Token refresh failed:', error); @@ -301,4 +275,3 @@ export async function getConnectedProviders(): Promise<{ providers: string[] }> export function listProviders(): { providers: string[] } { return { providers: getAvailableProviders() }; } - diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 157db9d0..2429204d 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -25,6 +25,7 @@ "googleapis": "^169.0.0", "node-html-markdown": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4", + "openid-client": "^6.8.1", "yaml": "^2.8.2", "zod": "^4.2.1" }, diff --git a/apps/x/packages/core/src/auth/discovery.ts b/apps/x/packages/core/src/auth/discovery.ts deleted file mode 100644 index 582b5812..00000000 --- a/apps/x/packages/core/src/auth/discovery.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { z } from 'zod'; - -/** - * OAuth 2.0 Authorization Server Metadata (RFC 8414) - * Also compatible with OpenID Connect Discovery - */ -export const AuthorizationServerMetadataSchema = z.object({ - issuer: z.url(), - authorization_endpoint: z.url(), - token_endpoint: z.url(), - registration_endpoint: z.url().optional(), // Indicates DCR support - revocation_endpoint: z.url().optional(), - jwks_uri: z.url().optional(), - scopes_supported: z.array(z.string()).optional(), - response_types_supported: z.array(z.string()).optional(), - grant_types_supported: z.array(z.string()).optional(), - token_endpoint_auth_methods_supported: z.array(z.string()).optional(), - code_challenge_methods_supported: z.array(z.string()).optional(), // For PKCE -}); - -export type AuthorizationServerMetadata = z.infer; - -/** - * Discover OAuth 2.0 authorization server metadata - * Tries RFC 8414 endpoint first, falls back to OIDC Discovery - */ -export async function discoverAuthorizationServer(issuer: string): Promise { - // Remove trailing slash from issuer - const baseUrl = issuer.replace(/\/$/, ''); - - // Try RFC 8414 endpoint first - const rfc8414Url = `${baseUrl}/.well-known/oauth-authorization-server`; - try { - const response = await fetch(rfc8414Url); - if (response.ok) { - console.log(`[OAuth Discovery] Using RFC 8414 endpoint for ${issuer}`); - const metadata = await response.json(); - return AuthorizationServerMetadataSchema.parse(metadata); - } - } catch { - // Fall through to OIDC Discovery - } - - // Fallback to OpenID Connect Discovery - const oidcUrl = `${baseUrl}/.well-known/openid-configuration`; - try { - console.log(`[OAuth Discovery] Falling back to OIDC discovery for ${issuer}`); - const response = await fetch(oidcUrl); - if (!response.ok) { - throw new Error(`Failed to fetch discovery document: ${response.status} ${response.statusText}`); - } - const metadata = await response.json(); - return AuthorizationServerMetadataSchema.parse(metadata); - } catch (error) { - if (error instanceof z.ZodError) { - const errorMessages = error.issues.map(issue => { - const path = issue.path.length > 0 ? issue.path.join('.') : 'root'; - return `${path}: ${issue.message}`; - }).join(', '); - throw new Error( - `Invalid authorization server metadata from ${issuer}: ${errorMessages}` - ); - } - throw new Error( - `Failed to discover authorization server metadata from ${issuer}: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -/** - * Construct metadata from static endpoints - */ -export function createStaticMetadata( - authorizationEndpoint: string, - tokenEndpoint: string, - revocationEndpoint?: string -): AuthorizationServerMetadata { - console.log(`[OAuth Discovery] Using static endpoints (no discovery)`); - return AuthorizationServerMetadataSchema.parse({ - issuer: new URL(authorizationEndpoint).origin, - authorization_endpoint: authorizationEndpoint, - token_endpoint: tokenEndpoint, - revocation_endpoint: revocationEndpoint, - }); -} - diff --git a/apps/x/packages/core/src/auth/oauth-client.ts b/apps/x/packages/core/src/auth/oauth-client.ts new file mode 100644 index 00000000..f41ee365 --- /dev/null +++ b/apps/x/packages/core/src/auth/oauth-client.ts @@ -0,0 +1,246 @@ +import * as client from 'openid-client'; +import { OAuthTokens, ClientRegistrationResponse } from './types.js'; + +/** + * Cached configurations per provider (issuer:clientId -> Configuration) + */ +const configCache = new Map(); + +/** + * Helper to convert openid-client token response to our OAuthTokens type + */ +function toOAuthTokens(response: client.TokenEndpointResponse): OAuthTokens { + const accessToken = response.access_token; + const refreshToken = response.refresh_token ?? null; + + // Calculate expires_at from expires_in + const expiresIn = response.expires_in ?? 3600; + const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; + + // Parse scopes from space-separated string + let scopes: string[] | undefined; + if (response.scope) { + scopes = response.scope.split(' ').filter(s => s.length > 0); + } + + return OAuthTokens.parse({ + access_token: accessToken, + refresh_token: refreshToken, + expires_at: expiresAt, + token_type: 'Bearer', + scopes, + }); +} + +/** + * Discover authorization server metadata and create configuration + */ +export async function discoverConfiguration( + issuerUrl: string, + clientId: string +): Promise { + const cacheKey = `${issuerUrl}:${clientId}`; + + const cached = configCache.get(cacheKey); + if (cached) { + console.log(`[OAuth] Using cached configuration for ${issuerUrl}`); + return cached; + } + + console.log(`[OAuth] Discovering authorization server metadata for ${issuerUrl}...`); + const config = await client.discovery( + new URL(issuerUrl), + clientId, + undefined, // no client_secret (PKCE flow) + client.None() // PKCE doesn't require client authentication + ); + + configCache.set(cacheKey, config); + console.log(`[OAuth] Discovery complete for ${issuerUrl}`); + return config; +} + +/** + * Create configuration from static endpoints (no discovery) + */ +export function createStaticConfiguration( + authorizationEndpoint: string, + tokenEndpoint: string, + clientId: string, + revocationEndpoint?: string +): client.Configuration { + console.log(`[OAuth] Creating static configuration (no discovery)`); + + const issuer = new URL(authorizationEndpoint).origin; + + // Create Configuration with static metadata + const serverMetadata: client.ServerMetadata = { + issuer, + authorization_endpoint: authorizationEndpoint, + token_endpoint: tokenEndpoint, + revocation_endpoint: revocationEndpoint, + }; + + return new client.Configuration( + serverMetadata, + clientId, + undefined, // no client_secret + client.None() // PKCE auth + ); +} + +/** + * Register client via Dynamic Client Registration (RFC 7591) + * Returns both the Configuration and the registration response (for persistence) + */ +export async function registerClient( + issuerUrl: string, + redirectUris: string[], + scopes: string[], + clientName: string = 'RowboatX Desktop App' +): Promise<{ config: client.Configuration; registration: ClientRegistrationResponse }> { + console.log(`[OAuth] Registering client via DCR at ${issuerUrl}...`); + + const config = await client.dynamicClientRegistration( + new URL(issuerUrl), + { + redirect_uris: redirectUris, + token_endpoint_auth_method: 'none', // PKCE flow + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_name: clientName, + scope: scopes.join(' '), + }, + client.None() + ); + + const metadata = config.clientMetadata(); + console.log(`[OAuth] DCR complete, client_id: ${metadata.client_id}`); + + // Extract registration response for persistence + const registration = ClientRegistrationResponse.parse({ + client_id: metadata.client_id, + client_secret: metadata.client_secret, + client_id_issued_at: metadata.client_id_issued_at, + client_secret_expires_at: metadata.client_secret_expires_at, + }); + + // Cache the configuration + const cacheKey = `${issuerUrl}:${metadata.client_id}`; + configCache.set(cacheKey, config); + + return { config, registration }; +} + +/** + * Generate PKCE code verifier and challenge + */ +export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + const verifier = client.randomPKCECodeVerifier(); + const challenge = await client.calculatePKCECodeChallenge(verifier); + return { verifier, challenge }; +} + +/** + * Generate random state for CSRF protection + */ +export function generateState(): string { + return client.randomState(); +} + +/** + * Build authorization URL with PKCE + */ +export function buildAuthorizationUrl( + config: client.Configuration, + params: { + redirectUri: string; + scope: string; + codeChallenge: string; + state: string; + } +): URL { + return client.buildAuthorizationUrl(config, { + redirect_uri: params.redirectUri, + scope: params.scope, + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + state: params.state, + }); +} + +/** + * Exchange authorization code for tokens + */ +export async function exchangeCodeForTokens( + config: client.Configuration, + callbackUrl: URL, + codeVerifier: string, + expectedState: string +): Promise { + console.log(`[OAuth] Exchanging authorization code for tokens...`); + + const response = await client.authorizationCodeGrant(config, callbackUrl, { + pkceCodeVerifier: codeVerifier, + expectedState, + }); + + console.log(`[OAuth] Token exchange successful`); + return toOAuthTokens(response); +} + +/** + * Refresh access token using refresh token + * Preserves existing scopes if not returned by server + */ +export async function refreshTokens( + config: client.Configuration, + refreshToken: string, + existingScopes?: string[] +): Promise { + console.log(`[OAuth] Refreshing access token...`); + + const response = await client.refreshTokenGrant(config, refreshToken); + + const tokens = toOAuthTokens(response); + + // Preserve existing scopes if server didn't return them + if (!tokens.scopes && existingScopes) { + tokens.scopes = existingScopes; + } + + console.log(`[OAuth] Token refresh successful`); + return tokens; +} + +/** + * Check if tokens are expired + */ +export function isTokenExpired(tokens: OAuthTokens): boolean { + const now = Math.floor(Date.now() / 1000); + return tokens.expires_at <= now; +} + +/** + * Clear configuration cache for a specific provider or all providers + */ +export function clearConfigCache(issuerUrl?: string, clientId?: string): void { + if (issuerUrl && clientId) { + configCache.delete(`${issuerUrl}:${clientId}`); + console.log(`[OAuth] Cleared configuration cache for ${issuerUrl}`); + } else { + configCache.clear(); + console.log(`[OAuth] Cleared all configuration cache`); + } +} + +/** + * Get cached configuration if available + */ +export function getCachedConfiguration(issuerUrl: string, clientId: string): client.Configuration | undefined { + return configCache.get(`${issuerUrl}:${clientId}`); +} + +// Re-export Configuration type for external use +export type { Configuration } from 'openid-client'; + diff --git a/apps/x/packages/core/src/auth/oauth.ts b/apps/x/packages/core/src/auth/oauth.ts deleted file mode 100644 index a45023cc..00000000 --- a/apps/x/packages/core/src/auth/oauth.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { AuthorizationServerMetadata } from './discovery.js'; -import { OAuthTokens, ClientRegistrationRequest, ClientRegistrationResponse } from './types.js'; - -/** - * Generic OAuth 2.0 service with PKCE support - */ -export class OAuthService { - constructor( - private metadata: AuthorizationServerMetadata, - private clientId: string, - private scopes: string[] - ) {} - - /** - * Build authorization URL with PKCE parameters - */ - buildAuthorizationUrl( - codeChallenge: string, - state: string, - redirectUri: string - ): string { - const params = new URLSearchParams({ - client_id: this.clientId, - redirect_uri: redirectUri, - response_type: 'code', - scope: this.scopes.join(' '), - state, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - }); - - return `${this.metadata.authorization_endpoint}?${params.toString()}`; - } - - /** - * Exchange authorization code for tokens using PKCE - */ - async exchangeCodeForTokens( - code: string, - codeVerifier: string, - redirectUri: string - ): Promise { - const params = new URLSearchParams({ - client_id: this.clientId, - code, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - code_verifier: codeVerifier, - }); - - const response = await fetch(this.metadata.token_endpoint, { - 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}`); - } - - const data = await response.json(); - - // Calculate expires_at from expires_in if provided - const expiresIn = data.expires_in || 3600; // Default to 1 hour - const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; - - // Extract granted scopes from response (may be space-separated string or array) - let scopes: string[] | undefined; - if (data.scope) { - if (typeof data.scope === 'string') { - scopes = data.scope.split(' ').filter((s: string) => s.length > 0); - } else if (Array.isArray(data.scope)) { - scopes = data.scope; - } - } - - return OAuthTokens.parse({ - access_token: data.access_token, - refresh_token: data.refresh_token || null, - expires_at: expiresAt, - token_type: data.token_type || 'Bearer', - scopes, - }); - } - - /** - * Refresh access token using refresh token - * Preserves existing scopes since refresh responses typically don't include them - */ - async refreshAccessToken(refreshToken: string, existingScopes?: string[]): Promise { - const params = new URLSearchParams({ - client_id: this.clientId, - refresh_token: refreshToken, - grant_type: 'refresh_token', - }); - - const response = await fetch(this.metadata.token_endpoint, { - 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}`); - } - - const data = await response.json(); - - // Calculate expires_at from expires_in if provided - const expiresIn = data.expires_in || 3600; - const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; - - // Extract scopes from refresh response if provided, otherwise preserve existing scopes - let scopes: string[] | undefined = existingScopes; - if (data.scope) { - if (typeof data.scope === 'string') { - scopes = data.scope.split(' ').filter((s: string) => s.length > 0); - } else if (Array.isArray(data.scope)) { - scopes = data.scope; - } - } - - return OAuthTokens.parse({ - access_token: data.access_token, - refresh_token: data.refresh_token || refreshToken, // Some providers don't return new refresh token - expires_at: expiresAt, - token_type: data.token_type || 'Bearer', - scopes, - }); - } - - /** - * Check if tokens are expired - */ - isTokenExpired(tokens: OAuthTokens): boolean { - const now = Math.floor(Date.now() / 1000); - return tokens.expires_at <= now; - } - - /** - * Register client using Dynamic Client Registration (RFC 7591) - */ - async registerClient( - redirectUris: string[], - scopes: string[] - ): Promise { - if (!this.metadata.registration_endpoint) { - throw new Error('Provider does not support Dynamic Client Registration'); - } - - const registrationRequest: ClientRegistrationRequest = { - redirect_uris: redirectUris, - token_endpoint_auth_method: 'none', // PKCE doesn't need client secret - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - client_name: 'RowboatX Desktop App', - scope: scopes.join(' '), - }; - - const response = await fetch(this.metadata.registration_endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify(registrationRequest), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Client registration failed: ${response.status} ${errorText}`); - } - - const data = await response.json(); - return ClientRegistrationResponse.parse(data); - } -} - diff --git a/apps/x/packages/core/src/auth/pkce.ts b/apps/x/packages/core/src/auth/pkce.ts deleted file mode 100644 index 53bfcc63..00000000 --- a/apps/x/packages/core/src/auth/pkce.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { randomBytes, createHash } from 'crypto'; - -/** - * Generate a random code verifier for PKCE - * Returns a base64url-encoded string of 128 characters - */ -export function generateCodeVerifier(): string { - // Generate 96 random bytes (768 bits) to ensure we have enough entropy - // After base64url encoding, this will be 128 characters - const bytes = randomBytes(96); - return base64UrlEncode(bytes); -} - -/** - * Generate a code challenge from a code verifier - * Uses SHA256 hash and base64url encoding - */ -export function generateCodeChallenge(verifier: string): string { - const hash = createHash('sha256').update(verifier).digest(); - return base64UrlEncode(hash); -} - -/** - * Base64url encode (RFC 4648) - * Replaces + with -, / with _, and removes padding - */ -function base64UrlEncode(buffer: Buffer): string { - return buffer - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts index d9a56d77..f32f492c 100644 --- a/apps/x/packages/core/src/knowledge/google-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -3,8 +3,8 @@ import container from '../di/container.js'; import { IOAuthRepo } from '../auth/repo.js'; import { IClientRegistrationRepo } from '../auth/client-repo.js'; import { getProviderConfig } from '../auth/providers.js'; -import { OAuthService } from '../auth/oauth.js'; -import { discoverAuthorizationServer, createStaticMetadata, AuthorizationServerMetadata } from '../auth/discovery.js'; +import * as oauthClient from '../auth/oauth-client.js'; +import type { Configuration } from '../auth/oauth-client.js'; import { OAuthTokens } from '../auth/types.js'; /** @@ -14,13 +14,11 @@ import { OAuthTokens } from '../auth/types.js'; export class GoogleClientFactory { private static readonly PROVIDER_NAME = 'google'; private static cache: { - metadata: AuthorizationServerMetadata | null; - clientId: string | null; + config: Configuration | null; client: OAuth2Client | null; tokens: OAuthTokens | null; } = { - metadata: null, - clientId: null, + config: null, client: null, tokens: null, }; @@ -37,35 +35,34 @@ export class GoogleClientFactory { return null; } - // Initialize auth cache if needed - await this.initializeAuthCache(); - if (!this.cache.metadata || !this.cache.clientId) { + // Initialize config cache if needed + await this.initializeConfigCache(); + if (!this.cache.config) { return null; } // Check if token is expired - const now = Math.floor(Date.now() / 1000); - if (tokens.expires_at <= now) { + if (oauthClient.isTokenExpired(tokens)) { // Token expired, try to refresh if (!tokens.refresh_token) { - console.log("Token expired and no refresh token available for Google."); + console.log("[OAuth] Token expired and no refresh token available for Google."); this.clearCache(); return null; } try { console.log(`[OAuth] Token expired, refreshing access token...`); - const config = getProviderConfig(this.PROVIDER_NAME); - const scopes = config.scopes || []; - const oauthService = new OAuthService(this.cache.metadata, this.cache.clientId, scopes); - const existingScopes = tokens.scopes; - const refreshedTokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes); + const refreshedTokens = await oauthClient.refreshTokens( + this.cache.config, + tokens.refresh_token, + existingScopes + ); await oauthRepo.saveTokens(this.PROVIDER_NAME, refreshedTokens); // Update cached tokens and recreate client this.cache.tokens = refreshedTokens; - this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId); + this.cache.client = this.createClientFromTokens(refreshedTokens); console.log(`[OAuth] Token refreshed successfully`); return this.cache.client; } catch (error) { @@ -83,7 +80,7 @@ export class GoogleClientFactory { // Create new client with current tokens console.log(`[OAuth] Creating new OAuth2Client instance`); this.cache.tokens = tokens; - this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId); + this.cache.client = this.createClientFromTokens(tokens); return this.cache.client; } @@ -116,69 +113,79 @@ export class GoogleClientFactory { */ static clearCache(): void { console.log(`[OAuth] Clearing Google auth cache`); + this.cache.config = null; this.cache.client = null; this.cache.tokens = null; } /** - * Initialize cached metadata and client ID (called once) + * Initialize cached configuration (called once) */ - private static async initializeAuthCache(): Promise { - if (this.cache.metadata && this.cache.clientId) { + private static async initializeConfigCache(): Promise { + if (this.cache.config) { return; // Already initialized } - console.log(`[OAuth] Initializing Google auth cache...`); - const config = getProviderConfig(this.PROVIDER_NAME); + console.log(`[OAuth] Initializing Google OAuth configuration...`); + const providerConfig = getProviderConfig(this.PROVIDER_NAME); - // Get metadata - let metadata: AuthorizationServerMetadata; - if (config.discovery.mode === 'issuer') { - console.log(`[OAuth] Discovery mode: issuer (${config.discovery.issuer})`); - metadata = await discoverAuthorizationServer(config.discovery.issuer); + if (providerConfig.discovery.mode === 'issuer') { + if (providerConfig.client.mode === 'static') { + // Discover endpoints, use static client ID + console.log(`[OAuth] Discovery mode: issuer with static client ID`); + this.cache.config = await oauthClient.discoverConfiguration( + providerConfig.discovery.issuer, + providerConfig.client.clientId + ); + } else { + // DCR mode - need existing registration + console.log(`[OAuth] Discovery mode: issuer with DCR`); + const clientRepo = container.resolve('clientRegistrationRepo'); + const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME); + + if (!existingRegistration) { + throw new Error('Google client not registered. Please connect account first.'); + } + + this.cache.config = await oauthClient.discoverConfiguration( + providerConfig.discovery.issuer, + existingRegistration.client_id + ); + } } else { - console.log(`[OAuth] Discovery mode: static endpoints`); - metadata = createStaticMetadata( - config.discovery.authorizationEndpoint, - config.discovery.tokenEndpoint, - config.discovery.revocationEndpoint + // Static endpoints + if (providerConfig.client.mode !== 'static') { + throw new Error('DCR requires discovery mode "issuer", not "static"'); + } + + console.log(`[OAuth] Using static endpoints (no discovery)`); + this.cache.config = oauthClient.createStaticConfiguration( + providerConfig.discovery.authorizationEndpoint, + providerConfig.discovery.tokenEndpoint, + providerConfig.client.clientId, + providerConfig.discovery.revocationEndpoint ); } - // Get client ID - let clientId: string; - if (config.client.mode === 'static') { - if (!config.client.clientId) { - throw new Error('Static client mode requires clientId in provider configuration for Google'); - } - console.log(`[OAuth] Client mode: static (using configured clientId)`); - clientId = config.client.clientId; - } else { - console.log(`[OAuth] Client mode: DCR (Dynamic Client Registration)`); - const clientRepo = container.resolve('clientRegistrationRepo'); - const registrationEndpoint = config.client.registrationEndpoint || metadata.registration_endpoint; - if (!registrationEndpoint) { - throw new Error('Google provider does not support Dynamic Client Registration'); - } - - const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME); - if (!existingRegistration) { - throw new Error('Google client not registered. Please connect account first.'); - } - console.log(`[OAuth] Using existing DCR client registration`); - clientId = existingRegistration.client_id; - } - - // Store in cache - this.cache.metadata = metadata; - this.cache.clientId = clientId; - console.log(`[OAuth] Google auth cache initialized`); + console.log(`[OAuth] Google OAuth configuration initialized`); } /** * Create OAuth2Client from OAuthTokens */ - private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client { + private static createClientFromTokens(tokens: OAuthTokens): OAuth2Client { + const providerConfig = getProviderConfig(this.PROVIDER_NAME); + + // Get client ID from config + let clientId: string; + if (providerConfig.client.mode === 'static') { + clientId = providerConfig.client.clientId; + } else { + // For DCR, we'd need to look up the registered client ID + // This is a fallback - normally initializeConfigCache handles this + throw new Error('Cannot create client without static client ID'); + } + // Create OAuth2Client directly (PKCE flow doesn't use client secret) const client = new OAuth2Client( clientId, @@ -197,4 +204,3 @@ export class GoogleClientFactory { return client; } } - diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index ff6cffc7..ddd8b282 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: ollama-ai-provider-v2: specifier: ^1.5.4 version: 1.5.5(zod@4.2.1) + openid-client: + specifier: ^6.8.1 + version: 6.8.1 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -3493,6 +3496,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + oauth4webapi@3.8.3: + resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3528,6 +3534,9 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + openid-client@6.8.1: + resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -7848,6 +7857,8 @@ snapshots: dependencies: boolbase: 1.0.0 + oauth4webapi@3.8.3: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -7882,6 +7893,11 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openid-client@6.8.1: + dependencies: + jose: 6.1.3 + oauth4webapi: 3.8.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4