Skip to main content

GitHub OAuth Integration Guide

Complete guide for integrating GitHub OAuth login in Viben platform


Table of Contents

  1. Overview
  2. Architecture
  3. Setup Guide
  4. Implementation Details
  5. Security Considerations
  6. Testing
  7. Troubleshooting
  8. API Reference

Overview

Viben uses GitHub OAuth 2.0 for social login, allowing users to:

  • Sign in with their GitHub account
  • Automatically link GitHub profile to user account
  • Skip email verification (GitHub email is pre-verified)
  • Sync username, display name, and avatar from GitHub

Features

  • ✅ OAuth 2.0 Authorization Code Flow
  • ✅ CSRF protection with state parameter
  • ✅ Automatic user creation or account linking
  • ✅ Secure token storage
  • ✅ Session management with JWE tokens
  • ✅ Email verification bypass for GitHub users

Architecture

Flow Diagram

Database Schema

oauth_connections table:

{
id: string (UUID)
userId: string (FK to users)
provider: 'github' | 'google' | ...
providerId: string (GitHub user ID)
accessToken: string (encrypted)
createdAt: Date
updatedAt: Date
}

users table (relevant fields):

{
id: string
email: string
username: string
displayName: string
avatarUrl: string | null
githubUsername: string | null
emailVerified: boolean
role: 'user' | 'developer' | 'admin'
}

Setup Guide

Step 1: Create GitHub OAuth App

  1. Navigate to GitHub:

  2. Configure Application:

    • Application name: Viben (or your app name)
    • Homepage URL: https://your-domain.com (or http://localhost:3000 for dev)
    • Authorization callback URL: https://your-domain.com/api/auth/github/callback
      • For development: http://localhost:3000/api/auth/github/callback
      • For production: https://your-actual-domain.vercel.app/api/auth/github/callback
  3. Get Credentials:

    • After creating, copy:
      • Client ID (starts with Iv1.)
      • Client Secret (click "Generate a new client secret")

Step 2: Environment Variables

Add to .env.local (development) or Vercel Environment Variables (production):

# OAuth - GitHub
NEXT_PUBLIC_GITHUB_CLIENT_ID="Iv1.abc123xyz456"
GITHUB_CLIENT_SECRET="ghp_abc123xyz456..."

# Required for OAuth flow
NEXT_PUBLIC_APP_URL="http://localhost:3000" # Production: https://your-domain.com
JWE_SECRET="your-32-byte-secret-for-session-encryption"

Generate secrets:

# Generate JWE_SECRET
openssl rand -base64 32

Step 3: Update Callback URL for Different Environments

For multi-environment setups:

Development:

http://localhost:3000/api/auth/github/callback

Staging:

https://staging.your-domain.com/api/auth/github/callback

Production:

https://your-domain.com/api/auth/github/callback

Note: You can add multiple OAuth Apps in GitHub for different environments, or use one app with multiple callback URLs (GitHub allows multiple callbacks).


Implementation Details

1. OAuth Initiation Route

File: app/api/auth/github/route.ts

export async function GET() {
const clientId = process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID;
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';

// Generate random state for CSRF protection
const state = generateId();

// Store state in httpOnly cookie
const cookieStore = await cookies();
cookieStore.set('oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 600, // 10 minutes
});

// Build GitHub authorization URL
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: `${appUrl}/api/auth/github/callback`,
scope: 'read:user user:email',
state,
});

return NextResponse.redirect(
`https://github.com/login/oauth/authorize?${params}`
);
}

Scopes requested:

  • read:user - Read user profile information
  • user:email - Read user email addresses

2. OAuth Callback Route

File: app/api/auth/github/callback/route.ts

Key steps:

  1. Verify State (CSRF Protection)

    const storedState = cookieStore.get('oauth_state')?.value;
    if (!code || !state || state !== storedState) {
    return NextResponse.redirect(`${appUrl}/login?error=invalid_state`);
    }
  2. Exchange Code for Access Token

    const tokenResponse = await fetch(
    'https://github.com/login/oauth/access_token',
    {
    method: 'POST',
    headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
    },
    body: JSON.stringify({
    client_id: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID,
    client_secret: process.env.GITHUB_CLIENT_SECRET,
    code,
    }),
    }
    );
    const { access_token } = await tokenResponse.json();
  3. Fetch User Profile

    // Get user info
    const userResponse = await fetch('https://api.github.com/user', {
    headers: { Authorization: `Bearer ${accessToken}` },
    });
    const githubUser = await userResponse.json();

    // Get primary email
    const emailResponse = await fetch('https://api.github.com/user/emails', {
    headers: { Authorization: `Bearer ${accessToken}` },
    });
    const emails = await emailResponse.json();
    const primaryEmail = emails.find(e => e.primary)?.email;
  4. User Matching Logic

    Case A: OAuth connection exists → Link to existing user

    const existingConnection = await db.query.oauthConnections.findFirst({
    where: and(
    eq(oauthConnections.provider, 'github'),
    eq(oauthConnections.providerId, String(githubUser.id))
    ),
    with: { user: true },
    });

    if (existingConnection) {
    // Update access token and use existing user
    await db.update(oauthConnections)
    .set({ accessToken })
    .where(eq(oauthConnections.id, existingConnection.id));
    user = existingConnection.user;
    }

    Case B: User with email exists → Link OAuth to existing user

    const existingUser = await db.query.users.findFirst({
    where: eq(users.email, primaryEmail),
    });

    if (existingUser) {
    // Create OAuth connection
    await db.insert(oauthConnections).values({
    id: generateId(),
    userId: existingUser.id,
    provider: 'github',
    providerId: String(githubUser.id),
    accessToken,
    });
    user = existingUser;
    }

    Case C: New user → Create user + OAuth connection

    const userId = generateId();
    await db.insert(users).values({
    id: userId,
    email: primaryEmail,
    username: githubUser.login,
    displayName: githubUser.name || githubUser.login,
    avatarUrl: githubUser.avatar_url,
    githubUsername: githubUser.login,
    role: 'user',
    emailVerified: true, // Skip verification for GitHub users
    });

    await db.insert(oauthConnections).values({
    id: generateId(),
    userId,
    provider: 'github',
    providerId: String(githubUser.id),
    accessToken,
    });
  5. Create Session

    await setSessionCookie({
    userId: user.id,
    username: user.username,
    email: user.email,
    role: user.role,
    avatarUrl: user.avatarUrl,
    });

    return NextResponse.redirect(`${appUrl}/mcp`);

3. Frontend OAuth Button

File: components/auth/oauth-buttons.tsx

'use client';

import { Button } from '@/components/ui/button';
import { Github } from 'lucide-react';

export function OAuthButtons() {
const handleGitHubLogin = () => {
window.location.href = '/api/auth/github';
};

return (
<Button variant="outline" onClick={handleGitHubLogin}>
<Github className="mr-2 h-4 w-4" />
Continue with GitHub
</Button>
);
}

Usage in login page:

// app/(auth)/login/page.tsx
import { OAuthButtons } from '@/components/auth/oauth-buttons';

export default function LoginPage() {
return (
<div>
<h1>Welcome back</h1>
<OAuthButtons />
{/* ...other login forms... */}
</div>
);
}

Security Considerations

1. CSRF Protection

Problem: Attackers can trick users into authorizing malicious OAuth flows.

Solution: State parameter validation

// Generate random state
const state = generateId(); // or crypto.randomBytes(32).toString('hex')

// Store in secure cookie
cookieStore.set('oauth_state', state, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 600,
});

// Verify on callback
if (state !== storedState) {
throw new Error('Invalid state');
}

2. Token Storage

Access Token Storage:

  • DO: Store in database (encrypted at rest)
  • DON'T: Store in localStorage or sessionStorage
  • DON'T: Expose to client-side JavaScript

Session Token:

  • DO: Use httpOnly cookies with JWE encryption
  • DO: Set secure: true in production
  • DO: Use sameSite: 'lax' for CSRF protection

3. Scope Minimization

Only request necessary scopes:

scope: 'read:user user:email' // ✅ Minimal
scope: 'repo admin:org' // ❌ Too broad

4. Environment Variable Protection

  • ✅ Never commit .env files to Git
  • ✅ Use different OAuth apps for dev/staging/prod
  • ✅ Rotate client secrets periodically
  • ✅ Use Vercel environment variables for production

5. Redirect URI Validation

GitHub validates redirect URIs automatically, but ensure:

  • Exact match with configured callback URL
  • No open redirects after authentication
  • Whitelist allowed domains

Testing

Local Development

  1. Start development server:

    pnpm dev
  2. Test OAuth flow:

  3. Verify database:

    pnpm db:studio

    Check:

    • users table has new entry
    • oauth_connections table has GitHub connection
    • emailVerified is true

Testing Different User Scenarios

Scenario 1: New User (First-time GitHub login)

  • Expected: Creates new user + OAuth connection
  • Verify: Check users and oauth_connections tables

Scenario 2: Existing User (Email match)

  • Precondition: User registered with email/password
  • Expected: Links OAuth to existing user account
  • Verify: User has both password and GitHub login

Scenario 3: Returning User (OAuth exists)

  • Precondition: User logged in with GitHub before
  • Expected: Updates access token, logs in
  • Verify: oauth_connections.accessToken is updated

Scenario 4: Email Mismatch

  • Precondition: User changes primary email on GitHub
  • Expected: May create duplicate account (by design)
  • Note: Consider email change detection logic

Error Testing

Test error handling:

# Invalid state
curl "http://localhost:3000/api/auth/github/callback?code=xxx&state=invalid"
# Expected: Redirect to /login?error=invalid_state

# Missing code
curl "http://localhost:3000/api/auth/github/callback?state=xxx"
# Expected: Redirect to /login?error=invalid_state

# Invalid credentials
# Remove GITHUB_CLIENT_SECRET from .env
# Expected: Redirect to /login?error=oauth_failed

Troubleshooting

Error: "GitHub OAuth not configured"

Cause: Missing NEXT_PUBLIC_GITHUB_CLIENT_ID

Fix:

# Add to .env.local
NEXT_PUBLIC_GITHUB_CLIENT_ID="Iv1.xxx"

Error: "Invalid state" or "invalid_state"

Cause: State parameter mismatch (CSRF protection)

Possible reasons:

  1. Cookie not set (check browser dev tools → Application → Cookies)
  2. Cookie expired (10-minute timeout)
  3. SameSite cookie issues in development

Fix:

  • Clear cookies and try again
  • Check sameSite cookie setting
  • Verify NEXT_PUBLIC_APP_URL matches actual URL

Error: "Redirect URI mismatch"

Cause: Callback URL doesn't match GitHub OAuth App settings

Fix:

  1. Check GitHub OAuth App settings
  2. Ensure callback URL is exact: https://your-domain.com/api/auth/github/callback
  3. No trailing slash
  4. Protocol must match (http vs https)

Error: "No access token received"

Cause: Invalid client credentials or code

Debug:

// Add logging in callback route
console.error('Token data:', tokenData);

Common causes:

  • Wrong GITHUB_CLIENT_SECRET
  • Code already used (refresh during callback)
  • Code expired (5-minute lifetime)

Error: "No email available"

Cause: User's GitHub account has no public email

Fix: Request email scope and fetch from /user/emails endpoint (already implemented)

Alternative: Allow users to add email manually after OAuth login

Error: Database connection issues

Cause: Missing POSTGRES_URL or database schema

Fix:

# Check environment variable
echo $POSTGRES_URL

# Push schema to database
pnpm db:push

API Reference

GET /api/auth/github

Initiates OAuth flow by redirecting to GitHub.

Query Parameters: None

Response: HTTP 302 Redirect to GitHub

Headers Set:

  • Set-Cookie: oauth_state={state}; HttpOnly; Secure; SameSite=Lax

GET /api/auth/github/callback

Handles OAuth callback from GitHub.

Query Parameters:

  • code (required) - Authorization code from GitHub
  • state (required) - CSRF protection token

Success Response: HTTP 302 Redirect to /mcp

  • Creates session cookie
  • Creates/updates user and OAuth connection

Error Responses:

ErrorRedirectCause
invalid_state/login?error=invalid_stateState mismatch or missing
no_token/login?error=no_tokenFailed to get access token
no_email/login?error=no_emailGitHub account has no email
oauth_failed/login?error=oauth_failedGeneral OAuth error

GitHub API Endpoints Used

1. Exchange Code for Token

POST https://github.com/login/oauth/access_token
Body: { client_id, client_secret, code }
Response: { access_token, token_type, scope }

2. Get User Profile

GET https://api.github.com/user
Headers: { Authorization: Bearer {access_token} }
Response: { id, login, name, email, avatar_url, ... }

3. Get User Emails

GET https://api.github.com/user/emails
Headers: { Authorization: Bearer {access_token} }
Response: [{ email, primary, verified, ... }]

Advanced Topics

Adding More OAuth Providers

To add Google, Twitter, etc.:

  1. Create provider route: app/api/auth/{provider}/route.ts
  2. Create callback route: app/api/auth/{provider}/callback/route.ts
  3. Update database: Add provider to oauth_connections.provider enum
  4. Update UI: Add button to OAuthButtons component

Example structure:

// Provider-specific config
const OAUTH_CONFIG = {
github: {
authUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userUrl: 'https://api.github.com/user',
scope: 'read:user user:email',
},
google: {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
scope: 'openid email profile',
},
};

Refresh Token Support

GitHub access tokens don't expire, but for providers that use refresh tokens:

  1. Store refresh token in oauth_connections table:

    refreshToken: text('refresh_token'),
    tokenExpiresAt: timestamp('token_expires_at'),
  2. Check expiry before API calls:

    if (Date.now() > connection.tokenExpiresAt) {
    const newToken = await refreshAccessToken(connection.refreshToken);
    await updateConnection(newToken);
    }

Account Unlinking

Allow users to unlink OAuth accounts:

// app/api/auth/oauth/unlink/route.ts
export async function DELETE(request: Request) {
const session = await getSession(request);
const { provider } = await request.json();

// Check user has alternative login method
const user = await db.query.users.findFirst({
where: eq(users.id, session.userId),
with: { oauthConnections: true },
});

if (!user.passwordHash && user.oauthConnections.length <= 1) {
return NextResponse.json(
{ error: 'Cannot unlink last login method' },
{ status: 400 }
);
}

// Delete OAuth connection
await db.delete(oauthConnections).where(
and(
eq(oauthConnections.userId, session.userId),
eq(oauthConnections.provider, provider)
)
);

return NextResponse.json({ success: true });
}

Summary

GitHub OAuth Integration Checklist:

  • Create GitHub OAuth App
  • Configure environment variables
  • Implement initiation route (/api/auth/github)
  • Implement callback route (/api/auth/github/callback)
  • Add CSRF protection with state parameter
  • Handle user creation and account linking
  • Store OAuth connections in database
  • Create session after successful auth
  • Add frontend OAuth button
  • Test all user scenarios
  • Deploy and configure production callback URL

Key Files:

  • app/api/auth/github/route.ts - OAuth initiation
  • app/api/auth/github/callback/route.ts - OAuth callback
  • components/auth/oauth-buttons.tsx - Frontend button
  • lib/db/schema.ts - Database schema (oauth_connections)
  • .env.local - Environment variables

Production Deployment:

  • Update GitHub OAuth App callback URL
  • Set environment variables in Vercel
  • Test OAuth flow in production
  • Monitor error logs

Last Updated: 2026-02-05