Repository navigation
google authentication #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() : ""; | ||
|
|
||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Tokens for other apps grant access 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
|
||
|
|
||
| if (payload.email) { | ||
| targetEmail = payload.email.toLowerCase(); | ||
| if (payload.name) targetName = payload.name; | ||
| } | ||
|
Comment on lines
+121
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Reassigned emails inherit accounts 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
|
||
| } 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 4. Forged credentials can access accounts 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
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 7. Concurrent first sign-ins can fail /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
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 5. Google users cannot change passwords 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
|
||
|
|
||
| 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({ | ||
|
|
@@ -116,3 +247,4 @@ router.get("/auth/me", requireAuth, async (req, res) => { | |
| }); | ||
|
|
||
| export default router; | ||
|
|
||
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3. Anyone can sign in as another user
🐞 Bug⛨ SecurityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools