mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 00:46:23 +02:00
refactor oauth for discovery and dcr
This commit is contained in:
parent
47ac8222cd
commit
642dd7a167
13 changed files with 661 additions and 300 deletions
75
apps/x/packages/core/src/auth/client-repo.ts
Normal file
75
apps/x/packages/core/src/auth/client-repo.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
86
apps/x/packages/core/src/auth/discovery.ts
Normal file
86
apps/x/packages/core/src/auth/discovery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
43
apps/x/packages/core/src/auth/types.ts
Normal file
43
apps/x/packages/core/src/auth/types.ts
Normal 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>;
|
||||
|
||||
|
|
@ -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;
|
||||
200
apps/x/packages/core/src/knowledge/google-client-factory.ts
Normal file
200
apps/x/packages/core/src/knowledge/google-client-factory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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...");
|
||||
|
|
|
|||
|
|
@ -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...");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue