Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 33 additions & 9 deletions app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ function getGitHubClientId() {
return process.env.GITHUB_CLIENT_ID || process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID
}

async function fetchPrimaryEmail(accessToken: string): Promise<string | null> {
try {
const res = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'User-Agent': 'RepoFuse',
},
cache: 'no-store',
})
if (!res.ok) return null
const emails = (await res.json()) as Array<{ email: string; primary: boolean; verified: boolean }>
const primary = emails.find((e) => e.primary && e.verified)
return primary?.email ?? emails.find((e) => e.verified)?.email ?? null
} catch {
return null
}
}

export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
Expand Down Expand Up @@ -63,12 +82,16 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(new URL('/?error=token_exchange_failed', getBaseUrl(request)))
}

const userResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/vnd.github+json',
},
})
// Fetch user profile and primary email in parallel
const [userResponse, primaryEmail] = await Promise.all([
fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/vnd.github+json',
},
}),
fetchPrimaryEmail(access_token),
])

if (!userResponse.ok) {
return NextResponse.redirect(new URL('/?error=github_user_fetch_failed', getBaseUrl(request)))
Expand All @@ -79,13 +102,14 @@ export async function GET(request: NextRequest) {
try {
const sql = getDb()
await sql`
INSERT INTO user_auth (github_id, github_username, github_avatar_url, access_token)
VALUES (${githubUser.id}, ${githubUser.login}, ${githubUser.avatar_url}, ${access_token})
INSERT INTO user_auth (github_id, github_username, github_avatar_url, access_token, email)
VALUES (${githubUser.id}, ${githubUser.login}, ${githubUser.avatar_url}, ${access_token}, ${primaryEmail})
ON CONFLICT (github_id)
DO UPDATE SET
access_token = ${access_token},
github_username = ${githubUser.login},
github_avatar_url = ${githubUser.avatar_url},
email = COALESCE(${primaryEmail}, user_auth.email),
updated_at = CURRENT_TIMESTAMP
`
await upsertSubscription({ github_id: githubUser.id })
Expand Down Expand Up @@ -133,4 +157,4 @@ export async function GET(request: NextRequest) {
console.error('OAuth callback error:', error)
return NextResponse.redirect(new URL('/?error=oauth_callback_failed', getBaseUrl(request)))
}
}
}
87 changes: 44 additions & 43 deletions app/api/auth/github/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,49 @@
import crypto from 'node:crypto'
import { NextRequest, NextResponse } from 'next/server'
import { sanitizeReturnTo } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server'
import { sanitizeReturnTo } from '@/lib/auth'

function getBaseUrl(request: NextRequest) {
return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin
}

function getGitHubClientId() {
return process.env.GITHUB_CLIENT_ID || process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID
}

export async function GET(request: NextRequest) {
const clientId = getGitHubClientId()
function getBaseUrl(request: NextRequest) {
return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin
}

if (!clientId) {
return NextResponse.redirect(new URL('/?error=github_oauth_not_configured', getBaseUrl(request)))
function getGitHubClientId() {
return process.env.GITHUB_CLIENT_ID || process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID
}

const state = crypto.randomUUID()
const redirectUri = `${getBaseUrl(request)}/api/auth/github/callback`
const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get('returnTo'))

const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user repo',
state,
})

const response = NextResponse.redirect(`https://github.com/login/oauth/authorize?${params.toString()}`)
response.cookies.set('github_oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
})
response.cookies.set('github_oauth_return_to', returnTo, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
})

return response
}
export async function GET(request: NextRequest) {
const clientId = getGitHubClientId()

if (!clientId) {
return NextResponse.redirect(new URL('/?error=github_oauth_not_configured', getBaseUrl(request)))
}

const state = crypto.randomUUID()
const redirectUri = `${getBaseUrl(request)}/api/auth/github/callback`
const returnTo = sanitizeReturnTo(request.nextUrl.searchParams.get('returnTo'))

const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email repo',
state,
})

const response = NextResponse.redirect(`https://github.com/login/oauth/authorize?${params.toString()}`)
response.cookies.set('github_oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
})
response.cookies.set('github_oauth_return_to', returnTo, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 10,
})

return response
}

10 changes: 10 additions & 0 deletions migrations/009_add_email_to_user_auth.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Migration: Add email column to user_auth for marketing list capture
-- Run after 008_*.sql migrations

ALTER TABLE user_auth
ADD COLUMN IF NOT EXISTS email TEXT;

-- Index for fast email lookups
CREATE INDEX IF NOT EXISTS idx_user_auth_email ON user_auth (email)
WHERE email IS NOT NULL;

Loading