Skip to content

Commit f9c97a5

Browse files
Honey-pgcursoragent
andcommitted
feat(auth): add OAuth2 login with Google and GitHub
Implement Passport OAuth strategies, extend the user model for provider accounts, add auth routes and login UI, and document setup in the README. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6c6bc3e commit f9c97a5

15 files changed

Lines changed: 702 additions & 86 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VITE_BACKEND_URL=http://localhost:5000

README.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,102 @@ $ npm i
6363
$ npm start
6464
```
6565

66+
### OAuth2 sign-in (Google & GitHub)
67+
68+
OAuth2 lets users sign in with Google or GitHub. Email/password login still works for local accounts.
69+
70+
#### 1. Copy environment files
71+
72+
```bash
73+
cp .env.example .env
74+
cp backend/.env.example backend/.env
75+
```
76+
77+
Set these in **`backend/.env`** (required for OAuth redirects and sessions):
78+
79+
| Variable | Description |
80+
|----------|-------------|
81+
| `SESSION_SECRET` | Long random string for express-session |
82+
| `MONGO_URI` | MongoDB connection string |
83+
| `BACKEND_URL` | Backend base URL (e.g. `http://localhost:5001`) |
84+
| `FRONTEND_URL` | Frontend URL (e.g. `http://localhost:5173`) |
85+
86+
Set in **`.env`** (project root, for the React app):
87+
88+
| Variable | Description |
89+
|----------|-------------|
90+
| `VITE_BACKEND_URL` | Same as `BACKEND_URL` (e.g. `http://localhost:5001`) |
91+
92+
> **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`.
93+
94+
#### 2. Set up Google OAuth2
95+
96+
1. Open [Google Cloud Console](https://console.cloud.google.com/) and create or select a project.
97+
2. Go to **APIs & Services → OAuth consent screen**, choose **External**, and complete the required app name and support email fields.
98+
3. Go to **APIs & Services → Credentials → Create Credentials → OAuth client ID**.
99+
4. Application type: **Web application**.
100+
5. **Authorized redirect URIs** — add:
101+
```
102+
http://localhost:5001/api/auth/google/callback
103+
```
104+
(Use your `BACKEND_URL` host/port in production, e.g. `https://your-api.example.com/api/auth/google/callback`.)
105+
6. Copy the **Client ID** and **Client secret** into `backend/.env`:
106+
```env
107+
GOOGLE_CLIENT_ID=your-google-client-id
108+
GOOGLE_CLIENT_SECRET=your-google-client-secret
109+
```
110+
111+
#### 3. Set up GitHub OAuth App
112+
113+
1. Open [GitHub Developer Settings → OAuth Apps](https://github.com/settings/developers) and click **New OAuth App**.
114+
2. Fill in:
115+
- **Application name:** e.g. `GitHub Tracker (local)`
116+
- **Homepage URL:** `http://localhost:5173` (or your deployed frontend URL)
117+
- **Authorization callback URL:**
118+
```
119+
http://localhost:5001/api/auth/github/callback
120+
```
121+
(Match your `BACKEND_URL` in production.)
122+
3. Click **Register application**, then **Generate a new client secret**.
123+
4. Add to `backend/.env`:
124+
```env
125+
GITHUB_OAUTH_CLIENT_ID=your-github-client-id
126+
GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret
127+
```
128+
129+
> Use a **GitHub OAuth App**, not a Personal Access Token (PAT).
130+
131+
#### 4. Install backend dependencies and restart
132+
133+
From the `backend` folder:
134+
135+
```bash
136+
cd backend
137+
npm install
138+
npm run dev
139+
```
140+
141+
Restart the frontend after changing `.env`:
142+
143+
```bash
144+
npm run dev
145+
```
146+
147+
#### 5. Verify OAuth login
148+
149+
1. Open `http://localhost:5173/login`.
150+
2. Click **Continue with Google** or **Continue with GitHub**.
151+
3. Complete the provider sign-in; you should be redirected back and logged in.
152+
153+
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.
154+
155+
**Callback URLs summary** (replace host/port with your `BACKEND_URL`):
156+
157+
| Provider | Callback URL |
158+
|----------|----------------|
159+
| Google | `{BACKEND_URL}/api/auth/google/callback` |
160+
| GitHub | `{BACKEND_URL}/api/auth/github/callback` |
161+
66162
## 🧪 Backend Unit & Integration Testing with Jasmine
67163

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

98195
### Jasmine Configuration
99196
The Jasmine config (`spec/support/jasmine.mjs`) is set to recognize `.cjs`, `.js`, and `.mjs` test files:

backend/.env.example

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
PORT=5000
2+
MONGO_URI=mongodb://127.0.0.1:27017/github_tracker
3+
SESSION_SECRET=replace-with-a-long-random-secret
4+
5+
# URLs used for OAuth redirects and CORS
6+
BACKEND_URL=http://localhost:5000
7+
FRONTEND_URL=http://localhost:5173
8+
9+
# Google OAuth2 (https://console.cloud.google.com/apis/credentials)
10+
GOOGLE_CLIENT_ID=
11+
GOOGLE_CLIENT_SECRET=
12+
# Optional override; defaults to BACKEND_URL/api/auth/google/callback
13+
GOOGLE_CALLBACK_URL=
14+
15+
# GitHub OAuth App (https://github.com/settings/developers)
16+
GITHUB_OAUTH_CLIENT_ID=
17+
GITHUB_OAUTH_CLIENT_SECRET=
18+
# Optional override; defaults to BACKEND_URL/api/auth/github/callback
19+
GITHUB_CALLBACK_URL=

backend/config/passportConfig.js

Lines changed: 115 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,128 @@
11
const passport = require("passport");
2-
const LocalStrategy = require('passport-local').Strategy;
2+
const LocalStrategy = require("passport-local").Strategy;
3+
const GoogleStrategy = require("passport-google-oauth20").Strategy;
4+
const GitHubStrategy = require("passport-github2").Strategy;
35
const User = require("../models/User");
6+
const {
7+
findOrCreateOAuthUser,
8+
toSessionUser,
9+
isOAuthProviderConfigured,
10+
} = require("../utils/oauthUser");
11+
12+
function getBackendUrl() {
13+
return process.env.BACKEND_URL || `http://localhost:${process.env.PORT || 5000}`;
14+
}
15+
16+
function getFrontendUrl() {
17+
return process.env.FRONTEND_URL || "http://localhost:5173";
18+
}
419

520
passport.use(
6-
new LocalStrategy(
7-
{ usernameField: "email" },
8-
async (email, password, done) => {
9-
try {
10-
const user = await User.findOne( {email} );
11-
if (!user) {
12-
return done(null, false, { message: 'Email is invalid '});
13-
}
14-
15-
const isMatch = await user.comparePassword(password);
16-
if (!isMatch) {
17-
return done(null, false, { message: 'Invalid password' });
18-
}
19-
20-
return done(null, {
21-
id : user._id.toString(),
22-
username: user.username,
23-
email: user.email
24-
});
25-
} catch (err) {
26-
return done(err);
27-
}
21+
new LocalStrategy(
22+
{ usernameField: "email" },
23+
async (email, password, done) => {
24+
try {
25+
const user = await User.findOne({ email });
26+
27+
if (!user) {
28+
return done(null, false, { message: "Email is invalid " });
2829
}
29-
)
30+
31+
if (user.provider !== "local" || !user.password) {
32+
return done(null, false, {
33+
message: `Please sign in with ${user.provider}`,
34+
});
35+
}
36+
37+
const isMatch = await user.comparePassword(password);
38+
if (!isMatch) {
39+
return done(null, false, { message: "Invalid password" });
40+
}
41+
42+
return done(null, toSessionUser(user));
43+
} catch (err) {
44+
return done(err);
45+
}
46+
}
47+
)
3048
);
3149

32-
// Serialize user (store user info in session)
50+
if (isOAuthProviderConfigured("google")) {
51+
passport.use(
52+
new GoogleStrategy(
53+
{
54+
clientID: process.env.GOOGLE_CLIENT_ID,
55+
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
56+
callbackURL:
57+
process.env.GOOGLE_CALLBACK_URL || `${getBackendUrl()}/api/auth/google/callback`,
58+
},
59+
async (_accessToken, _refreshToken, profile, done) => {
60+
try {
61+
const email = profile.emails?.[0]?.value;
62+
const sessionUser = await findOrCreateOAuthUser({
63+
provider: "google",
64+
providerId: profile.id,
65+
email,
66+
displayName: profile.displayName,
67+
username: profile.displayName,
68+
});
69+
return done(null, sessionUser);
70+
} catch (err) {
71+
return done(err, null);
72+
}
73+
}
74+
)
75+
);
76+
}
77+
78+
if (isOAuthProviderConfigured("github")) {
79+
passport.use(
80+
new GitHubStrategy(
81+
{
82+
clientID: process.env.GITHUB_OAUTH_CLIENT_ID,
83+
clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET,
84+
callbackURL:
85+
process.env.GITHUB_CALLBACK_URL || `${getBackendUrl()}/api/auth/github/callback`,
86+
scope: ["user:email"],
87+
},
88+
async (_accessToken, _refreshToken, profile, done) => {
89+
try {
90+
const email = profile.emails?.find((entry) => entry.primary)?.value
91+
|| profile.emails?.[0]?.value;
92+
93+
const sessionUser = await findOrCreateOAuthUser({
94+
provider: "github",
95+
providerId: profile.id,
96+
email,
97+
username: profile.username,
98+
displayName: profile.displayName,
99+
});
100+
return done(null, sessionUser);
101+
} catch (err) {
102+
return done(err, null);
103+
}
104+
}
105+
)
106+
);
107+
}
108+
33109
passport.serializeUser((user, done) => {
34-
done(null, user.id);
110+
done(null, user.id);
35111
});
36112

37-
// Deserialize user (retrieve user from session)
38113
passport.deserializeUser(async (id, done) => {
39-
try {
40-
const user = await User.findById(id);
41-
done(null, user);
42-
} catch (err) {
43-
done(err, null);
114+
try {
115+
const user = await User.findById(id);
116+
if (!user) {
117+
return done(null, false);
44118
}
119+
done(null, toSessionUser(user));
120+
} catch (err) {
121+
done(err, null);
122+
}
45123
});
124+
125+
module.exports = {
126+
getFrontendUrl,
127+
isOAuthProviderConfigured,
128+
};

backend/models/User.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const mongoose = require("mongoose");
22
const bcrypt = require("bcryptjs");
33

4+
const PROVIDERS = ["local", "google", "github"];
5+
46
const UserSchema = new mongoose.Schema({
57
username: {
68
type: String,
@@ -14,21 +16,33 @@ const UserSchema = new mongoose.Schema({
1416
},
1517
password: {
1618
type: String,
17-
required: true,
19+
required: function requiredPassword() {
20+
return this.provider === "local";
21+
},
22+
},
23+
provider: {
24+
type: String,
25+
enum: PROVIDERS,
26+
default: "local",
27+
},
28+
providerId: {
29+
type: String,
30+
sparse: true,
1831
},
1932
});
2033

21-
// ✅ FIXED: no next()
22-
UserSchema.pre('save', async function () {
23-
if (!this.isModified('password')) return;
34+
UserSchema.index({ provider: 1, providerId: 1 }, { unique: true, sparse: true });
35+
36+
UserSchema.pre("save", async function hashPasswordIfPresent() {
37+
if (!this.isModified("password") || !this.password) return;
2438

2539
const salt = await bcrypt.genSalt(10);
2640
this.password = await bcrypt.hash(this.password, salt);
2741
});
2842

29-
// ✅ password comparison
30-
UserSchema.methods.comparePassword = async function (enteredPassword) {
43+
UserSchema.methods.comparePassword = async function comparePassword(enteredPassword) {
44+
if (!this.password) return false;
3145
return bcrypt.compare(enteredPassword, this.password);
3246
};
3347

34-
module.exports = mongoose.model("User", UserSchema);
48+
module.exports = mongoose.model("User", UserSchema);

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"express-session": "^1.18.1",
2121
"mongoose": "^8.8.2",
2222
"passport": "^0.7.0",
23+
"passport-github2": "^0.1.12",
24+
"passport-google-oauth20": "^2.0.0",
2325
"passport-local": "^1.0.0",
2426
"winston": "^3.19.0",
2527
"zod": "^4.4.3"

0 commit comments

Comments
 (0)