-
Notifications
You must be signed in to change notification settings - Fork 202
feat(auth): add OAuth2 login with Google and GitHub #493
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| VITE_BACKEND_URL=http://localhost:5000 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| PORT=5000 | ||
| MONGO_URI=mongodb://127.0.0.1:27017/github_tracker | ||
| SESSION_SECRET=replace-with-a-long-random-secret | ||
|
|
||
| # URLs used for OAuth redirects and CORS | ||
| BACKEND_URL=http://localhost:5000 | ||
| FRONTEND_URL=http://localhost:5173 | ||
|
|
||
| # Google OAuth2 (https://console.cloud.google.com/apis/credentials) | ||
| GOOGLE_CLIENT_ID= | ||
| GOOGLE_CLIENT_SECRET= | ||
| # Optional override; defaults to BACKEND_URL/api/auth/google/callback | ||
| GOOGLE_CALLBACK_URL= | ||
|
|
||
| # GitHub OAuth App (https://github.com/settings/developers) | ||
| GITHUB_OAUTH_CLIENT_ID= | ||
| GITHUB_OAUTH_CLIENT_SECRET= | ||
| # Optional override; defaults to BACKEND_URL/api/auth/github/callback | ||
| GITHUB_CALLBACK_URL= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,45 +1,128 @@ | ||
| const passport = require("passport"); | ||
| const LocalStrategy = require('passport-local').Strategy; | ||
| const LocalStrategy = require("passport-local").Strategy; | ||
| const GoogleStrategy = require("passport-google-oauth20").Strategy; | ||
| const GitHubStrategy = require("passport-github2").Strategy; | ||
| const User = require("../models/User"); | ||
| const { | ||
| findOrCreateOAuthUser, | ||
| toSessionUser, | ||
| isOAuthProviderConfigured, | ||
| } = require("../utils/oauthUser"); | ||
|
|
||
| function getBackendUrl() { | ||
| return process.env.BACKEND_URL || `http://localhost:${process.env.PORT || 5000}`; | ||
| } | ||
|
|
||
| function getFrontendUrl() { | ||
| return process.env.FRONTEND_URL || "http://localhost:5173"; | ||
| } | ||
|
|
||
| passport.use( | ||
| new LocalStrategy( | ||
| { usernameField: "email" }, | ||
| async (email, password, done) => { | ||
| try { | ||
| const user = await User.findOne( {email} ); | ||
| if (!user) { | ||
| return done(null, false, { message: 'Email is invalid '}); | ||
| } | ||
|
|
||
| const isMatch = await user.comparePassword(password); | ||
| if (!isMatch) { | ||
| return done(null, false, { message: 'Invalid password' }); | ||
| } | ||
|
|
||
| return done(null, { | ||
| id : user._id.toString(), | ||
| username: user.username, | ||
| email: user.email | ||
| }); | ||
| } catch (err) { | ||
| return done(err); | ||
| } | ||
| new LocalStrategy( | ||
| { usernameField: "email" }, | ||
| async (email, password, done) => { | ||
| try { | ||
| const user = await User.findOne({ email }); | ||
|
|
||
| if (!user) { | ||
| return done(null, false, { message: "Email is invalid " }); | ||
| } | ||
| ) | ||
|
|
||
| if (user.provider !== "local" || !user.password) { | ||
| return done(null, false, { | ||
| message: `Please sign in with ${user.provider}`, | ||
| }); | ||
| } | ||
|
|
||
| const isMatch = await user.comparePassword(password); | ||
| if (!isMatch) { | ||
| return done(null, false, { message: "Invalid password" }); | ||
| } | ||
|
|
||
| return done(null, toSessionUser(user)); | ||
| } catch (err) { | ||
| return done(err); | ||
| } | ||
| } | ||
| ) | ||
| ); | ||
|
|
||
| // Serialize user (store user info in session) | ||
| if (isOAuthProviderConfigured("google")) { | ||
| passport.use( | ||
| new GoogleStrategy( | ||
| { | ||
| clientID: process.env.GOOGLE_CLIENT_ID, | ||
| clientSecret: process.env.GOOGLE_CLIENT_SECRET, | ||
| callbackURL: | ||
| process.env.GOOGLE_CALLBACK_URL || `${getBackendUrl()}/api/auth/google/callback`, | ||
| }, | ||
| async (_accessToken, _refreshToken, profile, done) => { | ||
| try { | ||
| const email = profile.emails?.[0]?.value; | ||
| const sessionUser = await findOrCreateOAuthUser({ | ||
| provider: "google", | ||
| providerId: profile.id, | ||
| email, | ||
| displayName: profile.displayName, | ||
| username: profile.displayName, | ||
| }); | ||
| return done(null, sessionUser); | ||
| } catch (err) { | ||
| return done(err, null); | ||
| } | ||
| } | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| if (isOAuthProviderConfigured("github")) { | ||
| passport.use( | ||
| new GitHubStrategy( | ||
| { | ||
| clientID: process.env.GITHUB_OAUTH_CLIENT_ID, | ||
| clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET, | ||
| callbackURL: | ||
| process.env.GITHUB_CALLBACK_URL || `${getBackendUrl()}/api/auth/github/callback`, | ||
| scope: ["user:email"], | ||
| }, | ||
| async (_accessToken, _refreshToken, profile, done) => { | ||
| try { | ||
| const email = profile.emails?.find((entry) => entry.primary)?.value | ||
| || profile.emails?.[0]?.value; | ||
|
|
||
| const sessionUser = await findOrCreateOAuthUser({ | ||
| provider: "github", | ||
| providerId: profile.id, | ||
| email, | ||
| username: profile.username, | ||
| displayName: profile.displayName, | ||
| }); | ||
| return done(null, sessionUser); | ||
| } catch (err) { | ||
| return done(err, null); | ||
| } | ||
| } | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| passport.serializeUser((user, done) => { | ||
| done(null, user.id); | ||
| done(null, user.id); | ||
| }); | ||
|
|
||
| // Deserialize user (retrieve user from session) | ||
| passport.deserializeUser(async (id, done) => { | ||
| try { | ||
| const user = await User.findById(id); | ||
| done(null, user); | ||
| } catch (err) { | ||
| done(err, null); | ||
| try { | ||
| const user = await User.findById(id); | ||
| if (!user) { | ||
| return done(null, false); | ||
| } | ||
| done(null, toSessionUser(user)); | ||
| } catch (err) { | ||
| done(err, null); | ||
| } | ||
| }); | ||
|
|
||
| module.exports = { | ||
| getFrontendUrl, | ||
| isOAuthProviderConfigured, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| const mongoose = require("mongoose"); | ||
| const bcrypt = require("bcryptjs"); | ||
|
|
||
| const PROVIDERS = ["local", "google", "github"]; | ||
|
|
||
| const UserSchema = new mongoose.Schema({ | ||
| username: { | ||
| type: String, | ||
|
|
@@ -14,21 +16,33 @@ const UserSchema = new mongoose.Schema({ | |
| }, | ||
| password: { | ||
| type: String, | ||
| required: true, | ||
| required: function requiredPassword() { | ||
| return this.provider === "local"; | ||
| }, | ||
| }, | ||
| provider: { | ||
| type: String, | ||
| enum: PROVIDERS, | ||
| default: "local", | ||
| }, | ||
| providerId: { | ||
| type: String, | ||
| sparse: true, | ||
| }, | ||
| }); | ||
|
|
||
| // ✅ FIXED: no next() | ||
| UserSchema.pre('save', async function () { | ||
| if (!this.isModified('password')) return; | ||
| UserSchema.index({ provider: 1, providerId: 1 }, { unique: true, sparse: true }); | ||
|
Contributor
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. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate and print the relevant portion of the schema
sed -n '1,120p' backend/models/User.js | cat -n
# Print index-related lines specifically
rg -n "UserSchema\.index|index\(" backend/models/User.js
# Check whether provider/providerId are set in defaults or validation logic
rg -n "providerId|provider" backend/models/User.jsRepository: GitMetricsLab/github_tracker Length of output: 1791 🌐 Web query:
💡 Result: MongoDB semantics for a “sparse unique compound index where providerId is missing / $exists:true / partialFilterExpression provider.providerId …” 1) sparse + unique means “uniqueness only among indexed documents; missing fields are allowed” - MongoDB sparse indexes only include documents that have the indexed field(s) present, and a sparse unique index prevents duplicates for the indexed key values while allowing multiple documents that omit the key [1]. - For sparse compound indexes, MongoDB indexes a document if it contains at least one of the indexed fields; the combination of sparse-ness with compound keys affects whether documents without all fields get included in the index entry [1]. 2) You cannot combine sparse with partialFilterExpression in a single index definition - MongoDB partial indexes explicitly say: “You cannot specify both the partialFilterExpression option and the sparse option.” [2][3]. So the “sparse unique … partialFilterExpression …” combination is not valid as a single index spec. 3) If your real goal is “unique only when provider.providerId exists (and maybe is non-null),” use a partial unique index (not sparse) - Partial indexes include only documents that match partialFilterExpression, and when a unique constraint is defined on that partial index, the uniqueness constraint applies only to documents that meet the partial filter [2][3]. - The docs example shows partial unique indexes expressed with { $exists: true } (e.g., password: { $exists: true }) [2][3]. Example pattern (provider/providerId): - Index that enforces uniqueness of (provider, providerId) only for documents where providerId exists: db.collection.createIndex( { "provider": 1, "provider.providerId": 1 }, { unique: true, partialFilterExpression: { "provider.providerId": { $exists: true } } }) This matches the documented behavior that partial unique constraints apply only to documents satisfying partialFilterExpression [2][3], and it avoids any need for sparse. 4) $exists:true in queries and index usage: sparse can help, but it’s not the right tool for “uniqueness conditioned on field presence” - The $exists operator matches documents that contain the field (including documents where the field value is null) [4]. - MongoDB docs also note that you can make $exists: true queries more efficient by creating a sparse index on that field [4]. However, for uniqueness guarantees specifically, partial indexes provide the conditional uniqueness semantics you want (unique only for docs matching the filter), whereas sparse unique semantics are based on presence rules of sparse indexing and compound sparse indexing can still index documents with only partial key field presence [1]. 5) Practical takeaway for “providerId missing” cases - If you use sparse+unique on a compound key, documents where providerId is missing can still end up being indexed depending on which compound fields are present (sparse compound indexes index a document if it contains at least one indexed field) [1]. That can lead to surprising uniqueness behavior. - If you instead use a partial unique index with partialFilterExpression: { "provider.providerId": { $exists: true } }, then documents missing providerId are simply not part of the unique index constraint [2][3]. Citations:
Fix User unique index to avoid local-providerId collisions
Proposed fix-UserSchema.index({ provider: 1, providerId: 1 }, { unique: true, sparse: true });
+UserSchema.index(
+ { provider: 1, providerId: 1 },
+ {
+ unique: true,
+ partialFilterExpression: { providerId: { $exists: true } },
+ }
+);🤖 Prompt for AI Agents |
||
|
|
||
| UserSchema.pre("save", async function hashPasswordIfPresent() { | ||
| if (!this.isModified("password") || !this.password) return; | ||
|
|
||
| const salt = await bcrypt.genSalt(10); | ||
| this.password = await bcrypt.hash(this.password, salt); | ||
| }); | ||
|
|
||
| // ✅ password comparison | ||
| UserSchema.methods.comparePassword = async function (enteredPassword) { | ||
| UserSchema.methods.comparePassword = async function comparePassword(enteredPassword) { | ||
| if (!this.password) return false; | ||
| return bcrypt.compare(enteredPassword, this.password); | ||
| }; | ||
|
|
||
| module.exports = mongoose.model("User", UserSchema); | ||
| module.exports = mongoose.model("User", UserSchema); | ||
Uh oh!
There was an error while loading. Please reload this page.