Skip to main content
Rowboat implements OAuth 2.0 with PKCE (Proof Key for Code Exchange) for secure authentication with third-party services like Google and Fireflies.ai.

Architecture Overview

Rowboat App

@x/core/auth/

openid-client (RFC 7636 PKCE)

┌──────────┬──────────┬──────────┐
│  Google  │Fireflies │   ...    │
└──────────┴──────────┴──────────┘

OAuth Flow with PKCE

PKCE (RFC 7636) eliminates the need for client secrets in native/desktop apps. Rowboat uses S256 code challenge method for maximum security.

Authorization Flow Diagram

┌──────────┐                                      ┌──────────┐
│ Rowboat  │                                      │  OAuth   │
│   App    │                                      │ Provider │
└────┬─────┘                                      └────┬─────┘
     │                                                 │
     │ 1. Generate PKCE verifier + challenge           │
     ├─────────────────────────────────────────────────┤
     │                                                 │
     │ 2. Open browser with authorization URL          │
     │    + code_challenge + state                     │
     ├────────────────────────────────────────────────>│
     │                                                 │
     │                            3. User authenticates│
     │                               & grants consent  │
     │                                                 │
     │ 4. Redirect to localhost with code + state      │
     │<────────────────────────────────────────────────┤
     │                                                 │
     │ 5. Exchange code + verifier for tokens          │
     ├────────────────────────────────────────────────>│
     │                                                 │
     │ 6. Return access_token + refresh_token          │
     │<────────────────────────────────────────────────┤
     │                                                 │
     │ 7. Store tokens in secure storage               │
     │                                                 │

Core Components

1. Provider Configuration

Each OAuth provider has a configuration defining discovery and client registration:
Location: core/src/auth/providers.tsSchema:
interface ProviderConfig {
  discovery: {
    mode: 'issuer' | 'static';
    issuer?: string;              // For OIDC discovery
    authorizationEndpoint?: string; // For static config
    tokenEndpoint?: string;       // For static config
    revocationEndpoint?: string;  // Optional
  };
  client: {
    mode: 'static' | 'dcr';       // Static ID or Dynamic Registration
    clientId?: string;            // For static mode
    registrationEndpoint?: string; // For DCR mode
  };
  scopes: string[];               // Requested OAuth scopes
}
Google Configuration:
const providerConfigs: ProviderConfig = {
  google: {
    discovery: {
      mode: 'issuer',
      issuer: 'https://accounts.google.com',
    },
    client: {
      mode: 'static',
      // clientId provided by user or environment
    },
    scopes: [
      'https://www.googleapis.com/auth/gmail.readonly',
      'https://www.googleapis.com/auth/calendar.events.readonly',
      'https://www.googleapis.com/auth/drive.readonly',
    ],
  },
};
Fireflies Configuration (DCR):
const providerConfigs: ProviderConfig = {
  'fireflies-ai': {
    discovery: {
      mode: 'issuer',
      issuer: 'https://api.fireflies.ai/.well-known/oauth-authorization-server',
    },
    client: {
      mode: 'dcr', // Dynamic Client Registration
    },
    scopes: ['profile', 'email'],
  },
};

2. OAuth Client (oauth-client.ts)

Discovery Process:
  1. OIDC Discovery (issuer mode):
    • Fetch {issuer}/.well-known/openid-configuration
    • Extract authorization_endpoint, token_endpoint, etc.
    • Cache configuration per issuer:clientId
  2. Static Configuration:
    • Use pre-configured endpoints
    • No discovery request needed
Implementation:
// core/src/auth/oauth-client.ts
import * as client from 'openid-client';

const configCache = new Map<string, client.Configuration>();

export async function discoverConfiguration(
  issuerUrl: string,
  clientId: string
): Promise<client.Configuration> {
  const cacheKey = `${issuerUrl}:${clientId}`;
  
  const cached = configCache.get(cacheKey);
  if (cached) return cached;
  
  console.log(`[OAuth] Discovering ${issuerUrl}...`);
  const config = await client.discovery(
    new URL(issuerUrl),
    clientId,
    undefined,        // no client_secret (PKCE)
    client.None()     // PKCE auth
  );
  
  configCache.set(cacheKey, config);
  return config;
}
Purpose: Generate cryptographically secure code verifier and challengeImplementation:
export async function generatePKCE(): Promise<{
  verifier: string;
  challenge: string;
}> {
  const verifier = client.randomPKCECodeVerifier();
  const challenge = await client.calculatePKCECodeChallenge(verifier);
  return { verifier, challenge };
}
RFC 7636 Spec:
  • Verifier: 43-128 characters, base64url-encoded
  • Challenge: SHA-256 hash of verifier, base64url-encoded
  • Method: S256 (SHA-256)
Process:
  1. Generate PKCE verifier + challenge
  2. Generate random state (CSRF protection)
  3. Build authorization URL with parameters
  4. Open URL in system browser
