refactor oauth for discovery and dcr

This commit is contained in:
Ramnique Singh 2026-01-07 08:08:08 +05:30
parent 47ac8222cd
commit 642dd7a167
13 changed files with 661 additions and 300 deletions

View file

@ -2,10 +2,12 @@ import { BrowserWindow } from 'electron';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { createAuthServer } from './auth-server.js'; import { createAuthServer } from './auth-server.js';
import { generateCodeVerifier, generateCodeChallenge } from '@x/core/dist/auth/pkce.js'; import { generateCodeVerifier, generateCodeChallenge } from '@x/core/dist/auth/pkce.js';
import { createOAuthService } from '@x/core/dist/auth/oauth.js'; import { OAuthService } from '@x/core/dist/auth/oauth.js';
import { getAvailableProviders } from '@x/core/dist/auth/providers.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 container from '@x/core/dist/di/container.js';
import { IOAuthRepo } from '@x/core/dist/auth/repo.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'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
@ -26,13 +28,106 @@ function getOAuthRepo(): IOAuthRepo {
return container.resolve<IOAuthRepo>('oauthRepo'); return container.resolve<IOAuthRepo>('oauthRepo');
} }
/**
* Get client registration repository from DI container
*/
function getClientRegistrationRepo(): IClientRegistrationRepo {
return container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
}
/**
* Discover or get provider metadata
*/
async function getProviderMetadata(provider: string): Promise<AuthorizationServerMetadata> {
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<string> {
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 * Initiate OAuth flow for a provider
*/ */
export async function connectProvider(provider: string): Promise<{ success: boolean; error?: string }> { export async function connectProvider(provider: string): Promise<{ success: boolean; error?: string }> {
try { try {
const oauthService = createOAuthService(provider); console.log(`[OAuth] Starting connection flow for ${provider}...`);
const oauthRepo = getOAuthRepo(); 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 // Generate PKCE codes
const codeVerifier = generateCodeVerifier(); const codeVerifier = generateCodeVerifier();
@ -56,6 +151,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool
try { try {
// Exchange code for tokens // Exchange code for tokens
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
const tokens = await oauthService.exchangeCodeForTokens( const tokens = await oauthService.exchangeCodeForTokens(
code, code,
flow.codeVerifier, flow.codeVerifier,
@ -63,6 +159,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool
); );
// Save tokens // Save tokens
console.log(`[OAuth] Token exchange successful for ${provider}`);
await oauthRepo.saveTokens(provider, tokens); await oauthRepo.saveTokens(provider, tokens);
} catch (error) { } catch (error) {
console.error('OAuth token exchange failed:', 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<string | null> { export async function getAccessToken(provider: string): Promise<string | null> {
try { try {
const oauthRepo = getOAuthRepo(); const oauthRepo = getOAuthRepo();
const oauthService = createOAuthService(provider); const config = getProviderConfig(provider);
let tokens = await oauthRepo.getTokens(provider); let tokens = await oauthRepo.getTokens(provider);
if (!tokens) { if (!tokens) {
return null; 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 // Check if token needs refresh
if (oauthService.isTokenExpired(tokens)) { if (oauthService.isTokenExpired(tokens)) {
if (!tokens.refresh_token) { if (!tokens.refresh_token) {
@ -158,7 +265,7 @@ export async function getAccessToken(provider: string): Promise<string | null> {
try { try {
// Refresh token, preserving existing scopes // Refresh token, preserving existing scopes
const existingScopes = (tokens).scopes; const existingScopes = tokens.scopes;
tokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes); tokens = await oauthService.refreshAccessToken(tokens.refresh_token, existingScopes);
await oauthRepo.saveTokens(provider, tokens); await oauthRepo.saveTokens(provider, tokens);
} catch (error) { } catch (error) {

View file

@ -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<ClientRegistrationResponse | null>;
saveClientRegistration(provider: string, registration: ClientRegistrationResponse): Promise<void>;
clearClientRegistration(provider: string): Promise<void>;
}
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<void> {
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<ClientRegistrationStorage> {
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<void> {
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
}
async getClientRegistration(provider: string): Promise<ClientRegistrationResponse | null> {
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<void> {
const config = await this.readConfig();
config[provider] = registration;
await this.writeConfig(config);
}
async clearClientRegistration(provider: string): Promise<void> {
const config = await this.readConfig();
delete config[provider];
await this.writeConfig(config);
}
}

View file

@ -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<typeof AuthorizationServerMetadataSchema>;
/**
* Discover OAuth 2.0 authorization server metadata
* Tries RFC 8414 endpoint first, falls back to OIDC Discovery
*/
export async function discoverAuthorizationServer(issuer: string): Promise<AuthorizationServerMetadata> {
// 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,
});
}

View file

@ -1,11 +1,15 @@
import { OAuthProviderConfig, getProviderConfig } from './providers.js'; import { AuthorizationServerMetadata } from './discovery.js';
import { OAuthTokens } from '@x/shared/dist/auth.js'; import { OAuthTokens, ClientRegistrationRequest, ClientRegistrationResponse } from './types.js';
/** /**
* Generic OAuth 2.0 service with PKCE support * Generic OAuth 2.0 service with PKCE support
*/ */
export class OAuthService { export class OAuthService {
constructor(private config: OAuthProviderConfig) {} constructor(
private metadata: AuthorizationServerMetadata,
private clientId: string,
private scopes: string[]
) {}
/** /**
* Build authorization URL with PKCE parameters * Build authorization URL with PKCE parameters
@ -16,16 +20,16 @@ export class OAuthService {
redirectUri: string redirectUri: string
): string { ): string {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: this.config.clientId, client_id: this.clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
response_type: 'code', response_type: 'code',
scope: this.config.scopes.join(' '), scope: this.scopes.join(' '),
state, state,
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: 'S256', 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 redirectUri: string
): Promise<OAuthTokens> { ): Promise<OAuthTokens> {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: this.config.clientId, client_id: this.clientId,
code, code,
redirect_uri: redirectUri, redirect_uri: redirectUri,
grant_type: 'authorization_code', grant_type: 'authorization_code',
code_verifier: codeVerifier, code_verifier: codeVerifier,
}); });
const response = await fetch(this.config.tokenEndpoint, { const response = await fetch(this.metadata.token_endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@ -88,12 +92,12 @@ export class OAuthService {
*/ */
async refreshAccessToken(refreshToken: string, existingScopes?: string[]): Promise<OAuthTokens> { async refreshAccessToken(refreshToken: string, existingScopes?: string[]): Promise<OAuthTokens> {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: this.config.clientId, client_id: this.clientId,
refresh_token: refreshToken, refresh_token: refreshToken,
grant_type: 'refresh_token', grant_type: 'refresh_token',
}); });
const response = await fetch(this.config.tokenEndpoint, { const response = await fetch(this.metadata.token_endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@ -138,13 +142,43 @@ export class OAuthService {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
return tokens.expires_at <= now; return tokens.expires_at <= now;
} }
}
/**
/** * Register client using Dynamic Client Registration (RFC 7591)
* Factory function to create OAuth service for a provider */
*/ async registerClient(
export function createOAuthService(providerName: string): OAuthService { redirectUris: string[],
const config = getProviderConfig(providerName); scopes: string[]
return new OAuthService(config); ): Promise<ClientRegistrationResponse> {
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);
}
} }

View file

@ -1,52 +1,95 @@
/** import { z } from 'zod';
* OAuth 2.0 provider configuration
*/
export interface OAuthProviderConfig {
name: string;
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
scopes: string[];
}
/** /**
* Get Google OAuth provider configuration * Discovery configuration - how to get OAuth endpoints
*/ */
export function getGoogleProviderConfig(): OAuthProviderConfig { const DiscoverySchema = z.discriminatedUnion('mode', [
// TODO: Replace with actual Google OAuth client ID z.object({
const GOOGLE_CLIENT_ID = '797410052581-ibmmvqec0l68stv5fmgh0juqfvbg08fc.apps.googleusercontent.com' 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', * Client configuration - how to get client credentials
clientId: GOOGLE_CLIENT_ID, */
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', const ClientSchema = z.discriminatedUnion('mode', [
tokenEndpoint: 'https://oauth2.googleapis.com/token', 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<typeof ProviderConfigSchema>;
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: [ scopes: [
'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/drive.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 * Get list of all configured OAuth providers
*/ */
export function getAvailableProviders(): string[] { export function getAvailableProviders(): string[] {
return ['google']; return Object.keys(providerConfigs);
// 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}`);
}
} }

View file

@ -1,7 +1,7 @@
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { OAuthTokens } from '@x/shared/dist/auth.js'; import { OAuthTokens } from './types.js';
export interface IOAuthRepo { export interface IOAuthRepo {
getTokens(provider: string): Promise<OAuthTokens | null>; getTokens(provider: string): Promise<OAuthTokens | null>;

View file

@ -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<typeof OAuthTokens>;
/**
* 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<typeof ClientRegistrationRequest>;
/**
* 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<typeof ClientRegistrationResponse>;

View file

@ -9,6 +9,7 @@ import { IBus, InMemoryBus } from "../application/lib/bus.js";
import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js"; import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js";
import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js"; import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
const container = createContainer({ const container = createContainer({
injectionMode: InjectionMode.PROXY, injectionMode: InjectionMode.PROXY,
@ -27,6 +28,7 @@ container.register({
agentsRepo: asClass<IAgentsRepo>(FSAgentsRepo).singleton(), agentsRepo: asClass<IAgentsRepo>(FSAgentsRepo).singleton(),
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(), runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(), oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
}); });
export default container; export default container;

View file

@ -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<OAuth2Client | null> {
const oauthRepo = container.resolve<IOAuthRepo>('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<boolean> {
const oauthRepo = container.resolve<IOAuthRepo>('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<void> {
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<IClientRegistrationRepo>('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;
}
}

View file

@ -3,12 +3,8 @@ import path from 'path';
import { google, calendar_v3 as cal, drive_v3 as drive } from 'googleapis'; import { google, calendar_v3 as cal, drive_v3 as drive } from 'googleapis';
import { OAuth2Client } from 'google-auth-library'; import { OAuth2Client } from 'google-auth-library';
import { NodeHtmlMarkdown } from 'node-html-markdown' 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 { WorkDir } from '../config/config.js';
import { OAuthTokens } from 'packages/shared/dist/auth.js'; import { GoogleClientFactory } from './google-client-factory.js';
// Configuration // Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); 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/calendar.readonly',
'https://www.googleapis.com/auth/drive.readonly' 'https://www.googleapis.com/auth/drive.readonly'
]; ];
const PROVIDER_NAME = 'google';
const nhm = new NodeHtmlMarkdown(); const nhm = new NodeHtmlMarkdown();
// --- Auth Functions ---
/**
* Get OAuth repository from DI container
*/
function getOAuthRepo(): IOAuthRepo {
return container.resolve<IOAuthRepo>('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<OAuth2Client | null> {
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<boolean> {
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 --- // --- Helper Functions ---
function cleanFilename(name: string): string { 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 // If 401, clear tokens to force re-auth next run
const e = error as { response?: { status?: number } }; const e = error as { response?: { status?: number } };
if (e.response?.status === 401) { if (e.response?.status === 401) {
console.log("401 Unauthorized. Clearing tokens to force re-authentication."); console.log("401 Unauthorized, clearing cache");
const oauthRepo = getOAuthRepo(); GoogleClientFactory.clearCache();
await oauthRepo.clearTokens(PROVIDER_NAME);
} }
throw error; // Re-throw to be handled by performSync 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 }); fs.mkdirSync(SYNC_DIR, { recursive: true });
} }
const auth = await createOAuth2Client(); const auth = await GoogleClientFactory.getClient();
if (!auth) { if (!auth) {
console.log("No valid OAuth credentials available."); console.log("No valid OAuth credentials available.");
return; return;
@ -310,9 +203,8 @@ async function performSync(syncDir: string, lookbackDays: number) {
// If 401, clear tokens to force re-auth next run // If 401, clear tokens to force re-auth next run
const e = error as { response?: { status?: number } }; const e = error as { response?: { status?: number } };
if (e.response?.status === 401) { if (e.response?.status === 401) {
console.log("401 Unauthorized. Clearing tokens to force re-authentication."); console.log("401 Unauthorized, clearing cache");
const oauthRepo = getOAuthRepo(); GoogleClientFactory.clearCache();
await oauthRepo.clearTokens(PROVIDER_NAME);
} }
} }
} }
@ -324,7 +216,7 @@ export async function init() {
while (true) { while (true) {
try { try {
// Check if credentials are available with required scopes // Check if credentials are available with required scopes
const hasCredentials = await hasValidCredentials(); const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPES);
if (!hasCredentials) { if (!hasCredentials) {
console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping..."); console.log("Google OAuth credentials not available or missing required Calendar/Drive scopes. Sleeping...");

View file

@ -4,120 +4,16 @@ import { google, gmail_v1 as gmail } from 'googleapis';
import { NodeHtmlMarkdown } from 'node-html-markdown' import { NodeHtmlMarkdown } from 'node-html-markdown'
import { OAuth2Client } from 'google-auth-library'; import { OAuth2Client } from 'google-auth-library';
import { WorkDir } from '../config/config.js'; 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 { buildGraph } from './build_graph.js';
import { GoogleClientFactory } from './google-client-factory.js';
// Configuration // Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const SYNC_INTERVAL_MS = 60 * 1000; // Check every minute const SYNC_INTERVAL_MS = 60 * 1000; // Check every minute
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
const PROVIDER_NAME = 'google';
const nhm = new NodeHtmlMarkdown(); const nhm = new NodeHtmlMarkdown();
// --- Auth Functions ---
/**
* Get OAuth repository from DI container
*/
function getOAuthRepo(): IOAuthRepo {
return container.resolve<IOAuthRepo>('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<OAuth2Client | null> {
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<boolean> {
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 --- // --- Helper Functions ---
function cleanFilename(name: string): string { function cleanFilename(name: string): string {
@ -350,9 +246,8 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
console.error("Error during partial sync:", error); console.error("Error during partial sync:", error);
// If 401, clear tokens to force re-auth next run // If 401, clear tokens to force re-auth next run
if (e.response?.status === 401) { if (e.response?.status === 401) {
console.log("401 Unauthorized. Clearing tokens to force re-authentication."); console.log("401 Unauthorized, clearing cache");
const oauthRepo = getOAuthRepo(); GoogleClientFactory.clearCache();
await oauthRepo.clearTokens(PROVIDER_NAME);
} }
} }
} }
@ -368,7 +263,7 @@ async function performSync() {
if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
try { try {
const auth = await createOAuth2Client(); const auth = await GoogleClientFactory.getClient();
if (!auth) { if (!auth) {
console.log("No valid OAuth credentials available."); console.log("No valid OAuth credentials available.");
return; return;
@ -406,7 +301,7 @@ export async function init() {
while (true) { while (true) {
try { try {
// Check if credentials are available with required scopes // Check if credentials are available with required scopes
const hasCredentials = await hasValidCredentials(); const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE);
if (!hasCredentials) { if (!hasCredentials) {
console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping...");

View file

@ -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<typeof OAuthTokens>;

View file

@ -3,5 +3,4 @@ import { PrefixLogger } from './prefix-logger.js';
export * as ipc from './ipc.js'; export * as ipc from './ipc.js';
export * as workspace from './workspace.js'; export * as workspace from './workspace.js';
export * as mcp from './mcp.js'; export * as mcp from './mcp.js';
export * as auth from './auth.js';
export { PrefixLogger }; export { PrefixLogger };