Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_BACKEND_URL=http://localhost:5000
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,102 @@ $ npm i
$ npm start
```

### OAuth2 sign-in (Google & GitHub)

OAuth2 lets users sign in with Google or GitHub. Email/password login still works for local accounts.

#### 1. Copy environment files

```bash
cp .env.example .env
cp backend/.env.example backend/.env
```

Set these in **`backend/.env`** (required for OAuth redirects and sessions):

| Variable | Description |
|----------|-------------|
| `SESSION_SECRET` | Long random string for express-session |
| `MONGO_URI` | MongoDB connection string |
| `BACKEND_URL` | Backend base URL (e.g. `http://localhost:5001`) |
| `FRONTEND_URL` | Frontend URL (e.g. `http://localhost:5173`) |

Set in **`.env`** (project root, for the React app):

| Variable | Description |
|----------|-------------|
| `VITE_BACKEND_URL` | Same as `BACKEND_URL` (e.g. `http://localhost:5001`) |

> **Note (macOS):** Port `5000` is often used by AirPlay. If the backend fails to start, set `PORT=5001` in `backend/.env` and use `5001` in `BACKEND_URL` and `VITE_BACKEND_URL`.

#### 2. Set up Google OAuth2

1. Open [Google Cloud Console](https://console.cloud.google.com/) and create or select a project.
2. Go to **APIs & Services → OAuth consent screen**, choose **External**, and complete the required app name and support email fields.
3. Go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**.
4. Application type: **Web application**.
5. **Authorized redirect URIs** — add:
```
http://localhost:5001/api/auth/google/callback
```
Comment thread
Honey-pg marked this conversation as resolved.
(Use your `BACKEND_URL` host/port in production, e.g. `https://your-api.example.com/api/auth/google/callback`.)
6. Copy the **Client ID** and **Client secret** into `backend/.env`:
```env
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```

#### 3. Set up GitHub OAuth App

1. Open [GitHub Developer Settings → OAuth Apps](https://github.com/settings/developers) and click **New OAuth App**.
2. Fill in:
- **Application name:** e.g. `GitHub Tracker (local)`
- **Homepage URL:** `http://localhost:5173` (or your deployed frontend URL)
- **Authorization callback URL:**
```
http://localhost:5001/api/auth/github/callback
```
(Match your `BACKEND_URL` in production.)
3. Click **Register application**, then **Generate a new client secret**.
4. Add to `backend/.env`:
```env
GITHUB_OAUTH_CLIENT_ID=your-github-client-id
GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret
```

> Use a **GitHub OAuth App**, not a Personal Access Token (PAT).

#### 4. Install backend dependencies and restart

From the `backend` folder:

```bash
cd backend
npm install
npm run dev
```

Restart the frontend after changing `.env`:

```bash
npm run dev
```

#### 5. Verify OAuth login

1. Open `http://localhost:5173/login`.
2. Click **Continue with Google** or **Continue with GitHub**.
3. Complete the provider sign-in; you should be redirected back and logged in.

If credentials are missing, the buttons still appear; clicking them shows a message to configure `backend/.env`. After adding secrets and restarting the backend, OAuth sign-in works end-to-end.

**Callback URLs summary** (replace host/port with your `BACKEND_URL`):

| Provider | Callback URL |
|----------|----------------|
| Google | `{BACKEND_URL}/api/auth/google/callback` |
| GitHub | `{BACKEND_URL}/api/auth/github/callback` |

## 🧪 Backend Unit & Integration Testing with Jasmine

This project uses the Jasmine framework for backend unit and integration tests. The tests cover:
Expand Down Expand Up @@ -94,6 +190,7 @@ npm install --save-dev jasmine @types/jasmine supertest express-session passport
### Test Files
- `spec/user.model.spec.cjs` — Unit tests for the User model
- `spec/auth.routes.spec.cjs` — Integration tests for authentication routes
- `spec/oauthUser.spec.cjs` — Unit tests for OAuth user helpers

### Jasmine Configuration
The Jasmine config (`spec/support/jasmine.mjs`) is set to recognize `.cjs`, `.js`, and `.mjs` test files:
Expand Down
19 changes: 19 additions & 0 deletions backend/.env.example
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=
147 changes: 115 additions & 32 deletions backend/config/passportConfig.js
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,
};
28 changes: 21 additions & 7 deletions backend/models/User.js
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,
Expand All @@ -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 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 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.js

Repository: GitMetricsLab/github_tracker

Length of output: 1791


🌐 Web query:

MongoDB sparse unique compound index semantics provider providerId missing providerId $exists true partialFilterExpression

💡 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

  • UserSchema.index({ provider: 1, providerId: 1 }, { unique: true, sparse: true }) can still index local documents because provider always exists; when providerId is missing, those documents share the same indexed key value for uniqueness, so the 2nd local signup can hit a duplicate-key error.
  • Apply uniqueness only to documents that actually have providerId via a partial unique index.
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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/models/User.js` at line 34, The current unique index on UserSchema
(UserSchema.index({ provider: 1, providerId: 1 }, { unique: true, sparse: true
})) still conflicts for local users because provider always exists and
providerId can be missing; replace this with a partial unique index so
uniqueness only applies when providerId is present by calling
UserSchema.index(...) with a partialFilterExpression such as { providerId: {
$exists: true } } (and keep unique: true) so only documents that actually have a
providerId are constrained.


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);
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"express-session": "^1.18.1",
"mongoose": "^8.8.2",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
"winston": "^3.19.0",
"zod": "^4.4.3"
Expand Down
Loading
Loading