Skip to content
Merged
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
57 changes: 42 additions & 15 deletions shatter-backend/src/controllers/auth_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,10 @@ export const linkedinAuth = async (req: Request, res: Response) => {
// Generate CSRF protection state token
const state = crypto.randomBytes(16).toString('hex');

const platform = req.query.platform === 'mobile' ? 'mobile' : 'web';

// Encode state as JWT with 5-minute expiration (stateless validation)
const stateToken = jwt.sign({ state }, JWT_SECRET, { expiresIn: '5m' });
const stateToken = jwt.sign({ state, platform }, JWT_SECRET, { expiresIn: '5m' });

// Build LinkedIn authorization URL and redirect
const authUrl = getLinkedInAuthUrl(stateToken);
Expand Down Expand Up @@ -241,9 +243,11 @@ export const linkedinLink = async (req: Request, res: Response) => {
});
}

const platform = req.query.platform === 'mobile' ? 'mobile' : 'web';

// Encode linking context into the state JWT (signed, tamper-proof)
const stateToken = jwt.sign(
{ linking: true, userId: user._id.toString() },
{ linking: true, userId: user._id.toString(), platform },
JWT_SECRET,
{ expiresIn: '5m' }
);
Expand All @@ -269,22 +273,18 @@ export const linkedinCallback = async (req: Request, res: Response) => {
error?: string;
};

const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006';
// Default to web frontend; mobile gets resolved after state verification below
let frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006';

// Handle user denial
if (oauthError === 'user_cancelled_authorize') {
return res.redirect(`${frontendUrl}/auth/error?message=Authorization cancelled`);
// State must always be present (even on cancel, LinkedIn returns it). Verify it first
// so we can resolve the correct redirect target before any branching.
if (!state) {
return res.status(400).json({ error: 'Missing state parameter' });
}

// Validate required parameters
if (!code || !state) {
return res.status(400).json({ error: 'Missing code or state parameter' });
}

// Verify state token (CSRF protection) and extract payload
let statePayload: { linking?: boolean; userId?: string };
let statePayload: { linking?: boolean; userId?: string; platform?: 'mobile' | 'web' };
try {
statePayload = jwt.verify(state, JWT_SECRET) as { linking?: boolean; userId?: string };
statePayload = jwt.verify(state, JWT_SECRET) as { linking?: boolean; userId?: string; platform?: 'mobile' | 'web' };
} catch (e: any) {
console.error('LinkedIn state verify failed:', {
name: e?.name,
Expand All @@ -294,6 +294,21 @@ export const linkedinCallback = async (req: Request, res: Response) => {
return res.status(401).json({ error: 'Invalid state parameter' });
}

// Resolve redirect target based on the originating client encoded in the signed state JWT
if (statePayload.platform === 'mobile') {
frontendUrl = 'shattermobile://auth';
}

// Handle user denial
if (oauthError === 'user_cancelled_authorize') {
return res.redirect(`${frontendUrl}/auth/error?message=Authorization cancelled`);
}

// Validate code is present for the success path
if (!code) {
return res.status(400).json({ error: 'Missing code parameter' });
}

// Exchange code for access token
const accessToken = await getLinkedInAccessToken(code);

Expand Down Expand Up @@ -404,7 +419,19 @@ export const linkedinCallback = async (req: Request, res: Response) => {
} catch (error: any) {
console.error('LinkedIn callback error:', error);

const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006';
// Best-effort attempt to recover platform from state for the error redirect.
// If state is missing/invalid we fall back to the web URL.
let frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006';
try {
const stateParam = (req.query as { state?: string }).state;
if (stateParam) {
const payload = jwt.verify(stateParam, JWT_SECRET) as { platform?: 'mobile' | 'web' };
if (payload.platform === 'mobile') frontendUrl = 'shattermobile://auth';
}
} catch {
// ignore — already defaulting to web
}

if (error.message?.includes('LinkedIn')) {
return res.redirect(`${frontendUrl}/auth/error?message=LinkedIn authentication failed`);
}
Expand Down
121 changes: 65 additions & 56 deletions shatter-mobile/app.json
Original file line number Diff line number Diff line change
@@ -1,58 +1,67 @@
{
"expo": {
"name": "shatter-mobile",
"slug": "shatter-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "shattermobile",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"
],
"package": "com.anonymous.shattermobile"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-camera",
{
"cameraPermission": "Allow Shatter to access your camera to scan QR codes"
}
],
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#2C3B5E"
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
"expo": {
"name": "Shatter",
"slug": "shatter-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "shattermobile",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.shatter.shattermobile",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.CAMERA"
],
"package": "com.anonymous.shattermobile"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-camera",
{
"cameraPermission": "Allow Shatter to access your camera to scan QR codes",
"microphonePermission": false,
"recordAudioAndroid": false
}
],
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#2C3B5E"
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "77d528c2-849b-42ac-819b-0e65da530be2"
}
}
}
}
Binary file added shatter-mobile/assets/images/adaptive-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added shatter-mobile/assets/images/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added shatter-mobile/assets/images/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added shatter-mobile/assets/images/splash-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions shatter-mobile/eas.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 18.9.1",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
2 changes: 1 addition & 1 deletion shatter-mobile/src/components/context/PusherClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const { Pusher } = require('pusher-js/react-native');