Implementation:
export function buildAuthorizationUrl(
  config: client.Configuration,
  params: Record<string, string>
): URL {
  return client.buildAuthorizationUrl(config, {
    code_challenge_method: 'S256',
    ...params,
  });
}
Example URL:
https://accounts.google.com/o/oauth2/v2/auth?
  client_id=123456.apps.googleusercontent.com
  &redirect_uri=http://localhost:8080/callback
  &response_type=code
  &scope=openid email profile
  &state=abc123
  &code_challenge=xyz789
  &code_challenge_method=S256
Process:
  1. Capture authorization code from redirect
  2. Validate state matches (CSRF protection)
  3. Exchange code + verifier for tokens
  4. Parse and return tokens
Implementation:
export async function exchangeCodeForTokens(
  config: client.Configuration,
  callbackUrl: URL,
  codeVerifier: string,
  expectedState: string
): Promise<OAuthTokens> {
  console.log(`[OAuth] Exchanging code for tokens...`);
  
  const response = await client.authorizationCodeGrant(
    config,
    callbackUrl,
    {
      pkceCodeVerifier: codeVerifier,
      expectedState,
    }
  );
  
  return toOAuthTokens(response);
}
Token Response:
interface OAuthTokens {
  access_token: string;
  refresh_token: string | null;
  expires_at: number;      // Unix timestamp
  token_type: 'Bearer';
  scopes?: string[];
}
Process:
  1. Check if access token is expired
  2. Use refresh token to get new access token
  3. Preserve existing scopes/refresh token if not returned
Implementation:
export async function refreshTokens(
  config: client.Configuration,
  refreshToken: string,
  existingScopes?: string[]
): Promise<OAuthTokens> {
  console.log(`[OAuth] Refreshing access token...`);
  
  const response = await client.refreshTokenGrant(config, refreshToken);
  const tokens = toOAuthTokens(response);
  
  // Preserve existing scopes if server didn't return them
  if (!tokens.scopes && existingScopes) {
    tokens.scopes = existingScopes;
  }
  
  // Preserve existing refresh token if server didn't return new one
  if (!tokens.refresh_token) {
    tokens.refresh_token = refreshToken;
  }
  
  return tokens;
}

export function isTokenExpired(tokens: OAuthTokens): boolean {
  const now = Math.floor(Date.now() / 1000);
  return tokens.expires_at <= now;
}

3. Dynamic Client Registration (DCR)

DCR (RFC 7591) allows apps to register OAuth clients programmatically, eliminating the need for pre-registered client IDs.
When to use DCR:
  • Provider supports RFC 7591
  • No pre-registered client ID available
  • Want to avoid hardcoding client credentials
Implementation:
export async function registerClient(
  issuerUrl: string,
  redirectUris: string[],
  scopes: string[],
  clientName: string = 'RowboatX Desktop App'
): Promise<{
  config: client.Configuration;
  registration: ClientRegistrationResponse;
}> {
  console.log(`[OAuth] Registering client via DCR at ${issuerUrl}...`);
  
  const config = await client.dynamicClientRegistration(
    new URL(issuerUrl),
    {
      redirect_uris: redirectUris,
      token_endpoint_auth_method: 'none', // PKCE flow
      grant_types: ['authorization_code', 'refresh_token'],
      response_types: ['code'],
      client_name: clientName,
      scope: scopes.join(' '),
    },
    client.None()
  );
  
  const metadata = config.clientMetadata();
  console.log(`[OAuth] DCR complete, client_id: ${metadata.client_id}`);
  
  // Extract registration response for persistence
  const registration = {
    client_id: metadata.client_id,
    client_secret: metadata.client_secret,
    client_id_issued_at: metadata.client_id_issued_at,
    client_secret_expires_at: metadata.client_secret_expires_at,
  };
  
  return { config, registration };
}
Registration Response:
interface ClientRegistrationResponse {
  client_id: string;
  client_secret?: string;           // Not used in PKCE
  client_id_issued_at?: number;
  client_secret_expires_at?: number;
}

4. Token Storage (repo.ts)

Purpose: Securely persist OAuth tokens per providerStorage Location: ~/.rowboat/auth/{provider}.jsonSchema:
interface StoredAuth {
  provider: string;
  tokens: OAuthTokens;
  registration?: ClientRegistrationResponse; // For DCR
  createdAt: string;
  updatedAt: string;
}
Implementation:
// core/src/auth/repo.ts
class FSAuthRepo {
  private authDir = path.join(WorkDir, 'auth');
  
