Skip to content

Commit c808fa7

Browse files
authored
Merge pull request #58 from techstartucalgary/linkedin-oauth
Add LinkedIn OAuth 2.0 authentication
2 parents 08ca74b + 8d78da7 commit c808fa7

7 files changed

Lines changed: 296 additions & 5 deletions

File tree

shatter-backend/package-lock.json

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shatter-backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"license": "ISC",
1414
"description": "",
1515
"dependencies": {
16+
"axios": "^1.13.5",
1617
"bcryptjs": "^3.0.3",
1718
"cors": "^2.8.5",
1819
"dotenv": "^17.2.3",

shatter-backend/src/controllers/auth_controller.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import crypto from 'crypto';
2+
import jwt from 'jsonwebtoken';
13
import { Request, Response } from 'express';
24
import { User } from '../models/user_model';
35
import { hashPassword, comparePassword } from '../utils/password_hash';
46
import { generateToken } from '../utils/jwt_utils';
7+
import { getLinkedInAuthUrl, getLinkedInAccessToken, getLinkedInProfile } from '../utils/linkedin_oauth';
8+
9+
const JWT_SECRET = process.env.JWT_SECRET || '';
510

611
// Email validation regex
712
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
@@ -140,7 +145,12 @@ export const login = async (req: Request, res: Response) => {
140145
});
141146
}
142147

143-
// 7 - verify password
148+
// 7 - verify password (OAuth users won't have a passwordHash)
149+
if (!user.passwordHash) {
150+
return res.status(401).json({
151+
error: 'This account uses LinkedIn login. Please sign in with LinkedIn.',
152+
});
153+
}
144154
const isPasswordValid = await comparePassword(password, user.passwordHash);
145155

146156
if (!isPasswordValid) {
@@ -173,3 +183,117 @@ export const login = async (req: Request, res: Response) => {
173183
}
174184
};
175185

