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

@ -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 { 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<OAuthTokens> {
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<OAuthTokens> {
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<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 @@
/**
* 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<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: [
'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);
}

View file

@ -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<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 { 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<IAgentsRepo>(FSAgentsRepo).singleton(),
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
});
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 { 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<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 ---
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...");

View file

@ -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<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 ---
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...");