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
120 changes: 117 additions & 3 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,149 @@
import { apiClient } from '../services/apiClient';
import { apiClient, setAuthToken, ApiError } from '../services/apiClient';
import type { User } from '../types/user';

/**
* Authentication tokens issued by the backend after a successful OAuth or credential login.
*/
export interface AuthTokens {
/** JWT access token used to authenticate API requests. */
access_token: string;
/** Long-lived token used to obtain a new access token when it expires. */
refresh_token: string;
/** Token type (typically "Bearer"). */
token_type: string;
}

/**
* Full response returned by the GitHub OAuth callback endpoint.
* Contains auth tokens plus the authenticated user's profile.
*/
export interface GitHubCallbackResponse extends AuthTokens {
/** Authenticated user profile. */
user: User;
}

/**
* Build the GitHub OAuth authorize URL directly using the configured client ID.
* Used as fallback when the backend /api/auth/github/authorize endpoint is unavailable.
*
* @param clientId - GitHub OAuth App client ID (defaults to VITE_GITHUB_CLIENT_ID)
* @param redirectUri - Callback URL (defaults to /github/callback)
* @param state - CSRF state token for security
*/
export function buildGitHubAuthorizeUrl(
clientId?: string,
redirectUri?: string,
state?: string,
): string {
const cid = clientId ?? import.meta.env?.VITE_GITHUB_CLIENT_ID ?? '';
if (!cid) {
throw new Error('Missing GitHub OAuth client configuration: VITE_GITHUB_CLIENT_ID is not set');
}
const uri = redirectUri ?? `${window.location.origin}/github/callback`;
const csrf = state ?? generateState();
// Persist CSRF state for validation in callback
sessionStorage.setItem('oauth_state', csrf);
const params = new URLSearchParams({
client_id: cid,
redirect_uri: uri,
scope: 'read:user user:email',
Comment on lines +38 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Client-side URL builder permits empty client_id

Line 27 defaults cid to '', and Lines 30-33 always emit an authorize URL even when client configuration is missing. This creates a guaranteed sign-in failure path with poor diagnosability and no early guardrail.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/api/auth.ts` around lines 27 - 33, The code currently falls back
to an empty cid and always builds an authorize URL, so add an explicit guard
that validates the resolved client_id (cid) before creating URLSearchParams: if
clientId and import.meta.env?.VITE_GITHUB_CLIENT_ID are both missing or cid is
empty, throw or return an informative error (or reject) rather than continuing;
update the function that calls cid/generateState/params to perform this check
and fail fast with a clear message about missing GitHub client configuration so
no invalid authorize URL is emitted.

state: csrf,
});
return `https://github.com/login/oauth/authorize?${params.toString()}`;
}

/** Generate a random state token 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('');
}
Comment on lines +43 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fallback OAuth flow breaks CSRF state guarantees

Line 29 generates a state token, but no persistence/association exists to validate it later. In frontend/src/pages/GitHubCallbackPage.tsx (Lines 19-28), state is only read from URL and forwarded to the backend, so fallback-generated state has no verifiable origin binding. This undermines CSRF protection in the client-built OAuth path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/api/auth.ts` around lines 29 - 44, The client-generated CSRF
state from generateState() is not persisted, so the fallback OAuth path can't
verify the state on return; update the authorize URL flow to store the generated
state (e.g., sessionStorage or a same-site secure cookie) when you call the
function that builds the GitHub authorize URL, and in GitHubCallbackPage.tsx
read the stored value and compare it to the state query param before forwarding
anything to the backend; ensure the code uses the same key for storing/reading,
removes the stored state after successful validation, and reject/handle
mismatches (do not forward callback to backend if the stored and returned states
differ).


/**
* Retrieve the GitHub OAuth authorize URL from the backend.
* Falls back to building the URL directly if the backend is unavailable.
*/
export async function getGitHubAuthorizeUrl(): Promise<string> {
const data = await apiClient<{ authorize_url: string }>('/api/auth/github/authorize');
return data.authorize_url;
try {
const data = await apiClient<{ authorize_url: string }>('/api/auth/github/authorize', {
retries: 0, // Fail fast — don't retry on 404
});
if (!data.authorize_url || typeof data.authorize_url !== 'string') {
throw new Error('Invalid authorize_url from server');
}
return data.authorize_url;
} catch (err) {
// Only fall back for genuine network errors (TypeError) or HTTP 5xx/503/504/404
const isNetworkError = err instanceof TypeError;
const isServerError = err instanceof ApiError && (
err.status === 404 ||
err.status === 503 ||
err.status === 504 ||
err.status >= 500
);
if (isNetworkError || isServerError) {
console.warn('[auth] Backend /api/auth/github/authorize unavailable, using direct OAuth URL:', err);
return buildGitHubAuthorizeUrl();
}
// For other errors (e.g., invalid response body), rethrow
throw err;
}
Comment on lines +67 to +90
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Catch-all fallback masks backend contract failures

Any exception (network errors, malformed payloads, server-side validation issues) triggers silent fallback to client-built OAuth URL. This can hide backend regressions and bypass server-controlled OAuth parameters/constraints expected by the sign-in architecture.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/api/auth.ts` around lines 51 - 63, The current catch-all around
the apiClient call masks contract failures by always falling back to
buildGitHubAuthorizeUrl(); change the logic so you validate the apiClient
response (the authorize_url check in this block) and throw when the payload is
malformed, and only perform the fallback to buildGitHubAuthorizeUrl() when the
error is a genuine network/backend-unavailable condition (e.g., network error or
HTTP 5xx/503/504/404 as surfaced by apiClient), otherwise rethrow or surface the
error so backend contract issues are visible; update the catch to inspect err
(or use apiClient's error shape/status) and only call buildGitHubAuthorizeUrl()
for network/unavailable errors, while keeping the existing validation of
authorize_url.

}

/**
* Exchange a GitHub OAuth authorization code for JWT auth tokens and user info.
* Called by the GitHub callback page after the user approves the OAuth request.
*
* @param code - The authorization code returned by GitHub in the callback URL
* @param state - The CSRF state token returned by GitHub (optional, validated server-side)
*/
export async function exchangeGitHubCode(code: string, state?: string): Promise<GitHubCallbackResponse> {
return apiClient<GitHubCallbackResponse>('/api/auth/github', {
method: 'POST',
body: { code, ...(state ? { state } : {}) },
});
}

/**
* Fetch the currently authenticated user's profile from the backend.
* Requires a valid access token in the Authorization header.
*/
export async function getMe(): Promise<User> {
return apiClient<User>('/api/auth/me');
}

/**
* Refresh the access token using a valid refresh token.
* Returns a new set of access + refresh tokens on success.
*
* @param refreshToken - The refresh token previously issued during login
*/
export async function refreshTokens(refreshToken: string): Promise<AuthTokens> {
return apiClient<AuthTokens>('/api/auth/refresh', {
method: 'POST',
body: { refresh_token: refreshToken },
});
}

/**
* Clear all authentication data from localStorage and memory.
*/
export function clearAuthStorage(): void {
localStorage.removeItem('sf_access_token');
localStorage.removeItem('sf_refresh_token');
localStorage.removeItem('sf_user');
setAuthToken(null);
}

/**
* Revoke the current session (server-side logout).
* Clears local tokens afterwards regardless of server response.
*/
export async function logout(): Promise<void> {
try {
await apiClient<void>('/api/auth/logout', { method: 'POST' });
clearAuthStorage();
} finally {
clearAuthStorage();
}
}
Comment on lines +138 to +149
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

logout() behavior contradicts its documented contract

Lines 85-87 state local tokens are cleared regardless of server response, but Lines 88-94 only attempt /api/auth/logout and swallow errors. Local token/state clearing actually lives in frontend/src/contexts/AuthContext.tsx (Lines 78-84). This mismatch can cause incorrect caller assumptions and stale auth state if this API is used directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/api/auth.ts` around lines 84 - 94, The logout() function
promises to clear local tokens regardless of server response but currently only
calls the server and swallows errors; fix by invoking the same local-clear logic
used in AuthContext.tsx (the function that resets/clears auth state/tokens in
AuthContext) from within logout() — e.g., import and call that exported
clear/reset function or move the clearing logic into logout and have AuthContext
call logout — and ensure the call runs in a finally block so local tokens/state
are cleared whether the API call succeeds or throws.

69 changes: 61 additions & 8 deletions frontend/src/components/bounty/BountyGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ChevronDown, Loader2, Plus } from 'lucide-react';
import { ChevronDown, Loader2, Plus, Search, X } from 'lucide-react';
import { BountyCard } from './BountyCard';
import { useInfiniteBounties } from '../../hooks/useBounties';
import { staggerContainer, staggerItem } from '../../lib/animations';

/** Skill filter options available in the bounty grid filter bar. */
const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript'];

/**
* BountyGrid — renders the full bounty listing page section.
* Includes a skill/language filter bar, status dropdown, search input,
* client-side debounced search, and a paginated card grid with infinite scroll.
*/
export function BountyGrid() {
const [activeSkill, setActiveSkill] = useState<string>('All');
const [statusFilter, setStatusFilter] = useState<string>('open');
const [searchInput, setSearchInput] = useState<string>('');
const [debouncedQuery, setDebouncedQuery] = useState<string>('');

// Debounce search input by 300ms
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchInput.trim().toLowerCase());
}, 300);
return () => clearTimeout(timer);
}, [searchInput]);

