mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
- Add GitHub OAuth 2.0 authentication flow - Create authentication routes: /auth/login, /auth/callback, /auth/logout - Implement OAuth server utilities with proper session management - Add authentication hooks and client-side state management - Update header with login/logout controls and user menu - Create user profile page with GitHub integration - Add environment variables for GitHub OAuth configuration - Include comprehensive documentation for setup and usage - Enhance profile store with authentication state - Add authentication status API endpoint Closes: GitHub authentication implementation
242 lines
6.6 KiB
TypeScript
242 lines
6.6 KiB
TypeScript
import { OAuthApp } from '@octokit/oauth-app';
|
|
import { createCookieSessionStorage, redirect } from '@remix-run/node';
|
|
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';
|
|
}
|
|
}
|
|
|
|
// Get environment variables
|
|
const clientId = process.env.GITHUB_CLIENT_ID;
|
|
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
|
|
const sessionSecret = process.env.SESSION_SECRET || 'buildify-session-secret';
|
|
|
|
// Validate required environment variables
|
|
if (!clientId || !clientSecret) {
|
|
console.warn(
|
|
'GitHub OAuth is not configured properly. Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.'
|
|
);
|
|
}
|
|
|
|
// Create OAuth app instance
|
|
export const oauthApp = new OAuthApp({
|
|
clientId: clientId || '',
|
|
clientSecret: clientSecret || '',
|
|
defaultScopes: ['read:user', 'user:email'],
|
|
});
|
|
|
|
// Create session storage
|
|
const sessionStorage = createCookieSessionStorage({
|
|
cookie: {
|
|
name: 'buildify_auth_session',
|
|
httpOnly: true,
|
|
path: '/',
|
|
sameSite: 'lax',
|
|
secrets: [sessionSecret],
|
|
secure: process.env.NODE_ENV === 'production',
|
|
},
|
|
});
|
|
|
|
// Get session from request
|
|
export async function getSession(request: Request) {
|
|
const cookie = request.headers.get('Cookie');
|
|
return sessionStorage.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) {
|
|
const session = await getSession(request);
|
|
session.set('oauth:state', state);
|
|
return sessionStorage.commitSession(session);
|
|
}
|
|
|
|
// Verify state from session
|
|
export async function verifyState(request: Request, state: string) {
|
|
const session = await getSession(request);
|
|
const storedState = session.get('oauth:state');
|
|
|
|
if (!storedState || storedState !== state) {
|
|
throw new AuthError('Invalid state parameter. Possible CSRF attack.');
|
|
}
|
|
|
|
// Clear the state after verification
|
|
session.unset('oauth:state');
|
|
return sessionStorage.commitSession(session);
|
|
}
|
|
|
|
// Generate authorization URL
|
|
export function getAuthorizationUrl(state: string, redirectUri?: string) {
|
|
return oauthApp.getWebFlowAuthorizationUrl({
|
|
state,
|
|
redirectUrl: redirectUri,
|
|
});
|
|
}
|
|
|
|
// Exchange code for token
|
|
export async function exchangeCodeForToken(code: string, state: string) {
|
|
try {
|
|
const { authentication } = await oauthApp.createToken({
|
|
code,
|
|
state,
|
|
});
|
|
|
|
return {
|
|
accessToken: authentication.token,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error exchanging code for token:', 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 {
|
|
const response = await fetch('https://api.github.com/user', {
|
|
headers: {
|
|
Accept: 'application/vnd.github.v3+json',
|
|
Authorization: `token ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`GitHub API responded with ${response.status}`);
|
|
}
|
|
|
|
const userData = await response.json() as GitHubUserResponse;
|
|
|
|
// 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: `token ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (emailsResponse.ok) {
|
|
const emails = await emailsResponse.json() as GitHubEmailResponse[];
|
|
const primaryEmail = emails.find((e) => e.primary);
|
|
email = primaryEmail?.email || null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: userData.id,
|
|
login: userData.login,
|
|
name: userData.name,
|
|
email: email,
|
|
avatar_url: userData.avatar_url,
|
|
html_url: userData.html_url,
|
|
};
|
|
} catch (error) {
|
|
console.error('Error fetching GitHub user:', error);
|
|
throw new AuthError('Failed to fetch user data from GitHub');
|
|
}
|
|
}
|
|
|
|
// Create user session
|
|
export async function createUserSession(request: Request, authSession: AuthSession, redirectTo: string) {
|
|
const session = await getSession(request);
|
|
|
|
// Store user data in session
|
|
session.set('auth:user', authSession.user);
|
|
session.set('auth:accessToken', authSession.accessToken);
|
|
|
|
// Commit session and redirect
|
|
return redirect(redirectTo, {
|
|
headers: {
|
|
'Set-Cookie': await sessionStorage.commitSession(session, {
|
|
maxAge: 60 * 60 * 24 * 7, // 1 week
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
// Get user from session
|
|
export async function getUserFromSession(request: Request): Promise<GitHubUser | null> {
|
|
const session = await getSession(request);
|
|
const user = session.get('auth:user');
|
|
return user || null;
|
|
}
|
|
|
|
// Get access token from session
|
|
export async function getAccessToken(request: Request): Promise<string | null> {
|
|
const session = await getSession(request);
|
|
const accessToken = session.get('auth:accessToken');
|
|
return accessToken || null;
|
|
}
|
|
|
|
// Check if user is authenticated
|
|
export async function isAuthenticated(request: Request): Promise<boolean> {
|
|
const user = await getUserFromSession(request);
|
|
return user !== null;
|
|
}
|
|
|
|
// Require authentication
|
|
export async function requireAuthentication(request: Request, redirectTo: string = '/login') {
|
|
const authenticated = await isAuthenticated(request);
|
|
|
|
if (!authenticated) {
|
|
const searchParams = new URLSearchParams([['redirectTo', request.url]]);
|
|
throw redirect(`${redirectTo}?${searchParams}`);
|
|
}
|
|
|
|
return await getUserFromSession(request);
|
|
}
|
|
|
|
// Logout - destroy session
|
|
export async function logout(request: Request, redirectTo: string = '/') {
|
|
const session = await getSession(request);
|
|
|
|
return redirect(redirectTo, {
|
|
headers: {
|
|
'Set-Cookie': await sessionStorage.destroySession(session),
|
|
},
|
|
});
|
|
}
|