Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions shatter-backend/docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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=<authCode>`
- **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=<authCode>`

**Redirect on success:** `{FRONTEND_URL}/auth/callback?code=<authCode>`
**Redirect on error:** `{FRONTEND_URL}/auth/error?message=<error>`
Expand Down
146 changes: 125 additions & 21 deletions shatter-backend/src/controllers/auth_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' });
}
Expand All @@ -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<string, any> = {
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
Expand Down
6 changes: 4 additions & 2 deletions shatter-backend/src/routes/auth_routes.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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;
Loading