diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 3bae6556..16b4993d 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -2,10 +2,12 @@ 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 { createOAuthService } from '@x/core/dist/auth/oauth.js'; -import { getAvailableProviders } from '@x/core/dist/auth/providers.js'; +import { OAuthService } from '@x/core/dist/auth/oauth.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'; @@ -26,13 +28,106 @@ function getOAuthRepo(): IOAuthRepo { return container.resolve('oauthRepo'); } +/** + * Get client registration repository from DI container + */ +function getClientRegistrationRepo(): IClientRegistrationRepo { + return container.resolve('clientRegistrationRepo'); +} + +/** + * Discover or get provider metadata + */ +async function getProviderMetadata(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); + } else { + // Use static endpoints + console.log(`[OAuth] Using static metadata for ${provider} (no discovery)`); + return createStaticMetadata( + config.discovery.authorizationEndpoint, + config.discovery.tokenEndpoint, + 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 */ export async function connectProvider(provider: string): Promise<{ success: boolean; error?: string }> { try { - const oauthService = createOAuthService(provider); + console.log(`[OAuth] Starting connection flow for ${provider}...`); const oauthRepo = getOAuthRepo(); + const config = 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); // Generate PKCE codes const codeVerifier = generateCodeVerifier(); @@ -56,6 +151,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool try { // Exchange code for tokens + console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); const tokens = await oauthService.exchangeCodeForTokens( code, flow.codeVerifier, @@ -63,6 +159,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool ); // Save tokens + console.log(`[OAuth] Token exchange successful for ${provider}`); await oauthRepo.saveTokens(provider, tokens); } catch (error) { console.error('OAuth token exchange failed:', error); @@ -142,13 +239,23 @@ export async function isConnected(provider: string): Promise<{ isConnected: bool export async function getAccessToken(provider: string): Promise { try { const oauthRepo = getOAuthRepo(); - const oauthService = createOAuthService(provider); + 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 (!tokens.refresh_token) { @@ -158,7 +265,7 @@ export async function getAccessToken(provider: string): Promise { try { // Refresh token, preserving existing scopes - const existingScopes = (tokens).scopes; + const existingScopes = tokens.scopes; tokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes); await oauthRepo.saveTokens(provider, tokens); } catch (error) { diff --git a/apps/x/packages/core/src/auth/client-repo.ts b/apps/x/packages/core/src/auth/client-repo.ts new file mode 100644 index 00000000..62593469 --- /dev/null +++ b/apps/x/packages/core/src/auth/client-repo.ts @@ -0,0 +1,75 @@ +import { WorkDir } from '../config/config.js'; +import fs from 'fs/promises'; +import path from 'path'; +import { ClientRegistrationResponse } from './types.js'; + +export interface IClientRegistrationRepo { + getClientRegistration(provider: string): Promise; + saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise; + clearClientRegistration(provider: string): Promise; +} + +type ClientRegistrationStorage = { + [provider: string]: ClientRegistrationResponse; +}; + +export class FSClientRegistrationRepo implements IClientRegistrationRepo { + private readonly configPath = path.join(WorkDir, 'config', 'oauth-clients.json'); + + constructor() { + this.ensureConfigFile(); + } + + private async ensureConfigFile(): Promise { + try { + await fs.access(this.configPath); + } catch { + // File doesn't exist, create it with empty object + await fs.writeFile(this.configPath, JSON.stringify({}, null, 2)); + } + } + + private async readConfig(): Promise { + try { + const content = await fs.readFile(this.configPath, 'utf8'); + const parsed = JSON.parse(content); + return parsed as ClientRegistrationStorage; + } catch { + return {}; + } + } + + private async writeConfig(config: ClientRegistrationStorage): Promise { + await fs.writeFile(this.configPath, JSON.stringify(config, null, 2)); + } + + async getClientRegistration(provider: string): Promise { + const config = await this.readConfig(); + const registration = config[provider]; + if (!registration) { + return null; + } + + // Validate registration structure + try { + return ClientRegistrationResponse.parse(registration); + } catch { + // Invalid registration, remove it + await this.clearClientRegistration(provider); + return null; + } + } + + async saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise { + const config = await this.readConfig(); + config[provider] = registration; + await this.writeConfig(config); + } + + async clearClientRegistration(provider: string): Promise { + const config = await this.readConfig(); + delete config[provider]; + await this.writeConfig(config); + } +} + diff --git a/apps/x/packages/core/src/auth/discovery.ts b/apps/x/packages/core/src/auth/discovery.ts new file mode 100644 index 00000000..582b5812 --- /dev/null +++ b/apps/x/packages/core/src/auth/discovery.ts @@ -0,0 +1,86 @@ +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.ts b/apps/x/packages/core/src/auth/oauth.ts index 953c80eb..a45023cc 100644 --- a/apps/x/packages/core/src/auth/oauth.ts +++ b/apps/x/packages/core/src/auth/oauth.ts @@ -1,11 +1,15 @@ -import { OAuthProviderConfig, getProviderConfig } from './providers.js'; -import { OAuthTokens } from '@x/shared/dist/auth.js'; +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 config: OAuthProviderConfig) {} + constructor( + private metadata: AuthorizationServerMetadata, + private clientId: string, + private scopes: string[] + ) {} /** * Build authorization URL with PKCE parameters @@ -16,16 +20,16 @@ export class OAuthService { redirectUri: string ): string { const params = new URLSearchParams({ - client_id: this.config.clientId, + client_id: this.clientId, redirect_uri: redirectUri, response_type: 'code', - scope: this.config.scopes.join(' '), + scope: this.scopes.join(' '), state, code_challenge: codeChallenge, code_challenge_method: 'S256', }); - return `${this.config.authorizationEndpoint}?${params.toString()}`; + return `${this.metadata.authorization_endpoint}?${params.toString()}`; } /** @@ -37,14 +41,14 @@ export class OAuthService { redirectUri: string ): Promise { const params = new URLSearchParams({ - client_id: this.config.clientId, + client_id: this.clientId, code, redirect_uri: redirectUri, grant_type: 'authorization_code', code_verifier: codeVerifier, }); - const response = await fetch(this.config.tokenEndpoint, { + const response = await fetch(this.metadata.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -88,12 +92,12 @@ export class OAuthService { */ async refreshAccessToken(refreshToken: string, existingScopes?: string[]): Promise { const params = new URLSearchParams({ - client_id: this.config.clientId, + client_id: this.clientId, refresh_token: refreshToken, grant_type: 'refresh_token', }); - const response = await fetch(this.config.tokenEndpoint, { + const response = await fetch(this.metadata.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -138,13 +142,43 @@ export class OAuthService { const now = Math.floor(Date.now() / 1000); return tokens.expires_at <= now; } -} - -/** - * Factory function to create OAuth service for a provider - */ -export function createOAuthService(providerName: string): OAuthService { - const config = getProviderConfig(providerName); - return new OAuthService(config); + + /** + * 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/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 9b58a235..327f2d41 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -1,52 +1,95 @@ -/** - * OAuth 2.0 provider configuration - */ -export interface OAuthProviderConfig { - name: string; - clientId: string; - authorizationEndpoint: string; - tokenEndpoint: string; - scopes: string[]; -} +import { z } from 'zod'; /** - * Get Google OAuth provider configuration + * Discovery configuration - how to get OAuth endpoints */ -export function getGoogleProviderConfig(): OAuthProviderConfig { - // TODO: Replace with actual Google OAuth client ID - const GOOGLE_CLIENT_ID = '797410052581-ibmmvqec0l68stv5fmgh0juqfvbg08fc.apps.googleusercontent.com' +const DiscoverySchema = z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal('issuer'), + issuer: z.url().describe('The issuer base url. To discover the endpoints, the client will fetch the .well-known/oauth-authorization-server from this url.'), + }), + z.object({ + mode: z.literal('static'), + authorizationEndpoint: z.url(), + tokenEndpoint: z.url(), + revocationEndpoint: z.url().optional(), + }), +]); - return { - name: 'google', - clientId: GOOGLE_CLIENT_ID, - authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', - tokenEndpoint: 'https://oauth2.googleapis.com/token', +/** + * Client configuration - how to get client credentials + */ +const ClientSchema = z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal('static'), + clientId: z.string().min(1), + }), + z.object({ + mode: z.literal('dcr'), + // If omitted, should be discovered from auth-server metadata as `registration_endpoint` + registrationEndpoint: z.url().optional(), + }), +]); + +/** + * Provider configuration schema + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const ProviderConfigSchema = z.record( + z.string(), + z.object({ + discovery: DiscoverySchema, + client: ClientSchema, + scopes: z.array(z.string()).optional(), + }) +); + +export type ProviderConfig = z.infer; +export type ProviderConfigEntry = ProviderConfig[string]; + +/** + * All configured OAuth providers + */ +const providerConfigs: ProviderConfig = { + google: { + discovery: { + mode: 'issuer', + issuer: 'https://accounts.google.com', + }, + client: { + mode: 'static', + clientId: '797410052581-ibmmvqec0l68stv5fmgh0juqfvbg08fc.apps.googleusercontent.com', + }, scopes: [ 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/drive.readonly', ], - }; + }, +}; + +/** + * Get provider configuration by name + */ +export function getProviderConfig(providerName: string): ProviderConfigEntry { + const config = providerConfigs[providerName]; + if (!config) { + throw new Error(`Unknown OAuth provider: ${providerName}`); + } + return config; +} + +/** + * Get all provider configurations + */ +export function getAllProviderConfigs(): ProviderConfig { + return providerConfigs; } /** * Get list of all configured OAuth providers */ export function getAvailableProviders(): string[] { - return ['google']; - // Future: Add more providers here - // return ['google', 'github', 'microsoft']; -} - -/** - * Get provider configuration by name - */ -export function getProviderConfig(providerName: string): OAuthProviderConfig { - switch (providerName) { - case 'google': - return getGoogleProviderConfig(); - default: - throw new Error(`Unknown OAuth provider: ${providerName}`); - } + return Object.keys(providerConfigs); } diff --git a/apps/x/packages/core/src/auth/repo.ts b/apps/x/packages/core/src/auth/repo.ts index 0add78c1..3e8a3f9a 100644 --- a/apps/x/packages/core/src/auth/repo.ts +++ b/apps/x/packages/core/src/auth/repo.ts @@ -1,7 +1,7 @@ import { WorkDir } from '../config/config.js'; import fs from 'fs/promises'; import path from 'path'; -import { OAuthTokens } from '@x/shared/dist/auth.js'; +import { OAuthTokens } from './types.js'; export interface IOAuthRepo { getTokens(provider: string): Promise; diff --git a/apps/x/packages/core/src/auth/types.ts b/apps/x/packages/core/src/auth/types.ts new file mode 100644 index 00000000..249d63b4 --- /dev/null +++ b/apps/x/packages/core/src/auth/types.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +/** + * OAuth 2.0 tokens structure + */ +export const OAuthTokens = z.object({ + access_token: z.string(), + refresh_token: z.string().nullable(), + expires_at: z.number(), // Unix timestamp + token_type: z.literal('Bearer').optional(), + scopes: z.array(z.string()).optional(), // Granted scopes from OAuth response +}); + +export type OAuthTokens = z.infer; + +/** + * Client Registration Request (RFC 7591) + */ +export const ClientRegistrationRequest = z.object({ + redirect_uris: z.array(z.url()), + token_endpoint_auth_method: z.string().optional(), // e.g., "none" for PKCE + grant_types: z.array(z.string()).optional(), // e.g., ["authorization_code", "refresh_token"] + response_types: z.array(z.string()).optional(), // e.g., ["code"] + client_name: z.string().optional(), + scope: z.string().optional(), // Space-separated scopes +}); + +export type ClientRegistrationRequest = z.infer; + +/** + * Client Registration Response (RFC 7591) + */ +export const ClientRegistrationResponse = z.object({ + client_id: z.string(), + client_secret: z.string().optional(), // Not used with PKCE + client_id_issued_at: z.number().optional(), + client_secret_expires_at: z.number().optional(), + registration_access_token: z.string().optional(), // For client management + registration_client_uri: z.url().optional(), // For client management +}); + +export type ClientRegistrationResponse = z.infer; + diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 855bca05..0297421d 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -9,6 +9,7 @@ import { IBus, InMemoryBus } from "../application/lib/bus.js"; import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js"; import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js"; import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; +import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -27,6 +28,7 @@ container.register({ agentsRepo: asClass(FSAgentsRepo).singleton(), runsRepo: asClass(FSRunsRepo).singleton(), oauthRepo: asClass(FSOAuthRepo).singleton(), + clientRegistrationRepo: asClass(FSClientRegistrationRepo).singleton(), }); export default container; \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts new file mode 100644 index 00000000..d9a56d77 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -0,0 +1,200 @@ +import { OAuth2Client } from 'google-auth-library'; +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 { OAuthTokens } from '../auth/types.js'; + +/** + * Factory for creating and managing Google OAuth2Client instances. + * Handles caching, token refresh, and client reuse for Google API SDKs. + */ +export class GoogleClientFactory { + private static readonly PROVIDER_NAME = 'google'; + private static cache: { + metadata: AuthorizationServerMetadata | null; + clientId: string | null; + client: OAuth2Client | null; + tokens: OAuthTokens | null; + } = { + metadata: null, + clientId: null, + client: null, + tokens: null, + }; + + /** + * Get or create OAuth2Client, reusing cached instance when possible + */ + static async getClient(): Promise { + const oauthRepo = container.resolve('oauthRepo'); + const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME); + + if (!tokens) { + this.clearCache(); + return null; + } + + // Initialize auth cache if needed + await this.initializeAuthCache(); + if (!this.cache.metadata || !this.cache.clientId) { + return null; + } + + // Check if token is expired + const now = Math.floor(Date.now() / 1000); + if (tokens.expires_at <= now) { + // Token expired, try to refresh + if (!tokens.refresh_token) { + console.log("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); + 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); + console.log(`[OAuth] Token refreshed successfully`); + return this.cache.client; + } catch (error) { + console.error("[OAuth] Failed to refresh token for Google:", error); + this.clearCache(); + return null; + } + } + + // Reuse client if tokens haven't changed + if (this.cache.client && this.cache.tokens && this.cache.tokens.access_token === tokens.access_token) { + return this.cache.client; + } + + // 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); + return this.cache.client; + } + + /** + * Check if credentials are available and have required scopes + */ + static async hasValidCredentials(requiredScopes: string | string[]): Promise { + const oauthRepo = container.resolve('oauthRepo'); + const isConnected = await oauthRepo.isConnected(this.PROVIDER_NAME); + + if (!isConnected) { + return false; + } + + const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME); + if (!tokens) { + return false; + } + + // Check if required scope(s) are present + const scopesArray = Array.isArray(requiredScopes) ? requiredScopes : [requiredScopes]; + if (!tokens.scopes || tokens.scopes.length === 0) { + return false; + } + return scopesArray.every(scope => tokens.scopes!.includes(scope)); + } + + /** + * Clear cache (useful for testing or when credentials are revoked) + */ + static clearCache(): void { + console.log(`[OAuth] Clearing Google auth cache`); + this.cache.client = null; + this.cache.tokens = null; + } + + /** + * Initialize cached metadata and client ID (called once) + */ + private static async initializeAuthCache(): Promise { + if (this.cache.metadata && this.cache.clientId) { + return; // Already initialized + } + + console.log(`[OAuth] Initializing Google auth cache...`); + const config = 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); + } else { + console.log(`[OAuth] Discovery mode: static endpoints`); + metadata = createStaticMetadata( + config.discovery.authorizationEndpoint, + config.discovery.tokenEndpoint, + config.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`); + } + + /** + * Create OAuth2Client from OAuthTokens + */ + private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client { + // Create OAuth2Client directly (PKCE flow doesn't use client secret) + const client = new OAuth2Client( + clientId, + undefined, // client_secret not needed for PKCE + undefined // redirect_uri not needed for token usage + ); + + // Set credentials + client.setCredentials({ + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || undefined, + expiry_date: tokens.expires_at * 1000, // Convert from seconds to milliseconds + scope: tokens.scopes?.join(' ') || undefined, + }); + + return client; + } +} + diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index c0e9f53c..ae1241ac 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -3,12 +3,8 @@ import path from 'path'; import { google, calendar_v3 as cal, drive_v3 as drive } from 'googleapis'; import { OAuth2Client } from 'google-auth-library'; import { NodeHtmlMarkdown } from 'node-html-markdown' -import container from '../di/container.js'; -import { IOAuthRepo } from '../auth/repo.js'; -import { getProviderConfig } from '../auth/providers.js'; -import { createOAuthService } from '../auth/oauth.js'; import { WorkDir } from '../config/config.js'; -import { OAuthTokens } from 'packages/shared/dist/auth.js'; +import { GoogleClientFactory } from './google-client-factory.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); @@ -18,111 +14,9 @@ const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/drive.readonly' ]; -const PROVIDER_NAME = 'google'; const nhm = new NodeHtmlMarkdown(); -// --- Auth Functions --- - -/** - * Get OAuth repository from DI container - */ -function getOAuthRepo(): IOAuthRepo { - return container.resolve('oauthRepo'); -} - -/** - * Check if all required scopes are present in the granted scopes - */ -function hasRequiredScopes(grantedScopes?: string[]): boolean { - if (!grantedScopes || grantedScopes.length === 0) { - return false; - } - // Check if all required scopes are present - return REQUIRED_SCOPES.every(scope => grantedScopes.includes(scope)); -} - -/** - * Convert OAuthTokens to OAuth2Client for use with googleapis - */ -async function createOAuth2Client(): Promise { - const oauthRepo = getOAuthRepo(); - const tokens = await oauthRepo.getTokens(PROVIDER_NAME); - - if (!tokens) { - return null; - } - - // Check if token is expired - const now = Math.floor(Date.now() / 1000); - if (tokens.expires_at <= now) { - // Token expired, try to refresh - if (!tokens.refresh_token) { - console.log("Token expired and no refresh token available."); - return null; - } - - try { - const oauthService = createOAuthService(PROVIDER_NAME); - const existingScopes = tokens.scopes; - const refreshedTokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes); - await oauthRepo.saveTokens(PROVIDER_NAME, refreshedTokens); - - // Use refreshed tokens - return createClientFromTokens(refreshedTokens); - } catch (error) { - console.error("Failed to refresh token:", error); - return null; - } - } - - return createClientFromTokens(tokens); -} - -/** - * Create OAuth2Client from OAuthTokens - */ -function createClientFromTokens(tokens: OAuthTokens): OAuth2Client { - const providerConfig = getProviderConfig(PROVIDER_NAME); - - // Create OAuth2Client directly (PKCE flow doesn't use client secret) - const client = new OAuth2Client( - providerConfig.clientId, - undefined, // client_secret not needed for PKCE - undefined // redirect_uri not needed for token usage - ); - - // Set credentials - client.setCredentials({ - access_token: tokens.access_token, - refresh_token: tokens.refresh_token || undefined, - expiry_date: tokens.expires_at * 1000, // Convert from seconds to milliseconds - scope: tokens.scopes?.join(' ') || undefined, - }); - - return client; -} - -/** - * Check if Google OAuth credentials are available with required scopes - */ -async function hasValidCredentials(): Promise { - const oauthRepo = getOAuthRepo(); - const isConnected = await oauthRepo.isConnected(PROVIDER_NAME); - - if (!isConnected) { - return false; - } - - const tokens = await oauthRepo.getTokens(PROVIDER_NAME); - if (!tokens) { - return false; - } - - // Check if all required scopes are present - return hasRequiredScopes(tokens.scopes); -} - // --- Helper Functions --- function cleanFilename(name: string): string { @@ -281,9 +175,8 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD // If 401, clear tokens to force re-auth next run const e = error as { response?: { status?: number } }; if (e.response?.status === 401) { - console.log("401 Unauthorized. Clearing tokens to force re-authentication."); - const oauthRepo = getOAuthRepo(); - await oauthRepo.clearTokens(PROVIDER_NAME); + console.log("401 Unauthorized, clearing cache"); + GoogleClientFactory.clearCache(); } throw error; // Re-throw to be handled by performSync } @@ -296,7 +189,7 @@ async function performSync(syncDir: string, lookbackDays: number) { fs.mkdirSync(SYNC_DIR, { recursive: true }); } - const auth = await createOAuth2Client(); + const auth = await GoogleClientFactory.getClient(); if (!auth) { console.log("No valid OAuth credentials available."); return; @@ -310,9 +203,8 @@ async function performSync(syncDir: string, lookbackDays: number) { // If 401, clear tokens to force re-auth next run const e = error as { response?: { status?: number } }; if (e.response?.status === 401) { - console.log("401 Unauthorized. Clearing tokens to force re-authentication."); - const oauthRepo = getOAuthRepo(); - await oauthRepo.clearTokens(PROVIDER_NAME); + console.log("401 Unauthorized, clearing cache"); + GoogleClientFactory.clearCache(); } } } @@ -324,7 +216,7 @@ export async function init() { while (true) { try { // Check if credentials are available with required scopes - const hasCredentials = await hasValidCredentials(); + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES); if (!hasCredentials) { console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index e03468a8..1d46a3b0 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -4,120 +4,16 @@ import { google, gmail_v1 as gmail } from 'googleapis'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { OAuth2Client } from 'google-auth-library'; import { WorkDir } from '../config/config.js'; -import container from '../di/container.js'; -import { IOAuthRepo } from '../auth/repo.js'; -import { getProviderConfig } from '../auth/providers.js'; -import { createOAuthService } from '../auth/oauth.js'; -import { OAuthTokens } from 'packages/shared/dist/auth.js'; import { buildGraph } from './build_graph.js'; +import { GoogleClientFactory } from './google-client-factory.js'; // Configuration const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const SYNC_INTERVAL_MS = 60 * 1000; // Check every minute const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; -const PROVIDER_NAME = 'google'; const nhm = new NodeHtmlMarkdown(); -// --- Auth Functions --- - -/** - * Get OAuth repository from DI container - */ -function getOAuthRepo(): IOAuthRepo { - return container.resolve('oauthRepo'); -} - -/** - * Check if the required Gmail scope is present in the granted scopes - */ -function hasRequiredScope(grantedScopes?: string[]): boolean { - if (!grantedScopes || grantedScopes.length === 0) { - return false; - } - return grantedScopes.includes(REQUIRED_SCOPE); -} - -/** - * Convert OAuthTokens to OAuth2Client for use with googleapis - */ -async function createOAuth2Client(): Promise { - const oauthRepo = getOAuthRepo(); - const tokens = await oauthRepo.getTokens(PROVIDER_NAME); - - if (!tokens) { - return null; - } - - // Check if token is expired - const now = Math.floor(Date.now() / 1000); - if (tokens.expires_at <= now) { - // Token expired, try to refresh - if (!tokens.refresh_token) { - console.log("Token expired and no refresh token available."); - return null; - } - - try { - const oauthService = createOAuthService(PROVIDER_NAME); - const existingScopes = tokens.scopes; - const refreshedTokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes); - await oauthRepo.saveTokens(PROVIDER_NAME, refreshedTokens); - - // Use refreshed tokens - return createClientFromTokens(refreshedTokens); - } catch (error) { - console.error("Failed to refresh token:", error); - return null; - } - } - - return createClientFromTokens(tokens); -} - -/** - * Create OAuth2Client from OAuthTokens - */ -function createClientFromTokens(tokens: OAuthTokens): OAuth2Client { - const providerConfig = getProviderConfig(PROVIDER_NAME); - - // Create OAuth2Client directly (PKCE flow doesn't use client secret) - const client = new OAuth2Client( - providerConfig.clientId, - undefined, // client_secret not needed for PKCE - undefined // redirect_uri not needed for token usage - ); - - // Set credentials - client.setCredentials({ - access_token: tokens.access_token, - refresh_token: tokens.refresh_token || undefined, - expiry_date: tokens.expires_at * 1000, // Convert from seconds to milliseconds - }); - - return client; -} - -/** - * Check if Google OAuth credentials are available with required scopes - */ -async function hasValidCredentials(): Promise { - const oauthRepo = getOAuthRepo(); - const isConnected = await oauthRepo.isConnected(PROVIDER_NAME); - - if (!isConnected) { - return false; - } - - const tokens = await oauthRepo.getTokens(PROVIDER_NAME); - if (!tokens) { - return false; - } - - // Check if required scope is present - return hasRequiredScope(tokens.scopes); -} - // --- Helper Functions --- function cleanFilename(name: string): string { @@ -350,9 +246,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: console.error("Error during partial sync:", error); // If 401, clear tokens to force re-auth next run if (e.response?.status === 401) { - console.log("401 Unauthorized. Clearing tokens to force re-authentication."); - const oauthRepo = getOAuthRepo(); - await oauthRepo.clearTokens(PROVIDER_NAME); + console.log("401 Unauthorized, clearing cache"); + GoogleClientFactory.clearCache(); } } } @@ -368,7 +263,7 @@ async function performSync() { if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); try { - const auth = await createOAuth2Client(); + const auth = await GoogleClientFactory.getClient(); if (!auth) { console.log("No valid OAuth credentials available."); return; @@ -406,7 +301,7 @@ export async function init() { while (true) { try { // Check if credentials are available with required scopes - const hasCredentials = await hasValidCredentials(); + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); if (!hasCredentials) { console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); diff --git a/apps/x/packages/shared/src/auth.ts b/apps/x/packages/shared/src/auth.ts deleted file mode 100644 index 79b9dfe9..00000000 --- a/apps/x/packages/shared/src/auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod'; - -/** - * OAuth 2.0 tokens structure - */ -export const OAuthTokens = z.object({ - access_token: z.string(), - refresh_token: z.string().nullable(), - expires_at: z.number(), // Unix timestamp - token_type: z.literal('Bearer').optional(), - scopes: z.array(z.string()).optional(), // Granted scopes from OAuth response -}); - -export type OAuthTokens = z.infer; - diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 008ebab5..878c1043 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -3,5 +3,4 @@ import { PrefixLogger } from './prefix-logger.js'; export * as ipc from './ipc.js'; export * as workspace from './workspace.js'; export * as mcp from './mcp.js'; -export * as auth from './auth.js'; export { PrefixLogger }; \ No newline at end of file