const params = {
status: statusFilter,
Expand All @@ -22,13 +38,44 @@ export function BountyGrid() {

const allBounties = data?.pages.flatMap((p) => p.items) ?? [];

// Client-side search filter across title, description, and skills
const filteredBounties = useMemo(() => {
if (!debouncedQuery) return allBounties;
return allBounties.filter(
(bounty) =>
bounty.title.toLowerCase().includes(debouncedQuery) ||
(bounty.description ?? '').toLowerCase().includes(debouncedQuery) ||
bounty.skills.some((skill) => skill.toLowerCase().includes(debouncedQuery)),
);
}, [allBounties, debouncedQuery]);

return (
<section id="bounties" className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4">
{/* Header row */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<h2 className="font-sans text-2xl font-semibold text-text-primary">Open Bounties</h2>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted pointer-events-none" />
<input
type="text"
placeholder="Search bounties..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 pr-8 py-1.5 bg-forge-800 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:border-emerald outline-none transition-colors duration-150 w-48 focus:w-64"
/>
{searchInput && (
<button
onClick={() => setSearchInput('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
aria-label="Clear search"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<Link
to="/bounties/create"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald text-forge-950 font-semibold text-sm hover:bg-emerald/90 transition-colors duration-150"
Expand Down Expand Up @@ -93,25 +140,31 @@ export function BountyGrid() {
)}

{/* Empty state */}
{!isLoading && !isError && allBounties.length === 0 && (
{!isLoading && !isError && filteredBounties.length === 0 && (
<div className="text-center py-16">
<p className="text-text-muted text-lg mb-2">No bounties found</p>
<p className="text-text-muted text-lg mb-2">
{debouncedQuery ? 'No bounties match your search' : 'No bounties found'}
</p>
<p className="text-text-muted text-sm">
{activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'}
{debouncedQuery
? 'Try a different search term or clear the filter.'
: activeSkill !== 'All'
? 'Try a different language filter.'
: 'Check back soon for new bounties.'}
</p>
</div>
)}

{/* Bounty grid */}
{!isLoading && allBounties.length > 0 && (
{!isLoading && filteredBounties.length > 0 && (
<motion.div
variants={staggerContainer}
initial="initial"
whileInView="animate"
viewport={{ once: true, margin: '-50px' }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"
>
{allBounties.map((bounty) => (
{filteredBounties.map((bounty) => (
<motion.div key={bounty.id} variants={staggerItem}>
<BountyCard bounty={bounty} />
</motion.div>
Expand Down
43 changes: 40 additions & 3 deletions frontend/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,25 @@ import { useAuth } from '../../hooks/useAuth';
import { useStats } from '../../hooks/useStats';
import { getGitHubAuthorizeUrl } from '../../api/auth';

/** GitHub mark SVG icon used in the sign-in button. */
const GitHubIcon = () => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
</svg>
);

/** Static navigation links rendered in the desktop navbar. */
const NAV_LINKS = [
{ label: 'Bounties', to: '/bounties' },
{ label: 'Leaderboard', to: '/leaderboard' },
{ label: 'How It Works', to: '/how-it-works' },
];

/**
* Navbar — the top navigation bar, fixed above content.
* Shows logo, nav links, live bounty count badge, and auth controls
* (GitHub sign-in button or user avatar dropdown).
*/
export function Navbar() {
const location = useLocation();
const navigate = useNavigate();
Expand All @@ -33,16 +40,46 @@ export function Navbar() {
return () => window.removeEventListener('scroll', onScroll);
}, []);

/**
* Initiate GitHub OAuth sign-in.
* Fetches the authorize URL from the backend (falling back to a direct URL if the backend is unavailable),
* validates that it is a same-origin HTTPS URL, then navigates the browser to GitHub.
* Shows an alert on failure.
*/
const handleGitHubSignIn = async () => {
try {
const url = await getGitHubAuthorizeUrl();
// Validate that we got a valid HTTPS URL with same-origin
if (!url) {
console.error('[Navbar] GitHub sign-in failed: empty authorization URL');
return;
}
let urlObj: URL;
try {
urlObj = new URL(url, window.location.origin);
} catch {
console.error('[Navbar] GitHub sign-in failed: invalid URL format', url);
return;
}
if (urlObj.protocol !== 'https:') {
console.error('[Navbar] GitHub sign-in failed: URL must use HTTPS', url);
return;
}
if (urlObj.origin !== window.location.origin) {
console.error('[Navbar] GitHub sign-in failed: URL must be same-origin', url);
return;
}
window.location.href = url;
} catch {
// Fallback: direct to backend authorize endpoint
window.location.href = '/api/auth/github/authorize';
} catch (err) {
console.error('[Navbar] GitHub sign-in failed:', err);
window.alert('Sign-in failed. Please try again or contact support.');
}
};

/**
* Returns true when the given path matches the current browser location.
* Root path "/" matches exactly; all other paths match if the pathname starts with the path.
*/
const isActive = (to: string) => {
if (to === '/') return location.pathname === '/';
return location.pathname.startsWith(to);
Expand Down
19 changes: 14 additions & 5 deletions frontend/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { setAuthToken, setOnAuthExpired } from '../services/apiClient';
import { logout as apiLogout } from '../api/auth';

export interface AuthUser {
id: string;
Expand Down Expand Up @@ -36,6 +37,13 @@ const TOKEN_KEY = 'sf_access_token';
const REFRESH_KEY = 'sf_refresh_token';
const USER_KEY = 'sf_user';

/**
* AuthContext provider — manages JWT token + current user state, persisted to localStorage.
* Provides login (token storage), logout, and the authenticated user object.
* Wire this into the app root to make auth state available via `useAuthContext`.
*
* @param children - The React subtree that will have access to the auth context
*/
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>({
user: null,
Expand Down Expand Up @@ -75,11 +83,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setState({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false });
}, []);

const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USER_KEY);
setAuthToken(null);
const logout = useCallback(async () => {
await apiLogout();
setState({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false, isLoading: false });
}, []);

Expand All @@ -105,6 +110,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
);
}

/**
* Hook to access the auth context value.
* Throws if called outside of an `AuthProvider` tree.
*/
export function useAuthContext(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuthContext must be used inside AuthProvider');
Expand Down
Loading