mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 17:06:23 +02:00
add connected accounts (oauth) feature
This commit is contained in:
parent
47ab50bfe7
commit
dfe940d0ba
17 changed files with 1084 additions and 24 deletions
150
apps/x/packages/core/src/auth/oauth.ts
Normal file
150
apps/x/packages/core/src/auth/oauth.ts
Normal 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);
|
||||
}
|
||||
|
||||
34
apps/x/packages/core/src/auth/pkce.ts
Normal file
34
apps/x/packages/core/src/auth/pkce.ts
Normal 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, '');
|
||||
}
|
||||
|
||||
52
apps/x/packages/core/src/auth/providers.ts
Normal file
52
apps/x/packages/core/src/auth/providers.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
101
apps/x/packages/core/src/auth/repo.ts
Normal file
101
apps/x/packages/core/src/auth/repo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue