16 KiB
Technical Approach: Web OAuth Flow Implementation
Document Version: 1.0
Last Updated: 2026-01-09
Status: Planning Phase
Architecture Overview
Current Architecture (Device Flow)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ OpenNoodl │────1───>│ Browser │────2───>│ GitHub │
│ Editor │ │ │ │ OAuth │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ 3. User enters │
│ device code │
│ │
└──────────────────4. Poll for token────────────┘
Limitations:
- No org/repo selection UI
- Polling is inefficient
- Cannot handle organization permissions properly
Target Architecture (Web OAuth Flow)
┌─────────────┐ 1. Auth URL ┌─────────────┐ 2. Navigate ┌─────────────┐
│ OpenNoodl │──────with state───>│ Browser │───────────────>│ GitHub │
│ Editor │ │ │ │ OAuth │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ │ 3. User selects │
│ │ orgs/repos │
│ │ │
│ │<─────4. Redirect with code─────┘
│ │
│<───────5. HTTP callback──────────┘
│ (localhost:PORT)
│
└────────────6. Exchange code for token──────────┐
│
┌──────────7. Store token + metadata──────────────┘
│
└────────────8. Update UI with orgs
Component Design
1. OAuth Callback Handler (Electron Main Process)
Location: packages/noodl-editor/src/main/github-oauth-handler.ts
Responsibilities:
- Create temporary HTTP server on localhost
- Handle OAuth callback requests
- Validate state parameter (CSRF protection)
- Exchange authorization code for access token
- Store installation metadata
- Notify renderer process of completion
Key Functions:
class GitHubOAuthCallbackHandler {
private server: http.Server | null = null;
private port: number = 3000;
private pendingAuth: Map<string, OAuthPendingAuth> = new Map();
/**
* Start HTTP server to handle OAuth callbacks
* Tries multiple ports if first is busy
*/
async startCallbackServer(): Promise<number>;
/**
* Handle incoming callback request
* Validates state and exchanges code for token
*/
private async handleCallback(req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
/**
* Exchange authorization code for access token
* Makes POST request to GitHub token endpoint
*/
private async exchangeCodeForToken(code: string): Promise<GitHubToken>;
/**
* Stop callback server
* Called after successful auth or timeout
*/
async stopCallbackServer(): Promise<void>;
}
Server Lifecycle:
- Started when user clicks "Connect GitHub"
- Listens on
http://localhost:PORT/github/callback - Handles single callback request
- Automatically stops after success or 5-minute timeout
Port Selection Strategy:
const PORTS_TO_TRY = [3000, 3001, 3002, 3003, 3004];
for (const port of PORTS_TO_TRY) {
try {
await server.listen(port);
return port; // Success
} catch (error) {
if (error.code === 'EADDRINUSE') {
continue; // Try next port
}
throw error; // Other error
}
}
throw new Error('No available ports for OAuth callback');
2. Web OAuth Flow (GitHubAuth Service)
Location: packages/noodl-editor/src/editor/src/services/github/GitHubAuth.ts
New Methods:
export class GitHubAuth {
/**
* Start Web OAuth flow
* Generates authorization URL and opens browser
*/
static async startWebOAuthFlow(onProgress?: (message: string) => void): Promise<GitHubWebAuthResult> {
// 1. Start callback server
const port = await this.startCallbackServer();
// 2. Generate OAuth state
const state = this.generateOAuthState();
// 3. Build authorization URL
const authUrl = this.buildAuthorizationUrl(state, port);
// 4. Open browser
shell.openExternal(authUrl);
// 5. Wait for callback
return this.waitForCallback(state);
}
/**
* Generate secure random state for CSRF protection
*/
private static generateOAuthState(): string {
return crypto.randomBytes(32).toString('hex');
}
/**
* Build GitHub authorization URL
*/
private static buildAuthorizationUrl(state: string, port: number): string {
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: `http://127.0.0.1:${port}/github/callback`,
scope: REQUIRED_SCOPES.join(' '),
state: state,
allow_signup: 'true'
});
return `https://github.com/login/oauth/authorize?${params}`;
}
/**
* Wait for OAuth callback with timeout
*/
private static async waitForCallback(
state: string,
timeoutMs: number = 300000 // 5 minutes
): Promise<GitHubWebAuthResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('OAuth flow timed out'));
}, timeoutMs);
// Listen for IPC message from main process
ipcRenderer.once('github-oauth-complete', (event, result) => {
clearTimeout(timeout);
resolve(result);
});
ipcRenderer.once('github-oauth-error', (event, error) => {
clearTimeout(timeout);
reject(new Error(error.message));
});
});
}
}
3. Installation Metadata Storage
Location: packages/noodl-editor/src/editor/src/services/github/GitHubTokenStore.ts
Enhanced Storage Schema:
interface StoredGitHubAuth {
token: GitHubToken;
user: GitHubUser;
storedAt: string;
// NEW: Installation metadata
installations?: GitHubInstallation[];
authMethod: 'device_flow' | 'web_oauth';
}
interface GitHubInstallation {
id: number;
account: {
login: string;
type: 'User' | 'Organization';
avatar_url: string;
};
repository_selection: 'all' | 'selected';
repositories?: GitHubRepository[];
created_at: string;
updated_at: string;
}
New Methods:
export class GitHubTokenStore {
/**
* Save token with installation metadata
*/
static saveTokenWithInstallations(token: GitHubToken, user: GitHubUser, installations: GitHubInstallation[]): void {
const auth: StoredGitHubAuth = {
token,
user,
storedAt: new Date().toISOString(),
installations,
authMethod: 'web_oauth'
};
store.set(STORAGE_KEY, auth);
}
/**
* Get installation metadata
*/
static getInstallations(): GitHubInstallation[] | null {
const auth = this.getToken();
return auth?.installations || null;
}
/**
* Check if token has access to specific org
*/
static hasOrganizationAccess(orgName: string): boolean {
const installations = this.getInstallations();
if (!installations) return false;
return installations.some(
(inst) => inst.account.login.toLowerCase() === orgName.toLowerCase() && inst.account.type === 'Organization'
);
}
}
4. UI Updates
Location: packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/components/GitProviderPopout/sections/CredentialsSection.tsx
Component Updates:
export function CredentialsSection() {
const [authState, setAuthState] = useState<GitHubAuthState | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleConnect = async () => {
setIsConnecting(true);
setError(null);
try {
await GitHubAuth.startWebOAuthFlow((message) => {
// Show progress
console.log('[OAuth]', message);
});
// Refresh auth state
const newState = GitHubAuth.getAuthState();
setAuthState(newState);
// Show success message
ToastLayer.showSuccess('Successfully connected to GitHub!');
} catch (err) {
setError(err.message);
ToastLayer.showError(`Failed to connect: ${err.message}`);
} finally {
setIsConnecting(false);
}
};
return (
<div className={css.credentials}>
{!authState.isAuthenticated ? (
<PrimaryButton onClick={handleConnect} disabled={isConnecting}>
{isConnecting ? 'Connecting...' : 'Connect GitHub Account'}
</PrimaryButton>
) : (
<GitHubConnectionStatus
user={authState.username}
installations={authState.installations}
onDisconnect={handleDisconnect}
/>
)}
</div>
);
}
New Component: GitHubConnectionStatus
interface GitHubConnectionStatusProps {
user: string;
installations?: GitHubInstallation[];
onDisconnect: () => void;
}
function GitHubConnectionStatus({ user, installations, onDisconnect }: GitHubConnectionStatusProps) {
const organizationCount = installations?.filter((i) => i.account.type === 'Organization').length || 0;
return (
<div className={css.connectionStatus}>
<div className={css.connectedUser}>
<Icon name="check-circle" color="success" />
<span>Connected as {user}</span>
</div>
{installations && installations.length > 0 && (
<div className={css.installations}>
<h4>Access granted to:</h4>
<ul>
{installations.map((inst) => (
<li key={inst.id}>
<span>{inst.account.login}</span>
{inst.repository_selection === 'selected' && inst.repositories && (
<span className={css.repoCount}>({inst.repositories.length} repos)</span>
)}
</li>
))}
</ul>
</div>
)}
<TextButton onClick={onDisconnect} variant="danger">
Disconnect GitHub
</TextButton>
</div>
);
}
Security Implementation
CSRF Protection (OAuth State Parameter)
Implementation:
// Generate cryptographically secure random state
const state = crypto.randomBytes(32).toString('hex'); // 64-character hex string
// Store state temporarily (in-memory, expires after 5 minutes)
const pendingAuth = {
state,
timestamp: Date.now(),
expiresAt: Date.now() + 300000 // 5 minutes
};
// Validate on callback
if (receivedState !== pendingAuth.state) {
throw new Error('Invalid OAuth state - possible CSRF attack');
}
if (Date.now() > pendingAuth.expiresAt) {
throw new Error('OAuth state expired - please try again');
}
Client Secret Handling
DO NOT store in code or config files!
Recommended Approach:
// Use Electron's safeStorage for production
import { safeStorage } from 'electron';
// Development: environment variable
const clientSecret =
process.env.GITHUB_CLIENT_SECRET || // Development
safeStorage.decryptString(storedEncryptedSecret); // Production
// Never expose to renderer process
// Main process only
Token Storage Encryption
Already implemented in GitHubTokenStore:
const store = new Store({
encryptionKey: 'opennoodl-github-credentials',
name: 'github-auth'
});
Error Handling
Error Categories
1. User-Cancelled:
// User closes browser or denies permission
if (callbackError?.error === 'access_denied') {
showMessage('GitHub connection cancelled');
// Don't show error - user intentionally cancelled
}
2. Network Errors:
// Timeout, connection refused, DNS failure
catch (error) {
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNREFUSED') {
showError('Network error - check your internet connection');
}
}
3. Invalid State/CSRF:
// State mismatch indicates potential attack
if (receivedState !== expected State) {
console.error('[Security] OAuth state mismatch - possible CSRF');
showError('Security error - please try again');
// Log security event
}
4. Port Conflicts:
// All callback ports in use
if (noPortsAvailable) {
showError('Could not start OAuth server. Please close some applications and try again.', {
details: 'Ports 3000-3004 are all in use'
});
}
Performance Considerations
Callback Server Lifecycle
- Start: Only when user clicks "Connect" (not on app startup)
- Duration: Active only during OAuth flow (max 5 minutes)
- Resources: Minimal - single HTTP server, no persistent connections
- Cleanup: Automatic shutdown after success or timeout
Token Refresh
Current Implementation: Tokens don't expire (personal access tokens)
Future Enhancement (if using GitHub Apps with installation tokens):
// Installation tokens expire after 1 hour
if (isTokenExpired(token)) {
const newToken = await refreshInstallationToken(installationId);
GitHubTokenStore.saveToken(newToken, user);
}
Testing Strategy
Unit Tests
describe('GitHubOAuthCallbackHandler', () => {
it('starts server on available port', async () => {
const handler = new GitHubOAuthCallbackHandler();
const port = await handler.startCallbackServer();
expect(port).toBeGreaterThanOrEqual(3000);
await handler.stopCallbackServer();
});
it('validates OAuth state correctly', () => {
const expectedState = 'abc123';
expect(() => handler.validateState('wrong', expectedState)).toThrow('Invalid OAuth state');
expect(() => handler.validateState('abc123', expectedState)).not.toThrow();
});
it('handles expired state', () => {
const expiredAuth = {
state: 'abc123',
expiresAt: Date.now() - 1000 // Expired
};
expect(() => handler.validateState('abc123', expiredAuth)).toThrow('expired');
});
});
Integration Tests
describe('Web OAuth Flow', () => {
it('completes full OAuth cycle', async () => {
// Mock GitHub API responses
nock('https://github.com').post('/login/oauth/access_token').reply(200, {
access_token: 'test_token',
token_type: 'bearer',
scope: 'repo,user:email'
});
const result = await GitHubAuth.startWebOAuthFlow();
expect(result.token).toBe('test_token');
});
});
Migration Path
Detect Auth Method
const authState = GitHubAuth.getAuthState();
if (authState.authMethod === 'device_flow') {
// Show upgrade prompt
showUpgradeModal({
title: 'Upgrade GitHub Connection',
message:
'Connect to organization repositories with our improved OAuth flow.\n\nYour current connection will continue to work, but we recommend upgrading for better organization support.',
primaryAction: {
label: 'Upgrade Now',
onClick: async () => {
await GitHubAuth.startWebOAuthFlow();
}
},
secondaryAction: {
label: 'Maybe Later',
onClick: () => {
// Dismiss
}
}
});
}
Deployment Checklist
Before releasing Web OAuth Flow:
- GitHub App callback URL configured in settings
- Client secret securely stored (not in code)
- Callback server tested on all platforms (macOS, Windows, Linux)
- Port conflict handling tested
- OAuth state validation tested
- Installation metadata storage tested
- UI shows connected organizations correctly
- Disconnect flow clears all data
- Error messages are user-friendly
- Documentation updated
- Migration path from Device Flow tested
Next: See IMPLEMENTATION-STEPS.md for detailed step-by-step guide.