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 */}
+
+
+ {/* 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 */}
+
+
+ {/* 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);