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
192 changes: 192 additions & 0 deletions apps/web/src/app/api/github/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* GitHub OAuth Callback Handler
* GET /api/github/auth/callback
*
* Handles the OAuth callback from GitHub and stores the connection
*/

import { githubConnections } from '@pagespace/db';
import { db, eq } from '@pagespace/db';
import { z } from 'zod/v4';
import { checkRateLimit, RATE_LIMIT_CONFIGS } from '@pagespace/lib/server';
import { loggers } from '@pagespace/lib/server';
import { NextResponse } from 'next/server';
import { GitHubService } from '@pagespace/lib/services/github-service';

const githubCallbackSchema = z.object({
code: z.string().min(1, 'Authorization code is required'),
state: z.string().min(1, 'State parameter is required'),
});

export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');

const baseUrl = process.env.NEXTAUTH_URL || process.env.WEB_APP_URL || req.url;

if (error) {
loggers.auth.warn('GitHub OAuth error', { error });

let errorParam = 'github_oauth_error';
if (error === 'access_denied') {
errorParam = 'github_access_denied';
}

return NextResponse.redirect(new URL(`/settings?error=${errorParam}`, baseUrl));
}

const validation = githubCallbackSchema.safeParse({ code, state });
if (!validation.success) {
loggers.auth.warn('Invalid GitHub OAuth callback parameters', validation.error);
return NextResponse.redirect(new URL('/settings?error=github_invalid_request', baseUrl));
}

const { code: authCode, state: encodedState } = validation.data;

// Decode and validate state
let stateData: {
state: string;
userId: string;
driveId?: string;
returnUrl?: string;
timestamp: number;
};

try {
const decodedState = Buffer.from(encodedState, 'base64url').toString('utf-8');
stateData = JSON.parse(decodedState);

// Validate state timestamp (prevent replay attacks, 10 minutes expiry)
const stateAge = Date.now() - stateData.timestamp;
if (stateAge > 10 * 60 * 1000) {
loggers.auth.warn('Expired GitHub OAuth state', { age: stateAge });
return NextResponse.redirect(new URL('/settings?error=github_state_expired', baseUrl));
}
} catch (e) {
loggers.auth.error('Invalid GitHub OAuth state', e as Error);
return NextResponse.redirect(new URL('/settings?error=github_invalid_state', baseUrl));
Comment on lines +49 to +70

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Verify OAuth state with a server-side secret

The callback decodes the state query parameter and only enforces a timestamp check before using the embedded userId to upsert a connection later in the handler. Because the value is merely base64‑encoded JSON with no server lookup or signature, anyone who knows a victim’s user ID can forge a state payload and complete the OAuth flow to bind their GitHub token to that victim account. This defeats the CSRF protection that the state parameter is meant to provide. Persist or sign the state on the server before trusting it.

Useful? React with 👍 / 👎.

}

// Rate limiting
const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';

const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN);
if (!ipRateLimit.allowed) {
return NextResponse.redirect(new URL('/settings?error=github_rate_limit', baseUrl));
}

// Check environment variables
if (!process.env.GITHUB_OAUTH_CLIENT_ID || !process.env.GITHUB_OAUTH_CLIENT_SECRET || !process.env.GITHUB_OAUTH_REDIRECT_URI) {
loggers.auth.error('GitHub OAuth not configured');
return NextResponse.redirect(new URL('/settings?error=github_not_configured', baseUrl));
}

// Exchange authorization code for access token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: process.env.GITHUB_OAUTH_CLIENT_ID,
client_secret: process.env.GITHUB_OAUTH_CLIENT_SECRET,
code: authCode,
redirect_uri: process.env.GITHUB_OAUTH_REDIRECT_URI,
}),
});

if (!tokenResponse.ok) {
loggers.auth.error('Failed to exchange GitHub code for token', {
status: tokenResponse.status,
});
return NextResponse.redirect(new URL('/settings?error=github_token_exchange_failed', baseUrl));
}

const tokenData = await tokenResponse.json();

if (tokenData.error) {
loggers.auth.error('GitHub OAuth token error', { error: tokenData.error });
return NextResponse.redirect(new URL('/settings?error=github_token_error', baseUrl));
}

const { access_token, token_type, scope } = tokenData;

if (!access_token) {
loggers.auth.error('No access token received from GitHub');
return NextResponse.redirect(new URL('/settings?error=github_no_token', baseUrl));
}

// Get GitHub user information
const githubService = new GitHubService(access_token);
const githubUser = await githubService.getAuthenticatedUser();

if (!githubUser) {
loggers.auth.error('Failed to get GitHub user information');
return NextResponse.redirect(new URL('/settings?error=github_user_info_failed', baseUrl));
}

// Check if connection already exists
const existingConnection = await db.query.githubConnections.findFirst({
where: eq(githubConnections.userId, stateData.userId),
});

// Encrypt the access token before storing
const encryptedToken = GitHubService.encryptToken(access_token);

if (existingConnection) {
// Update existing connection
await db.update(githubConnections)
.set({
githubUserId: githubUser.id.toString(),
githubUsername: githubUser.login,
githubEmail: githubUser.email || null,
githubAvatarUrl: githubUser.avatar_url,
encryptedAccessToken: encryptedToken,
tokenType: token_type || 'Bearer',
scope: scope || null,
lastUsed: new Date(),
updatedAt: new Date(),
revokedAt: null, // Clear revoked status if reconnecting
})
.where(eq(githubConnections.id, existingConnection.id));

loggers.auth.info('Updated GitHub connection', {
userId: stateData.userId,
githubUsername: githubUser.login,
});
} else {
// Create new connection
await db.insert(githubConnections).values({
userId: stateData.userId,
githubUserId: githubUser.id.toString(),
githubUsername: githubUser.login,
githubEmail: githubUser.email || null,
githubAvatarUrl: githubUser.avatar_url,
encryptedAccessToken: encryptedToken,
tokenType: token_type || 'Bearer',
scope: scope || null,
lastUsed: new Date(),
});

loggers.auth.info('Created new GitHub connection', {
userId: stateData.userId,
githubUsername: githubUser.login,
});
}

// Redirect to return URL or settings page
const redirectUrl = stateData.returnUrl || '/settings?tab=integrations&github=connected';
return NextResponse.redirect(new URL(redirectUrl, baseUrl));

} catch (error) {
loggers.auth.error('GitHub OAuth callback error', error as Error);
const baseUrl = process.env.NEXTAUTH_URL || process.env.WEB_APP_URL || req.url;
return NextResponse.redirect(new URL('/settings?error=github_callback_error', baseUrl));
}
}
158 changes: 158 additions & 0 deletions apps/web/src/app/api/github/auth/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* GitHub OAuth Connection Initiation
* POST /api/github/auth/connect
*
* Initiates the GitHub OAuth flow for connecting a user's GitHub account
*/

import { z } from 'zod/v4';
import { checkRateLimit, RATE_LIMIT_CONFIGS } from '@pagespace/lib/server';
import { loggers } from '@pagespace/lib/server';
import { verify } from '@pagespace/lib/server';
import { createId } from '@paralleldrive/cuid2';

const githubConnectSchema = z.object({
driveId: z.string().optional(),
returnUrl: z.string().optional(),
});

export async function POST(req: Request) {
try {
// Verify authentication
const authHeader = req.headers.get('authorization');
if (!authHeader) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}

const token = authHeader.replace('Bearer ', '');
const payload = await verify(token);
if (!payload) {
return Response.json({ error: 'Invalid token' }, { status: 401 });
}

const body = await req.json();
const validation = githubConnectSchema.safeParse(body);

if (!validation.success) {
return Response.json({ errors: validation.error.flatten().fieldErrors }, { status: 400 });
}

// Rate limiting by IP address
const clientIP = req.headers.get('x-forwarded-for')?.split(',')[0] ||
req.headers.get('x-real-ip') ||
'unknown';

const ipRateLimit = checkRateLimit(clientIP, RATE_LIMIT_CONFIGS.LOGIN);
if (!ipRateLimit.allowed) {
return Response.json(
{
error: 'Too many connection attempts. Please try again later.',
retryAfter: ipRateLimit.retryAfter
},
{
status: 429,
headers: {
'Retry-After': ipRateLimit.retryAfter?.toString() || '900'
}
}
);
}

const { driveId, returnUrl } = validation.data;

// Check environment variables
if (!process.env.GITHUB_OAUTH_CLIENT_ID || !process.env.GITHUB_OAUTH_REDIRECT_URI) {
loggers.auth.error('GitHub OAuth not configured');
return Response.json({ error: 'GitHub integration not configured' }, { status: 500 });
}

// Generate state parameter for CSRF protection
const state = createId();

// Store state in a way that can be verified in callback
// For now, we'll encode the userId, driveId, and returnUrl in the state
const stateData = {
state,
userId: payload.userId,
driveId,
returnUrl,
timestamp: Date.now(),
};
const encodedState = Buffer.from(JSON.stringify(stateData)).toString('base64url');

// Generate OAuth URL
const scopes = ['repo', 'read:user', 'user:email'];
const params = new URLSearchParams({
client_id: process.env.GITHUB_OAUTH_CLIENT_ID,
redirect_uri: process.env.GITHUB_OAUTH_REDIRECT_URI,
scope: scopes.join(' '),
state: encodedState,
allow_signup: 'true',
});

const oauthUrl = `https://github.com/login/oauth/authorize?${params.toString()}`;

loggers.auth.info('GitHub OAuth connection initiated', {
userId: payload.userId,
driveId,
});

return Response.json({ url: oauthUrl, state });

} catch (error) {
loggers.auth.error('GitHub OAuth connect error', error as Error);
return Response.json({ error: 'An unexpected error occurred.' }, { status: 500 });
}
}

export async function GET(req: Request) {
try {
// Verify authentication
const { searchParams } = new URL(req.url);
const token = searchParams.get('token');

if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}

const payload = await verify(token);
if (!payload) {
return Response.json({ error: 'Invalid token' }, { status: 401 });
}

// Check environment variables
if (!process.env.GITHUB_OAUTH_CLIENT_ID || !process.env.GITHUB_OAUTH_REDIRECT_URI) {
loggers.auth.error('GitHub OAuth not configured');
const baseUrl = process.env.WEB_APP_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000';
return Response.redirect(new URL('/settings?error=github_not_configured', baseUrl).toString());
}

// Generate state parameter
const state = createId();
const stateData = {
state,
userId: payload.userId,
timestamp: Date.now(),
};
const encodedState = Buffer.from(JSON.stringify(stateData)).toString('base64url');

// Generate OAuth URL for direct link access
const scopes = ['repo', 'read:user', 'user:email'];
const params = new URLSearchParams({
client_id: process.env.GITHUB_OAUTH_CLIENT_ID,
redirect_uri: process.env.GITHUB_OAUTH_REDIRECT_URI,
scope: scopes.join(' '),
state: encodedState,
allow_signup: 'true',
});

const oauthUrl = `https://github.com/login/oauth/authorize?${params.toString()}`;

return Response.redirect(oauthUrl);

} catch (error) {
loggers.auth.error('GitHub OAuth connect GET error', error as Error);
const baseUrl = process.env.WEB_APP_URL || process.env.NEXTAUTH_URL || 'http://localhost:3000';
return Response.redirect(new URL('/settings?error=github_error', baseUrl).toString());
}
}
Loading
Loading