  async saveTokens(
    provider: string,
    tokens: OAuthTokens,
    registration?: ClientRegistrationResponse
  ): Promise<void> {
    const authFile = path.join(this.authDir, `${provider}.json`);
    const data: StoredAuth = {
      provider,
      tokens,
      registration,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    
    await fs.writeFile(authFile, JSON.stringify(data, null, 2));
  }
  
  async getTokens(provider: string): Promise<OAuthTokens | null> {
    const authFile = path.join(this.authDir, `${provider}.json`);
    
    try {
      const data = await fs.readFile(authFile, 'utf8');
      const stored: StoredAuth = JSON.parse(data);
      return stored.tokens;
    } catch {
      return null;
    }
  }
  
  async deleteTokens(provider: string): Promise<void> {
    const authFile = path.join(this.authDir, `${provider}.json`);
    await fs.unlink(authFile);
  }
}

Complete OAuth Flow Example

Google OAuth Flow

import {
  discoverConfiguration,
  generatePKCE,
  generateState,
  buildAuthorizationUrl,
  exchangeCodeForTokens,
} from '@x/core/dist/auth/oauth-client.js';
import { getProviderConfig } from '@x/core/dist/auth/providers.js';
import { FSAuthRepo } from '@x/core/dist/auth/repo.js';

// 1. Get provider configuration
const providerConfig = getProviderConfig('google');
const clientId = '123456.apps.googleusercontent.com'; // From Google Console

// 2. Discover OAuth endpoints
const config = await discoverConfiguration(
  providerConfig.discovery.issuer,
  clientId
);

// 3. Generate PKCE parameters
const { verifier, challenge } = await generatePKCE();
const state = generateState();

// 4. Build authorization URL
const authUrl = buildAuthorizationUrl(config, {
  redirect_uri: 'http://localhost:8080/callback',
  scope: providerConfig.scopes.join(' '),
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
});

// 5. Open browser and wait for callback
shell.openExternal(authUrl.toString());
const callbackUrl = await waitForCallback(); // Start local server

// 6. Exchange authorization code for tokens
const tokens = await exchangeCodeForTokens(
  config,
  callbackUrl,
  verifier,
  state
);

// 7. Save tokens
const authRepo = new FSAuthRepo();
await authRepo.saveTokens('google', tokens);

console.log('OAuth flow complete!');

Security Considerations

NEVER store client secrets in desktop apps. Use PKCE instead, which eliminates the need for secrets.
  • No client secret: Cannot be extracted from app bundle
  • One-time verifier: Different verifier for each authorization
  • Challenge verification: Server validates verifier matches challenge
  • MITM protection: Attacker cannot use intercepted code without verifier
Purpose: Prevent cross-site request forgery attacksHow it works:
  1. Generate random state before authorization
  2. Include state in authorization URL
  3. Provider returns state in callback
  4. Validate returned state matches original
Implementation:
const state = generateState(); // Random 32-char string

// Store state temporarily (in-memory or session)
const pendingStates = new Map<string, { verifier: string }>();
pendingStates.set(state, { verifier });

// Validate on callback
const returnedState = callbackUrl.searchParams.get('state');
if (returnedState !== state) {
  throw new Error('State mismatch - possible CSRF attack');
}
Best practices:
  • Use http://localhost:{port}/callback for desktop apps
  • Choose random available port (8080-8090)
  • Close local server after receiving callback
  • Validate redirect URI matches registered URI
Implementation:
import http from 'http';

async function waitForCallback(): Promise<URL> {
  return new Promise((resolve, reject) => {
    const server = http.createServer((req, res) => {
      const url = new URL(req.url!, `http://localhost:8080`);
      
      // Send success response to browser
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end('<h1>Authorization successful! You can close this window.</h1>');
      
      // Close server and resolve
      server.close();
      resolve(url);
    });
    
    server.listen(8080);
  });
}
Current implementation:
  • Tokens stored in ~/.rowboat/auth/ as JSON files
  • File permissions: 600 (owner read/write only)
  • No encryption (relies on OS file permissions)
Future improvements:
  • Use OS keychain (macOS Keychain, Windows Credential Manager)
  • Encrypt tokens at rest
  • Use secure enclave for token encryption keys

Error Handling

try {
  const tokens = await exchangeCodeForTokens(...);
} catch (error) {
  if (error instanceof Error) {
    // Handle specific errors
    if (error.message.includes('invalid_grant')) {
      console.error('Authorization code expired or invalid');
    } else if (error.message.includes('invalid_client')) {
      console.error('Invalid client ID or configuration');
    } else if (error.message.includes('access_denied')) {
      console.error('User denied authorization');
    } else {
      console.error('OAuth error:', error.message);
    }
  }
}
Error Types:
  • invalid_grant - Code expired, already used, or invalid
  • invalid_client - Client ID not recognized
  • invalid_request - Missing required parameters
  • access_denied - User declined authorization
  • server_error - Provider had an internal error

Testing OAuth Flows

// Test discovery
const config = await discoverConfiguration(
  'https://accounts.google.com',
  'test-client-id'
);
console.log('Discovery successful:', config);

// Test PKCE generation
const { verifier, challenge } = await generatePKCE();
console.log('Verifier:', verifier);
console.log('Challenge:', challenge);

// Test authorization URL
const authUrl = buildAuthorizationUrl(config, {
  redirect_uri: 'http://localhost:8080/callback',
  scope: 'openid email',
  state: 'test-state',
  code_challenge: challenge,
  code_challenge_method: 'S256',
});
console.log('Auth URL:', authUrl.toString());

Code References

  • OAuth Client: core/src/auth/oauth-client.ts:1
  • Provider Configs: core/src/auth/providers.ts:53
  • Token Repository: core/src/auth/repo.ts
  • Token Types: core/src/auth/types.ts
  • Client ID Provider: core/src/auth/provider-client-id.ts

Build docs developers (and LLMs) love