From a4c5c4625cc937e560dfd3af8ea5e1acd3b069d4 Mon Sep 17 00:00:00 2001 From: rxmox Date: Thu, 9 Apr 2026 19:36:10 -0600 Subject: [PATCH 1/2] Add LinkedIn account linking for guest users New GET /api/auth/linkedin/link endpoint (protected, guest-only) that initiates LinkedIn OAuth with the guest user's identity encoded in the state JWT. The existing linkedinCallback detects the linking context and atomically upgrades the guest user to authProvider 'linkedin', populating linkedinId, email, profilePhoto, and name from LinkedIn. Includes validation for non-guest users (403), already-linked accounts (409), and LinkedIn accounts already tied to another user. --- shatter-backend/docs/API_REFERENCE.md | 26 +++- .../src/controllers/auth_controller.ts | 132 +++++++++++++++--- shatter-backend/src/routes/auth_routes.ts | 6 +- 3 files changed, 140 insertions(+), 24 deletions(-) diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index d67b14b..51d855c 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 guest account (guest only) | | 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 guest user. Redirects to LinkedIn's authorization page with the guest user's identity encoded in the state token. + +- **Auth:** Protected (guest users only) +- **Response:** 302 redirect to LinkedIn + +**Error Responses:** + +| Status | Condition | +|--------|-----------| +| 401 | Missing or invalid JWT | +| 403 | User is not a guest account | +| 404 | User not found | +| 409 | LinkedIn account already linked | + +**Flow:** After LinkedIn authorization, the callback detects the linking context from the state token, attaches the LinkedIn profile to the existing guest user, and upgrades `authProvider` from `'guest'` to `'linkedin'`. + +--- + ### 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..c054b2e 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 guest user. + * Protected route - requires JWT auth (guest 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 !== 'guest') { + return res.status(403).json({ + error: 'Only guest accounts can link a LinkedIn profile', + }); + } + + 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,80 @@ 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 -> linkedin) --- - if (existingEmailUser) { + // 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=Email already registered with password&suggestion=Please login with your password` + `${frontendUrl}/auth/error?message=This LinkedIn account is already linked to another user` ); } - // Create new user from LinkedIn data - user = await User.create({ - name: linkedinProfile.name, - email: linkedinProfile.email.toLowerCase().trim(), + // Atomically update the guest user (filter ensures still a guest) + const updateFields: Record = { linkedinId: linkedinProfile.sub, - profilePhoto: linkedinProfile.picture, authProvider: 'linkedin', lastLogin: new Date(), - }); + }; + + // Only fill in fields that are currently empty on the guest user + if (linkedinProfile.email) { + updateFields.email = linkedinProfile.email.toLowerCase().trim(); + } + if (linkedinProfile.picture) { + updateFields.profilePhoto = linkedinProfile.picture; + } + if (linkedinProfile.name) { + updateFields.name = linkedinProfile.name; + } + + user = await User.findOneAndUpdate( + { _id: statePayload.userId, authProvider: 'guest' }, + { $set: updateFields }, + { new: true } + ); + + if (!user) { + return res.redirect( + `${frontendUrl}/auth/error?message=Account not found or no longer a guest account` + ); + } } 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; From 2be0e93f24118a0bf21e57652ef4358f2f730807 Mon Sep 17 00:00:00 2001 From: rxmox Date: Thu, 9 Apr 2026 19:45:04 -0600 Subject: [PATCH 2/2] Allow local accounts to link LinkedIn profile Extends the linkedin/link endpoint to accept both guest and local users. Guest users still get fully upgraded to authProvider 'linkedin'. Local users keep authProvider 'local' (preserving password login) and gain linkedinId and profilePhoto from LinkedIn. Only fills in email and profilePhoto when the user doesn't already have them set. --- shatter-backend/docs/API_REFERENCE.md | 10 ++--- .../src/controllers/auth_controller.ts | 40 ++++++++++++------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index 51d855c..5bdead0 100644 --- a/shatter-backend/docs/API_REFERENCE.md +++ b/shatter-backend/docs/API_REFERENCE.md @@ -75,7 +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 guest account (guest only) | +| 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 | @@ -247,9 +247,9 @@ 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 guest user. Redirects to LinkedIn's authorization page with the guest user's identity encoded in the state token. +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 users only) +- **Auth:** Protected (guest or local users only) - **Response:** 302 redirect to LinkedIn **Error Responses:** @@ -257,11 +257,11 @@ Initiate LinkedIn OAuth flow for linking a LinkedIn account to an existing guest | Status | Condition | |--------|-----------| | 401 | Missing or invalid JWT | -| 403 | User is not a guest account | +| 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, attaches the LinkedIn profile to the existing guest user, and upgrades `authProvider` from `'guest'` to `'linkedin'`. +**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. --- diff --git a/shatter-backend/src/controllers/auth_controller.ts b/shatter-backend/src/controllers/auth_controller.ts index c054b2e..76d7df3 100644 --- a/shatter-backend/src/controllers/auth_controller.ts +++ b/shatter-backend/src/controllers/auth_controller.ts @@ -214,8 +214,8 @@ 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 guest user. - * Protected route - requires JWT auth (guest users only). + * 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 { @@ -229,9 +229,9 @@ export const linkedinLink = async (req: Request, res: Response) => { return res.status(404).json({ error: 'User not found' }); } - if (user.authProvider !== 'guest') { + if (user.authProvider === 'linkedin') { return res.status(403).json({ - error: 'Only guest accounts can link a LinkedIn profile', + error: 'Account is already a LinkedIn account', }); } @@ -306,7 +306,7 @@ export const linkedinCallback = async (req: Request, res: Response) => { let user; if (statePayload.linking && statePayload.userId) { - // --- LinkedIn linking flow (guest -> linkedin) --- + // --- 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 }); @@ -316,33 +316,45 @@ export const linkedinCallback = async (req: Request, res: Response) => { ); } - // Atomically update the guest user (filter ensures still a guest) + // 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=Account not found or already a LinkedIn account` + ); + } + const updateFields: Record = { linkedinId: linkedinProfile.sub, - authProvider: 'linkedin', lastLogin: new Date(), }; - // Only fill in fields that are currently empty on the guest user - if (linkedinProfile.email) { + // 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) { + if (linkedinProfile.picture && !existingUser.profilePhoto) { updateFields.profilePhoto = linkedinProfile.picture; } - if (linkedinProfile.name) { + if (linkedinProfile.name && existingUser.authProvider === 'guest') { updateFields.name = linkedinProfile.name; } - user = await User.findOneAndUpdate( - { _id: statePayload.userId, authProvider: 'guest' }, + user = await User.findByIdAndUpdate( + statePayload.userId, { $set: updateFields }, { new: true } ); if (!user) { return res.redirect( - `${frontendUrl}/auth/error?message=Account not found or no longer a guest account` + `${frontendUrl}/auth/error?message=Account not found` ); } } else {