-
Notifications
You must be signed in to change notification settings - Fork 23
[jules] enhance: Mobile biometric authentication #278
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -124,12 +124,12 @@ | |
|
|
||
| ### Mobile | ||
|
|
||
| - [ ] **[ux]** Biometric authentication option | ||
| - Files: `mobile/context/AuthContext.js`, add local auth | ||
| - Context: FaceID/TouchID for quick login | ||
| - Impact: Faster, more secure login | ||
| - Size: ~70 lines | ||
| - Added: 2026-01-01 | ||
| - [x] **[ux]** Biometric authentication option | ||
| - Completed: 2026-02-14 | ||
| - Files: `mobile/context/AuthContext.js`, `mobile/screens/LoginScreen.js`, `mobile/screens/AccountScreen.js` | ||
| - Context: Integrated FaceID/TouchID for quick, secure login | ||
| - Impact: Faster login experience while maintaining security | ||
| - Size: ~150 lines | ||
|
Comment on lines
+127
to
+132
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider adding this task to the "✅ Completed Tasks" section for consistency. Other completed tasks in this file follow a pattern of appearing both in their original priority section (marked with
The biometric authentication task should follow the same pattern to maintain documentation consistency. 🤖 Prompt for AI Agents |
||
|
|
||
| --- | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| import AsyncStorage from "@react-native-async-storage/async-storage"; | ||
| import { createContext, useEffect, useState } from "react"; | ||
| import * as authApi from "../api/auth"; | ||
| import * as LocalAuthentication from "expo-local-authentication"; | ||
| import * as SecureStore from "expo-secure-store"; | ||
| import { | ||
| clearAuthTokens, | ||
| setAuthTokens, | ||
|
|
@@ -14,14 +16,26 @@ export const AuthProvider = ({ children }) => { | |
| const [token, setToken] = useState(null); | ||
| const [refresh, setRefresh] = useState(null); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const [isBiometricSupported, setIsBiometricSupported] = useState(false); | ||
| const [isBiometricEnabled, setIsBiometricEnabled] = useState(false); | ||
|
|
||
| // Load token and user data from AsyncStorage on app start | ||
| useEffect(() => { | ||
| const loadStoredAuth = async () => { | ||
| try { | ||
| const storedToken = await AsyncStorage.getItem("auth_token"); | ||
| const storedRefresh = await AsyncStorage.getItem("refresh_token"); | ||
| const storedUser = await AsyncStorage.getItem("user_data"); | ||
| const storedUser = await AsyncStorage.getItem("user_data"); | ||
| const biometricEnabled = await AsyncStorage.getItem("biometric_enabled"); | ||
|
|
||
| if (biometricEnabled === 'true') { | ||
| setIsBiometricEnabled(true); | ||
| } | ||
|
|
||
| // Check biometric support | ||
| const hasHardware = await LocalAuthentication.hasHardwareAsync(); | ||
| const isEnrolled = await LocalAuthentication.isEnrolledAsync(); | ||
| setIsBiometricSupported(hasHardware && isEnrolled); | ||
|
|
||
| if (storedToken && storedUser) { | ||
| setToken(storedToken); | ||
|
|
@@ -123,6 +137,20 @@ export const AuthProvider = ({ children }) => { | |
| ? { ...userData, _id: userData.id } | ||
| : userData; | ||
| setUser(normalizedUser); | ||
|
|
||
| // If biometric is enabled, check if we are logging in as a different user | ||
| if (isBiometricEnabled) { | ||
| const storedUserData = await SecureStore.getItemAsync("secure_user_data"); | ||
| const storedUser = storedUserData ? JSON.parse(storedUserData) : null; | ||
|
|
||
| // If there is a stored user and it doesn't match the new user, disable biometrics | ||
| // to prevent account crossover/confusion. | ||
| if (storedUser && storedUser._id !== normalizedUser._id) { | ||
| await disableBiometrics(); | ||
| } | ||
| // If same user (or no stored user), the useEffect will handle syncing the new tokens | ||
| } | ||
|
|
||
| return true; | ||
| } catch (error) { | ||
| console.error( | ||
|
|
@@ -148,10 +176,19 @@ export const AuthProvider = ({ children }) => { | |
|
|
||
| const logout = async () => { | ||
| try { | ||
| // Clear stored authentication data | ||
| // Clear stored authentication data from AsyncStorage | ||
| await AsyncStorage.removeItem("auth_token"); | ||
| await AsyncStorage.removeItem("refresh_token"); | ||
| await AsyncStorage.removeItem("user_data"); | ||
|
|
||
| // NOTE: We deliberately do NOT clear SecureStore if biometric is enabled | ||
| // This allows "Login with FaceID" to work after logout | ||
| if (!isBiometricEnabled) { | ||
| await SecureStore.deleteItemAsync("secure_auth_token"); | ||
| await SecureStore.deleteItemAsync("secure_refresh_token"); | ||
| await SecureStore.deleteItemAsync("secure_user_data"); | ||
| } | ||
|
|
||
| } catch (error) { | ||
| console.error("Failed to clear stored authentication:", error); | ||
| } | ||
|
|
@@ -162,6 +199,95 @@ export const AuthProvider = ({ children }) => { | |
| await clearAuthTokens(); | ||
| }; | ||
|
|
||
| const enableBiometrics = async () => { | ||
| try { | ||
| const result = await LocalAuthentication.authenticateAsync({ | ||
| promptMessage: "Authenticate to enable biometric login", | ||
| }); | ||
|
|
||
| if (result.success) { | ||
| if (token && refresh && user) { | ||
| await SecureStore.setItemAsync("secure_auth_token", token); | ||
| await SecureStore.setItemAsync("secure_refresh_token", refresh); | ||
| await SecureStore.setItemAsync("secure_user_data", JSON.stringify(user)); | ||
| await AsyncStorage.setItem("biometric_enabled", "true"); | ||
| setIsBiometricEnabled(true); | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } catch (error) { | ||
| console.error("Failed to enable biometrics:", error); | ||
| return false; | ||
| } | ||
| }; | ||
|
|
||
| const disableBiometrics = async () => { | ||
| try { | ||
| await SecureStore.deleteItemAsync("secure_auth_token"); | ||
| await SecureStore.deleteItemAsync("secure_refresh_token"); | ||
| await SecureStore.deleteItemAsync("secure_user_data"); | ||
| await AsyncStorage.setItem("biometric_enabled", "false"); | ||
| setIsBiometricEnabled(false); | ||
| return true; | ||
| } catch (error) { | ||
| console.error("Failed to disable biometrics:", error); | ||
| return false; | ||
| } | ||
| }; | ||
|
|
||
| const loginWithBiometrics = async () => { | ||
| try { | ||
| const result = await LocalAuthentication.authenticateAsync({ | ||
| promptMessage: "Login with biometrics", | ||
| }); | ||
|
|
||
| if (result.success) { | ||
| const storedToken = await SecureStore.getItemAsync("secure_auth_token"); | ||
| const storedRefresh = await SecureStore.getItemAsync("secure_refresh_token"); | ||
| const storedUser = await SecureStore.getItemAsync("secure_user_data"); | ||
|
|
||
| if (storedToken && storedUser) { | ||
| setToken(storedToken); | ||
| setRefresh(storedRefresh); | ||
| await setAuthTokens({ | ||
| newAccessToken: storedToken, | ||
| newRefreshToken: storedRefresh || storedToken, // Fallback if refresh missing | ||
| }); | ||
|
|
||
| const parsed = JSON.parse(storedUser); | ||
| const normalized = parsed?._id | ||
| ? parsed | ||
| : parsed?.id | ||
| ? { ...parsed, _id: parsed.id } | ||
| : parsed; | ||
| setUser(normalized); | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } catch (error) { | ||
| console.error("Biometric login failed:", error); | ||
| return false; | ||
| } | ||
| }; | ||
|
Comment on lines
+239
to
+273
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: access token used as refresh token fallback on Line 255. newRefreshToken: storedRefresh || storedToken, // Fallback if refresh missingIf Additionally, Proposed fix if (storedToken && storedUser) {
setToken(storedToken);
setRefresh(storedRefresh);
await setAuthTokens({
newAccessToken: storedToken,
- newRefreshToken: storedRefresh || storedToken, // Fallback if refresh missing
+ newRefreshToken: storedRefresh,
});
+ // Attempt a token refresh to ensure credentials are still valid
+ if (storedRefresh) {
+ try {
+ const refreshResponse = await authApi.refresh(storedRefresh);
+ const { access_token, refresh_token } = refreshResponse.data;
+ setToken(access_token);
+ setRefresh(refresh_token);
+ await setAuthTokens({
+ newAccessToken: access_token,
+ newRefreshToken: refresh_token,
+ });
+ } catch (refreshError) {
+ // Refresh failed — tokens are stale, force re-login
+ console.error("Stored tokens expired:", refreshError);
+ await disableBiometrics();
+ return false;
+ }
+ } else {
+ // No refresh token stored — cannot guarantee session validity
+ await disableBiometrics();
+ return false;
+ }
+
const parsed = JSON.parse(storedUser);🤖 Prompt for AI Agents |
||
|
|
||
| // Sync tokens and user data to SecureStore whenever they change, if biometrics is enabled | ||
| useEffect(() => { | ||
| const syncSecureStore = async () => { | ||
| if (isBiometricEnabled && token && refresh && user) { | ||
| try { | ||
| await SecureStore.setItemAsync("secure_auth_token", token); | ||
| await SecureStore.setItemAsync("secure_refresh_token", refresh); | ||
| await SecureStore.setItemAsync("secure_user_data", JSON.stringify(user)); | ||
| } catch (error) { | ||
| console.error("Failed to sync secure store:", error); | ||
| } | ||
| } | ||
| }; | ||
| syncSecureStore(); | ||
| }, [token, refresh, user, isBiometricEnabled]); | ||
|
|
||
| const updateUserInContext = (updatedUser) => { | ||
| // Normalize on updates too | ||
| const normalizedUser = updatedUser?._id | ||
|
|
@@ -178,10 +304,15 @@ export const AuthProvider = ({ children }) => { | |
| user, | ||
| token, | ||
| isLoading, | ||
| isBiometricSupported, | ||
| isBiometricEnabled, | ||
| login, | ||
| signup, | ||
| logout, | ||
| updateUserInContext, | ||
| enableBiometrics, | ||
| disableBiometrics, | ||
| loginWithBiometrics | ||
| }} | ||
| > | ||
| {children} | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,6 +18,8 @@ | |||||||||
| "expo": "^54.0.25", | ||||||||||
| "expo-haptics": "~15.0.8", | ||||||||||
| "expo-image-picker": "~17.0.8", | ||||||||||
| "expo-local-authentication": "^17.0.8", | ||||||||||
| "expo-secure-store": "^15.0.8", | ||||||||||
|
Comment on lines
+21
to
+22
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Inconsistent version range operators for Expo packages. The existing Expo packages ( Proposed fix: align with existing convention- "expo-local-authentication": "^17.0.8",
- "expo-secure-store": "^15.0.8",
+ "expo-local-authentication": "~17.0.8",
+ "expo-secure-store": "~15.0.8",📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| "expo-status-bar": "~3.0.8", | ||||||||||
| "react": "19.1.0", | ||||||||||
| "react-dom": "19.1.0", | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,11 +1,18 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useContext } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Alert, StyleSheet, View } from "react-native"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Appbar, Avatar, Divider, List, Text } from "react-native-paper"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Appbar, Avatar, Divider, List, Switch, Text } from "react-native-paper"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { HapticListItem } from '../components/ui/HapticList'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { AuthContext } from "../context/AuthContext"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const AccountScreen = ({ navigation }) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { user, logout } = useContext(AuthContext); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logout, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isBiometricSupported, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isBiometricEnabled, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| enableBiometrics, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| disableBiometrics | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } = useContext(AuthContext); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleLogout = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logout(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -15,6 +22,20 @@ const AccountScreen = ({ navigation }) => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Alert.alert("Coming Soon", "This feature is not yet implemented."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const toggleBiometric = async (value) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (value) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const success = await enableBiometrics(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!success) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Alert.alert("Error", "Failed to enable biometric authentication."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const success = await disableBiometrics(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!success) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Alert.alert("Error", "Failed to disable biometric authentication."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <View style={styles.container}> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Appbar.Header> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -52,6 +73,28 @@ const AccountScreen = ({ navigation }) => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| accessibilityRole="button" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Divider /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {isBiometricSupported && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <HapticListItem | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| title="Biometric Login" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description="Use FaceID/TouchID to login" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| left={() => <List.Icon icon="face-recognition" />} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| right={() => ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Switch | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value={isBiometricEnabled} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onValueChange={toggleBiometric} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onPress={() => toggleBiometric(!isBiometricEnabled)} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| accessibilityLabel="Enable Biometric Login" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| accessibilityRole="switch" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| accessibilityState={{ checked: isBiometricEnabled }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <Divider /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+77
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential double-toggle: When the user taps the Switch area, React Native Paper's Consider removing the Proposed fix: remove redundant onPress <HapticListItem
title="Biometric Login"
description="Use FaceID/TouchID to login"
left={() => <List.Icon icon="face-recognition" />}
right={() => (
<Switch
value={isBiometricEnabled}
onValueChange={toggleBiometric}
/>
)}
- onPress={() => toggleBiometric(!isBiometricEnabled)}
accessibilityLabel="Enable Biometric Login"
accessibilityRole="switch"
accessibilityState={{ checked: isBiometricEnabled }}
/>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <HapticListItem | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| title="Send Feedback" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| left={() => <List.Icon icon="message-alert-outline" />} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Completion date is in the future relative to PR creation.
The task shows a completion date of 2026-02-14, but this PR was created on 2026-02-09. This creates a 5-day discrepancy. Either the completion date should reflect when the work was actually finished (likely on or before 2026-02-09), or the task should not yet be marked as complete.
📅 Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents