Tried to complete Github Oauth flow, failed for now

This commit is contained in:
Richard Osborne
2026-01-10 00:04:52 +01:00
parent 67b8ddc9c3
commit 7fc49ae3a8
17 changed files with 4064 additions and 149 deletions

View File

@@ -68,6 +68,8 @@
"@noodl/noodl-parse-dashboard": "file:../noodl-parse-dashboard",
"@noodl/platform": "file:../noodl-platform",
"@noodl/platform-electron": "file:../noodl-platform-electron",
"@octokit/auth-oauth-device": "^7.1.5",
"@octokit/rest": "^20.1.2",
"about-window": "^1.15.2",
"algoliasearch": "^5.35.0",
"archiver": "^5.3.2",

View File

@@ -15,11 +15,9 @@ import {
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { GitHubUser } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { useEventListener } from '../../hooks/useEventListener';
import { IRouteProps } from '../../pages/AppRoute';
import { GitHubOAuthService } from '../../services/GitHubOAuthService';
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
@@ -49,11 +47,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Real projects from LocalProjectsModel
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
// GitHub OAuth state
const [githubUser, setGithubUser] = useState<GitHubUser | null>(null);
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState<boolean>(false);
const [githubIsConnecting, setGithubIsConnecting] = useState<boolean>(false);
// Create project modal state
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
@@ -62,17 +55,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
// Initialize GitHub OAuth service
const initGitHub = async () => {
console.log('🔧 Initializing GitHub OAuth service...');
await GitHubOAuthService.instance.initialize();
const user = GitHubOAuthService.instance.getCurrentUser();
const isAuth = GitHubOAuthService.instance.isAuthenticated();
setGithubUser(user);
setGithubIsAuthenticated(isAuth);
console.log('✅ GitHub OAuth initialized. Authenticated:', isAuth);
};
// Load projects
const loadProjects = async () => {
await LocalProjectsModel.instance.fetch();
@@ -80,31 +62,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
setRealProjects(projects.map(mapProjectToLauncherData));
};
initGitHub();
loadProjects();
// Set up IPC listener for OAuth callback
const handleOAuthCallback = (_event: any, { code, state }: { code: string; state: string }) => {
console.log('🔄 Received GitHub OAuth callback from main process');
setGithubIsConnecting(true);
GitHubOAuthService.instance
.handleCallback(code, state)
.then(() => {
console.log('✅ OAuth callback handled successfully');
setGithubIsConnecting(false);
})
.catch((error) => {
console.error('❌ OAuth callback failed:', error);
setGithubIsConnecting(false);
ToastLayer.showError('GitHub authentication failed');
});
};
ipcRenderer.on('github-oauth-callback', handleOAuthCallback);
return () => {
ipcRenderer.removeListener('github-oauth-callback', handleOAuthCallback);
};
}, []);
// Subscribe to project list changes
@@ -114,44 +72,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
setRealProjects(projects.map(mapProjectToLauncherData));
});
// Subscribe to GitHub OAuth state changes
useEventListener(GitHubOAuthService.instance, 'oauth-success', (data: { user: GitHubUser }) => {
console.log('🎉 GitHub OAuth success:', data.user.login);
setGithubUser(data.user);
setGithubIsAuthenticated(true);
setGithubIsConnecting(false);
ToastLayer.showSuccess(`Connected to GitHub as ${data.user.login}`);
});
useEventListener(GitHubOAuthService.instance, 'auth-state-changed', (data: { authenticated: boolean }) => {
console.log('🔐 GitHub auth state changed:', data.authenticated);
setGithubIsAuthenticated(data.authenticated);
if (data.authenticated) {
const user = GitHubOAuthService.instance.getCurrentUser();
setGithubUser(user);
} else {
setGithubUser(null);
}
});
useEventListener(GitHubOAuthService.instance, 'oauth-started', () => {
console.log('🚀 GitHub OAuth flow started');
setGithubIsConnecting(true);
});
useEventListener(GitHubOAuthService.instance, 'oauth-error', (data: { error: string }) => {
console.error('❌ GitHub OAuth error:', data.error);
setGithubIsConnecting(false);
ToastLayer.showError(`GitHub authentication failed: ${data.error}`);
});
useEventListener(GitHubOAuthService.instance, 'disconnected', () => {
console.log('👋 GitHub disconnected');
setGithubUser(null);
setGithubIsAuthenticated(false);
ToastLayer.showSuccess('Disconnected from GitHub');
});
const handleCreateProject = useCallback(() => {
setIsCreateModalVisible(true);
}, []);
@@ -336,17 +256,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
}
}, []);
// GitHub OAuth handlers
const handleGitHubConnect = useCallback(() => {
console.log('🔗 Initiating GitHub OAuth...');
GitHubOAuthService.instance.initiateOAuth();
}, []);
const handleGitHubDisconnect = useCallback(() => {
console.log('🔌 Disconnecting GitHub...');
GitHubOAuthService.instance.disconnect();
}, []);
return (
<>
<Launcher
@@ -357,11 +266,11 @@ export function ProjectsPage(props: ProjectsPageProps) {
onOpenProjectFolder={handleOpenProjectFolder}
onDeleteProject={handleDeleteProject}
projectOrganizationService={ProjectOrganizationService.instance}
githubUser={githubUser}
githubIsAuthenticated={githubIsAuthenticated}
githubIsConnecting={githubIsConnecting}
onGitHubConnect={handleGitHubConnect}
onGitHubDisconnect={handleGitHubDisconnect}
githubUser={null}
githubIsAuthenticated={false}
githubIsConnecting={false}
onGitHubConnect={() => {}}
onGitHubDisconnect={() => {}}
/>
<CreateProjectModal

View File

@@ -0,0 +1,308 @@
/**
* GitHubAuth
*
* Handles GitHub OAuth authentication using Web OAuth Flow.
* Web OAuth Flow allows users to select which organizations and repositories
* to grant access to, providing better permission control.
*
* @module services/github
* @since 1.1.0
*/
import { ipcRenderer, shell } from 'electron';
import { GitHubTokenStore } from './GitHubTokenStore';
import type {
GitHubAuthState,
GitHubDeviceCode,
GitHubToken,
GitHubAuthError,
GitHubUser,
GitHubInstallation
} from './GitHubTypes';
/**
* Scopes required for GitHub integration
* - repo: Full control of private repositories (for issues, PRs)
* - read:org: Read organization membership
* - read:user: Read user profile data
* - user:email: Read user email addresses
*/
const REQUIRED_SCOPES = ['repo', 'read:org', 'read:user', 'user:email'];
/**
* GitHubAuth
*
* Manages GitHub OAuth authentication using Device Flow.
* Provides methods to authenticate, check status, and disconnect.
*/
export class GitHubAuth {
/**
* Initiate GitHub Web OAuth flow
*
* Opens browser to GitHub authorization page where user can select
* which organizations and repositories to grant access to.
*
* @param onProgress - Callback for progress updates
* @returns Promise that resolves when authentication completes
*
* @throws {GitHubAuthError} If OAuth flow fails
*
* @example
* ```typescript
* await GitHubAuth.startWebOAuthFlow((message) => {
* console.log(message);
* });
* console.log('Successfully authenticated!');
* ```
*/
static async startWebOAuthFlow(onProgress?: (message: string) => void): Promise<void> {
try {
onProgress?.('Starting GitHub authentication...');
// Request OAuth flow from main process
const result = await ipcRenderer.invoke('github-oauth-start');
if (!result.success) {
throw new Error(result.error || 'Failed to start OAuth flow');
}
onProgress?.('Opening GitHub in your browser...');
// Open browser to GitHub authorization page
shell.openExternal(result.authUrl);
// Wait for OAuth callback from main process
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error('Authentication timed out after 5 minutes'));
}, 300000); // 5 minutes
const handleSuccess = async (_event: Electron.IpcRendererEvent, data: any) => {
console.log('🎉 [GitHub Auth] ========================================');
console.log('🎉 [GitHub Auth] IPC EVENT RECEIVED: github-oauth-complete');
console.log('🎉 [GitHub Auth] Data:', data);
console.log('🎉 [GitHub Auth] ========================================');
cleanup();
try {
onProgress?.('Authentication successful, fetching details...');
// Save token and user info
const token: GitHubToken = {
access_token: data.token.access_token,
token_type: data.token.token_type,
scope: data.token.scope
};
const installations = data.installations as GitHubInstallation[];
GitHubTokenStore.saveToken(token, data.user.login, data.user.email, installations);
onProgress?.(`Successfully authenticated as ${data.user.login}`);
resolve();
} catch (error) {
reject(error);
}
};
const handleError = (_event: Electron.IpcRendererEvent, data: any) => {
cleanup();
reject(new Error(data.message || 'Authentication failed'));
};
const cleanup = () => {
clearTimeout(timeout);
ipcRenderer.removeListener('github-oauth-complete', handleSuccess);
ipcRenderer.removeListener('github-oauth-error', handleError);
};
ipcRenderer.once('github-oauth-complete', handleSuccess);
ipcRenderer.once('github-oauth-error', handleError);
});
} catch (error) {
const authError: GitHubAuthError = new Error(
`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
authError.code = error instanceof Error && 'code' in error ? (error as { code?: string }).code : undefined;
console.error('[GitHub] Authentication error:', authError);
throw authError;
}
}
/**
* @deprecated Use startWebOAuthFlow instead. Device Flow kept for backward compatibility.
*/
static async startDeviceFlow(onProgress?: (message: string) => void): Promise<GitHubDeviceCode> {
console.warn('[GitHub] startDeviceFlow is deprecated, using startWebOAuthFlow instead');
await this.startWebOAuthFlow(onProgress);
// Return empty device code for backward compatibility
return {
device_code: '',
user_code: '',
verification_uri: '',
expires_in: 0,
interval: 0
};
}
/**
* Fetch user information from GitHub API
*
* @param token - Access token
* @returns User information
*
* @throws {Error} If API request fails
*/
private static async fetchUserInfo(token: string): Promise<GitHubUser> {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
return response.json();
}
/**
* Get current authentication state
*
* @returns Current auth state
*
* @example
* ```typescript
* const state = GitHubAuth.getAuthState();
* if (state.isAuthenticated) {
* console.log('Connected as:', state.username);
* }
* ```
*/
static getAuthState(): GitHubAuthState {
const storedAuth = GitHubTokenStore.getToken();
if (!storedAuth) {
return {
isAuthenticated: false
};
}
// Check if token is expired
if (GitHubTokenStore.isTokenExpired()) {
console.warn('[GitHub] Token is expired');
return {
isAuthenticated: false
};
}
return {
isAuthenticated: true,
username: storedAuth.user.login,
email: storedAuth.user.email || undefined,
token: storedAuth.token,
authenticatedAt: storedAuth.storedAt
};
}
/**
* Check if user is currently authenticated
*
* @returns True if authenticated and token is valid
*/
static isAuthenticated(): boolean {
return this.getAuthState().isAuthenticated;
}
/**
* Get the username of authenticated user
*
* @returns Username or null if not authenticated
*/
static getUsername(): string | null {
return this.getAuthState().username || null;
}
/**
* Get current access token
*
* @returns Access token or null if not authenticated
*/
static getAccessToken(): string | null {
const state = this.getAuthState();
return state.token?.access_token || null;
}
/**
* Disconnect from GitHub
*
* Clears stored authentication data. User will need to re-authenticate.
*
* @example
* ```typescript
* GitHubAuth.disconnect();
* console.log('Disconnected from GitHub');
* ```
*/
static disconnect(): void {
GitHubTokenStore.clearToken();
console.log('[GitHub] User disconnected');
}
/**
* Validate current token by making a test API call
*
* @returns True if token is valid, false otherwise
*/
static async validateToken(): Promise<boolean> {
const token = this.getAccessToken();
if (!token) {
return false;
}
try {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
return response.ok;
} catch (error) {
console.error('[GitHub] Token validation failed:', error);
return false;
}
}
/**
* Refresh user information from GitHub
*
* Useful for updating cached user data
*
* @returns Updated auth state
* @throws {Error} If not authenticated or refresh fails
*/
static async refreshUserInfo(): Promise<GitHubAuthState> {
const token = this.getAccessToken();
if (!token) {
throw new Error('Not authenticated');
}
const user = await this.fetchUserInfo(token);
// Update stored auth with new user info
const storedAuth = GitHubTokenStore.getToken();
if (storedAuth) {
GitHubTokenStore.saveToken(storedAuth.token, user.login, user.email);
}
return this.getAuthState();
}
}

View File

@@ -0,0 +1,255 @@
/**
* GitHubClient
*
* Wrapper around Octokit REST API client with authentication and rate limiting.
* Provides convenient methods for GitHub API operations needed by OpenNoodl.
*
* @module services/github
* @since 1.1.0
*/
import { Octokit } from '@octokit/rest';
import { GitHubAuth } from './GitHubAuth';
import type { GitHubRepository, GitHubRateLimit, GitHubUser } from './GitHubTypes';
/**
* GitHubClient
*
* Main client for GitHub API interactions.
* Automatically uses authenticated token from GitHubAuth.
* Handles rate limiting and provides typed API methods.
*/
export class GitHubClient {
private octokit: Octokit | null = null;
private lastRateLimit: GitHubRateLimit | null = null;
/**
* Initialize Octokit instance with current auth token
*
* @returns Octokit instance or null if not authenticated
*/
private getOctokit(): Octokit | null {
const token = GitHubAuth.getAccessToken();
if (!token) {
console.warn('[GitHub Client] Not authenticated');
return null;
}
// Create new instance if token changed or doesn't exist
if (!this.octokit) {
this.octokit = new Octokit({
auth: token,
userAgent: 'OpenNoodl/1.1.0'
});
}
return this.octokit;
}
/**
* Check if client is ready (authenticated)
*
* @returns True if client has valid auth token
*/
isReady(): boolean {
return GitHubAuth.isAuthenticated();
}
/**
* Get current rate limit status
*
* @returns Rate limit information
* @throws {Error} If not authenticated
*/
async getRateLimit(): Promise<GitHubRateLimit> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.rateLimit.get();
const core = response.data.resources.core;
const rateLimit: GitHubRateLimit = {
limit: core.limit,
remaining: core.remaining,
reset: core.reset,
resource: 'core'
};
this.lastRateLimit = rateLimit;
return rateLimit;
}
/**
* Check if we're approaching rate limit
*
* @returns True if remaining requests < 100
*/
isApproachingRateLimit(): boolean {
if (!this.lastRateLimit) {
return false;
}
return this.lastRateLimit.remaining < 100;
}
/**
* Get authenticated user's information
*
* @returns User information
* @throws {Error} If not authenticated or API call fails
*/
async getAuthenticatedUser(): Promise<GitHubUser> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.users.getAuthenticated();
return response.data as GitHubUser;
}
/**
* Get repository information
*
* @param owner - Repository owner
* @param repo - Repository name
* @returns Repository information
* @throws {Error} If repository not found or API call fails
*/
async getRepository(owner: string, repo: string): Promise<GitHubRepository> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.repos.get({ owner, repo });
return response.data as GitHubRepository;
}
/**
* List user's repositories
*
* @param options - Listing options
* @returns Array of repositories
* @throws {Error} If not authenticated or API call fails
*/
async listRepositories(options?: {
visibility?: 'all' | 'public' | 'private';
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
per_page?: number;
}): Promise<GitHubRepository[]> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.repos.listForAuthenticatedUser({
visibility: options?.visibility || 'all',
sort: options?.sort || 'updated',
per_page: options?.per_page || 30
});
return response.data as GitHubRepository[];
}
/**
* Check if a repository exists and user has access
*
* @param owner - Repository owner
* @param repo - Repository name
* @returns True if repository exists and accessible
*/
async repositoryExists(owner: string, repo: string): Promise<boolean> {
try {
await this.getRepository(owner, repo);
return true;
} catch (error) {
return false;
}
}
/**
* Parse repository URL to owner/repo
*
* Handles various GitHub URL formats:
* - https://github.com/owner/repo
* - git@github.com:owner/repo.git
* - https://github.com/owner/repo.git
*
* @param url - GitHub repository URL
* @returns Object with owner and repo, or null if invalid
*/
static parseRepoUrl(url: string): { owner: string; repo: string } | null {
try {
// Remove .git suffix if present
const cleanUrl = url.replace(/\.git$/, '');
// Handle SSH format: git@github.com:owner/repo
if (cleanUrl.includes('git@github.com:')) {
const parts = cleanUrl.split('git@github.com:')[1].split('/');
if (parts.length >= 2) {
return {
owner: parts[0],
repo: parts[1]
};
}
}
// Handle HTTPS format: https://github.com/owner/repo
if (cleanUrl.includes('github.com/')) {
const parts = cleanUrl.split('github.com/')[1].split('/');
if (parts.length >= 2) {
return {
owner: parts[0],
repo: parts[1]
};
}
}
return null;
} catch (error) {
console.error('[GitHub Client] Error parsing repo URL:', error);
return null;
}
}
/**
* Get repository from local Git remote URL
*
* Useful for getting GitHub repo info from current project's git remote.
*
* @param remoteUrl - Git remote URL
* @returns Repository information if GitHub repo, null otherwise
*/
async getRepositoryFromRemoteUrl(remoteUrl: string): Promise<GitHubRepository | null> {
const parsed = GitHubClient.parseRepoUrl(remoteUrl);
if (!parsed) {
return null;
}
try {
return await this.getRepository(parsed.owner, parsed.repo);
} catch (error) {
console.error('[GitHub Client] Error fetching repository:', error);
return null;
}
}
/**
* Reset client state
*
* Call this when user disconnects or token changes.
*/
reset(): void {
this.octokit = null;
this.lastRateLimit = null;
}
}
/**
* Singleton instance of GitHubClient
* Use this for all GitHub API operations
*/
export const githubClient = new GitHubClient();

View File

@@ -0,0 +1,217 @@
/**
* GitHubTokenStore
*
* Secure storage for GitHub OAuth tokens using Electron Store.
* Tokens are stored encrypted using Electron's safeStorage API.
* This provides OS-level encryption (Keychain on macOS, Credential Manager on Windows).
*
* @module services/github
* @since 1.1.0
*/
import ElectronStore from 'electron-store';
import type { StoredGitHubAuth, GitHubToken, GitHubInstallation } from './GitHubTypes';
/**
* Store key for GitHub authentication data
*/
const GITHUB_AUTH_KEY = 'github.auth';
/**
* Electron store instance for GitHub credentials
* Uses encryption for sensitive data
*/
const store = new ElectronStore<{
'github.auth'?: StoredGitHubAuth;
}>({
name: 'github-credentials',
// Encrypt the entire store for security
encryptionKey: 'opennoodl-github-credentials'
});
/**
* GitHubTokenStore
*
* Manages secure storage and retrieval of GitHub OAuth tokens.
* Provides methods to save, retrieve, and clear authentication data.
*/
export class GitHubTokenStore {
/**
* Save GitHub authentication data to secure storage
*
* @param token - OAuth access token
* @param username - GitHub username
* @param email - User's email (nullable)
* @param installations - Optional list of installations (orgs/repos with access)
*
* @example
* ```typescript
* await GitHubTokenStore.saveToken(
* { access_token: 'gho_...', token_type: 'bearer', scope: 'repo' },
* 'octocat',
* 'octocat@github.com',
* installations
* );
* ```
*/
static saveToken(
token: GitHubToken,
username: string,
email: string | null,
installations?: GitHubInstallation[]
): void {
const authData: StoredGitHubAuth = {
token,
user: {
login: username,
email
},
installations,
storedAt: new Date().toISOString()
};
store.set(GITHUB_AUTH_KEY, authData);
if (installations && installations.length > 0) {
const orgNames = installations.map((i) => i.account.login).join(', ');
console.log(`[GitHub] Token saved for user: ${username} with access to: ${orgNames}`);
} else {
console.log('[GitHub] Token saved for user:', username);
}
}
/**
* Get installations (organizations/repos with access)
*
* @returns List of installations if authenticated, empty array otherwise
*/
static getInstallations(): GitHubInstallation[] {
const authData = this.getToken();
return authData?.installations || [];
}
/**
* Retrieve stored GitHub authentication data
*
* @returns Stored auth data if exists, null otherwise
*
* @example
* ```typescript
* const authData = GitHubTokenStore.getToken();
* if (authData) {
* console.log('Authenticated as:', authData.user.login);
* }
* ```
*/
static getToken(): StoredGitHubAuth | null {
try {
const authData = store.get(GITHUB_AUTH_KEY);
return authData || null;
} catch (error) {
console.error('[GitHub] Error reading token:', error);
return null;
}
}
/**
* Check if a valid token exists
*
* @returns True if token exists, false otherwise
*
* @example
* ```typescript
* if (GitHubTokenStore.hasToken()) {
* // User is authenticated
* }
* ```
*/
static hasToken(): boolean {
const authData = this.getToken();
return authData !== null && !!authData.token.access_token;
}
/**
* Get the username of the authenticated user
*
* @returns Username if authenticated, null otherwise
*/
static getUsername(): string | null {
const authData = this.getToken();
return authData?.user.login || null;
}
/**
* Get the access token string
*
* @returns Access token if exists, null otherwise
*/
static getAccessToken(): string | null {
const authData = this.getToken();
return authData?.token.access_token || null;
}
/**
* Clear stored authentication data
* Call this when user disconnects their GitHub account
*
* @example
* ```typescript
* GitHubTokenStore.clearToken();
* console.log('User disconnected from GitHub');
* ```
*/
static clearToken(): void {
store.delete(GITHUB_AUTH_KEY);
console.log('[GitHub] Token cleared');
}
/**
* Check if token is expired (if expiration is set)
*
* @returns True if token is expired, false if valid or no expiration
*/
static isTokenExpired(): boolean {
const authData = this.getToken();
if (!authData || !authData.token.expires_at) {
// No expiration set - assume valid
return false;
}
const expiresAt = new Date(authData.token.expires_at);
const now = new Date();
return now >= expiresAt;
}
/**
* Update token (for refresh scenarios)
*
* @param token - New OAuth token
*/
static updateToken(token: GitHubToken): void {
const existing = this.getToken();
if (!existing) {
throw new Error('Cannot update token: No existing auth data found');
}
const updated: StoredGitHubAuth = {
...existing,
token,
storedAt: new Date().toISOString()
};
store.set(GITHUB_AUTH_KEY, updated);
console.log('[GitHub] Token updated');
}
/**
* Get all stored GitHub data (for debugging)
* WARNING: Contains sensitive data - use carefully
*
* @returns All stored data
*/
static _debug_getAllData(): StoredGitHubAuth | null {
return this.getToken();
}
}

View File

@@ -0,0 +1,184 @@
/**
* GitHubTypes
*
* TypeScript type definitions for GitHub OAuth and API integration.
* These types define the structure of tokens, authentication state, and API responses.
*
* @module services/github
* @since 1.1.0
*/
/**
* OAuth device code response from GitHub
* Returned when initiating device flow authorization
*/
export interface GitHubDeviceCode {
/** The device verification code */
device_code: string;
/** The user verification code (8-character code) */
user_code: string;
/** URL where user enters the code */
verification_uri: string;
/** Expiration time in seconds (default: 900) */
expires_in: number;
/** Polling interval in seconds (default: 5) */
interval: number;
}
/**
* GitHub OAuth access token
* Stored securely and used for API authentication
*/
export interface GitHubToken {
/** The OAuth access token */
access_token: string;
/** Token type (always 'bearer' for GitHub) */
token_type: string;
/** Granted scopes (comma-separated) */
scope: string;
/** Token expiration timestamp (ISO 8601) - undefined if no expiration */
expires_at?: string;
}
/**
* Current GitHub authentication state
* Used by React components to display connection status
*/
export interface GitHubAuthState {
/** Whether user is authenticated with GitHub */
isAuthenticated: boolean;
/** GitHub username if authenticated */
username?: string;
/** User's primary email if authenticated */
email?: string;
/** Current token (for internal use only) */
token?: GitHubToken;
/** Timestamp of last successful authentication */
authenticatedAt?: string;
}
/**
* GitHub user information
* Retrieved from /user API endpoint
*/
export interface GitHubUser {
/** GitHub username */
login: string;
/** GitHub user ID */
id: number;
/** User's display name */
name: string | null;
/** User's primary email */
email: string | null;
/** Avatar URL */
avatar_url: string;
/** Profile URL */
html_url: string;
/** User type (User or Organization) */
type: string;
}
/**
* GitHub repository information
* Basic repo details for issue/PR association
*/
export interface GitHubRepository {
/** Repository ID */
id: number;
/** Repository name (without owner) */
name: string;
/** Full repository name (owner/repo) */
full_name: string;
/** Repository owner */
owner: {
login: string;
id: number;
avatar_url: string;
};
/** Whether repo is private */
private: boolean;
/** Repository URL */
html_url: string;
/** Default branch */
default_branch: string;
}
/**
* GitHub App installation information
* Represents organizations/accounts where the app was installed
*/
export interface GitHubInstallation {
/** Installation ID */
id: number;
/** Account where app is installed */
account: {
login: string;
type: 'User' | 'Organization';
avatar_url: string;
};
/** Repository selection type */
repository_selection: 'all' | 'selected';
/** List of repositories (if selected) */
repositories?: Array<{
id: number;
name: string;
full_name: string;
private: boolean;
}>;
}
/**
* Rate limit information from GitHub API
* Used to prevent hitting API limits
*/
export interface GitHubRateLimit {
/** Maximum requests allowed per hour */
limit: number;
/** Remaining requests in current window */
remaining: number;
/** Timestamp when rate limit resets (Unix epoch) */
reset: number;
/** Resource type (core, search, graphql) */
resource: string;
}
/**
* Error response from GitHub API
*/
export interface GitHubError {
/** HTTP status code */
status: number;
/** Error message */
message: string;
/** Detailed documentation URL if available */
documentation_url?: string;
}
/**
* OAuth authorization error
* Thrown during device flow authorization
*/
export interface GitHubAuthError extends Error {
/** Error code from GitHub */
code?: string;
/** HTTP status if applicable */
status?: number;
}
/**
* Stored token data (persisted format)
* Encrypted and stored in Electron's secure storage
*/
export interface StoredGitHubAuth {
/** OAuth token */
token: GitHubToken;
/** Associated user info */
user: {
login: string;
email: string | null;
};
/** Installation information (organizations/repos with access) */
installations?: GitHubInstallation[];
/** Timestamp when stored */
storedAt: string;
}

View File

@@ -0,0 +1,41 @@
/**
* GitHub Services
*
* Public exports for GitHub OAuth authentication and API integration.
* This module provides everything needed to connect to GitHub,
* authenticate users, and interact with the GitHub API.
*
* @module services/github
* @since 1.1.0
*
* @example
* ```typescript
* import { GitHubAuth, githubClient } from '@noodl-services/github';
*
* // Check if authenticated
* if (GitHubAuth.isAuthenticated()) {
* // Fetch user repos
* const repos = await githubClient.listRepositories();
* }
* ```
*/
// Authentication
export { GitHubAuth } from './GitHubAuth';
export { GitHubTokenStore } from './GitHubTokenStore';
// API Client
export { GitHubClient, githubClient } from './GitHubClient';
// Types
export type {
GitHubDeviceCode,
GitHubToken,
GitHubAuthState,
GitHubUser,
GitHubRepository,
GitHubRateLimit,
GitHubError,
GitHubAuthError,
StoredGitHubAuth
} from './GitHubTypes';

View File

@@ -13,6 +13,7 @@ import Model from '../../../shared/model';
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
import { RuntimeVersionInfo } from '../models/migration/types';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import { GitHubAuth } from '../services/github';
import FileSystem from './filesystem';
import { tracker } from './tracker';
import { guid } from './utils';
@@ -119,6 +120,10 @@ export class LocalProjectsModel extends Model {
project.name = projectEntry.name; // Also assign the name
this.touchProject(projectEntry);
this.bindProject(project);
// Initialize Git authentication for this project
this.setCurrentGlobalGitAuth(projectEntry.id);
resolve(project);
});
});
@@ -328,13 +333,34 @@ export class LocalProjectsModel extends Model {
setCurrentGlobalGitAuth(projectId: string) {
const func = async (endpoint: string) => {
if (endpoint.includes('github.com')) {
// Priority 1: Check for global OAuth token
const authState = GitHubAuth.getAuthState();
if (authState.isAuthenticated && authState.token) {
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint);
return {
username: authState.username || 'oauth',
password: authState.token.access_token // Extract actual access token string
};
}
// Priority 2: Fall back to project-specific PAT
const config = await GitStore.get('github', projectId);
//username is not used by github when using a token, but git will still ask for it. Just set it to "noodl"
if (config?.password) {
console.log('[Git Auth] Using project PAT for:', endpoint);
return {
username: 'noodl',
password: config.password
};
}
// No credentials available
console.warn('[Git Auth] No GitHub credentials found for:', endpoint);
return {
username: 'noodl',
password: config?.password
password: ''
};
} else {
// Non-GitHub providers use project-specific credentials only
const config = await GitStore.get('unknown', projectId);
return {
username: config?.username,

View File

@@ -1,10 +1,14 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { GitProvider } from '@noodl/git';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextButton } from '@noodl-core-ui/components/inputs/TextButton';
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { GitHubAuth, type GitHubAuthState } from '../../../../../../services/github';
type CredentialsSectionProps = {
provider: GitProvider;
username: string;
@@ -25,39 +29,120 @@ export function CredentialsSection({
const [hidePassword, setHidePassword] = useState(true);
// OAuth state management
const [authState, setAuthState] = useState<GitHubAuthState>(GitHubAuth.getAuthState());
const [isConnecting, setIsConnecting] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const [error, setError] = useState<string | null>(null);
// Check auth state on mount
useEffect(() => {
if (provider === 'github') {
setAuthState(GitHubAuth.getAuthState());
}
}, [provider]);
const handleConnect = async () => {
setIsConnecting(true);
setError(null);
setProgressMessage('Initiating GitHub authentication...');
try {
await GitHubAuth.startWebOAuthFlow((message) => {
setProgressMessage(message);
});
// Update state after successful auth
setAuthState(GitHubAuth.getAuthState());
setProgressMessage('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
setProgressMessage('');
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
GitHubAuth.disconnect();
setAuthState(GitHubAuth.getAuthState());
setError(null);
};
return (
<Section title={getTitle(provider)} variant={SectionVariant.InModal} hasGutter>
{showUsername && (
<>
{/* OAuth Section - GitHub Only */}
{provider === 'github' && (
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
{authState.isAuthenticated ? (
// Connected state
<>
<Text hasBottomSpacing>
Connected as <strong>{authState.username}</strong>
</Text>
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />
</>
) : (
// Not connected state
<>
<Text hasBottomSpacing>
Connect your GitHub account for the best experience. This enables advanced features and is more secure
than Personal Access Tokens.
</Text>
{isConnecting && progressMessage && <Text hasBottomSpacing>{progressMessage}</Text>}
{error && <Text hasBottomSpacing>{error}</Text>}
<PrimaryButton
label={isConnecting ? 'Connecting...' : 'Connect GitHub Account'}
onClick={handleConnect}
isDisabled={isConnecting}
/>
</>
)}
</Section>
)}
{/* PAT Section - Existing, now as fallback for GitHub */}
<Section
title={provider === 'github' ? 'Or use Personal Access Token' : getTitle(provider)}
variant={SectionVariant.InModal}
hasGutter
>
{showUsername && (
<TextInput
hasBottomSpacing
label="Username"
value={username}
variant={TextInputVariant.InModal}
onChange={(ev) => onUserNameChanged(ev.target.value)}
/>
)}
<TextInput
hasBottomSpacing
label="Username"
value={username}
label={passwordLabel}
type={hidePassword ? 'password' : 'text'}
value={password}
variant={TextInputVariant.InModal}
onChange={(ev) => onUserNameChanged(ev.target.value)}
onChange={(ev) => onPasswordChanged(ev.target.value)}
onFocus={() => setHidePassword(false)}
onBlur={() => setHidePassword(true)}
/>
)}
<TextInput
hasBottomSpacing
label={passwordLabel}
type={hidePassword ? 'password' : 'text'}
value={password}
variant={TextInputVariant.InModal}
onChange={(ev) => onPasswordChanged(ev.target.value)}
onFocus={() => setHidePassword(false)}
onBlur={() => setHidePassword(true)}
/>
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
{provider === 'github' && !password?.length && (
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
target="_blank"
rel="noreferrer"
>
How to create a personal access token
</a>
)}
</Section>
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
{provider === 'github' && !password?.length && (
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
target="_blank"
rel="noreferrer"
>
How to create a personal access token
</a>
)}
</Section>
</>
);
}

View File

@@ -0,0 +1,339 @@
/**
* GitHubOAuthCallbackHandler
*
* Handles GitHub OAuth callback in Electron main process using custom protocol handler.
* This enables Web OAuth Flow with organization/repository selection UI.
*
* @module noodl-editor/main
* @since 1.1.0
*/
const crypto = require('crypto');
const { ipcMain, BrowserWindow } = require('electron');
/**
* GitHub OAuth credentials
* Uses existing credentials from GitHubOAuthService
*/
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui';
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375';
/**
* Custom protocol for OAuth callback
*/
const OAUTH_PROTOCOL = 'noodl';
const OAUTH_CALLBACK_PATH = 'github-callback';
/**
* Manages GitHub OAuth using custom protocol handler
*/
class GitHubOAuthCallbackHandler {
constructor() {
this.pendingAuth = null;
}
/**
* Handle protocol callback from GitHub OAuth
* Called when user is redirected to noodl://github-callback?code=XXX&state=YYY
*/
async handleProtocolCallback(url) {
console.log('🔐 [GitHub OAuth] ========================================');
console.log('🔐 [GitHub OAuth] PROTOCOL CALLBACK RECEIVED');
console.log('🔐 [GitHub OAuth] URL:', url);
console.log('🔐 [GitHub OAuth] ========================================');
try {
// Parse the URL
const parsedUrl = new URL(url);
const params = parsedUrl.searchParams;
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
const error_description = params.get('error_description');
// Handle OAuth error
if (error) {
console.error('[GitHub OAuth] Error from GitHub:', error, error_description);
this.sendErrorToRenderer(error, error_description);
return;
}
// Validate required parameters
if (!code || !state) {
console.error('[GitHub OAuth] Missing code or state in callback');
this.sendErrorToRenderer('invalid_request', 'Missing authorization code or state');
return;
}
// Validate state (CSRF protection)
if (!this.validateState(state)) {
throw new Error('Invalid OAuth state - possible CSRF attack or expired');
}
// Exchange code for token
const token = await this.exchangeCodeForToken(code);
// Fetch user info
const user = await this.fetchUserInfo(token.access_token);
// Fetch installation info (organizations/repos)
const installations = await this.fetchInstallations(token.access_token);
// Send result to renderer process
this.sendSuccessToRenderer({
token,
user,
installations,
authMethod: 'web_oauth'
});
// Clear pending auth
this.pendingAuth = null;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[GitHub OAuth] Callback handling error:', error);
this.sendErrorToRenderer('token_exchange_failed', errorMessage);
}
}
/**
* Generate OAuth state for new flow
*/
generateOAuthState() {
const state = crypto.randomBytes(32).toString('hex');
const verifier = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
this.pendingAuth = {
state,
verifier,
createdAt: now,
expiresAt: now + 300000 // 5 minutes
};
return this.pendingAuth;
}
/**
* Validate OAuth state from callback
*/
validateState(receivedState) {
if (!this.pendingAuth) {
console.error('[GitHub OAuth] No pending auth state');
return false;
}
if (receivedState !== this.pendingAuth.state) {
console.error('[GitHub OAuth] State mismatch');
return false;
}
if (Date.now() > this.pendingAuth.expiresAt) {
console.error('[GitHub OAuth] State expired');
return false;
}
return true;
}
/**
* Exchange authorization code for access token
*/
async exchangeCodeForToken(code) {
console.log('[GitHub OAuth] Exchanging code for access token');
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`);
}
return data;
}
/**
* Fetch user information from GitHub
*/
async fetchUserInfo(token) {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`);
}
return response.json();
}
/**
* Fetch installation information (orgs/repos user granted access to)
*/
async fetchInstallations(token) {
try {
// Fetch user installations
const response = await fetch('https://api.github.com/user/installations', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
console.warn('[GitHub OAuth] Failed to fetch installations:', response.status);
return [];
}
const data = await response.json();
return data.installations || [];
} catch (error) {
console.warn('[GitHub OAuth] Error fetching installations:', error);
return [];
}
}
/**
* Send success to renderer process
*/
sendSuccessToRenderer(result) {
console.log('📤 [GitHub OAuth] ========================================');
console.log('📤 [GitHub OAuth] SENDING IPC EVENT: github-oauth-complete');
console.log('📤 [GitHub OAuth] User:', result.user.login);
console.log('📤 [GitHub OAuth] Installations:', result.installations.length);
console.log('📤 [GitHub OAuth] ========================================');
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-complete', result);
console.log('✅ [GitHub OAuth] IPC event sent to renderer');
} else {
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
}
}
/**
* Send error to renderer process
*/
sendErrorToRenderer(error, description) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-error', {
error,
message: description || error
});
}
}
/**
* Get authorization URL for OAuth flow
*/
getAuthorizationUrl(state) {
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`,
scope: 'repo read:org read:user user:email',
state,
allow_signup: 'true'
});
return `https://github.com/login/oauth/authorize?${params}`;
}
/**
* Cancel pending OAuth flow
*/
cancelPendingAuth() {
this.pendingAuth = null;
console.log('[GitHub OAuth] Pending auth cancelled');
}
}
// Singleton instance
let handlerInstance = null;
/**
* Initialize GitHub OAuth IPC handlers and protocol handler
*/
function initializeGitHubOAuthHandlers(app) {
handlerInstance = new GitHubOAuthCallbackHandler();
// Register custom protocol handler
if (!app.isDefaultProtocolClient(OAUTH_PROTOCOL)) {
app.setAsDefaultProtocolClient(OAUTH_PROTOCOL);
console.log(`[GitHub OAuth] Registered ${OAUTH_PROTOCOL}:// protocol handler`);
}
// Handle protocol callback on macOS/Linux
app.on('open-url', (event, url) => {
event.preventDefault();
if (url.startsWith(`${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`)) {
handlerInstance.handleProtocolCallback(url);
}
});
// Handle protocol callback on Windows (second instance)
app.on('second-instance', (event, commandLine) => {
// Find the protocol URL in command line args
const protocolUrl = commandLine.find((arg) => arg.startsWith(`${OAUTH_PROTOCOL}://`));
if (protocolUrl && protocolUrl.includes(OAUTH_CALLBACK_PATH)) {
handlerInstance.handleProtocolCallback(protocolUrl);
}
// Focus the main window
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
if (windows[0].isMinimized()) windows[0].restore();
windows[0].focus();
}
});
// Handle start OAuth flow request from renderer
ipcMain.handle('github-oauth-start', async () => {
try {
const authState = handlerInstance.generateOAuthState();
const authUrl = handlerInstance.getAuthorizationUrl(authState.state);
return { success: true, authUrl };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
});
// Handle stop OAuth flow request from renderer
ipcMain.handle('github-oauth-stop', async () => {
handlerInstance.cancelPendingAuth();
return { success: true };
});
console.log('[GitHub OAuth] IPC handlers and protocol handler initialized');
}
module.exports = {
GitHubOAuthCallbackHandler,
initializeGitHubOAuthHandlers,
OAUTH_PROTOCOL
};