186+
187+
/**
188+
* GET /api/auth/linkedin
189+
* Initiates LinkedIn OAuth flow by redirecting to LinkedIn
190+
*/
191+
export const linkedinAuth = async (req: Request, res: Response) => {
192+
try {
193+
// Generate CSRF protection state token
194+
const state = crypto.randomBytes(16).toString('hex');
195+
196+
// Encode state as JWT with 5-minute expiration (stateless validation)
197+
const stateToken = jwt.sign({ state }, JWT_SECRET, { expiresIn: '5m' });
198+
199+
// Build LinkedIn authorization URL and redirect
200+
const authUrl = getLinkedInAuthUrl(stateToken);
201+
res.redirect(authUrl);
202+
} catch (error) {
203+
console.error('LinkedIn auth initiation error:', error);
204+
res.status(500).json({ error: 'Failed to initiate LinkedIn authentication' });
205+
}
206+
};
207+
208+
209+
/**
210+
* GET /api/auth/linkedin/callback
211+
* LinkedIn redirects here after user authorization
212+
*/
213+
export const linkedinCallback = async (req: Request, res: Response) => {
214+
try {
215+
const { code, state, error: oauthError } = req.query as {
216+
code?: string;
217+
state?: string;
218+
error?: string;
219+
};
220+
221+
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006';
222+
223+
// Handle user denial
224+
if (oauthError === 'user_cancelled_authorize') {
225+
return res.redirect(`${frontendUrl}/auth/error?message=Authorization cancelled`);
226+
}
227+
228+
// Validate required parameters
229+
if (!code || !state) {
230+
return res.status(400).json({ error: 'Missing code or state parameter' });
231+
}
232+
233+
// Verify state token (CSRF protection)
234+
try {
235+
jwt.verify(state, JWT_SECRET);
236+
} catch {
237+
return res.status(401).json({ error: 'Invalid state parameter' });
238+
}
239+
240+
// Exchange code for access token
241+
const accessToken = await getLinkedInAccessToken(code);
242+
243+
// Fetch user profile from LinkedIn
244+
const linkedinProfile = await getLinkedInProfile(accessToken);
245+
246+
// Validate email is provided
247+
if (!linkedinProfile.email) {
248+
return res.status(400).json({
249+
error: 'Email address required',
250+
suggestion: 'Please make your email visible to third-party apps in LinkedIn settings',
251+
});
252+
}
253+
254+
// Find existing user by LinkedIn ID
255+
let user = await User.findOne({ linkedinId: linkedinProfile.sub });
256+
257+
if (!user) {
258+
// Check if email already exists with password auth (email conflict)
259+
const existingEmailUser = await User.findOne({
260+
email: linkedinProfile.email.toLowerCase().trim(),
261+
});
262+
263+
if (existingEmailUser) {
264+
return res.redirect(
265+
`${frontendUrl}/auth/error?message=Email already registered with password&suggestion=Please login with your password`
266+
);
267+
}
268+
269+
// Create new user from LinkedIn data
270+
user = await User.create({
271+
name: linkedinProfile.name,
272+
email: linkedinProfile.email.toLowerCase().trim(),
273+
linkedinId: linkedinProfile.sub,
274+
profilePhoto: linkedinProfile.picture,
275+
authProvider: 'linkedin',
276+
lastLogin: new Date(),
277+
});
278+
} else {
279+
// Update existing user's last login
280+
user.lastLogin = new Date();
281+
await user.save();
282+
}
283+
284+
// Generate JWT token and redirect to frontend
285+
const token = generateToken(user._id.toString());
286+
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&userId=${user._id}`);
287+
288+
} catch (error: any) {
289+
console.error('LinkedIn callback error:', error);
290+
291+
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006';
292+
if (error.message?.includes('LinkedIn')) {
293+
return res.redirect(`${frontendUrl}/auth/error?message=LinkedIn authentication failed`);
294+
}
295+
296+
res.status(500).json({ error: 'Authentication failed. Please try again.' });
297+
}
298+
};
299+

shatter-backend/src/models/user_model.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { Schema, model } from "mongoose";
99
export interface IUser {
1010
name: string;
1111
email: string;
12-
passwordHash: string;
12+
passwordHash?: string;
13+
linkedinId?: string;
14+
linkedinUrl?: string;
15+
profilePhoto?: string;
16+
authProvider: 'local' | 'linkedin';
1317
lastLogin?: Date;
1418
passwordChangedAt?: Date;
1519
createdAt?: Date;
@@ -42,9 +46,28 @@ const UserSchema = new Schema<IUser>(
4246
},
4347
passwordHash: {
4448
type: String,
45-
required: true,
49+
required: false,
4650
select: false, // Don't return in queries by default
4751
},
52+
linkedinId: {
53+
type: String,
54+
unique: true,
55+
sparse: true, // allows null but enforces uniqueness when set
56+
},
57+
linkedinUrl: {
58+
type: String,
59+
unique: true,
60+
sparse: true,
61+
},
62+
profilePhoto: {
63+
type: String,
64+
},
65+
authProvider: {
66+
type: String,
67+
enum: ['local', 'linkedin'],
68+
default: 'local',
69+
required: true,
70+
},
4871
lastLogin: {
4972
type: Date,
5073
default: null,
@@ -68,7 +91,15 @@ const UserSchema = new Schema<IUser>(
6891
}
6992
);
7093

71-
// Add middleware to auto-update passwordChangedAt
94+
// Ensure local auth users have a password
95+
UserSchema.pre("save", function (next) {
96+
if (this.authProvider === "local" && !this.passwordHash) {
97+
return next(new Error("Password required for local authentication"));
98+
}
99+
next();
100+
});
101+
102+
// Auto-update passwordChangedAt
72103
UserSchema.pre("save", function (next) {
73104
if (this.isModified("passwordHash") && !this.isNew) {
74105
this.passwordChangedAt = new Date();

shatter-backend/src/routes/auth_routes.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Router } from 'express';
2-
import { signup, login } from '../controllers/auth_controller';
2+
import { signup, login, linkedinAuth, linkedinCallback } from '../controllers/auth_controller';
33

44
const router = Router();
55

@@ -9,4 +9,8 @@ router.post('/signup', signup);
99
// POST /api/auth/login - authenticate user
1010
router.post('/login', login);
1111

12+
// LinkedIn OAuth routes
13+
router.get('/linkedin', linkedinAuth);
14+
router.get('/linkedin/callback', linkedinCallback);
15+
1216
export default router;

shatter-backend/src/server.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ const MONGODB_URI = process.env.MONGO_URI;
77

88
async function start() {
99
try {
10+
// Validate required environment variables
11+
const requiredEnvVars = [
12+
'MONGO_URI',
13+
'JWT_SECRET',
14+
'LINKEDIN_CLIENT_ID',
15+
'LINKEDIN_CLIENT_SECRET',
16+
'LINKEDIN_CALLBACK_URL',
17+
];
18+
19+
for (const envVar of requiredEnvVars) {
20+
if (!process.env[envVar]) {
21+
throw new Error(`Missing required environment variable: ${envVar}`);
22+
}
23+
}
24+
1025
if (!MONGODB_URI) {
1126
throw new Error("MONGO_URI is not set");
1227
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import axios from 'axios';
2+
3+
const LINKEDIN_AUTH_URL = 'https://www.linkedin.com/oauth/v2/authorization';
4+
const LINKEDIN_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken';
5+
const LINKEDIN_PROFILE_URL = 'https://api.linkedin.com/v2/userinfo';
6+
7+
export interface LinkedInProfile {
8+
sub: string; // LinkedIn user ID
9+
name: string; // Full name
10+
email: string; // Email address
11+
picture?: string; // Profile picture URL
12+
}
13+
14+
/**
15+
* Generate LinkedIn authorization URL
16+
*/
17+
export const getLinkedInAuthUrl = (state: string): string => {
18+
const params = new URLSearchParams({
19+
response_type: 'code',
20+
client_id: process.env.LINKEDIN_CLIENT_ID!,
21+
redirect_uri: process.env.LINKEDIN_CALLBACK_URL!,
22+
state: state,
23+
scope: 'openid profile email',
24+
});
25+
26+
return `${LINKEDIN_AUTH_URL}?${params.toString()}`;
27+
};
28+
29+
/**
30+
* Exchange authorization code for access token
31+
*/
32+
export const getLinkedInAccessToken = async (code: string): Promise<string> => {
33+
try {
34+
const response = await axios.post(
35+
LINKEDIN_TOKEN_URL,
36+
new URLSearchParams({
37+
grant_type: 'authorization_code',
38+
code: code,
39+
client_id: process.env.LINKEDIN_CLIENT_ID!,
40+
client_secret: process.env.LINKEDIN_CLIENT_SECRET!,
41+
redirect_uri: process.env.LINKEDIN_CALLBACK_URL!,
42+
}),
43+
{
44+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
45+
}
46+
);
47+
48+
return response.data.access_token;
49+
} catch (error: any) {
50+
console.error('LinkedIn token exchange error:', error.response?.data || error.message);
51+
throw new Error('Failed to obtain LinkedIn access token');
52+
}
53+
};
54+
55+
/**
56+
* Fetch user profile from LinkedIn using access token
57+
*/
58+
export const getLinkedInProfile = async (accessToken: string): Promise<LinkedInProfile> => {
59+
try {
60+
const response = await axios.get(LINKEDIN_PROFILE_URL, {
61+
headers: { Authorization: `Bearer ${accessToken}` },
62+
});
63+
64+
if (!response.data || !response.data.sub) {
65+
throw new Error('Invalid LinkedIn API response format');
66+
}
67+
68+
return {
69+
sub: response.data.sub,
70+
name: response.data.name || 'LinkedIn User',
71+
email: response.data.email || '',
72+
picture: response.data.picture,
73+
};
74+
} catch (error: any) {
75+
console.error('LinkedIn profile fetch error:', error.response?.data || error.message);
76+
throw new Error('Failed to fetch LinkedIn profile');
77+
}
78+
};

0 commit comments

Comments
 (0)