diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index d67b14b..134d5bb 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=` @@ -537,7 +559,7 @@ Update a user's profile. Users can only update their own profile. | `password` | string | Minimum 8 characters | | `bio` | string | | | `profilePhoto` | string | URL | -| `socialLinks` | object | `{ linkedin?, github?, other? }` | +| `socialLinks` | object | `{ linkedin?: string, github?: string, other?: string[] }` — `other` replaces the whole array on update | | `organization` | string | Where the user works/studies | | `title` | string | Job title or role | @@ -831,7 +853,7 @@ Join an event as a guest (no account required). | `socialLinks` | object | No* | Social links | | `socialLinks.linkedin` | string | No | LinkedIn URL | | `socialLinks.github` | string | No | GitHub URL | -| `socialLinks.other` | string | No | Other URL | +| `socialLinks.other` | string[] | No | Array of additional social/profile URLs | | `organization` | string | No* | Where the guest works/studies | | `title` | string | No | Job title or role | diff --git a/shatter-backend/docs/DATABASE_SCHEMA.md b/shatter-backend/docs/DATABASE_SCHEMA.md index 4835720..ef47b29 100644 --- a/shatter-backend/docs/DATABASE_SCHEMA.md +++ b/shatter-backend/docs/DATABASE_SCHEMA.md @@ -67,7 +67,7 @@ | `title` | String | No | — | Trimmed | | `bio` | String | No | — | Trimmed | | `profilePhoto` | String | No | — | | -| `socialLinks` | Object | No | — | `{ linkedin?: String, github?: String, other?: String }` | +| `socialLinks` | Object | No | — | `{ linkedin?: String, github?: String, other?: String[] }` — `other` is an array so users can add multiple miscellaneous links | | `authProvider` | String (enum) | Yes | `'local'` | One of: `'local'`, `'linkedin'`, `'guest'` | | `lastLogin` | Date | No | `null` | | | `passwordChangedAt`| Date | No | `null` | | 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/controllers/event_controller.ts b/shatter-backend/src/controllers/event_controller.ts index 561cc57..dfa4a8b 100644 --- a/shatter-backend/src/controllers/event_controller.ts +++ b/shatter-backend/src/controllers/event_controller.ts @@ -277,7 +277,7 @@ export async function joinEventAsGuest(req: Request, res: Response) { const { name, email, socialLinks, organization, title } = req.body as { name?: string; email?: string; - socialLinks?: { linkedin?: string; github?: string; other?: string }; + socialLinks?: { linkedin?: string; github?: string; other?: string[] }; organization?: string; title?: string; }; @@ -295,7 +295,7 @@ export async function joinEventAsGuest(req: Request, res: Response) { const hasSocialLink = socialLinks && ( socialLinks.linkedin?.trim() || socialLinks.github?.trim() || - socialLinks.other?.trim() + socialLinks.other?.some((s) => s.trim()) ); const hasOrganization = organization && organization.trim(); diff --git a/shatter-backend/src/controllers/user_controller.ts b/shatter-backend/src/controllers/user_controller.ts index 54a1ea9..42c7d7b 100644 --- a/shatter-backend/src/controllers/user_controller.ts +++ b/shatter-backend/src/controllers/user_controller.ts @@ -121,7 +121,7 @@ export const updateUser = async (req: Request, res: Response) => { password?: string; bio?: string; profilePhoto?: string; - socialLinks?: { linkedin?: string; github?: string; other?: string }; + socialLinks?: { linkedin?: string; github?: string; other?: string[] }; organization?: string; title?: string; }; diff --git a/shatter-backend/src/models/user_model.ts b/shatter-backend/src/models/user_model.ts index 3d4bb1c..1b7e0f6 100644 --- a/shatter-backend/src/models/user_model.ts +++ b/shatter-backend/src/models/user_model.ts @@ -19,7 +19,7 @@ export interface IUser { socialLinks?: { linkedin?: string; github?: string; - other?: string; + other?: string[]; }; authProvider: 'local' | 'linkedin' | 'guest'; lastLogin?: Date; @@ -85,7 +85,7 @@ const UserSchema = new Schema( socialLinks: { linkedin: { type: String }, github: { type: String }, - other: { type: String }, + other: { type: [String], default: undefined }, }, authProvider: { type: String, 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;