diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index d67b14b..5bdead0 100644 --- a/shatter-backend/docs/API_REFERENCE.md +++ b/shatter-backend/docs/API_REFERENCE.md @@ -18,6 +18,7 @@ - [POST `/api/auth/signup`](#post-apiauthsignup) - [POST `/api/auth/login`](#post-apiauthlogin) - [GET `/api/auth/linkedin`](#get-apiauthlinkedin) + - [GET `/api/auth/linkedin/link`](#get-apiauthlinkedinlink) - [GET `/api/auth/linkedin/callback`](#get-apiauthlinkedincallback) - [POST `/api/auth/exchange`](#post-apiauthexchange) - [Users (`/api/users`)](#users-apiusers) @@ -74,6 +75,7 @@ Quick reference of all implemented endpoints. See detailed sections below for re | POST | `/api/auth/signup` | Public | Create new user account | | POST | `/api/auth/login` | Public | Log in with email + password | | GET | `/api/auth/linkedin` | Public | Initiate LinkedIn OAuth flow | +| GET | `/api/auth/linkedin/link` | Protected | Link LinkedIn to existing account (guest or local) | | GET | `/api/auth/linkedin/callback` | Public | LinkedIn OAuth callback (not called directly) | | POST | `/api/auth/exchange` | Public | Exchange OAuth auth code for JWT | | GET | `/api/users` | Public | List all users | @@ -243,12 +245,32 @@ Initiate LinkedIn OAuth flow. Redirects the browser to LinkedIn's authorization --- +### GET `/api/auth/linkedin/link` + +Initiate LinkedIn OAuth flow for linking a LinkedIn account to an existing user. Redirects to LinkedIn's authorization page with the user's identity encoded in the state token. + +- **Auth:** Protected (guest or local users only) +- **Response:** 302 redirect to LinkedIn + +**Error Responses:** + +| Status | Condition | +|--------|-----------| +| 401 | Missing or invalid JWT | +| 403 | Account is already a LinkedIn account | +| 404 | User not found | +| 409 | LinkedIn account already linked | + +**Flow:** After LinkedIn authorization, the callback detects the linking context from the state token and attaches the LinkedIn profile to the existing user. Guest users get upgraded to `authProvider: 'linkedin'`. Local users keep `authProvider: 'local'` (preserves password login) and gain LinkedIn profile data. + +--- + ### GET `/api/auth/linkedin/callback` -LinkedIn OAuth callback. Not called directly by frontend — LinkedIn redirects here after user authorization. +LinkedIn OAuth callback. Not called directly by frontend - LinkedIn redirects here after user authorization. - **Auth:** Public (called by LinkedIn) -- **Flow:** Verifies CSRF state → exchanges code for access token → fetches LinkedIn profile → upserts user → creates single-use auth code → redirects to frontend with `?code=` +- **Flow:** Verifies CSRF state -> exchanges code for access token -> fetches LinkedIn profile -> upserts user (or links to guest account) -> creates single-use auth code -> redirects to frontend with `?code=` **Redirect on success:** `{FRONTEND_URL}/auth/callback?code=` **Redirect on error:** `{FRONTEND_URL}/auth/error?message=` diff --git a/shatter-backend/src/controllers/auth_controller.ts b/shatter-backend/src/controllers/auth_controller.ts index 9303990..76d7df3 100644 --- a/shatter-backend/src/controllers/auth_controller.ts +++ b/shatter-backend/src/controllers/auth_controller.ts @@ -212,6 +212,51 @@ export const linkedinAuth = async (req: Request, res: Response) => { }; +/** + * GET /api/auth/linkedin/link + * Initiates LinkedIn OAuth flow for linking a LinkedIn account to an existing user. + * Protected route - requires JWT auth (guest or local users only). + */ +export const linkedinLink = async (req: Request, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const user = await User.findById(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + if (user.authProvider === 'linkedin') { + return res.status(403).json({ + error: 'Account is already a LinkedIn account', + }); + } + + if (user.linkedinId) { + return res.status(409).json({ + error: 'LinkedIn account already linked', + }); + } + + // Encode linking context into the state JWT (signed, tamper-proof) + const stateToken = jwt.sign( + { linking: true, userId: user._id.toString() }, + JWT_SECRET, + { expiresIn: '5m' } + ); + + const authUrl = getLinkedInAuthUrl(stateToken); + res.redirect(authUrl); + } catch (error) { + console.error('LinkedIn link initiation error:', error); + res.status(500).json({ error: 'Failed to initiate LinkedIn linking' }); + } +}; + + /** * GET /api/auth/linkedin/callback * LinkedIn redirects here after user authorization @@ -236,9 +281,10 @@ export const linkedinCallback = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Missing code or state parameter' }); } - // Verify state token (CSRF protection) + // Verify state token (CSRF protection) and extract payload + let statePayload: { linking?: boolean; userId?: string }; try { - jwt.verify(state, JWT_SECRET); + statePayload = jwt.verify(state, JWT_SECRET) as { linking?: boolean; userId?: string }; } catch { return res.status(401).json({ error: 'Invalid state parameter' }); } @@ -257,34 +303,92 @@ export const linkedinCallback = async (req: Request, res: Response) => { }); } - // Find existing user by LinkedIn ID - let user = await User.findOne({ linkedinId: linkedinProfile.sub }); + let user; - if (!user) { - // Check if email already exists with password auth (email conflict) - const existingEmailUser = await User.findOne({ - email: linkedinProfile.email.toLowerCase().trim(), - }); + if (statePayload.linking && statePayload.userId) { + // --- LinkedIn linking flow (guest or local -> attach LinkedIn) --- + + // Check if this LinkedIn account is already linked to another user + const existingLinkedinUser = await User.findOne({ linkedinId: linkedinProfile.sub }); + if (existingLinkedinUser) { + return res.redirect( + `${frontendUrl}/auth/error?message=This LinkedIn account is already linked to another user` + ); + } - if (existingEmailUser) { + // Look up the user to determine their current authProvider + const existingUser = await User.findById(statePayload.userId); + if (!existingUser || existingUser.authProvider === 'linkedin') { return res.redirect( - `${frontendUrl}/auth/error?message=Email already registered with password&suggestion=Please login with your password` + `${frontendUrl}/auth/error?message=Account not found or already a LinkedIn account` ); } - // Create new user from LinkedIn data - user = await User.create({ - name: linkedinProfile.name, - email: linkedinProfile.email.toLowerCase().trim(), + const updateFields: Record = { linkedinId: linkedinProfile.sub, - profilePhoto: linkedinProfile.picture, - authProvider: 'linkedin', lastLogin: new Date(), - }); + }; + + // Guest users get fully upgraded to linkedin authProvider + // Local users keep their authProvider (preserves password login) + if (existingUser.authProvider === 'guest') { + updateFields.authProvider = 'linkedin'; + } + + // Only fill in fields that are currently empty + if (linkedinProfile.email && !existingUser.email) { + updateFields.email = linkedinProfile.email.toLowerCase().trim(); + } + if (linkedinProfile.picture && !existingUser.profilePhoto) { + updateFields.profilePhoto = linkedinProfile.picture; + } + if (linkedinProfile.name && existingUser.authProvider === 'guest') { + updateFields.name = linkedinProfile.name; + } + + user = await User.findByIdAndUpdate( + statePayload.userId, + { $set: updateFields }, + { new: true } + ); + + if (!user) { + return res.redirect( + `${frontendUrl}/auth/error?message=Account not found` + ); + } } else { - // Update existing user's last login - user.lastLogin = new Date(); - await user.save(); + // --- Normal LinkedIn signup/login flow --- + + // Find existing user by LinkedIn ID + user = await User.findOne({ linkedinId: linkedinProfile.sub }); + + if (!user) { + // Check if email already exists with password auth (email conflict) + const existingEmailUser = await User.findOne({ + email: linkedinProfile.email.toLowerCase().trim(), + }); + + if (existingEmailUser) { + return res.redirect( + `${frontendUrl}/auth/error?message=Email already registered with password&suggestion=Please login with your password` + ); + } + + // Create new user from LinkedIn data + user = await User.create({ + name: linkedinProfile.name, + email: linkedinProfile.email.toLowerCase().trim(), + linkedinId: linkedinProfile.sub, + profilePhoto: linkedinProfile.picture, + authProvider: 'linkedin', + lastLogin: new Date(), + }); + } else { + // Update existing user's last login + user.lastLogin = new Date(); + await user.save(); + } } // Generate single-use auth code and redirect to frontend diff --git a/shatter-backend/src/routes/auth_routes.ts b/shatter-backend/src/routes/auth_routes.ts index 736250c..0eb4cd1 100644 --- a/shatter-backend/src/routes/auth_routes.ts +++ b/shatter-backend/src/routes/auth_routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; -import { signup, login, linkedinAuth, linkedinCallback, exchangeAuthCode } from '../controllers/auth_controller.js'; +import { signup, login, linkedinAuth, linkedinLink, linkedinCallback, exchangeAuthCode } from '../controllers/auth_controller.js'; +import { authMiddleware } from '../middleware/auth_middleware.js'; const router = Router(); @@ -11,9 +12,10 @@ router.post('/login', login); // LinkedIn OAuth routes router.get('/linkedin', linkedinAuth); +router.get('/linkedin/link', authMiddleware, linkedinLink); router.get('/linkedin/callback', linkedinCallback); -// Auth code exchange (OAuth callback → JWT) +// Auth code exchange (OAuth callback -> JWT) router.post('/exchange', exchangeAuthCode); export default router;