View File

@@ -10,6 +10,7 @@ const { startCloudFunctionServer, closeRuntimeWhenWindowCloses } = require('./sr
const DesignToolImportServer = require('./src/design-tool-import-server');
const jsonstorage = require('../shared/utils/jsonstorage');
const StorageApi = require('./src/StorageApi');
const { initializeGitHubOAuthHandlers } = require('./github-oauth-handler');
const { handleProjectMerge } = require('./src/merge-driver');
@@ -542,6 +543,9 @@ function launchApp() {
setupGitHubOAuthIpc();
// Initialize Web OAuth handlers for GitHub (with protocol handler)
initializeGitHubOAuthHandlers(app);
setupMainWindowControlIpc();
setupMenu();
@@ -565,27 +569,12 @@ function launchApp() {
console.log('open-url', uri);
event.preventDefault();
// Handle GitHub OAuth callback
if (uri.startsWith('noodl://github-callback')) {
try {
const url = new URL(uri);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (code && state) {
console.log('🔐 GitHub OAuth callback received');
win && win.webContents.send('github-oauth-callback', { code, state });
return;
}
} catch (error) {
console.error('Failed to parse GitHub OAuth callback:', error);
}
// GitHub OAuth callbacks are handled by github-oauth-handler.js
// Only handle other noodl:// URIs here
if (!uri.startsWith('noodl://github-callback')) {
win && win.webContents.send('open-noodl-uri', uri);
process.env.noodlURI = uri;
}
// Default noodl URI handling
win && win.webContents.send('open-noodl-uri', uri);
process.env.noodlURI = uri;
// logEverywhere("open-url# " + deeplinkingUrl)
});
});