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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ SOLANA_RPC_URL=https://api.devnet.solana.com
# Frontend
FRONTEND_PORT=3000
VITE_API_URL=http://localhost:8000
VITE_GITHUB_CLIENT_ID=

# Deploy health-check URLs (set as GitHub repository variables, not secrets)
STAGING_HEALTH_URL=https://staging-api.solfoundry.org/health
Expand Down
54 changes: 52 additions & 2 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,59 @@ export interface GitHubCallbackResponse extends AuthTokens {
user: User;
}

const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';

/**
* Generate a cryptographically-random state string for CSRF protection.
*/
function generateState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('');
}

/**
* Get the GitHub OAuth authorize URL.
*
* Primary: fetch from backend API (returns 404 when backend is offline).
* Fallback: construct directly from VITE_GITHUB_CLIENT_ID env var.
*/
export async function getGitHubAuthorizeUrl(): Promise<string> {
const data = await apiClient<{ authorize_url: string }>('/api/auth/github/authorize');
return data.authorize_url;
// Try backend API first
try {
const data = await apiClient<{ authorize_url: string }>('/api/auth/github/authorize');
if (data?.authorize_url) return data.authorize_url;
} catch {
// Backend unavailable 鈥?fall through to frontend fallback
}

// Fallback: build the URL ourselves
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID as string | undefined;
if (!clientId) {
throw new Error(
'VITE_GITHUB_CLIENT_ID is not configured. ' +
'Set it in your .env file or ensure the backend /api/auth/github/authorize endpoint is running.',
);
}

const redirectUri = `${window.location.origin}/github/callback`;
const state = generateState();

// Store state for CSRF verification on callback
try {
sessionStorage.setItem('sf_oauth_state', state);
} catch {
// sessionStorage may be unavailable 鈥?non-critical
}

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

return `${GITHUB_AUTH_URL}?${params.toString()}`;
}

export async function exchangeGitHubCode(code: string, state?: string): Promise<GitHubCallbackResponse> {
Expand Down
15 changes: 12 additions & 3 deletions frontend/src/components/auth/AuthGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ export function AuthGuard({ children }: AuthGuardProps) {
}

if (!isAuthenticated) {
const [signInError, setSignInError] = React.useState<string | null>(null);

const handleSignIn = async () => {
setSignInError(null);
try {
const url = await getGitHubAuthorizeUrl();
window.location.href = url;
} catch {
// fallback: redirect to backend OAuth endpoint directly
window.location.href = '/api/auth/github/authorize';
} catch (err) {
setSignInError(
err instanceof Error ? err.message : 'Failed to initiate sign-in. Is VITE_GITHUB_CLIENT_ID configured?',
);
}
};

Expand All @@ -54,6 +58,11 @@ export function AuthGuard({ children }: AuthGuardProps) {
<GitBranch className="w-4 h-4" />
Sign in with GitHub
</button>
{signInError && (
<p className="mt-3 text-xs text-status-error text-center">
{signInError}
</p>
)}
<Link
to="/"
className="mt-4 block text-sm text-text-muted hover:text-text-secondary transition-colors duration-150"
Expand Down
54 changes: 48 additions & 6 deletions frontend/src/pages/GitHubCallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { motion } from 'framer-motion';
import { AlertCircle } from 'lucide-react';
import { useAuth } from '../hooks/useAuth';
import { exchangeGitHubCode } from '../api/auth';
import { setAuthToken } from '../services/apiClient';
Expand All @@ -11,37 +12,78 @@ export function GitHubCallbackPage() {
const navigate = useNavigate();
const { login } = useAuth();
const didRun = useRef(false);
const [error, setError] = React.useState<string | null>(null);

useEffect(() => {
if (didRun.current) return;
didRun.current = true;

const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
const errorParam = searchParams.get('error');

if (error || !code) {
if (errorParam || !code) {
navigate('/', { replace: true });
return;
}

// Validate OAuth state for CSRF protection
const savedState = sessionStorage.getItem('sf_oauth_state');
sessionStorage.removeItem('sf_oauth_state');

// When the backend handles the OAuth callback we may not have a stored state
// Only enforce validation when state was stored (frontend-initiated flow)
if (state && savedState && state !== savedState) {
setError('Security validation failed. Please try signing in again.');
return;
}

exchangeGitHubCode(code, state ?? undefined)
.then((response) => {
// Store tokens + user in auth context
const authUser = { ...response.user, wallet_verified: false };
login(response.access_token, response.refresh_token ?? '', authUser);
setAuthToken(response.access_token);
// Store refresh token for future use
if (response.refresh_token) {
localStorage.setItem('sf_refresh_token', response.refresh_token);
}
navigate('/', { replace: true });
})
.catch(() => {
navigate('/', { replace: true });
.catch((err) => {
setError(
err instanceof Error
? err.message
: 'Authentication failed. Please try again.',
);
});
}, []);

if (error) {
return (
<div className="min-h-screen bg-forge-950 flex items-center justify-center px-4">
<motion.div
variants={fadeIn}
initial="initial"
animate="animate"
className="max-w-sm w-full text-center"
>
<div className="rounded-xl border border-border bg-forge-900 p-8">
<AlertCircle className="w-10 h-10 text-status-error mx-auto mb-4" />
<h2 className="font-sans text-lg font-semibold text-text-primary mb-2">
Sign-In Failed
</h2>
<p className="text-text-muted text-sm mb-6">{error}</p>
<button
onClick={() => navigate('/', { replace: true })}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-forge-800 text-text-secondary text-sm hover:bg-forge-700 transition-colors"
>
Go Home
</button>
</div>
</motion.div>
</div>
);
}

return (
<div className="min-h-screen bg-forge-950 flex items-center justify-center">
<motion.div
Expand Down