Skip to content
Merged
57 changes: 15 additions & 42 deletions shatter-backend/src/controllers/auth_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,8 @@ 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, platform }, JWT_SECRET, { expiresIn: '5m' });
const stateToken = jwt.sign({ state }, JWT_SECRET, { expiresIn: '5m' });

// Build LinkedIn authorization URL and redirect
const authUrl = getLinkedInAuthUrl(stateToken);
Expand Down Expand Up @@ -243,11 +241,9 @@ 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(), platform },
{ linking: true, userId: user._id.toString() },
JWT_SECRET,
{ expiresIn: '5m' }
);
Expand All @@ -273,18 +269,22 @@ export const linkedinCallback = async (req: Request, res: Response) => {
error?: string;
};

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

// 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' });
// Handle user denial
if (oauthError === 'user_cancelled_authorize') {
return res.redirect(`${frontendUrl}/auth/error?message=Authorization cancelled`);
}

let statePayload: { linking?: boolean; userId?: string; platform?: 'mobile' | 'web' };
// 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 };
try {
statePayload = jwt.verify(state, JWT_SECRET) as { linking?: boolean; userId?: string; platform?: 'mobile' | 'web' };
statePayload = jwt.verify(state, JWT_SECRET) as { linking?: boolean; userId?: string };
} catch (e: any) {
console.error('LinkedIn state verify failed:', {
name: e?.name,
Expand All @@ -294,21 +294,6 @@ 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 @@ -419,19 +404,7 @@ export const linkedinCallback = async (req: Request, res: Response) => {
} catch (error: any) {
console.error('LinkedIn callback error:', error);

// 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
}

const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:19006';
if (error.message?.includes('LinkedIn')) {
return res.redirect(`${frontendUrl}/auth/error?message=LinkedIn authentication failed`);
}
Expand Down
121 changes: 56 additions & 65 deletions shatter-mobile/app.json
Original file line number Diff line number Diff line change
@@ -1,67 +1,58 @@
{
"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"
}
}
}
"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
}
}
}
14 changes: 8 additions & 6 deletions shatter-mobile/app/(tabs)/JoinEventPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { useState } from "react";
import {
ActivityIndicator,
ImageBackground,
KeyboardAvoidingView,
Platform,
Text,
TextInput,
TouchableOpacity,
View,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAuth } from "../../src/components/context/AuthContext";
Expand Down Expand Up @@ -51,17 +51,19 @@ export default function JoinEventPage() {
style={styles.background}
resizeMode="cover"
>
<SafeAreaView style={styles.safe}>
<SafeAreaView style={styles.safe} edges={["top"]}>
<KeyboardAvoidingView
style={{ flex: 1 }}
style={{ flex: 1, width: "100%" }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={80}
>
<View style={styles.header}>
<Text style={styles.pageTitle}>Start Shattering</Text>
<Text style={styles.subtitleName}>
Hey {user?.name || "there"},
</Text>
<Text style={styles.subtitle}>
Hey {user?.name || "there"}, Ready to Start Shattering Some
Boundaries?
Ready to Start Shattering Some Boundaries?
</Text>
</View>

Expand Down
13 changes: 8 additions & 5 deletions shatter-mobile/app/auth/callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ActivityIndicator, Text, View } from "react-native";

export default function AuthCallback() {
const { code } = useLocalSearchParams<{ code: string }>();
const { authenticate, authStorage } = useAuth();
const { authenticate } = useAuth();
const [error, setError] = useState("");

useEffect(() => {
Expand All @@ -28,21 +28,24 @@ export default function AuthCallback() {
// Fetch full user profile using returned userId + token
const userData = await userFetch(response.userId, response.token);

const existingSocialLinks = userData.user.socialLinks ?? {};
const linkedinUrl = existingSocialLinks.linkedin
?? `https://www.linkedin.com/in/${userData.user.name.toLowerCase().replace(/\s+/g, "-")}/`;

const user: User = {
_id: response.userId,
name: userData.user.name,
email: userData.user.email,
socialLinks: userData.user.socialLinks ?? {},
socialLinks: { ...existingSocialLinks, linkedin: linkedinUrl },
profilePhoto: userData.user.profilePhoto,
isGuest: false,
};

// Store user + JWT in auth context
await authenticate(user, response.token, false);

// Update stored user with LinkedIn data
const token = authStorage.accessToken;
userUpdate(response.userId, user, token);
// Persist LinkedIn URL to backend using the token from the exchange response
userUpdate(response.userId, user, response.token);
router.replace("/JoinEventPage");
} catch (err) {
setError((err as Error).message || "Authentication failed.");
Expand Down
Binary file removed shatter-mobile/assets/images/favicon.png
Binary file not shown.
21 changes: 0 additions & 21 deletions shatter-mobile/eas.json

This file was deleted.

Loading
Loading