diff --git a/gsecure/.env.local.example b/gsecure/.env.local.example index 48ae05d..00ef280 100644 --- a/gsecure/.env.local.example +++ b/gsecure/.env.local.example @@ -15,4 +15,9 @@ SUPPROT_EMAIL=your_support_email SMTP_HOST=your_smtp_server SMTP_PORT=465 SMTP_USER=your_smtp_user -SMTP_PASS=your_smtp_pass \ No newline at end of file +SMTP_PASS=your_smtp_pass +# GitHub OAuth (register an OAuth App: GitHub -> Settings -> Developer settings +# -> OAuth Apps -> New OAuth App; set the callback to GITHUB_OAUTH_CALLBACK_URL) +GITHUB_OAUTH_CLIENT_ID=your_github_oauth_client_id +GITHUB_OAUTH_CLIENT_SECRET=your_github_oauth_client_secret +GITHUB_OAUTH_CALLBACK_URL=http://localhost:3000/api/v1/auth/github/callback diff --git a/gsecure/app/(auth)/login/page.js b/gsecure/app/(auth)/login/page.js index ff3f855..8302e4b 100644 --- a/gsecure/app/(auth)/login/page.js +++ b/gsecure/app/(auth)/login/page.js @@ -252,6 +252,25 @@ function Login(props) { )} + {/* Divider */} +
+
+ or +
+
+ + {/* Continue with GitHub */} + + + Continue with GitHub + + {/* Register link */}

diff --git a/gsecure/app/(auth)/register/page.js b/gsecure/app/(auth)/register/page.js index e27c26d..ea6a335 100644 --- a/gsecure/app/(auth)/register/page.js +++ b/gsecure/app/(auth)/register/page.js @@ -338,6 +338,25 @@ function Signup() { )} + {/* Divider */} +

+
+ or +
+
+ + {/* Continue with GitHub */} + + + Continue with GitHub + + {/* Login link */}

diff --git a/gsecure/app/(auth)/success/page.js b/gsecure/app/(auth)/success/page.js new file mode 100644 index 0000000..8591fb9 --- /dev/null +++ b/gsecure/app/(auth)/success/page.js @@ -0,0 +1,140 @@ +"use client" +import { useAuth } from '@/lib/contexts/AuthContext'; +import { useRouter } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; + +// Post-OAuth interstitial. +// +// GitHub OAuth issues the `authToken` cookie on a redirect response. A few +// browsers don't make that freshly-Set-Cookie value available to the very next +// navigation, so landing straight on /vault could show a logged-out state +// until a manual refresh. This page confirms the session is live by calling +// /api/v1/auth/me (which reads the cookie server-side) and only forwards to +// /vault once that succeeds. The check retries a few times to absorb the +// brief cookie-propagation lag; each attempt is individually time-bounded so +// a hung network request cannot stall the loop indefinitely. +const MAX_ATTEMPTS = 5; +const RETRY_DELAY_MS = 400; +const FETCH_TIMEOUT_MS = 5000; + +function AuthSuccess() { + const { setUser, setAuthenticated } = useAuth(); + const router = useRouter(); + const [status, setStatus] = useState('Confirming your secure session...'); + + useEffect(() => { + let active = true; + const apiHost = process.env.NEXT_PUBLIC_API_HOST || ''; + + const confirmSession = async () => { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(`${apiHost}/api/v1/auth/me`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + }); + + if (response.ok) { + const data = await response.json(); + if (data?.data?.user) { + if (!active) return; + setUser(data.data.user); + setAuthenticated(true); + setStatus('Success! Redirecting to your vault...'); + router.replace('/vault'); + return; + } + } + } catch (error) { + console.error('Session confirmation attempt failed:', error); + } finally { + clearTimeout(timer); + } + + // Cookie may not be readable yet - wait and retry. + if (attempt < MAX_ATTEMPTS) { + if (!active) return; + setStatus('Verifying your login...'); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } + } + + if (!active) return; + setStatus('Could not confirm your session. Redirecting to login...'); + toast.error('We could not confirm your session. Please sign in again.'); + router.replace('/login'); + }; + + confirmSession(); + + return () => { + active = false; + }; + }, [router, setUser, setAuthenticated]); + + return ( +

+ {/* Animated background elements */} +
+ {/* Grid pattern */} +
+ + {/* Gradient orbs */} +
+
+
+ + {/* Main content with glass effect */} +
+
+ {/* Glow effect */} +
+ + {/* Inner content */} +
+ {/* Lock icon */} +
+
+ + + +
+
+ +

+ Finishing sign-in +

+ + {/* Spinner + status */} +
+ + + + + {status} +
+ + {/* Security note */} +
+
+ + + +

+ Verifying your encrypted session before opening your vault +

