diff --git a/Backend/.env.example b/Backend/.env.example new file mode 100644 index 0000000..0e7fdf0 --- /dev/null +++ b/Backend/.env.example @@ -0,0 +1,32 @@ +# ------------------------------------------------------- +# GitQuest Backend – Environment Variable Reference +# ------------------------------------------------------- +# Copy relevant values into: +# • appsettings.Development.json (local dev) +# • Azure App Service / Render environment settings (production) +# +# ASP.NET Core maps environment variables to configuration using "__" as +# the section separator. For example the variable +# JwtSettings__Key=your-secret +# is equivalent to appsettings.json > JwtSettings > Key. +# ------------------------------------------------------- + +# --- Database --- +ConnectionStrings__DefaultConnection="Server=YOUR_SERVER;Database=GitQuestDB;User Id=YOUR_USER;Password=YOUR_PASSWORD;" + +# --- JWT --- +JwtSettings__Key="replace-with-a-long-random-secret-min-32-chars" +JwtSettings__Issuer="GitQuestBackend" +JwtSettings__Audience="GitQuestFrontend" +JwtSettings__DurationInMinutes="1440" + +# --- GitHub OAuth App --- +GitHub__ClientId="your-github-oauth-app-client-id" +GitHub__ClientSecret="your-github-oauth-app-client-secret" +GitHub__CallbackUrl="https://YOUR_FRONTEND_DOMAIN/api/auth/callback/github" + +# --- CORS (comma-separated list is NOT supported; use JSON array in appsettings) --- +# In production appsettings / environment, set the AllowedOrigins JSON array: +# AllowedOrigins__0="https://your-frontend.vercel.app" +# AllowedOrigins__1="https://www.yourdomain.com" +AllowedOrigins__0="http://localhost:3000" diff --git a/Backend/Backend/Controllers/AuthController.cs b/Backend/Backend/Controllers/AuthController.cs index 883a247..a57305a 100644 --- a/Backend/Backend/Controllers/AuthController.cs +++ b/Backend/Backend/Controllers/AuthController.cs @@ -26,6 +26,7 @@ public AuthController(IConfiguration config, GitQuestContext context, IHttpClien } [HttpPost("github-login")] + [HttpPost("github")] public async Task GitHubLogin([FromBody] string code) { // 1. Exchange Code for GitHub Access Token diff --git a/Backend/Backend/Program.cs b/Backend/Backend/Program.cs index 28761fa..6452b04 100644 --- a/Backend/Backend/Program.cs +++ b/Backend/Backend/Program.cs @@ -79,13 +79,28 @@ builder.Services.AddAuthorization(); // 5. CORS +var allowedOrigins = builder.Configuration + .GetSection("AllowedOrigins") + .Get() + ?? new[] { "http://localhost:3000" }; + +if (builder.Environment.IsProduction() && + allowedOrigins.Length == 1 && + allowedOrigins[0] == "http://localhost:3000") +{ + throw new InvalidOperationException( + "AllowedOrigins is not configured for production. " + + "Set at least one production origin in appsettings or environment variables."); +} + builder.Services.AddCors(options => { options.AddPolicy("GitQuestPolicy", policy => { - policy.WithOrigins("http://localhost:3000") + policy.WithOrigins(allowedOrigins) .AllowAnyHeader() - .AllowAnyMethod(); + .AllowAnyMethod() + .AllowCredentials(); }); }); diff --git a/Backend/Backend/appsettings.json b/Backend/Backend/appsettings.json index 6df1133..15959e2 100644 --- a/Backend/Backend/appsettings.json +++ b/Backend/Backend/appsettings.json @@ -6,6 +6,9 @@ } }, "AllowedHosts": "*", + "AllowedOrigins": [ + "http://localhost:3000" + ], "ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=GitQuestDB;Trusted_Connection=True;MultipleActiveResultSets=true" }, diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..d66be1e --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,18 @@ +# ------------------------------------------------------- +# GitQuest Frontend – Environment Variable Reference +# ------------------------------------------------------- +# Copy this file to .env.local for local development. +# In Vercel (production) set these in Project > Settings > Environment Variables. +# ------------------------------------------------------- + +# Base URL of the ASP.NET Core backend. +# Local dev: http://localhost:5198 +# Production: https://your-backend.azurewebsites.net (or Render URL) +NEXT_PUBLIC_API_BASE_URL=http://localhost:5198 + +# GitHub OAuth App credentials +# Create an OAuth App at https://github.com/settings/developers +# Set the Authorization callback URL to: +# Local: http://localhost:3000/api/auth/callback/github +# Production: https://your-frontend.vercel.app/api/auth/callback/github +NEXT_PUBLIC_GITHUB_CLIENT_ID=your-github-oauth-app-client-id diff --git a/frontend/app/api/auth/callback/github/route.ts b/frontend/app/api/auth/callback/github/route.ts new file mode 100644 index 0000000..df7947f --- /dev/null +++ b/frontend/app/api/auth/callback/github/route.ts @@ -0,0 +1,48 @@ +/** + * app/api/auth/callback/github/route.ts + * + * Next.js API route – GitHub OAuth callback. + * + * Flow: + * 1. GitHub redirects here with ?code= + * 2. We forward the code to the ASP.NET backend (POST /api/auth/github) + * 3. The backend exchanges the code for a GitHub access token, fetches the + * user profile, persists it, and returns a signed JWT. + * 4. We store the JWT in an HttpOnly cookie and redirect the user to /discover. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { loginWithGitHub } from "@/lib/api"; +import { buildAuthCookies } from "@/lib/auth"; + +export async function GET(request: NextRequest): Promise { + const { searchParams } = request.nextUrl; + const code = searchParams.get("code"); + const error = searchParams.get("error"); + + // GitHub can send an error param (e.g. when the user denies access) + if (error || !code) { + return NextResponse.redirect( + new URL("/?auth_error=access_denied", request.url) + ); + } + + const { data, error: apiError } = await loginWithGitHub(code); + + if (apiError || !data) { + return NextResponse.redirect( + new URL("/?auth_error=login_failed", request.url) + ); + } + + const cookies = buildAuthCookies(data.token, { + gitHubUsername: data.user.gitHubUsername, + avatarUrl: data.user.avatarUrl, + }); + + const response = NextResponse.redirect(new URL("/discover", request.url)); + for (const cookie of cookies) { + response.headers.append("Set-Cookie", cookie); + } + return response; +} diff --git a/frontend/app/api/auth/logout/route.ts b/frontend/app/api/auth/logout/route.ts new file mode 100644 index 0000000..220ab4f --- /dev/null +++ b/frontend/app/api/auth/logout/route.ts @@ -0,0 +1,16 @@ +/** + * app/api/auth/logout/route.ts + * + * Clears the HttpOnly JWT cookie (server-side logout). + */ + +import { NextRequest, NextResponse } from "next/server"; +import { buildClearAuthCookies } from "@/lib/auth"; + +export async function POST(_request: NextRequest): Promise { + const response = NextResponse.json({ ok: true }); + for (const cookie of buildClearAuthCookies()) { + response.headers.append("Set-Cookie", cookie); + } + return response; +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..8602697 --- /dev/null +++ b/frontend/lib/api.ts @@ -0,0 +1,180 @@ +/** + * lib/api.ts + * + * Typed fetch wrapper for the GitQuest backend. + * Works in both Server Components (Node.js) and Client Components (browser). + */ + +export const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:5198"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ApiError { + message: string; + status: number; +} + +export interface ApiResponse { + data: T | null; + error: ApiError | null; +} + +// Shape returned by POST /api/auth/github-login (or /api/auth/github) +export interface AuthResponse { + token: string; + user: { + id: string; + gitHubUsername: string; + avatarUrl: string | null; + experiencePoints: number; + currentStreak: number; + }; +} + +// Shape of a single issue returned by GET /api/issues/discover +export interface GitHubIssue { + gitHubIssueId: number; + title: string; + description: string | null; + repoFullName: string; + language: string | null; + issueUrl: string; + difficulty: string; + xpReward: number; + isActive: boolean; +} + +// Shape returned by POST /api/issues/{id}/claim +export interface ClaimResponse { + message: string; + questId: string; +} + +// --------------------------------------------------------------------------- +// Core fetch helper +// --------------------------------------------------------------------------- + +async function apiFetch( + path: string, + options: RequestInit = {}, + token?: string +): Promise> { + const headers: HeadersInit = { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }; + + try { + const res = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers, + }); + + if (!res.ok) { + let message = res.statusText; + try { + const body = await res.text(); + if (body) message = body; + } catch { + // ignore body parse failure + } + return { data: null, error: { message, status: res.status } }; + } + + // 204 No Content + if (res.status === 204) { + return { data: null, error: null }; + } + + const data: T = await res.json(); + return { data, error: null }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Network error"; + return { data: null, error: { message, status: 0 } }; + } +} + +// --------------------------------------------------------------------------- +// Auth API +// --------------------------------------------------------------------------- + +/** + * Exchange a GitHub OAuth code for a GitQuest JWT. + * Calls POST /api/auth/github + */ +export async function loginWithGitHub( + code: string +): Promise> { + // ASP.NET Core [FromBody] string reads a raw JSON string value ("abc123"), + // which is exactly what JSON.stringify produces for a plain string. + return apiFetch("/api/auth/github", { + method: "POST", + body: JSON.stringify(code), + }); +} + +// --------------------------------------------------------------------------- +// Issues API +// --------------------------------------------------------------------------- + +/** + * Fetch recommended open-source issues. + * Calls GET /api/issues/discover?language= + */ +export async function discoverIssues( + language = "typescript", + token?: string +): Promise> { + return apiFetch( + `/api/issues/discover?language=${encodeURIComponent(language)}`, + {}, + token + ); +} + +/** + * Claim an issue as a quest. + * Calls POST /api/issues/{id}/claim + * Requires JWT. + */ +export async function claimIssue( + id: number, + token: string +): Promise> { + return apiFetch( + `/api/issues/${id}/claim`, + { method: "POST" }, + token + ); +} + +/** + * Fetch the current user's active quests. + * Calls GET /api/issues/my-active-quests + * Requires JWT. + */ +export async function getMyQuests( + token: string +): Promise> { + return apiFetch("/api/issues/my-active-quests", {}, token); +} + +/** + * Submit a completed quest. + * Calls POST /api/issues/{id}/submit + * Requires JWT. + */ +export async function submitQuest( + id: number, + token: string +): Promise> { + return apiFetch<{ message: string; totalXp: number }>( + `/api/issues/${id}/submit`, + { method: "POST" }, + token + ); +} diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts new file mode 100644 index 0000000..a8a1fc8 --- /dev/null +++ b/frontend/lib/auth.ts @@ -0,0 +1,123 @@ +/** + * lib/auth.ts + * + * JWT token management using HttpOnly cookies (server-side) and + * a lightweight in-memory accessor for client components. + * + * Storage strategy: + * • The JWT is written as an HttpOnly, Secure, SameSite=Lax cookie by the + * Next.js API route that handles the OAuth callback. This prevents + * JavaScript from reading the token directly (XSS protection). + * • Client components that need to know whether the user is authenticated + * rely on a lightweight /api/auth/me endpoint (or a non-sensitive cookie + * like `gq_user`) rather than decoding the JWT in JS. + */ + +const TOKEN_COOKIE = "gq_token"; +const USER_COOKIE = "gq_user"; + +const AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24; // 24 hours – must match JwtSettings:DurationInMinutes + +// --------------------------------------------------------------------------- +// Server-side helpers (use only in Next.js route handlers / Server Components) +// --------------------------------------------------------------------------- + +/** + * Build Set-Cookie header values for storing the JWT. + * Called from the GitHub OAuth callback API route. + */ +export function buildAuthCookies( + token: string, + user: { gitHubUsername: string; avatarUrl: string | null } +): string[] { + const isProduction = process.env.NODE_ENV === "production"; + const maxAge = AUTH_COOKIE_MAX_AGE_SECONDS; + + const tokenCookie = [ + `${TOKEN_COOKIE}=${token}`, + `Max-Age=${maxAge}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + ...(isProduction ? ["Secure"] : []), + ].join("; "); + + // A non-HttpOnly cookie so client JS can read basic user info without + // exposing the token itself. + const userPayload = encodeURIComponent( + JSON.stringify({ + username: user.gitHubUsername, + avatar: user.avatarUrl ?? "", + }) + ); + const userCookie = [ + `${USER_COOKIE}=${userPayload}`, + `Max-Age=${maxAge}`, + "Path=/", + "SameSite=Lax", + ...(isProduction ? ["Secure"] : []), + ].join("; "); + + return [tokenCookie, userCookie]; +} + +/** + * Build Set-Cookie header values that clear the auth cookies (logout). + */ +export function buildClearAuthCookies(): string[] { + return [ + `${TOKEN_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`, + `${USER_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`, + ]; +} + +/** + * Extract the JWT from an incoming request's cookie header. + * Safe to call from Next.js middleware, route handlers, and Server Components. + */ +export function getTokenFromCookieHeader(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + const match = cookieHeader + .split(";") + .map((c) => c.trim()) + .find((c) => c.startsWith(`${TOKEN_COOKIE}=`)); + return match ? match.slice(TOKEN_COOKIE.length + 1) : null; +} + +// --------------------------------------------------------------------------- +// Client-side helpers (safe to call in "use client" components) +// --------------------------------------------------------------------------- + +export interface UserInfo { + username: string; + avatar: string; +} + +/** + * Read basic user info from the non-HttpOnly `gq_user` cookie. + * Returns null when the user is not authenticated. + * Safe to call in Client Components (browser only). + */ +export function getClientUserInfo(): UserInfo | null { + if (typeof document === "undefined") return null; + const match = document.cookie + .split(";") + .map((c) => c.trim()) + .find((c) => c.startsWith(`${USER_COOKIE}=`)); + if (!match) return null; + try { + return JSON.parse(decodeURIComponent(match.slice(USER_COOKIE.length + 1))); + } catch { + return null; + } +} + +/** + * Clear auth cookies from the client side (non-HttpOnly cookie only). + * For a full logout, also call the /api/auth/logout Next.js route which + * clears the HttpOnly token cookie server-side. + */ +export function clearClientUserInfo(): void { + if (typeof document === "undefined") return; + document.cookie = `${USER_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`; +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 4cd9948..f721c55 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -6,6 +6,12 @@ const nextConfig = { images: { unoptimized: true, }, + // Make the backend base URL available to Server Components through env. + // Client Components use NEXT_PUBLIC_API_BASE_URL set in .env.local / Vercel. + env: { + NEXT_PUBLIC_API_BASE_URL: + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:5198", + }, } export default nextConfig diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8de7d82..b75687f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2877,7 +2877,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2887,7 +2887,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3830,6 +3830,7 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, "funding": [ { "type": "opencollective",