diff --git a/shatter-backend/src/controllers/auth_controller.ts b/shatter-backend/src/controllers/auth_controller.ts index 7aa3c11..d1c3f4c 100644 --- a/shatter-backend/src/controllers/auth_controller.ts +++ b/shatter-backend/src/controllers/auth_controller.ts @@ -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); @@ -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' } ); @@ -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, @@ -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); @@ -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`); } diff --git a/shatter-mobile/app.json b/shatter-mobile/app.json index a10bf72..2b896aa 100644 --- a/shatter-mobile/app.json +++ b/shatter-mobile/app.json @@ -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" + } + } + } } diff --git a/shatter-mobile/assets/images/adaptive-icon.png b/shatter-mobile/assets/images/adaptive-icon.png new file mode 100644 index 0000000..29b49ad Binary files /dev/null and b/shatter-mobile/assets/images/adaptive-icon.png differ diff --git a/shatter-mobile/assets/images/favicon.png b/shatter-mobile/assets/images/favicon.png new file mode 100644 index 0000000..e8d5ce8 Binary files /dev/null and b/shatter-mobile/assets/images/favicon.png differ diff --git a/shatter-mobile/assets/images/icon.png b/shatter-mobile/assets/images/icon.png new file mode 100644 index 0000000..76149bd Binary files /dev/null and b/shatter-mobile/assets/images/icon.png differ diff --git a/shatter-mobile/assets/images/splash-icon.png b/shatter-mobile/assets/images/splash-icon.png new file mode 100644 index 0000000..9da6c74 Binary files /dev/null and b/shatter-mobile/assets/images/splash-icon.png differ diff --git a/shatter-mobile/eas.json b/shatter-mobile/eas.json new file mode 100644 index 0000000..50f1308 --- /dev/null +++ b/shatter-mobile/eas.json @@ -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": {} + } +} diff --git a/shatter-mobile/src/components/context/PusherClient.tsx b/shatter-mobile/src/components/context/PusherClient.tsx index e910ecc..fc51e4c 100644 --- a/shatter-mobile/src/components/context/PusherClient.tsx +++ b/shatter-mobile/src/components/context/PusherClient.tsx @@ -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 = () => { diff --git a/shatter-mobile/src/components/login-signup/LoginForm.tsx b/shatter-mobile/src/components/login-signup/LoginForm.tsx index 9822282..db2b121 100644 --- a/shatter-mobile/src/components/login-signup/LoginForm.tsx +++ b/shatter-mobile/src/components/login-signup/LoginForm.tsx @@ -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, @@ -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 () => { diff --git a/shatter-mobile/src/components/login-signup/SignupForm.tsx b/shatter-mobile/src/components/login-signup/SignupForm.tsx index fe9c5de..f389df7 100644 --- a/shatter-mobile/src/components/login-signup/SignupForm.tsx +++ b/shatter-mobile/src/components/login-signup/SignupForm.tsx @@ -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, @@ -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 () => { diff --git a/shatter-mobile/src/services/linkedin_auth.service.ts b/shatter-mobile/src/services/linkedin_auth.service.ts new file mode 100644 index 0000000..1bb6891 --- /dev/null +++ b/shatter-mobile/src/services/linkedin_auth.service.ts @@ -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 }; +}