let pusher: any = null;

const API_KEY = process.env.EXPO_PUBLIC_PUSHER_KEY!;
const API_KEY = process.env.EXPO_PUBLIC_PUSHER_KEY!;
const API_CLUSTER = process.env.EXPO_PUBLIC_PUSHER_CLUSTER!;

export const getPusherClient = () => {
Expand Down
20 changes: 16 additions & 4 deletions shatter-mobile/src/components/login-signup/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//called by Profile.tsx for logging in
import { User } from "@/src/interfaces/User";
import { loginWithLinkedIn } from "@/src/services/linkedin_auth.service";
import { userFetch, userLogin } from "@/src/services/user.service";
import { router, Stack } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useState } from "react";
import {
ActivityIndicator,
Expand All @@ -27,9 +27,21 @@ export default function LoginForm() {
const [err, setError] = useState("");

const handleLinkedIn = async () => {
await WebBrowser.openBrowserAsync(
`${process.env.EXPO_PUBLIC_API_BASE}/api/auth/linkedin`,
);
setError("");
setLoading(true);
try {
const result = await loginWithLinkedIn();
if (!result) return;
await authenticate(result.user, result.token, false);
router.push("/JoinEventPage");
} catch (err) {
console.log("LinkedIn login failed:", err);
setError(
(err as Error).message || "LinkedIn login failed. Please try again.",
);
} finally {
setLoading(false);
}
};

const handleLogin = async () => {
Expand Down
20 changes: 16 additions & 4 deletions shatter-mobile/src/components/login-signup/SignupForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//called by Profile.tsx for signing up
import { User } from "@/src/interfaces/User";
import { loginWithLinkedIn } from "@/src/services/linkedin_auth.service";
import { userSignup, userUpdate } from "@/src/services/user.service";
import { router, Stack } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import { useState } from "react";
import {
ActivityIndicator,
Expand All @@ -28,9 +28,21 @@ export default function SignUpForm() {
const [err, setError] = useState("");

const handleLinkedIn = async () => {
await WebBrowser.openBrowserAsync(
`${process.env.EXPO_PUBLIC_API_BASE}/api/auth/linkedin`,
);
setError("");
setLoading(true);
try {
const result = await loginWithLinkedIn();
if (!result) return;
await authenticate(result.user, result.token, false);
router.push("/(tabs)/JoinEventPage");
} catch (err) {
console.log("LinkedIn signup failed:", err);
setError(
(err as Error).message || "LinkedIn signup failed. Please try again.",
);
} finally {
setLoading(false);
}
};

const handleSignup = async () => {
Expand Down
40 changes: 40 additions & 0 deletions shatter-mobile/src/services/linkedin_auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as Linking from "expo-linking";
import * as WebBrowser from "expo-web-browser";
import { User } from "../interfaces/User";
import { exchangeLinkedInCode, userFetch } from "./user.service";

const REDIRECT_SCHEME = "shattermobile://auth";

export async function loginWithLinkedIn(): Promise<
{ user: User; token: string } | null
> {
const result = await WebBrowser.openAuthSessionAsync(
`${process.env.EXPO_PUBLIC_API_BASE}/api/auth/linkedin?platform=mobile`,
REDIRECT_SCHEME,
);

if (result.type !== "success") return null;

const { queryParams } = Linking.parse(result.url);
const errorMessage = queryParams?.message as string | undefined;
if (errorMessage) throw new Error(errorMessage);

const code = queryParams?.code as string | undefined;
if (!code) return null;

const { userId, token } = await exchangeLinkedInCode(code);
const userData = await userFetch(userId, token);

const user: User = {
_id: userId,
name: userData.user.name,
email: userData.user.email,
socialLinks: userData.user.socialLinks ?? {},
profilePhoto: userData.user.profilePhoto,
organization: userData.user.organization,
title: userData.user.title,
isGuest: false,
};

return { user, token };
}
Loading