From 1587e3309933082a6b743514396ccf53c9308c8d Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 14 May 2026 08:46:43 +0100 Subject: [PATCH 1/7] fix(auth): restore missing Apple logo on apple sign-in button --- apps/expo/app/auth/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/app/auth/index.tsx b/apps/expo/app/auth/index.tsx index 9ef7e948d5..82dbff5f14 100644 --- a/apps/expo/app/auth/index.tsx +++ b/apps/expo/app/auth/index.tsx @@ -119,7 +119,7 @@ export default function AuthIndexScreen() { size={Platform.select({ ios: 'lg', default: 'md' })} onPress={signInWithApple} > - + {''} {t('auth.continueWithApple')} )} From b9e0862985ffd09405be0fb9b5c317c7479717f2 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 14 May 2026 10:00:51 +0100 Subject: [PATCH 2/7] fix(auth): remove setNativeProps call on OTP field focus --- apps/expo/app/auth/one-time-password.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/expo/app/auth/one-time-password.tsx b/apps/expo/app/auth/one-time-password.tsx index 7702a0cedc..57222fc452 100644 --- a/apps/expo/app/auth/one-time-password.tsx +++ b/apps/expo/app/auth/one-time-password.tsx @@ -15,7 +15,6 @@ import { type NativeSyntheticEvent, Platform, Pressable, - type TargetedEvent, type TextInput, type TextInputKeyPressEventData, View, @@ -27,7 +26,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; const LOGO_SOURCE = require('expo-app/assets/packrat-app-icon-gradient.png'); const COUNTDOWN_SECONDS_TO_RESEND_CODE = 60; -const NUM_OF_CODE_CHARACTERS = 5; +const NUM_OF_CODE_CHARACTERS = 6; const SCREEN_OPTIONS = { headerBackTitle: 'Back', headerTransparent: true, @@ -266,12 +265,6 @@ function OTPField({ } } - function onFocus(_e: NativeSyntheticEvent) { - inputRef.current?.setNativeProps({ - selection: { start: 0, end: value?.toString().length }, - }); - } - function onChangeText(text: string) { setCodeValues((prev) => { const values = [...prev]; @@ -311,7 +304,6 @@ ios:border ios:border-border ios:rounded-lg " clearButtonMode="never" materialHideActionIcons materialRingColor={hasError ? colors.destructive : undefined} - onFocus={onFocus} onKeyPress={onKeyPress} onChangeText={onChangeText} onSubmitEditing={ From a0e210f1fe76ea1b78d9d2f90099469ca5b676e0 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 14 May 2026 10:05:08 +0100 Subject: [PATCH 3/7] feat(auth): replace Better Auth password reset with custom 6-digit OTP flow - Add passwordResetService: generates OTP, stores in verification table (15-min TTL), sends via Resend, verifies with timing-safe compare, updates credential account password (falls back to users.passwordHash for legacy users) - Add POST /api/password-reset/request and /verify routes - Wire forgotPassword/resetPassword in useAuthActions to custom endpoints - Update OTP screen from 5 to 6 characters (NIST SP 800-63B) - Update ResetPasswordRequestSchema code length to 6 --- .../features/auth/hooks/useAuthActions.ts | 27 ++++-- packages/api/src/routes/index.ts | 2 + packages/api/src/routes/passwordReset.ts | 45 ++++++++++ packages/api/src/schemas/auth.ts | 2 +- .../api/src/services/passwordResetService.ts | 82 +++++++++++++++++++ 5 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 packages/api/src/routes/passwordReset.ts create mode 100644 packages/api/src/services/passwordResetService.ts diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index e908830db5..c80857b729 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -1,3 +1,4 @@ +import { clientEnvs } from '@packrat/env/expo-client'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin, @@ -187,19 +188,27 @@ export function useAuthActions() { }; const forgotPassword = async (email: string) => { - const { error } = await authClient.requestPasswordReset({ - email, - redirectTo: 'packrat://reset-password', + const res = await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/password-reset/request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), }); - if (error) throw new Error(error.message ?? 'Forgot password failed'); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? 'Forgot password failed'); + } }; - const resetPassword = async (_email: string, opts: { token: string; newPassword: string }) => { - const { error } = await authClient.resetPassword({ - token: opts.token, - newPassword: opts.newPassword, + const resetPassword = async (email: string, opts: { token: string; newPassword: string }) => { + const res = await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/password-reset/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, code: opts.token, newPassword: opts.newPassword }), }); - if (error) throw new Error(error.message ?? 'Reset password failed'); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? 'Reset password failed'); + } }; const verifyEmail = async (_email: string, token: string) => { diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index db72d1d13f..ed9ffdac47 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -9,6 +9,7 @@ import { guidesRoutes } from './guides'; import { knowledgeBaseRoutes } from './knowledgeBase'; import { packsRoutes } from './packs'; import { packTemplatesRoutes } from './packTemplates'; +import { passwordResetRoutes } from './passwordReset'; import { seasonSuggestionsRoutes } from './seasonSuggestions'; import { trailConditionsRoutes } from './trailConditions'; import { trailsRoutes } from './trails'; @@ -35,6 +36,7 @@ export const routes = new Elysia({ prefix: '/api' }) .use(weatherRoutes) .use(packTemplatesRoutes) .use(seasonSuggestionsRoutes) + .use(passwordResetRoutes) .use(userRoutes) .use(uploadRoutes) .use(trailConditionsRoutes) diff --git a/packages/api/src/routes/passwordReset.ts b/packages/api/src/routes/passwordReset.ts new file mode 100644 index 0000000000..7eb5a09b69 --- /dev/null +++ b/packages/api/src/routes/passwordReset.ts @@ -0,0 +1,45 @@ +import { ForgotPasswordRequestSchema, ResetPasswordRequestSchema } from '@packrat/api/schemas/auth'; +import { + requestPasswordReset, + verifyOtpAndResetPassword, +} from '@packrat/api/services/passwordResetService'; +import { Elysia, status } from 'elysia'; + +export const passwordResetRoutes = new Elysia({ prefix: '/password-reset' }) + .post( + '/request', + async ({ body }) => { + await requestPasswordReset(body.email); + return { success: true, message: 'If an account exists, a reset code has been sent.' }; + }, + { + body: ForgotPasswordRequestSchema, + detail: { + tags: ['Auth'], + summary: 'Request password reset', + description: + 'Send a 6-digit OTP to the user email. Always returns success to prevent email enumeration.', + }, + }, + ) + .post( + '/verify', + async ({ body }) => { + try { + await verifyOtpAndResetPassword(body); + return { success: true, message: 'Password reset successfully.' }; + } catch (error) { + return status(400, { + error: error instanceof Error ? error.message : 'Password reset failed', + }); + } + }, + { + body: ResetPasswordRequestSchema, + detail: { + tags: ['Auth'], + summary: 'Verify OTP and reset password', + description: 'Validate the 6-digit OTP and set a new password.', + }, + }, + ); diff --git a/packages/api/src/schemas/auth.ts b/packages/api/src/schemas/auth.ts index b6784b07fb..69fb82e5c8 100644 --- a/packages/api/src/schemas/auth.ts +++ b/packages/api/src/schemas/auth.ts @@ -79,7 +79,7 @@ export const ForgotPasswordResponseSchema = z.object({ export const ResetPasswordRequestSchema = z.object({ email: z.string().email(), - code: z.string().length(5), + code: z.string().length(6), newPassword: z.string().min(8), }); diff --git a/packages/api/src/services/passwordResetService.ts b/packages/api/src/services/passwordResetService.ts new file mode 100644 index 0000000000..1b235957ca --- /dev/null +++ b/packages/api/src/services/passwordResetService.ts @@ -0,0 +1,82 @@ +import { hashPassword } from '@better-auth/utils/password'; +import { createDb } from '@packrat/api/db'; +import { account, users, verification } from '@packrat/api/db/schema'; +import { timingSafeEqual } from '@packrat/api/utils/auth'; +import { sendPasswordResetEmail } from '@packrat/api/utils/email'; +import { and, eq, gt } from 'drizzle-orm'; + +const OTP_LENGTH = 6; +const OTP_TTL_MS = 15 * 60 * 1000; // 15 minutes +const IDENTIFIER_PREFIX = 'password-reset:'; + +function generateOtp(): string { + return Array.from({ length: OTP_LENGTH }, () => Math.floor(Math.random() * 10)).join(''); +} + +export async function requestPasswordReset(email: string): Promise { + const db = createDb(); + + const user = await db.query.users.findFirst({ where: eq(users.email, email) }); + if (!user) return; // Don't reveal whether the email is registered + + const code = generateOtp(); + const identifier = `${IDENTIFIER_PREFIX}${email}`; + const now = new Date(); + const expiresAt = new Date(now.getTime() + OTP_TTL_MS); + + await db.delete(verification).where(eq(verification.identifier, identifier)); + await db.insert(verification).values({ + id: crypto.randomUUID(), + identifier, + value: code, + expiresAt, + createdAt: now, + updatedAt: now, + }); + + await sendPasswordResetEmail({ to: email, code }); +} + +export async function verifyOtpAndResetPassword({ + email, + code, + newPassword, +}: { + email: string; + code: string; + newPassword: string; +}): Promise { + const db = createDb(); + const identifier = `${IDENTIFIER_PREFIX}${email}`; + + const record = await db.query.verification.findFirst({ + where: and(eq(verification.identifier, identifier), gt(verification.expiresAt, new Date())), + }); + + if (!record || !timingSafeEqual(record.value, code)) { + throw new Error('Invalid or expired reset code'); + } + + const user = await db.query.users.findFirst({ where: eq(users.email, email) }); + if (!user) throw new Error('User not found'); + + const hashedPassword = await hashPassword(newPassword); + const now = new Date(); + + // Update the credential account record (Better Auth email/password users) + const updated = await db + .update(account) + .set({ password: hashedPassword, updatedAt: now }) + .where(and(eq(account.userId, user.id), eq(account.providerId, 'credential'))) + .returning({ id: account.id }); + + // Fallback for legacy users whose password lives on the users row + if (updated.length === 0) { + await db + .update(users) + .set({ passwordHash: hashedPassword, updatedAt: now }) + .where(eq(users.id, user.id)); + } + + await db.delete(verification).where(eq(verification.identifier, identifier)); +} From e1edb96de42be1ce93d873f4c270926717139985 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 14 May 2026 10:11:19 +0100 Subject: [PATCH 4/7] fix(auth): annotate password reset routes as public --- packages/api/src/routes/passwordReset.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/api/src/routes/passwordReset.ts b/packages/api/src/routes/passwordReset.ts index 7eb5a09b69..3144533cb2 100644 --- a/packages/api/src/routes/passwordReset.ts +++ b/packages/api/src/routes/passwordReset.ts @@ -6,6 +6,7 @@ import { import { Elysia, status } from 'elysia'; export const passwordResetRoutes = new Elysia({ prefix: '/password-reset' }) + // public-route: unauthenticated users need this to initiate a password reset .post( '/request', async ({ body }) => { @@ -22,6 +23,7 @@ export const passwordResetRoutes = new Elysia({ prefix: '/password-reset' }) }, }, ) + // public-route: unauthenticated users need this to verify OTP and set a new password .post( '/verify', async ({ body }) => { From 19b85f79cfc10a31b50217974fd4ad3beccc0a5b Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 14 May 2026 15:32:18 +0100 Subject: [PATCH 5/7] fix(auth): read session token from SecureStore to stop spurious reauth banner getAccessToken now reads the expoClient cookie JSON from SecureStore directly instead of calling authClient.getSession() on every API request, eliminating concurrent network calls from Legend State polling intervals. onNeedsReauth now verifies the session is genuinely gone before setting needsReauthAtom, so transient 401s no longer trigger the sync-paused banner. --- apps/expo/lib/api/packrat.ts | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/expo/lib/api/packrat.ts b/apps/expo/lib/api/packrat.ts index 05dac97f7a..24313908d3 100644 --- a/apps/expo/lib/api/packrat.ts +++ b/apps/expo/lib/api/packrat.ts @@ -3,18 +3,46 @@ import { clientEnvs } from '@packrat/env/expo-client'; import { store } from 'expo-app/atoms/store'; import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { authClient } from 'expo-app/lib/auth-client'; +import * as SecureStore from 'expo-secure-store'; + +// The expoClient plugin serialises all cookies into SecureStore under this key. +// Parsing it locally avoids a network round-trip on every API request. +const COOKIE_STORE_KEY = 'packrat_cookie'; + +// expoClient stores cookies as JSON: { "better-auth.session_token": { value, expires } } +function parseSessionToken(cookieJson: string | null): string | null { + console.log('[auth] Parsing session token from cookie string:', cookieJson); + if (!cookieJson) return null; + try { + const cookies = JSON.parse(cookieJson) as Record; + console.log( + '[auth] Parsed session token from cookie string:', + cookies['better-auth.session_token']?.value ?? 'null', + ); + return cookies['better-auth.session_token']?.value ?? null; + } catch (err) { + console.warn('[auth] Failed to parse session token from cookie string:', err); + return null; + } +} export const apiClient = createApiClient({ baseUrl: clientEnvs.EXPO_PUBLIC_API_URL, auth: { + // Read the token from SecureStore — no network call on every API request. getAccessToken: async () => { - const { data } = await authClient.getSession(); - return data?.session?.token ?? null; + const cookieStr = await SecureStore.getItemAsync(COOKIE_STORE_KEY); + return parseSessionToken(cookieStr); }, - // Better Auth manages session renewal internally — no separate refresh token flow. + // Better Auth has no separate refresh-token endpoint; the 7-day session + // token is the only credential. Returning null here is intentional. getRefreshToken: () => null, onAccessTokenRefreshed: () => {}, - onNeedsReauth: () => { + onNeedsReauth: async () => { + // A 401 can be transient (e.g. the server briefly returned an error). + // Verify the session is actually gone before alarming the user. + const { data } = await authClient.getSession(); + if (data?.session) return; store.set(needsReauthAtom, true); }, }, From 420f2ebbd5738d25ae6f18dde80e49501d950a8c Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 14 May 2026 18:51:42 +0100 Subject: [PATCH 6/7] chore(expo/lib/api/packrat): improve typing --- apps/expo/lib/api/packrat.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/expo/lib/api/packrat.ts b/apps/expo/lib/api/packrat.ts index 24313908d3..3e616f0146 100644 --- a/apps/expo/lib/api/packrat.ts +++ b/apps/expo/lib/api/packrat.ts @@ -1,25 +1,29 @@ import { createApiClient } from '@packrat/api-client'; import { clientEnvs } from '@packrat/env/expo-client'; +import { fromZod } from '@packrat/guards'; import { store } from 'expo-app/atoms/store'; import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { authClient } from 'expo-app/lib/auth-client'; import * as SecureStore from 'expo-secure-store'; +import { z } from 'zod'; // The expoClient plugin serialises all cookies into SecureStore under this key. // Parsing it locally avoids a network round-trip on every API request. const COOKIE_STORE_KEY = 'packrat_cookie'; +const CookieStoreSchema = z.record(z.object({ value: z.string() })); + // expoClient stores cookies as JSON: { "better-auth.session_token": { value, expires } } function parseSessionToken(cookieJson: string | null): string | null { console.log('[auth] Parsing session token from cookie string:', cookieJson); if (!cookieJson) return null; try { - const cookies = JSON.parse(cookieJson) as Record; + const cookies = fromZod(CookieStoreSchema)(JSON.parse(cookieJson)); console.log( '[auth] Parsed session token from cookie string:', - cookies['better-auth.session_token']?.value ?? 'null', + cookies?.['better-auth.session_token']?.value ?? 'null', ); - return cookies['better-auth.session_token']?.value ?? null; + return cookies?.['better-auth.session_token']?.value ?? null; } catch (err) { console.warn('[auth] Failed to parse session token from cookie string:', err); return null; From cfcad3193cd5a8c7441ccabeb4d7123b23255f98 Mon Sep 17 00:00:00 2001 From: Ibrahim Isa Jajere Date: Thu, 14 May 2026 19:14:26 +0100 Subject: [PATCH 7/7] chore(api/passwordResetService): resolve type issue --- packages/api/src/services/passwordResetService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/services/passwordResetService.ts b/packages/api/src/services/passwordResetService.ts index 1b235957ca..0f7dbe0a03 100644 --- a/packages/api/src/services/passwordResetService.ts +++ b/packages/api/src/services/passwordResetService.ts @@ -68,7 +68,7 @@ export async function verifyOtpAndResetPassword({ .update(account) .set({ password: hashedPassword, updatedAt: now }) .where(and(eq(account.userId, user.id), eq(account.providerId, 'credential'))) - .returning({ id: account.id }); + .returning(); // Fallback for legacy users whose password lives on the users row if (updated.length === 0) {