+
+
+
+
+
+
+ ); +} + +export default AuthSuccess; diff --git a/gsecure/app/api/v1/auth/github/callback/route.js b/gsecure/app/api/v1/auth/github/callback/route.js new file mode 100644 index 0000000..015e6cc --- /dev/null +++ b/gsecure/app/api/v1/auth/github/callback/route.js @@ -0,0 +1,209 @@ +import { NextResponse } from 'next/server'; +import connectingtoDB from '@/lib/db/mongodb'; +import User from '@/lib/models/User'; +import { generateAccessToken } from '@/lib/utils/jwt'; + +const STATE_COOKIE = 'github_oauth_state'; +const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; +const GITHUB_USER_URL = 'https://api.github.com/user'; +const GITHUB_EMAILS_URL = 'https://api.github.com/user/emails'; +const GITHUB_FETCH_TIMEOUT_MS = 8000; + +/** + * fetch() wrapper that aborts after timeoutMs so a stalled GitHub upstream + * cannot hang the auth request indefinitely. + * @param {string} url + * @param {RequestInit} [options] + * @param {number} [timeoutMs] + * @returns {Promise} + */ +async function fetchWithTimeout(url, options = {}, timeoutMs = GITHUB_FETCH_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +/** + * Build a redirect back to /login, optionally with an ?error code so the UI + * can surface what went wrong without leaking details. + * @param {string} [errorCode] + * @returns {NextResponse} + */ +function loginRedirect(errorCode) { + const base = process.env.NEXT_PUBLIC_API_HOST || 'http://localhost:3000'; + const url = new URL('/login', base); + if (errorCode) url.searchParams.set('error', errorCode); + return NextResponse.redirect(url); +} + +/** + * GET /api/v1/auth/github/callback + * + * OAuth callback: validates the CSRF state, exchanges the code for an access + * token, resolves a GitHub-verified email (server-side), then links or creates + * the account and issues the same authToken cookie the password login uses. + * Only verified emails are ever trusted for linking, and an email already bound + * to a different GitHub account is never silently re-linked. + * @param {Request} req + * @returns {Promise} + */ +export async function GET(req) { + try { + const { searchParams } = new URL(req.url); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + // 1. CSRF: state must be present and match the cookie set at initiation. + const storedState = req.cookies.get(STATE_COOKIE)?.value; + if (!code || !state || !storedState || state !== storedState) { + return loginRedirect('github_state_mismatch'); + } + + const clientId = process.env.GITHUB_OAUTH_CLIENT_ID; + const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET; + const callbackUrl = process.env.GITHUB_OAUTH_CALLBACK_URL; + if (!clientId || !clientSecret || !callbackUrl) { + return loginRedirect('github_oauth_not_configured'); + } + + // 2. Exchange the authorization code for an access token. + const tokenRes = await fetchWithTimeout(GITHUB_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: callbackUrl, + }), + }); + const tokenData = await tokenRes.json(); + const accessToken = tokenData?.access_token; + if (!accessToken) { + return loginRedirect('github_token_exchange_failed'); + } + + // 3. Fetch the GitHub profile. + const ghHeaders = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'gsecure', + }; + const userRes = await fetchWithTimeout(GITHUB_USER_URL, { headers: ghHeaders }); + if (!userRes.ok) { + return loginRedirect('github_profile_fetch_failed'); + } + const ghUser = await userRes.json(); + const githubId = ghUser?.id != null ? String(ghUser.id) : null; + const login = ghUser?.login; + if (!githubId || !login) { + return loginRedirect('github_profile_incomplete'); + } + + // 4. Resolve a GitHub-VERIFIED email only. We never trust an unverified + // address for account creation/linking (it would be an account-takeover + // vector). If none is verified, use a deterministic no-reply fallback so + // the required, unique email field is always satisfied. A failed/stalled + // emails fetch also falls through to the no-reply fallback. + let email = null; + try { + const emailsRes = await fetchWithTimeout(GITHUB_EMAILS_URL, { headers: ghHeaders }); + if (emailsRes.ok) { + const emails = await emailsRes.json(); + if (Array.isArray(emails)) { + const primaryVerified = emails.find((e) => e.primary && e.verified); + const anyVerified = emails.find((e) => e.verified); + email = (primaryVerified || anyVerified)?.email || null; + } + } + } catch { + // ignore - fall through to the no-reply fallback below + } + if (!email) { + email = `${githubId}+${login}@users.noreply.github.com`; + } + email = email.toLowerCase(); + + await connectingtoDB(); + + // 5. Account resolution (prevents duplicates and unsafe linking): + // a) returning GitHub user -> matched by githubId + // b) existing account with the same VERIFIED email and no githubId (or + // the same githubId) -> link githubId onto it + // c) email already bound to a DIFFERENT githubId -> refuse (no hijack) + // d) otherwise -> create a new GitHub-authenticated account + let user = await User.findOne({ githubId }); + + if (!user) { + const byEmail = await User.findOne({ email }); + if (byEmail) { + if (byEmail.githubId && byEmail.githubId !== githubId) { + return loginRedirect('github_account_conflict'); + } + byEmail.githubId = githubId; + await byEmail.save(); + user = byEmail; + } + } + + if (!user) { + // Dedupe the chosen username against the unique index. + // A cap prevents a theoretical runaway loop on a severely saturated + // namespace; in practice random suffixes make collisions negligible. + const MAX_USERNAME_ATTEMPTS = 10; + let username = login.toLowerCase(); + let attempt = 0; + while (await User.findOne({ username })) { + attempt += 1; + if (attempt >= MAX_USERNAME_ATTEMPTS) { + return loginRedirect('username_generation_failed'); + } + username = + attempt === 1 + ? `${login.toLowerCase()}-${githubId.slice(-4)}` + : `${login.toLowerCase()}-${Math.random().toString(36).slice(2, 8)}`; + } + user = await User.create({ + username, + email, + githubId, + authProvider: 'github', + }); + } + + // 6. Issue the same session token + cookie the password login uses. + const { authToken } = await generateAccessToken(user._id); + + const base = process.env.NEXT_PUBLIC_API_HOST || 'http://localhost:3000'; + // Redirect to an interstitial page that confirms the auth cookie is + // readable (via /api/v1/auth/me) before sending the user on to /vault. + // Some browsers don't expose a freshly-Set-Cookie value to the very next + // navigation, which previously required a manual refresh of /vault. + const response = NextResponse.redirect(new URL('/success', base)); + + response.cookies.set('authToken', authToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7, // 7 days + path: '/', + }); + // Clear the one-time CSRF state cookie. + response.cookies.set(STATE_COOKIE, '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 0, + path: '/', + }); + + return response; + } catch (error) { + console.error('GitHub OAuth callback error:', error); + return loginRedirect('github_oauth_failed'); + } +} diff --git a/gsecure/app/api/v1/auth/github/route.js b/gsecure/app/api/v1/auth/github/route.js new file mode 100644 index 0000000..f5e67c6 --- /dev/null +++ b/gsecure/app/api/v1/auth/github/route.js @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; + +const GITHUB_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'; +const STATE_COOKIE = 'github_oauth_state'; + +// GET /api/v1/auth/github +// Kicks off the GitHub OAuth flow: sets a short-lived CSRF "state" cookie and +// redirects the browser to GitHub's authorize screen. +export async function GET() { + const clientId = process.env.GITHUB_OAUTH_CLIENT_ID; + const callbackUrl = process.env.GITHUB_OAUTH_CALLBACK_URL; + const base = process.env.NEXT_PUBLIC_API_HOST || 'http://localhost:3000'; + + if (!clientId || !callbackUrl) { + // Misconfigured server -> send the user back to login instead of erroring. + const url = new URL('/login', base); + url.searchParams.set('error', 'github_oauth_not_configured'); + return NextResponse.redirect(url); + } + + // Random, unguessable state echoed back by GitHub and verified in the callback. + const state = crypto.randomUUID(); + + const authorizeUrl = new URL(GITHUB_AUTHORIZE_URL); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', callbackUrl); + authorizeUrl.searchParams.set('scope', 'read:user user:email'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('allow_signup', 'true'); + + const response = NextResponse.redirect(authorizeUrl); + + // sameSite 'lax' (NOT 'strict') so this CSRF cookie survives GitHub's + // cross-site redirect back to the callback. The session cookie stays strict. + response.cookies.set(STATE_COOKIE, state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 600, // 10 minutes + path: '/', + }); + + return response; +} diff --git a/gsecure/lib/models/User.js b/gsecure/lib/models/User.js index 1caf78d..e83f6fd 100644 --- a/gsecure/lib/models/User.js +++ b/gsecure/lib/models/User.js @@ -20,11 +20,30 @@ const UserSchema = new mongoose.Schema({ }, password: { type: String, - required: [true, "Password can't be blank"] + // Only local (username/password) accounts need a password. OAuth + // accounts (e.g. GitHub) authenticate with the provider instead. + required: [ + function () { return this.authProvider === "local"; }, + "Password can't be blank" + ] }, keyword: { // as G-tag type: String, - required: true, + // Required for local sign-ups only; OAuth accounts don't set one. + required: function () { return this.authProvider === "local"; }, + }, + authProvider: { + type: String, + enum: ["local", "github"], + default: "local" + }, + githubId: { + // GitHub numeric account id (stored as string). Sparse + unique so + // existing local users (no githubId) are excluded from the index and + // never collide on null. + type: String, + unique: true, + sparse: true }, passwordChangedAt: { type: Date @@ -56,4 +75,4 @@ UserSchema.methods.generateAccessToken = function () { ) } -export default mongoose.models.User || mongoose.model("User", UserSchema); \ No newline at end of file +export default mongoose.models.User || mongoose.model("User", UserSchema);