bolt.diy/app/lib/auth/github-oauth.server.ts
2025-06-16 21:20:30 -04:00

337 lines
10 KiB
TypeScript

import { OAuthApp } from '@octokit/oauth-app';
import { createCookieSessionStorage, redirect } from '@remix-run/cloudflare';
import { v4 as uuidv4 } from 'uuid';
// Define types for GitHub user data
export interface GitHubUser {
id: number;
login: string;
name: string | null;
email: string | null;
avatar_url: string;
html_url: string;
}
// GitHub API response types
interface GitHubUserResponse {
id: number;
login: string;
name: string | null;
email: string | null;
avatar_url: string;
html_url: string;
[key: string]: any; // For other properties we don't explicitly use
}
interface GitHubEmailResponse {
email: string;
primary: boolean;
verified: boolean;
visibility: string | null;
}
// Define types for session data
export interface AuthSession {
accessToken: string;
user: GitHubUser;
}
// Error class for authentication errors
export class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthError';
}
}
// Global environment variable cache to ensure consistency across context availability
let globalEnvCache: { [key: string]: string } = {};
// Helper function to get environment variables from context or process.env
function getEnvVar(context: any, key: string, defaultValue: string = ''): string {
// Use cached global value if available
if (globalEnvCache[key]) {
return globalEnvCache[key];
}
let value = '';
// Try context.cloudflare.env first (Cloudflare Workers with wrangler bindings)
if (context?.cloudflare?.env?.[key]) {
value = context.cloudflare.env[key];
}
// Try context.env (other Cloudflare Workers setups)
else if (context?.env?.[key]) {
value = context.env[key];
}
// Fallback to process.env (Node.js)
else {
value = process.env[key] || defaultValue;
}
// Cache environment variables globally for consistency
if (value && !globalEnvCache[key]) {
globalEnvCache[key] = value;
}
return value;
}
// Create OAuth app instance with environment variables
function createOAuthApp(context?: any) {
const clientId = getEnvVar(context, 'GITHUB_CLIENT_ID');
const clientSecret = getEnvVar(context, 'GITHUB_CLIENT_SECRET');
if (!clientId || !clientSecret) {
console.warn(
'GitHub OAuth is not configured properly. Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.'
);
}
return new OAuthApp({
clientId: clientId || '',
clientSecret: clientSecret || '',
defaultScopes: ['read:user', 'user:email'],
});
}
// Create session storage with context-aware secrets
function createSessionStorage(context?: any, request?: Request) {
const sessionSecret = getEnvVar(context, 'SESSION_SECRET', 'buildify-session-secret');
// Determine if we should use secure cookies based on environment and protocol
let secure = false;
// Check if we're in production environment
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction) {
// In production, check for HTTPS indicators
if (request) {
const proto = request.headers.get('x-forwarded-proto');
const host = request.headers.get('host');
// Use secure cookies if we have HTTPS indicators or production domain
secure = proto === 'https' || host?.includes('phexhub-np.int.bayer.com') || false;
} else {
// Default to secure in production if no request context
secure = true;
}
}
return createCookieSessionStorage({
cookie: {
name: 'buildify_auth_session',
httpOnly: true,
path: '/',
sameSite: 'lax',
secrets: [sessionSecret],
secure,
},
});
}
// Get session from request
export async function getSession(request: Request, context?: any) {
const cookie = request.headers.get('Cookie');
const storage = createSessionStorage(context, request);
return storage.getSession(cookie);
}
// Generate state parameter for OAuth flow to prevent CSRF attacks
export function generateState() {
return uuidv4();
}
// Store state in session
export async function storeState(request: Request, state: string, context?: any) {
const session = await getSession(request, context);
session.set('oauth:state', state);
const storage = createSessionStorage(context, request);
return storage.commitSession(session);
}
// Verify state from session
export async function verifyState(request: Request, state: string, context?: any) {
const session = await getSession(request, context);
const storedState = session.get('oauth:state');
if (!storedState || storedState !== state) {
throw new AuthError(`Invalid state parameter. Expected: ${storedState}, Got: ${state}`);
}
// Clear the state after verification
session.unset('oauth:state');
const storage = createSessionStorage(context, request);
return storage.commitSession(session);
}
// Generate authorization URL
export function getAuthorizationUrl(state: string, redirectUri?: string, context?: any) {
const oauthApp = createOAuthApp(context);
return oauthApp.getWebFlowAuthorizationUrl({
state,
redirectUrl: redirectUri,
});
}
// Exchange code for token
export async function exchangeCodeForToken(code: string, state: string, context?: any) {
try {
console.log('🔑 Token exchange: Starting with code length:', code?.length);
const oauthApp = createOAuthApp(context);
console.log('🔑 Token exchange: OAuth app created');
const { authentication } = await oauthApp.createToken({
code,
state,
});
console.log('🔑 Token exchange: GitHub responded successfully');
return {
accessToken: authentication.token,
};
} catch (error) {
console.error('🔑 Token exchange: Failed -', error);
throw new AuthError('Failed to exchange code for token');
}
}
// Fetch user data from GitHub API
export async function fetchGitHubUser(accessToken: string): Promise<GitHubUser> {
try {
console.log('👤 User fetch: Starting with token length:', accessToken?.length);
const response = await fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'Buildify-App/1.0',
},
});
console.log('👤 User fetch: GitHub API response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('👤 User fetch: GitHub API error response:', errorText);
throw new Error(`GitHub API responded with ${response.status}: ${errorText}`);
}
const userData = await response.json() as GitHubUserResponse;
console.log('👤 User fetch: User data received for:', userData.login);
// If email is not public, try to get primary email
let email = userData.email;
if (!email) {
const emailsResponse = await fetch('https://api.github.com/user/emails', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'Buildify-App/1.0',
},
});
if (emailsResponse.ok) {
const emails = await emailsResponse.json() as GitHubEmailResponse[];
const primaryEmail = emails.find((e) => e.primary);
email = primaryEmail?.email || null;
}
}
const user = {
id: userData.id,
login: userData.login,
name: userData.name,
email: email,
avatar_url: userData.avatar_url,
html_url: userData.html_url,
};
console.log('👤 User fetch: Successfully processed user data');
return user;
} catch (error) {
console.error('👤 User fetch: Failed -', error);
throw new AuthError('Failed to fetch user data from GitHub');
}
}
// Create user session
export async function createUserSession(request: Request, authSession: AuthSession, redirectTo: string, context?: any) {
try {
console.log('💾 Session create: Starting for user:', authSession.user.login);
const session = await getSession(request, context);
console.log('💾 Session create: Session retrieved');
// Store user data in session
session.set('auth:user', authSession.user);
session.set('auth:accessToken', authSession.accessToken);
console.log('💾 Session create: User data stored in session');
// Commit session and redirect
const storage = createSessionStorage(context, request);
console.log('💾 Session create: Session storage created');
const result = redirect(redirectTo, {
headers: {
'Set-Cookie': await storage.commitSession(session, {
maxAge: 60 * 60 * 24 * 7, // 1 week
}),
},
});
console.log('💾 Session create: Redirect prepared to:', redirectTo);
return result;
} catch (error) {
console.error('💾 Session create: Failed -', error);
throw new AuthError('Failed to create user session');
}
}
// Get user from session
export async function getUserFromSession(request: Request, context?: any): Promise<GitHubUser | null> {
const session = await getSession(request, context);
const user = session.get('auth:user');
return user || null;
}
// Get access token from session
export async function getAccessToken(request: Request, context?: any): Promise<string | null> {
const session = await getSession(request, context);
const accessToken = session.get('auth:accessToken');
return accessToken || null;
}
// Check if user is authenticated
export async function isAuthenticated(request: Request, context?: any): Promise<boolean> {
const user = await getUserFromSession(request, context);
const result = user !== null;
return result;
}
// Require authentication
export async function requireAuthentication(request: Request, redirectTo: string = '/login', context?: any) {
const authenticated = await isAuthenticated(request, context);
if (!authenticated) {
const searchParams = new URLSearchParams([['redirectTo', request.url]]);
throw redirect(`${redirectTo}?${searchParams}`);
}
return await getUserFromSession(request, context);
}
// Logout - destroy session
export async function logout(request: Request, redirectTo: string = '/', context?: any) {
const session = await getSession(request, context);
const storage = createSessionStorage(context, request);
return redirect(redirectTo, {
headers: {
'Set-Cookie': await storage.destroySession(session),
},
});
}