add connected accounts (oauth) feature

This commit is contained in:
Ramnique Singh 2026-01-06 06:56:42 +05:30
parent 47ab50bfe7
commit dfe940d0ba
17 changed files with 1084 additions and 24 deletions

View file

@ -0,0 +1,150 @@
import { OAuthProviderConfig, getProviderConfig } from './providers.js';
import { OAuthTokens } from '@x/shared/dist/auth.js';
/**
* Generic OAuth 2.0 service with PKCE support
*/
export class OAuthService {
constructor(private config: OAuthProviderConfig) {}
/**
* Build authorization URL with PKCE parameters
*/
buildAuthorizationUrl(
codeChallenge: string,
state: string,
redirectUri: string
): string {
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: this.config.scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${this.config.authorizationEndpoint}?${params.toString()}`;
}
/**
* Exchange authorization code for tokens using PKCE
*/
async exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string
): Promise<OAuthTokens> {
const params = new URLSearchParams({
client_id: this.config.clientId,
code,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
code_verifier: codeVerifier,
});
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
const data = await response.json();
// Calculate expires_at from expires_in if provided
const expiresIn = data.expires_in || 3600; // Default to 1 hour
const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
// Extract granted scopes from response (may be space-separated string or array)
let scopes: string[] | undefined;
if (data.scope) {
if (typeof data.scope === 'string') {
scopes = data.scope.split(' ').filter((s: string) => s.length > 0);
} else if (Array.isArray(data.scope)) {
scopes = data.scope;
}
}
return OAuthTokens.parse({
access_token: data.access_token,
refresh_token: data.refresh_token || null,
expires_at: expiresAt,
token_type: data.token_type || 'Bearer',
scopes,
});
}
/**
* Refresh access token using refresh token
* Preserves existing scopes since refresh responses typically don't include them
*/
async refreshAccessToken(refreshToken: string, existingScopes?: string[]): Promise<OAuthTokens> {
const params = new URLSearchParams({
client_id: this.config.clientId,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
}
const data = await response.json();
// Calculate expires_at from expires_in if provided
const expiresIn = data.expires_in || 3600;
const expiresAt = Math.floor(Date.now() / 1000) + expiresIn;
// Extract scopes from refresh response if provided, otherwise preserve existing scopes
let scopes: string[] | undefined = existingScopes;
if (data.scope) {
if (typeof data.scope === 'string') {
scopes = data.scope.split(' ').filter((s: string) => s.length > 0);
} else if (Array.isArray(data.scope)) {
scopes = data.scope;
}
}
return OAuthTokens.parse({
access_token: data.access_token,
refresh_token: data.refresh_token || refreshToken, // Some providers don't return new refresh token
expires_at: expiresAt,
token_type: data.token_type || 'Bearer',
scopes,
});
}
/**
* Check if tokens are expired
*/
isTokenExpired(tokens: OAuthTokens): boolean {
const now = Math.floor(Date.now() / 1000);
return tokens.expires_at <= now;
}
}
/**
* Factory function to create OAuth service for a provider
*/
export function createOAuthService(providerName: string): OAuthService {
const config = getProviderConfig(providerName);
return new OAuthService(config);
}

View file

@ -0,0 +1,34 @@
import { randomBytes, createHash } from 'crypto';
/**
* Generate a random code verifier for PKCE
* Returns a base64url-encoded string of 128 characters
*/
export function generateCodeVerifier(): string {
// Generate 96 random bytes (768 bits) to ensure we have enough entropy
// After base64url encoding, this will be 128 characters
const bytes = randomBytes(96);
return base64UrlEncode(bytes);
}
/**
* Generate a code challenge from a code verifier
* Uses SHA256 hash and base64url encoding
*/
export function generateCodeChallenge(verifier: string): string {
const hash = createHash('sha256').update(verifier).digest();
return base64UrlEncode(hash);
}
/**
* Base64url encode (RFC 4648)
* Replaces + with -, / with _, and removes padding
*/
function base64UrlEncode(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}

View file

@ -0,0 +1,52 @@
/**
* OAuth 2.0 provider configuration
*/
export interface OAuthProviderConfig {
name: string;
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
scopes: string[];
}
/**
* Get Google OAuth provider configuration
*/
export function getGoogleProviderConfig(): OAuthProviderConfig {
// TODO: Replace with actual Google OAuth client ID
const GOOGLE_CLIENT_ID = '797410052581-ibmmvqec0l68stv5fmgh0juqfvbg08fc.apps.googleusercontent.com'
return {
name: 'google',
clientId: GOOGLE_CLIENT_ID,
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
scopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/drive.readonly',
],
};
}
/**
* 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}`);
}
}

View file

@ -0,0 +1,101 @@
import { WorkDir } from '../config/config.js';
import fs from 'fs/promises';
import path from 'path';
import { OAuthTokens } from '@x/shared/dist/auth.js';
export interface IOAuthRepo {
getTokens(provider: string): Promise<OAuthTokens | null>;
saveTokens(provider: string, tokens: OAuthTokens): Promise<void>;
clearTokens(provider: string): Promise<void>;
isConnected(provider: string): Promise<boolean>;
getConnectedProviders(): Promise<string[]>;
}
type OAuthStorage = {
[provider: string]: OAuthTokens;
};
export class FSOAuthRepo implements IOAuthRepo {
private readonly configPath = path.join(WorkDir, 'config', 'oauth.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<OAuthStorage> {
try {
const content = await fs.readFile(this.configPath, 'utf8');
const parsed = JSON.parse(content);
return parsed as OAuthStorage;
} catch {
return {};
}
}
private async writeConfig(config: OAuthStorage): Promise<void> {
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
}
async getTokens(provider: string): Promise<OAuthTokens | null> {
const config = await this.readConfig();
const tokens = config[provider];
if (!tokens) {
return null;
}
// Validate tokens structure
try {
return OAuthTokens.parse(tokens);
} catch {
// Invalid tokens, remove them
await this.clearTokens(provider);
return null;
}
}
async saveTokens(provider: string, tokens: OAuthTokens): Promise<void> {
const config = await this.readConfig();
config[provider] = tokens;
await this.writeConfig(config);
}
async clearTokens(provider: string): Promise<void> {
const config = await this.readConfig();
delete config[provider];
await this.writeConfig(config);
}
async isConnected(provider: string): Promise<boolean> {
const tokens = await this.getTokens(provider);
if (!tokens) {
return false;
}
// Check if token is expired
const now = Math.floor(Date.now() / 1000);
return tokens.expires_at > now;
}
async getConnectedProviders(): Promise<string[]> {
const config = await this.readConfig();
const connected: string[] = [];
for (const provider of Object.keys(config)) {
if (await this.isConnected(provider)) {
connected.push(provider);
}
}
return connected;
}
}

View file

@ -8,6 +8,7 @@ import { IMessageQueue, InMemoryMessageQueue } from "../application/lib/message-
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";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -25,6 +26,7 @@ container.register({
modelConfigRepo: asClass<IModelConfigRepo>(FSModelConfigRepo).singleton(),
agentsRepo: asClass<IAgentsRepo>(FSAgentsRepo).singleton(),
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
});
export default container;