Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 6 additions & 1 deletion gsecure/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
19 changes: 19 additions & 0 deletions gsecure/app/(auth)/login/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,25 @@ function Login(props) {
)}
</button>

{/* Divider */}
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-white/10"></div>
<span className="text-xs text-gray-500">or</span>
<div className="h-px flex-1 bg-white/10"></div>
</div>

{/* Continue with GitHub */}
<a
href="/api/v1/auth/github"
aria-label="Continue with GitHub"
className="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-xl font-medium text-white bg-white/5 border border-white/15 hover:bg-white/10 transition-all duration-300"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span>Continue with GitHub</span>
</a>

{/* Register link */}
<div className="text-center pt-4 border-t border-white/10">
<p className="text-gray-400 text-sm">
Expand Down
19 changes: 19 additions & 0 deletions gsecure/app/(auth)/register/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,25 @@ function Signup() {
)}
</button>

{/* Divider */}
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-white/10"></div>
<span className="text-xs text-gray-500">or</span>
<div className="h-px flex-1 bg-white/10"></div>
</div>

{/* Continue with GitHub */}
<a
href="/api/v1/auth/github"
aria-label="Continue with GitHub"
className="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-xl font-medium text-white bg-white/5 border border-white/15 hover:bg-white/10 transition-all duration-300"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span>Continue with GitHub</span>
</a>

{/* Login link */}
<div className="text-center pt-6 border-t border-white/10">
<p className="text-gray-400">
Expand Down
202 changes: 202 additions & 0 deletions gsecure/app/api/v1/auth/github/callback/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
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<Response>}
*/
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<NextResponse>}
*/
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.
let username = login.toLowerCase();
let attempt = 0;
while (await User.findOne({ username })) {
attempt += 1;
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('/auth/success', base));

response.cookies.set('authToken', authToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
// 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');
}
}
44 changes: 44 additions & 0 deletions gsecure/app/api/v1/auth/github/route.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading