Marketplace github-oauth-nango-integration
Use when implementing GitHub OAuth + GitHub App authentication with Nango - provides two-connection pattern for user login and repo access with webhook handling
install
source · Clone the upstream repo
git clone https://github.com/aiskillstore/marketplace
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/aiskillstore/marketplace "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/agentworkforce/github-oauth-nango-integration" ~/.claude/skills/aiskillstore-marketplace-github-oauth-nango-integration && rm -rf "$T"
manifest:
skills/agentworkforce/github-oauth-nango-integration/SKILL.mdsource content
GitHub OAuth + Nango Integration
Overview
Implements dual-connection OAuth pattern: one for user identity (
github integration), another for repository access (github-app-oauth integration). This separation enables secure login while maintaining granular repo permissions through GitHub App installations.
When to Use
- Setting up GitHub OAuth login via Nango
- Implementing GitHub App installation webhooks
- Reconciling OAuth users with GitHub App installations
- Building apps that need both user auth and repo access
- Handling Nango sync webhooks for GitHub data
Why Two Connections?
GitHub has two different authentication mechanisms that serve different purposes:
GitHub OAuth App (github
integration)
github- What it is: Traditional OAuth for user identity
- What it gives you: User profile (name, email, avatar, GitHub ID)
- What it DOESN'T give you: Access to repositories
- Use for: Login, "Sign in with GitHub"
GitHub App (github-app-oauth
integration)
github-app-oauth- What it is: Installable app with granular repo permissions
- What it gives you: Access to specific repos the user installed it on
- What it DOESN'T give you: User identity (it knows the installation, not who's using it)
- Use for: Reading PRs, commits, files; posting comments; webhooks
The Reconciliation Problem
OAuth App alone: "User john@example.com logged in" → but which repos can they access? GitHub App alone: "Installation #12345 has access to repo X" → but who is the user?
Solution: Two separate OAuth flows linked by user ID:
- Login flow → User authenticates → Store user identity +
nangoConnectionId - Repo flow → Same user authorizes app → Store repos + link via
ownerId
This lets you answer: "User john@example.com can access repos X, Y, Z"
Quick Reference
| Connection Type | Nango Integration | Purpose | Stored In |
|---|---|---|---|
| User Login | | Authentication, identity | |
| Repo Access | | PR operations, file access | |
| Flow | Endpoint | Webhook Type |
|---|---|---|
| Login | | + |
| Repo Connect | | + |
| Data Sync | N/A (scheduled) | |
Implementation
1. Database Schema
// users table - stores login connection export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), githubId: text('github_id').unique().notNull(), githubUsername: text('github_username').notNull(), email: text('email'), avatarUrl: text('avatar_url'), nangoConnectionId: text('nango_connection_id'), // Permanent login connection incomingConnectionId: text('incoming_connection_id'), // Temp polling connection pendingInstallationRequest: timestamp('pending_installation_request'), // Org approval wait }); // repos table - stores per-repo app connection export const repos = pgTable('repos', { id: uuid('id').primaryKey().defaultRandom(), githubRepoId: text('github_repo_id').unique().notNull(), fullName: text('full_name').notNull(), installationId: uuid('installation_id').references(() => githubInstallations.id), ownerId: uuid('owner_id').references(() => users.id), nangoConnectionId: text('nango_connection_id'), // App connection for this repo }); // github_installations - tracks app installations export const githubInstallations = pgTable('github_installations', { id: uuid('id').primaryKey().defaultRandom(), installationId: text('installation_id').unique().notNull(), accountType: text('account_type'), // 'user' | 'organization' accountLogin: text('account_login'), installedById: uuid('installed_by_id').references(() => users.id), });
2. Constants
// constants.ts export const NANGO_INTEGRATION = { GITHUB_USER: 'github', // Login only GITHUB_APP_OAUTH: 'github-app-oauth' // Repo access } as const;
3. Login Flow Routes
// GET /auth/nango-session - Create login OAuth session app.get('/auth/nango-session', async (c) => { const tempUserId = randomUUID(); const { sessionToken } = await nangoClient.createConnectSession({ end_user: { id: tempUserId }, allowed_integrations: [NANGO_INTEGRATION.GITHUB_USER], }); return c.json({ sessionToken, tempUserId }); }); // GET /auth/nango/status/:connectionId - Poll login completion app.get('/auth/nango/status/:connectionId', async (c) => { const { connectionId } = c.req.param(); // Check if user exists with this incoming connection const user = await userRepo.findByIncomingConnectionId(connectionId); if (!user) { return c.json({ ready: false }); } // Issue JWT and return const token = authService.issueToken(user); await userRepo.clearIncomingConnectionId(user.id); return c.json({ ready: true, token, user }); });
4. App OAuth Flow Routes
// GET /auth/github-app-session - Create app OAuth session (authenticated) app.get('/auth/github-app-session', authMiddleware, async (c) => { const user = c.get('user'); const { sessionToken } = await nangoClient.createConnectSession({ end_user: { id: user.id, email: user.email }, allowed_integrations: [NANGO_INTEGRATION.GITHUB_APP_OAUTH], }); return c.json({ sessionToken }); }); // GET /auth/github-app/status/:connectionId - Poll repo sync app.get('/auth/github-app/status/:connectionId', authMiddleware, async (c) => { const user = c.get('user'); // Check for pending org approval if (user.pendingInstallationRequest) { return c.json({ ready: false, pendingApproval: true }); } // Check if repos synced const repos = await repoRepo.findByOwnerId(user.id); return c.json({ ready: repos.length > 0, repos }); });
5. Auth Webhook Handler
// auth-webhook-service.ts export async function handleAuthWebhook(payload: NangoAuthWebhook): Promise<boolean> { const { connectionId, providerConfigKey, endUser } = payload; if (providerConfigKey === NANGO_INTEGRATION.GITHUB_USER) { return handleLoginWebhook(connectionId, endUser); } if (providerConfigKey === NANGO_INTEGRATION.GITHUB_APP_OAUTH) { return handleAppOAuthWebhook(connectionId, endUser); } return false; } async function handleLoginWebhook(connectionId: string, endUser?: EndUser) { // Fetch GitHub user info via Nango const githubUser = await nangoService.getGitHubUser(connectionId); // Check if user exists const existingUser = await userRepo.findByGitHubId(String(githubUser.id)); if (existingUser) { // Returning user - store temp connection for polling await userRepo.update(existingUser.id, { incomingConnectionId: connectionId, }); // Delete duplicate connection later await nangoService.deleteConnection(connectionId); } else { // New user - create record const user = await userRepo.create({ githubId: String(githubUser.id), githubUsername: githubUser.login, email: githubUser.email, avatarUrl: githubUser.avatar_url, nangoConnectionId: connectionId, incomingConnectionId: connectionId, }); // Update connection with real user ID await nangoService.patchConnection(connectionId, { end_user: { id: user.id, email: user.email }, }); } return true; } async function handleAppOAuthWebhook(connectionId: string, endUser?: EndUser) { const userId = endUser?.id; if (!userId) throw new Error('No user ID in app OAuth webhook'); const user = await userRepo.findById(userId); if (!user) throw new Error('User not found'); try { // Fetch repos user has access to const repos = await githubService.getInstallationReposRaw(connectionId); // Sync repos to database for (const repo of repos) { await repoRepo.upsert({ githubRepoId: String(repo.id), fullName: repo.full_name, ownerId: user.id, nangoConnectionId: connectionId, }); } // Trigger Nango syncs await nangoService.triggerSync(connectionId, ['pull-requests', 'commits']); } catch (error) { if (error.status === 403) { // Org approval pending await userRepo.update(user.id, { pendingInstallationRequest: new Date(), }); return true; // Graceful degradation } throw error; } return true; }
6. Webhook Route with Signature Verification
// webhooks.ts app.post('/api/webhooks/nango', async (c) => { const signature = c.req.header('X-Nango-Signature'); const body = await c.req.text(); // Verify signature const expectedSignature = createHmac('sha256', NANGO_SECRET_KEY) .update(body) .digest('hex'); if (signature !== expectedSignature) { return c.json({ error: 'Invalid signature' }, 401); } const payload = JSON.parse(body); if (payload.type === 'auth') { const success = await handleAuthWebhook(payload); return c.json({ success }); } if (payload.type === 'sync') { await processSyncWebhook(payload); return c.json({ success: true }); } return c.json({ success: false }); });
7. Frontend Integration
// Login flow async function handleLogin() { const res = await fetch('/api/auth/nango-session'); const { sessionToken } = await res.json(); const nango = new Nango({ connectSessionToken: sessionToken }); nango.openConnectUI({ onEvent: async (event) => { if (event.type === 'connect') { // Poll for completion const result = await pollForAuth(event.payload.connectionId); if (result.ready) { localStorage.setItem('token', result.token); navigate('/dashboard'); } } }, }); } // Repo connection flow (after login) async function handleConnectRepos() { const res = await fetch('/api/auth/github-app-session', { headers: { Authorization: `Bearer ${token}` }, }); const { sessionToken } = await res.json(); const nango = new Nango({ connectSessionToken: sessionToken }); nango.openConnectUI({ onEvent: async (event) => { if (event.type === 'connect') { const result = await pollForRepos(event.payload.connectionId); if (result.pendingApproval) { showMessage('Waiting for org admin approval...'); } else if (result.ready) { setRepos(result.repos); } } }, }); }
Complete Flow Diagram
USER LOGIN: Frontend → GET /auth/nango-session → Nango.openConnectUI(sessionToken) → User authorizes GitHub → Nango webhook (type: auth, providerConfigKey: github) → Backend creates/updates user → Frontend polls /auth/nango/status/:connectionId → Returns JWT token REPO CONNECTION (authenticated): Frontend → GET /auth/github-app-session (with JWT) → Nango.openConnectUI(sessionToken) → User authorizes GitHub App → Nango webhook (type: auth, providerConfigKey: github-app-oauth) → Backend fetches repos, syncs to DB → Frontend polls /auth/github-app/status/:connectionId → Returns repos list DATA SYNCS (background): Nango → Scheduled sync every 4 hours → Webhook (type: sync, model: GithubPullRequest) → Backend processes incremental updates
Common Mistakes
| Mistake | Fix |
|---|---|
| Using same connection for login and repo access | Use two integrations: for login, for repos |
| Not handling org approval pending | Check for 403 error, set flag |
Missing in connection | Always set in , update after user creation |
| Polling wrong connection ID | Store separately for returning users |
| Not verifying webhook signature | Always verify with HMAC-SHA256 |
| Keeping duplicate connections | Delete temp connection after returning user authenticates |
Environment Variables
# Required NANGO_SECRET_KEY=your-nango-secret-key JWT_SECRET=your-jwt-secret-min-32-chars DATABASE_URL=postgres://... # Configure in Nango Dashboard # - github integration: OAuth App credentials # - github-app-oauth integration: GitHub App credentials
Nango Dashboard Setup
-
Create
integration (for login):github- Type: OAuth2
- Client ID/Secret: From GitHub OAuth App
- Scopes:
,read:useruser:email
-
Create
integration (for repos):github-app-oauth- Type: GitHub App
- App ID, Private Key, Client ID/Secret: From GitHub App
- Scopes:
,repo
, etc.pull_request
-
Configure webhook URL:
https://your-domain/api/webhooks/nango -
Enable syncs:
,pull-requests
,commits
, etc.issues