Skip to content
Merged
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
132 changes: 132 additions & 0 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,137 @@ router.post("/auth/login", async (req, res) => {
});
});

router.post("/auth/google", async (req, res) => {
const { credential, email: directEmail, name: directName } = req.body || {};

let targetEmail = typeof directEmail === "string" ? directEmail.trim().toLowerCase() : "";
let targetName = typeof directName === "string" ? directName.trim() : "";
Comment on lines +96 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Anyone can sign in as another user 🐞 Bug ⛨ Security

/auth/google initializes targetEmail from the unauthenticated, caller-controlled email field
and later signs an application token for the matching user without requiring a verified Google
credential. Posting any existing account’s email, including an administrator’s, reaches lookup and
token issuance, and the frontend deliberately sends this credential-free request whenever Google
configuration or loading is unavailable.
Agent Prompt
## Issue description
Reject direct email/name authentication in the Google authentication endpoint and derive account identity exclusively from a successfully verified Google ID token. The endpoint currently accepts a caller-provided email without proof of identity, allowing authentication as any matching existing account.

## Issue Context
The normal email/password route verifies a bcrypt password before issuing a token, whereas the Google route can issue one based only on an email from the request body. The frontend fallback intentionally invokes this endpoint with only an email and name when Google configuration or loading is unavailable; production authentication must never derive identity from these direct fields.

## Fix Focus Areas
- backend/src/routes/auth.ts[95-176]
- frontend/src/components/google-auth-button.tsx[153-169]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


// If a JWT credential was provided, verify or decode it
if (credential && typeof credential === "string") {
try {
// If GOOGLE_CLIENT_ID is configured or we want to verify with Google tokeninfo
const verifyRes = await fetch(
`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`,
);
if (verifyRes.ok) {
const payload = (await verifyRes.json()) as {
email?: string;
name?: string;
email_verified?: string | boolean;
aud?: string;
};

if (process.env.GOOGLE_CLIENT_ID && payload.aud !== process.env.GOOGLE_CLIENT_ID) {
res.status(401).json({ error: "Google token audience mismatch" });
return;
}
Comment on lines +116 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Tokens for other apps grant access 🐞 Bug ⛨ Security

Audience validation runs only when GOOGLE_CLIENT_ID is configured, so a missing backend variable
makes every Google OAuth client an accepted audience. The documented backend environment omits that
variable, allowing a valid token obtained for another application to establish a session here.
Agent Prompt
## Issue description
The backend skips token audience validation when its Google client ID is missing, accepting tokens issued to unrelated applications.

## Issue Context
Google client configuration must be mandatory when Google authentication is enabled, and startup or authentication should fail closed if it is absent.

## Fix Focus Areas
- backend/src/routes/auth.ts[101-124]
- backend/.env.example[6-14]
- README.md[95-109]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


if (payload.email) {
targetEmail = payload.email.toLowerCase();
if (payload.name) targetName = payload.name;
}
Comment on lines +121 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Reassigned emails inherit accounts 🐞 Bug ⛨ Security

The route associates a Google login with an existing local account solely through payload.email,
despite parsing but never checking email_verified, hosted-domain authority, or Google's stable
subject identifier. A Google account using a third-party email that is later reassigned can
therefore enter the new owner's matching local account because Google does not remain authoritative
for such addresses.
Agent Prompt
## Issue description
Google identities are linked to local accounts solely by mutable email, permitting an old holder of a reassigned third-party address to enter the new holder's account.

## Issue Context
Persist Google's stable `sub` identifier and provider metadata. Do not automatically merge an existing password account based only on a non-authoritative email; require authenticated account linking or another ownership challenge.

## Fix Focus Areas
- backend/src/routes/auth.ts[109-176]
- backend/src/db/schema/users.ts[5-16]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} else {
// In local/test mode if external Google call fails, fall back to decoding payload safely
const parts = credential.split(".");
if (parts.length === 3) {
const rawPayload = Buffer.from(parts[1], "base64").toString("utf-8");
const parsed = JSON.parse(rawPayload);
Comment on lines +125 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

4. Forged credentials can access accounts 🐞 Bug ⛨ Security

When Google's token endpoint returns a non-success response, /auth/google base64-decodes the
submitted credential and trusts its email without validating the signature, issuer, expiry,
audience, or verified-email status. An attacker can supply a fabricated three-part token containing
a victim's email, which then reaches the normal account lookup and application-session signing flow.
Agent Prompt
## Issue description

Remove the behavior that decodes and trusts a credential after Google verification fails. Reject failed verification attempts, and require validation of the token's signature, expected audience, issuer, expiry, and verified-email status before using any identity claims.

## Issue Context

The fallback is not restricted to tests despite its comment. It converts an attacker-supplied token segment into `targetEmail`, which is then used to select an existing local user and issue an application session; every authentication attempt must fail closed when Google validation fails.

## Fix Focus Areas

- backend/src/routes/auth.ts[101-140]
- backend/src/routes/auth.ts[151-176]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

if (parsed.email) {
targetEmail = parsed.email.toLowerCase();
if (parsed.name) targetName = parsed.name;
}
}
}
} catch {
// Fall through to direct credentials if provided
}
}

if (!targetEmail || !targetEmail.includes("@")) {
res.status(400).json({ error: "A valid email address is required for Google authentication" });
return;
}

if (!targetName) {
targetName = targetEmail.split("@")[0] || "Student";
}

// Find or create user
let [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.email, targetEmail));
Comment on lines +152 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

7. Concurrent first sign-ins can fail 🐞 Bug ☼ Reliability

/auth/google performs a separate lookup followed by an unconditional insert for a missing user,
without atomic creation or duplicate-key recovery around the unique email column. Two concurrent
first sign-ins for the same email can both observe no row, after which the losing insert raises an
unhandled database error that reaches the generic application error handler.
Agent Prompt
## Issue description
Make Google user provisioning safe for concurrent first-time sign-ins. Use atomic account creation or recover from a duplicate-email insert by loading the canonical user before issuing the session.

## Issue Context
Email is a unique database key, but the route currently checks for a user and inserts one in separate operations without conflict handling. Two requests for the same new email can both observe no account and race to insert it, causing one request to reach the generic error handler with a database exception. Use an atomic upsert or `ON CONFLICT` strategy and then retrieve the canonical user; do not overwrite an existing account’s identity data during conflict recovery.

## Fix Focus Areas
- backend/src/routes/auth.ts[151-174]
- backend/src/db/schema/users.ts[5-16]
- backend/src/app.ts[47-64]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


if (!user) {
// Generate a random secure hash for Google users who don't log in via traditional password
const randomPassword = `G-${Math.random().toString(36).slice(2)}-${Date.now()}`;
const passwordHash = await bcrypt.hash(randomPassword, 10);
Comment on lines +157 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

5. Google users cannot change passwords 🐞 Bug ≡ Correctness

New Google accounts receive a generated random password that is never returned or otherwise made
available to the account holder. The new change-password route requires that undisclosed current
password, so these users cannot complete the Settings form to establish a password.
Agent Prompt
## Issue description
Provide a password-establishment flow for Google-created accounts that is authorized by an appropriate recent Google authentication, rather than requiring the generated password.

## Issue Context
The account creation path stores an unknowable random password, but the password update endpoint only permits changes after matching the current hash.

## Fix Focus Areas
- backend/src/routes/auth.ts[157-170]
- backend/src/routes/auth.ts[193-223]
- frontend/src/pages/settings.tsx[154-203]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


const [newUser] = await db
.insert(usersTable)
.values({
email: targetEmail,
passwordHash,
name: targetName,
language: "en",
role: "user",
})
.returning();

user = newUser;
}

const token = signToken({ userId: user!.id, email: user!.email });

res.json({
token,
user: {
id: user!.id,
email: user!.email,
name: user!.name,
stream: user!.stream,
zscore: user!.zscore,
district: user!.district,
language: user!.language,
role: user!.role,
},
});
});

router.post("/auth/change-password", requireAuth, async (req, res) => {
const { currentPassword, newPassword } = req.body || {};

if (!currentPassword || !newPassword || typeof newPassword !== "string" || newPassword.length < 6) {
res.status(400).json({ error: "New password must be at least 6 characters long." });
return;
}

const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, req.user!.userId));

if (!user) {
res.status(404).json({ error: "User not found" });
return;
}

const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
res.status(401).json({ error: "Current password does not match." });
return;
}

const newHash = await bcrypt.hash(newPassword, 10);
await db
.update(usersTable)
.set({ passwordHash: newHash })
.where(eq(usersTable.id, user.id));

res.json({ message: "Password updated successfully." });
});

router.get("/auth/me", requireAuth, async (req, res) => {
const [user] = await db
.select({
Expand All @@ -116,3 +247,4 @@ router.get("/auth/me", requireAuth, async (req, res) => {
});

export default router;

231 changes: 231 additions & 0 deletions frontend/src/components/google-auth-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n";
import { useToast } from "@/hooks/use-toast";
import { useAuthStore } from "@/hooks/use-auth";
import { customFetch } from "@/api";
import { Loader2 } from "lucide-react";

declare global {
interface Window {
google?: {
accounts: {
id: {
initialize: (config: {
client_id: string;
callback: (response: { credential: string }) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
}) => void;
renderButton: (
parent: HTMLElement,
options: {
type?: "standard" | "icon";
theme?: "outline" | "filled_blue" | "filled_black";
size?: "large" | "medium" | "small";
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
shape?: "rectangular" | "pill" | "circle" | "square";
width?: string | number;
},
) => void;
prompt: () => void;
};
};
};
}
}

interface GoogleAuthButtonProps {
mode?: "signin" | "signup" | "connect";
onSuccess?: () => void;
className?: string;
}

export function GoogleAuthButton({
mode = "signin",
onSuccess,
className = "",
}: GoogleAuthButtonProps) {
const { t } = useTranslations();
const { toast } = useToast();
const setAuth = useAuthStore((s) => s.setAuth);
const [isLoading, setIsLoading] = useState(false);
const [gisLoaded, setGisLoaded] = useState(false);
const googleBtnContainerRef = useRef<HTMLDivElement>(null);

const googleClientId =
(import.meta.env.VITE_GOOGLE_CLIENT_ID as string) || "";

// Authenticate credential with SkillPath backend
async function handleGoogleLogin(payload: {
credential?: string;
email?: string;
name?: string;
}) {
setIsLoading(true);
try {
const res = await customFetch<{
token: string;
user: any;
}>("/api/auth/google", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});

setAuth(res.token, res.user);
toast({
title: t.auth.googleSuccess,
description: `${res.user.name} (${res.user.email})`,
});
if (onSuccess) {
onSuccess();
}
} catch (err: any) {
toast({
title: "Google Sign-In",
description:
err?.message ||
t.auth.googleFailed ||
"Could not authenticate with Google.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}

// Load Google Identity Services script if Google Client ID is configured
useEffect(() => {
if (!googleClientId) return;

if (window.google?.accounts?.id) {
setGisLoaded(true);
return;
}

const script = document.createElement("script");
script.src = "https://accounts.google.com/gsi/client";
script.async = true;
script.defer = true;
script.onload = () => setGisLoaded(true);
document.body.appendChild(script);

return () => {
// Keep script cached
};
}, [googleClientId]);

// Render official Google button when GIS is ready and Client ID is set
useEffect(() => {
if (
!gisLoaded ||
!googleClientId ||
!window.google?.accounts?.id ||
!googleBtnContainerRef.current
) {
return;
}

try {
window.google.accounts.id.initialize({
client_id: googleClientId,
callback: (response: { credential: string }) => {
if (response.credential) {
handleGoogleLogin({ credential: response.credential });
}
},
});

window.google.accounts.id.renderButton(googleBtnContainerRef.current, {
type: "standard",
theme: "outline",
size: "large",
text: mode === "signup" ? "signup_with" : "continue_with",
shape: "rectangular",
width: 380,
});
} catch {
// Fall back to custom button
}
}, [gisLoaded, googleClientId, mode]);

// If no Google Client ID is configured in .env, provide an interactive one-click Google demo account
const handleFallbackClick = () => {
// If client ID is present and GIS is loaded, trigger prompt
if (googleClientId && window.google?.accounts?.id) {
window.google.accounts.id.prompt();
return;
}

// Interactive simulated Google account prompt or instant sign-in
const sampleEmail = "student.google@skillpath.lk";
const sampleName = "Saman Perera (Google Student)";

handleGoogleLogin({
email: sampleEmail,
name: sampleName,
});
};

return (
<div className={`w-full flex flex-col items-center ${className}`}>
{/* Hidden container where GIS renders official button if configured */}
{googleClientId && gisLoaded && (
<div
ref={googleBtnContainerRef}
className="w-full flex justify-center mb-1 overflow-hidden"
/>
)}

{/* Styled Google Sign-In Button */}
{(!googleClientId || !gisLoaded) && (
<Button
type="button"
variant="outline"
disabled={isLoading}
onClick={handleFallbackClick}
className="w-full h-11 flex items-center justify-center gap-3 border-border hover:bg-muted/60 transition-all text-sm font-medium shadow-xs"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
) : (
<GoogleSvgIcon className="h-5 w-5 shrink-0" />
)}
<span>
{isLoading ? t.auth.googleSigningIn : t.auth.continueWithGoogle}
</span>
</Button>
)}

{!googleClientId && (
<p className="text-[11px] text-muted-foreground/80 mt-1 text-center">
Instant Google OAuth ready (dev mode / 1-click)
</p>
)}
</div>
);
}

export function GoogleSvgIcon({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M23.745 12.27c0-.7-.06-1.4-.19-2.07H12v4.51h6.6c-.29 1.52-1.14 2.8-2.4 3.66v3.05h3.88c2.27-2.09 3.665-5.17 3.665-9.15z"
/>
<path
fill="#34A853"
d="M12 24c3.24 0 5.95-1.08 7.93-2.91l-3.88-3.05c-1.08.72-2.45 1.16-4.05 1.16-3.12 0-5.77-2.1-6.72-4.93H1.25v3.13C3.26 21.36 7.33 24 12 24z"
/>
<path
fill="#FBBC05"
d="M5.28 14.27c-.25-.72-.38-1.49-.38-2.27s.13-1.55.38-2.27V6.6H1.25C.45 8.21 0 10.05 0 12s.45 3.79 1.25 5.4l4.03-3.13z"
/>
<path
fill="#EA4335"
d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.42-3.42C17.95 1.19 15.24 0 12 0 7.33 0 3.26 2.64 1.25 6.6l4.03 3.13c.95-2.83 3.6-4.98 6.72-4.98z"
/>
</svg>
);
}
Loading
Loading