Conversation
…nd reinstall detection - Add keychainService and AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY to prevent iCloud sync/backup token restoration - Detect app reinstall via AsyncStorage marker and clear stale Keychain tokens - Migrate existing keychain items from old (unscoped) to new (namespaced) keychain
…side token validation - Expand SessionStatus with 'hydrating' and 'checking' intermediate states - Add verifySession() that validates tokens via /api/student/me and refresh flow - Update useLoadAssets to run full hydrate → verify pipeline - Update RootNavigator to show splash during hydrating/checking states
…lows - Add middleware-free openapi-fetch client (bareClient) for bootstrap/auth operations - Migrate postRefreshToken, authMiddleware, and verifyStudentSession from raw fetch to bareClient - Gain type safety from OpenAPI schema while avoiding authMiddleware circular dependency
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR aims to ensure sessions are properly reset on app reinstall (especially iOS Keychain persistence) while strengthening credential storage and adding a bootstrap-time server session verification flow.
Changes:
- Harden SecureStore usage by namespacing Keychain items (
keychainService) and disabling iCloud sync/backup restoration (AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY), plus add reinstall detection. - Expand auth state machine to include
hydrating/checkingand add averifySession()step that validates/api/student/meand refreshes tokens if needed. - Introduce a middleware-free
bareClient(openapi-fetch) and refactor refresh/profile calls to use it.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/native/src/utils/auth.ts | Adds SecureStore options, keychain migration helper, and reinstall detection hook in hydration. |
| apps/native/src/stores/authStore.ts | Adds new session statuses and server-side session verification with refresh-token rotation. |
| apps/native/src/hooks/useLoadAssets.ts | Sequences auth hydration → store hydration → server verification during app bootstrap. |
| apps/native/src/navigation/RootNavigator.tsx | Keeps splash visible during hydrating/checking to prevent entering the app before verification completes. |
| apps/native/src/apis/bareClient.ts | Adds a middleware-free OpenAPI client for bootstrap/auth flows. |
| apps/native/src/apis/controller/student/auth/postRefreshToken.ts | Switches refresh-token call to bareClient. |
| apps/native/src/apis/authMiddleware.ts | Uses bareClient for /me calls (instead of raw fetch/env). |
| apps/native/src/apis/index.ts | Exports bareClient from the APIs barrel. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export const handleReinstallDetection = async () => { | ||
| if (!useSecureStore) return; | ||
|
|
||
| try { | ||
| const marker = await AsyncStorage.getItem(INSTALL_MARKER_KEY); | ||
|
|
||
| if (marker === null) { | ||
| const allKeys = Object.keys(memory) as (keyof AuthMemory)[]; | ||
| await Promise.all( | ||
| allKeys.map(async (key) => { | ||
| const storageKey = buildKey(key); | ||
| try { | ||
| await SecureStore.deleteItemAsync(storageKey, secureStoreOptions); | ||
| await SecureStore.deleteItemAsync(storageKey); | ||
| } catch { | ||
| // noop | ||
| } | ||
| }) | ||
| ); | ||
|
|
||
| await AsyncStorage.setItem(INSTALL_MARKER_KEY, Date.now().toString()); | ||
| } |
There was a problem hiding this comment.
handleReinstallDetection treats a missing AsyncStorage marker as a reinstall and deletes all SecureStore items. On the first app run after introducing this feature (i.e., an app update), existing users will also have no marker, so this will forcibly clear their tokens and also prevents the intended old→new keychain migration from ever running. Consider using a second marker persisted in SecureStore (e.g., an installId stored both in SecureStore and AsyncStorage) to distinguish “upgrade with no marker yet” from a true reinstall, and only clear keychain when SecureStore indicates a prior install but AsyncStorage is missing.
| name?: string; | ||
| grade?: string; |
There was a problem hiding this comment.
verifyStudentSession types name/grade as string, but StudentProfile elsewhere allows string | null and /me responses often model nullable fields. Returning null while typing as string forces downstream truthy checks and can lead to stale profile values. Update the return type to name?: string | null / grade?: string | null (and propagate that through the store) so nullability is handled explicitly.
| name?: string; | |
| grade?: string; | |
| name?: string | null; | |
| grade?: string | null; |
| if (result.name !== undefined || result.grade !== undefined) { | ||
| if (result.name) await setName(result.name); | ||
| if (result.grade) await setGrade(result.grade); | ||
| } |
There was a problem hiding this comment.
In verifySession, if (result.name) ... / if (result.grade) ... will skip persisting valid falsy values (e.g., empty string) and also won’t clear previously stored values when the server returns null. Prefer checking against undefined (and allowing null to be written) so local storage matches the server state.
| if (result.name !== undefined || result.grade !== undefined) { | |
| if (result.name) await setName(result.name); | |
| if (result.grade) await setGrade(result.grade); | |
| } | |
| if (result.name !== undefined) { | |
| await setName(result.name); | |
| } | |
| if (result.grade !== undefined) { | |
| await setGrade(result.grade); | |
| } |
| const { data, error } = await bareClient.POST('/api/student/auth/refresh', { | ||
| body: { | ||
| refreshToken: getRefreshToken() ?? '', |
There was a problem hiding this comment.
When no refresh token exists, this sends refreshToken: '' to the refresh endpoint. That creates an avoidable network call and can make server-side logs/metrics noisy. Consider returning { isSuccess: false } early (or throwing) when getRefreshToken() is null/empty, and keep the request body strictly valid when a token is present.
| const { data, error } = await bareClient.POST('/api/student/auth/refresh', { | |
| body: { | |
| refreshToken: getRefreshToken() ?? '', | |
| const refreshToken = getRefreshToken(); | |
| if (!refreshToken) { | |
| return { isSuccess: false, error: new Error('No refresh token') }; | |
| } | |
| const { data, error } = await bareClient.POST('/api/student/auth/refresh', { | |
| body: { | |
| refreshToken, |
| if (!isTeacher && !getName() && !getGrade()) { | ||
| const result = await fetch(`${env.apiBaseUrl}/api/student/me`, { | ||
| headers: { | ||
| Authorization: `Bearer ${accessToken}`, | ||
| }, | ||
| const { data } = await bareClient.GET('/api/student/me', { | ||
| headers: { Authorization: `Bearer ${accessToken}` }, | ||
| }); |
There was a problem hiding this comment.
bareClient.GET('/api/student/me', ...) is executed even when accessToken is null (e.g., refresh failed and reissueStudentToken() returned null), resulting in an Authorization: Bearer null request. Add a guard like if (accessToken && ...) (or return early when token refresh fails) to avoid the extra call and confusing 401s.
📌 Related Issue Number
✅ Key Changes
src/utils/auth.tssrc/stores/authStore.tssrc/hooks/useLoadAssets.tssrc/navigation/RootNavigator.tsx