Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/expo/app/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export default function AuthIndexScreen() {
size={Platform.select({ ios: 'lg', default: 'md' })}
onPress={signInWithApple}
>
<Text className="ios:text-foreground absolute left-4 text-[22px]"></Text>
<Text className="ios:text-foreground absolute left-4 text-[22px]">{''}</Text>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hide the decorative Apple glyph from accessibility output.

Line 122 adds a private-use glyph that can be read aloud by VoiceOver. Keep it decorative so the button is announced cleanly from the text label.

Suggested fix
-                  <Text className="ios:text-foreground absolute left-4 text-[22px]">{''}</Text>
+                  <Text accessible={false} className="ios:text-foreground absolute left-4 text-[22px]">
+                    {''}
+                  </Text>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Text className="ios:text-foreground absolute left-4 text-[22px]">{''}</Text>
<Text accessible={false} className="ios:text-foreground absolute left-4 text-[22px]">
{''}
</Text>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/app/auth/index.tsx` at line 122, The decorative Apple glyph Text
element is being announced by screen readers; mark that Text as non-accessible
so only the button label is read. In the Text component rendering the glyph (the
Text with content ''), add the accessibility props to hide it from assistive
tech (e.g., accessible={false} and/or accessibilityElementsHidden={true} and
importantForAccessibility="no") so the glyph remains visual-only while the
surrounding button label is announced.

<Text className="ios:text-foreground">{t('auth.continueWithApple')}</Text>
</Button>
)}
Expand Down
10 changes: 1 addition & 9 deletions apps/expo/app/auth/one-time-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
type NativeSyntheticEvent,
Platform,
Pressable,
type TargetedEvent,
type TextInput,
type TextInputKeyPressEventData,
View,
Expand All @@ -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,
Expand Down Expand Up @@ -266,12 +265,6 @@ function OTPField({
}
}

function onFocus(_e: NativeSyntheticEvent<TargetedEvent>) {
inputRef.current?.setNativeProps({
selection: { start: 0, end: value?.toString().length },
});
}

function onChangeText(text: string) {
setCodeValues((prev) => {
const values = [...prev];
Expand Down Expand Up @@ -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={
Expand Down
27 changes: 18 additions & 9 deletions apps/expo/features/auth/hooks/useAuthActions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { clientEnvs } from '@packrat/env/expo-client';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
GoogleSignin,
Expand Down Expand Up @@ -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');
}
};
Comment on lines +191 to 212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the Treaty path for the new password-reset routes (Elysia exposes the prefix as a key).
rg -nP -C2 "prefix:\s*'/password-reset'" packages/api/src/routes
rg -nP -C3 'apiClient\.' apps/expo --type=ts | head -50

Repository: PackRat-AI/PackRat

Length of output: 4537


🏁 Script executed:

# Get the full passwordReset routes structure to understand endpoint paths
cat -n packages/api/src/routes/passwordReset.ts

Repository: PackRat-AI/PackRat

Length of output: 1944


🏁 Script executed:

# Check the useAuthActions.ts file to confirm direct fetch() usage
sed -n '180,220p' apps/expo/features/auth/hooks/useAuthActions.ts | cat -n

Repository: PackRat-AI/PackRat

Length of output: 2164


🏁 Script executed:

# Verify how the apiClient is imported and used in the auth module
grep -n "apiClient" apps/expo/features/auth/hooks/useAuthActions.ts | head -20

Repository: PackRat-AI/PackRat

Length of output: 44


🏁 Script executed:

# Check imports and top of useAuthActions.ts file
head -40 apps/expo/features/auth/hooks/useAuthActions.ts | cat -n

Repository: PackRat-AI/PackRat

Length of output: 1821


🏁 Script executed:

# Search for apiClient definition in the auth feature
grep -rn "apiClient" apps/expo/features/auth/ --type=ts

Repository: PackRat-AI/PackRat

Length of output: 183


🏁 Script executed:

# Search for apiClient in the expo app using rg
rg -n "import.*apiClient" apps/expo/features/ | head -20

Repository: PackRat-AI/PackRat

Length of output: 2277


🏁 Script executed:

# Check if apiClient exists and how it's initialized
rg -n "apiClient\s*=" apps/expo/lib --type ts -A 3 | head -40

Repository: PackRat-AI/PackRat

Length of output: 365


🏁 Script executed:

# Verify passwordReset.ts export and how routes are structured
grep -n "export\|\.post\|\.get" packages/api/src/routes/passwordReset.ts

Repository: PackRat-AI/PackRat

Length of output: 165


🏁 Script executed:

# Check the apiClient definition and what routes it has
cat -n apps/expo/lib/api/packrat.ts | head -60

Repository: PackRat-AI/PackRat

Length of output: 2727


🏁 Script executed:

# Check what Treaty routes are available in the apiClient by looking at the type/implementation
rg -n "password-reset|passwordReset" apps/expo/lib/api/ --type ts -B 2 -A 2

Repository: PackRat-AI/PackRat

Length of output: 44


🏁 Script executed:

# Verify the API schema for password reset
rg -n "ForgotPasswordRequestSchema|ResetPasswordRequestSchema" packages/api/src --type ts -B 1 -A 3

Repository: PackRat-AI/PackRat

Length of output: 1711


🏁 Script executed:

# Check how createApiClient is implemented and what response format it uses
rg -n "createApiClient\|export.*function createApiClient" packages/api-client --type ts -A 10 | head -50

Repository: PackRat-AI/PackRat

Length of output: 44


🏁 Script executed:

# Look at how other features handle apiClient error responses
rg -n "const.*error.*await apiClient" apps/expo/features --type ts -A 2 | head -40

Repository: PackRat-AI/PackRat

Length of output: 3314


🏁 Script executed:

# Check how the apiClient is generated and if password-reset routes are registered
rg -n "password-reset|passwordReset" packages/api-client --type ts -B 3 -A 3

Repository: PackRat-AI/PackRat

Length of output: 44


🏁 Script executed:

# Look at the main API index to see how routes are structured and exported
cat -n packages/api/src/index.ts | head -80

Repository: PackRat-AI/PackRat

Length of output: 3517


🏁 Script executed:

# Verify the type of apiClient to see available routes
rg -n "typeof.*apiClient|type.*Api.*=" apps/expo/lib/api --type ts -B 2 -A 5

Repository: PackRat-AI/PackRat

Length of output: 206


🏁 Script executed:

# Check the routes index to see if passwordResetRoutes are exported
cat -n packages/api/src/routes/index.ts

Repository: PackRat-AI/PackRat

Length of output: 1966


🏁 Script executed:

# Look at how routes are structured and composed
rg -n "export.*routes\|passwordResetRoutes" packages/api/src/routes --type ts

Repository: PackRat-AI/PackRat

Length of output: 44


Use the typed API client, not raw fetch.

The Expo app's coding guidelines explicitly forbid direct fetch() calls and require the Eden Treaty client. Both forgotPassword and resetPassword must use apiClient (imported from 'expo-app/lib/api/packrat') so they inherit the typed contract, base-URL handling, and standardized { data, error, status } response shape.

Update the functions to:

const { error } = await apiClient['password-reset'].request.post({ email });
if (error) throw new Error((error.value as { error?: string })?.error ?? 'Forgot password failed');
const { error } = await apiClient['password-reset'].verify.post({
  email,
  code: opts.token,
  newPassword: opts.newPassword,
});
if (error) throw new Error((error.value as { error?: string })?.error ?? 'Reset password failed');

This also fixes a latent bug in the current code: await res.json() will throw on non-JSON error responses (proxy HTML, empty body, network appliance errors), masking the real failure with a SyntaxError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/features/auth/hooks/useAuthActions.ts` around lines 191 - 212,
Replace the raw fetch usage in forgotPassword and resetPassword with the typed
apiClient from 'expo-app/lib/api/packrat'; specifically call
apiClient['password-reset'].request.post({ email }) in forgotPassword and
apiClient['password-reset'].verify.post({ email, code: opts.token, newPassword:
opts.newPassword }) in resetPassword, then inspect the returned { error } and
throw new Error((error.value as { error?: string })?.error ?? 'Forgot password
failed') or 'Reset password failed' as appropriate — remove the await res.json()
parsing and any direct fetch/URL usage so the functions use the apiClient
contract and avoid JSON parse errors on non-JSON responses.


const verifyEmail = async (_email: string, token: string) => {
Expand Down
40 changes: 36 additions & 4 deletions apps/expo/lib/api/packrat.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,52 @@
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 = fromZod(CookieStoreSchema)(JSON.parse(cookieJson));
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);
Comment on lines +18 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Remove session-token logging — this is a secret leak.

console.log(...) of the raw cookie blob and the parsed better-auth.session_token.value writes the live session credential to device logs, Metro, and any crash/analytics tooling that captures console. Anyone with that log stream can replay the token and impersonate the user for 7 days. The console.warn on parse failure is fine; the other two must go before this ships.

🔒 Proposed fix
 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<string, { value: string }>;
-    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;
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log('[auth] Parsing session token from cookie string:', cookieJson);
if (!cookieJson) return null;
try {
const cookies = JSON.parse(cookieJson) as Record<string, { value: string }>;
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);
function parseSessionToken(cookieJson: string | null): string | null {
if (!cookieJson) return null;
try {
const cookies = JSON.parse(cookieJson) as Record<string, { value: string }>;
return cookies['better-auth.session_token']?.value ?? null;
} catch (err) {
console.warn('[auth] Failed to parse session token from cookie string:', err);
return null;
}
}
🧰 Tools
🪛 GitHub Actions: Checks / 0_checks.txt

[error] 17-17: bun check:casts:strict failed. Unsafe type cast(s) found. Replace 'JSON.parse(cookieJson) as Record<string, { value: string }>' with guards from @packrat/guards.

🪛 GitHub Actions: Checks / checks

[error] 17-17: bun check:casts:strict failed. Found 1 unsafe type cast(s). Replace casts with guards from @packrat/guards. Unsafe cast: "JSON.parse(cookieJson) as Record<string, { value: string }>".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/lib/api/packrat.ts` around lines 14 - 24, Remove the sensitive
session-token logging: eliminate the console.log calls that print cookieJson and
the parsed cookies['better-auth.session_token'] value inside the function in
packrat.ts so the raw cookie blob and session credential are not written to
logs; keep the existing try/catch flow and the console.warn on parse failure,
but do not log cookieJson, the cookies object, or
cookies['better-auth.session_token'] anywhere (retain cookieJson parsing logic
into cookies and return cookies['better-auth.session_token']?.value as before).

return null;
}
}
Comment on lines +17 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Respect the cookie's expires field.

The stored payload includes { value, expires }, but parseSessionToken only reads value. An expired cookie will still be returned and sent to the server, which then 401s and triggers the onNeedsReauth round-trip — exactly the network call the rest of this refactor is trying to avoid. Cheap to fix at parse time.

♻️ Proposed fix
-    const cookies = JSON.parse(cookieJson) as Record<string, { value: string }>;
-    return cookies['better-auth.session_token']?.value ?? null;
+    const cookies = JSON.parse(cookieJson) as Record<string, { value: string; expires?: string }>;
+    const entry = cookies['better-auth.session_token'];
+    if (!entry) return null;
+    if (entry.expires && Date.parse(entry.expires) <= Date.now()) return null;
+    return entry.value ?? null;

(Once you switch to a @packrat/guards validator per the CI failure above, fold the same check into the validated shape.)

🧰 Tools
🪛 GitHub Actions: Checks / 0_checks.txt

[error] 17-17: bun check:casts:strict failed. Unsafe type cast(s) found. Replace 'JSON.parse(cookieJson) as Record<string, { value: string }>' with guards from @packrat/guards.

🪛 GitHub Actions: Checks / checks

[error] 17-17: bun check:casts:strict failed. Found 1 unsafe type cast(s). Replace casts with guards from @packrat/guards. Unsafe cast: "JSON.parse(cookieJson) as Record<string, { value: string }>".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/expo/lib/api/packrat.ts` around lines 13 - 27, parseSessionToken
currently returns the cookie.value without checking cookie.expires, so expired
tokens get sent; update parseSessionToken to inspect the parsed cookie object
for the 'better-auth.session_token' entry and if it has an expires field, parse
that (accepting ISO string or numeric timestamp), compare to Date.now(), and
return null when expired; otherwise return the value as before; reference the
parseSessionToken function and the cookie shape { value, expires } (and later
fold the same check into the `@packrat/guards` validator as mentioned).


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);
},
},
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions packages/api/src/routes/passwordReset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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' })
// public-route: unauthenticated users need this to initiate a 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.',
},
},
)
// public-route: unauthenticated users need this to verify OTP and set a new password
.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',
});
}
Comment on lines +29 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return a generic error message on verify failure.

Reflecting error.message straight to the client works, but it leaks branchable signals: "Invalid or expired reset code" vs "User not found" vs whatever a future contributor throws. Since both paths are reachable only with a valid OTP (so the user-enumeration risk is small), this is minor — but a constant string keeps the surface stable and unambiguous.

🔒 Proposed fix
-      } catch (error) {
-        return status(400, {
-          error: error instanceof Error ? error.message : 'Password reset failed',
-        });
+      } catch (error) {
+        console.error('[password-reset] verify failed:', error);
+        return status(400, { error: 'Invalid or expired reset code' });
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/routes/passwordReset.ts` around lines 29 - 37, The handler
currently returns error.message to the client on verifyOtpAndResetPassword
failure which can leak differing failure signals; change the catch block to
always return a fixed generic message (e.g., "Password reset failed") via the
status(...) response instead of using error.message, keeping the status(400, {
error: ... }) shape and leaving verifyOtpAndResetPassword and status usage
unchanged.

},
{
body: ResetPasswordRequestSchema,
detail: {
tags: ['Auth'],
summary: 'Verify OTP and reset password',
description: 'Validate the 6-digit OTP and set a new password.',
},
},
);
2 changes: 1 addition & 1 deletion packages/api/src/schemas/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand Down
82 changes: 82 additions & 0 deletions packages/api/src/services/passwordResetService.ts
Original file line number Diff line number Diff line change
@@ -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('');
}
Comment on lines +12 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Use a cryptographically secure RNG for the OTP.

Math.random() is not cryptographically secure — its output is predictable and unsuitable for security tokens. With a 1,000,000-code space and a 15-minute window, a determined attacker doesn't need much luck either way, but the RNG should at minimum be unguessable.

🔒 Proposed fix using crypto.getRandomValues
 function generateOtp(): string {
-  return Array.from({ length: OTP_LENGTH }, () => Math.floor(Math.random() * 10)).join('');
+  const bytes = new Uint8Array(OTP_LENGTH);
+  crypto.getRandomValues(bytes);
+  return Array.from(bytes, (b) => (b % 10).toString()).join('');
 }

Note: b % 10 introduces a tiny modulo bias on a 256-value space (digits 0–5 are slightly more likely). If you want it perfectly uniform, rejection-sample bytes ≥ 250.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function generateOtp(): string {
return Array.from({ length: OTP_LENGTH }, () => Math.floor(Math.random() * 10)).join('');
}
function generateOtp(): string {
const bytes = new Uint8Array(OTP_LENGTH);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => (b % 10).toString()).join('');
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/services/passwordResetService.ts` around lines 12 - 14, The
generateOtp function uses Math.random() which is not cryptographically secure;
replace its RNG with a crypto-secure source (Node's crypto.randomBytes or
browser crypto.getRandomValues) in generateOtp so digits are drawn from secure
random bytes, using rejection sampling to avoid modulo bias and produce exactly
OTP_LENGTH numeric characters; ensure you import/require the crypto module and
preserve the function signature generateOtp(): string and OTP_LENGTH usage.


export async function requestPasswordReset(email: string): Promise<void> {
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 });
Comment on lines +27 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Store OTPs hashed and add a per-OTP attempt limit.

Two issues here:

  1. Plaintext OTP storage. value: code writes the raw 6-digit code into verification. A read-only DB leak (logs, backups, a SQL-injection elsewhere) hands attackers live reset codes. Hash before storing and timingSafeEqual the hashes on verify.
  2. Unbounded verify attempts. verifyOtpAndResetPassword only deletes the record on success, so an attacker has the full 15 minutes to brute-force 1,000,000 codes (which is well within reach over HTTP). Track attempts on the verification row and invalidate after N (e.g. 5) failures.

Together, these turn a small theoretical risk into a practical attack surface. Also consider rate-limiting /password-reset/verify per IP/email at the route layer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/services/passwordResetService.ts` around lines 27 - 37, The
verification record is storing OTPs in plaintext and has no attempt limit;
instead hash the OTP before inserting and track attempts to invalidate after N
failures. In the code that inserts into verification (the block calling
db.insert(verification).values({...}) and sendPasswordResetEmail), replace
storing value: code with a securely-derived hash (e.g., HMAC or PBKDF2/bcrypt
with per-code salt) and persist the salt/hashed field and an attempts integer
(default 0). In verifyOtpAndResetPassword, fetch the verification row, compute
the same hash from the provided code (using the stored salt/secret), compare
with timingSafeEqual, and on mismatch increment attempts via
db.update(verification).set({ attempts: attempts + 1 }) and delete/invalidate
the row once attempts >= MAX_ATTEMPTS (e.g., 5); on success delete the row and
continue reset. Use identifiers verification, verifyOtpAndResetPassword, and
sendPasswordResetEmail to locate where to apply these changes.

}

export async function verifyOtpAndResetPassword({
email,
code,
newPassword,
}: {
email: string;
code: string;
newPassword: string;
}): Promise<void> {
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();

// 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));
}
Comment on lines +67 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm passwordHash still exists on the users schema and find any consumers.
rg -nP -C3 'passwordHash' packages/api/src/db
rg -nP -C3 '\bpasswordHash\b' packages/api/src --type=ts

Repository: PackRat-AI/PackRat

Length of output: 6773


🏁 Script executed:

rg -nP -C5 'passwordHash.*eq|passwordHash.*compare|verify.*passwordHash' packages/api/src --type=ts
rg -nP -C5 'credential.*account|provider.*credential' packages/api/src/services --type=ts
rg -nP 'from.*@better-auth' packages/api/src --type=ts | head -20

Repository: PackRat-AI/PackRat

Length of output: 1887


🏁 Script executed:

rg -nP 'verifyPassword' packages/api/src --type=ts -A3 -B3
rg -nP 'signIn.*credential|credential.*signIn|login' packages/api/src --type=ts | head -30

Repository: PackRat-AI/PackRat

Length of output: 4345


🏁 Script executed:

rg -nP 'password.*field|passwordHash.*schema' packages/api/src/auth --type=ts -A2 -B2
cat packages/api/src/auth/auth.config.ts

Repository: PackRat-AI/PackRat

Length of output: 2970


Remove the users.passwordHash fallback — it will not work and is dead code.

Better Auth's emailAndPassword provider exclusively uses the account table (with providerId='credential') for password storage and verification. The passwordHash field on the users table is just a custom additional field — it's never consulted during login. If a user has no credential account row, they cannot authenticate, even if users.passwordHash is updated.

If you need to support legacy users without credential accounts, create the credential account instead of falling back to a field Better Auth won't check. Otherwise, remove this fallback entirely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/services/passwordResetService.ts` around lines 67 - 79,
Remove the dead fallback that updates users.passwordHash: the password store for
Better Auth is the account table (providerId='credential'), so delete the branch
that updates users in passwordResetService (the block that checks updated.length
=== 0 and calls db.update(users).set(...)). Instead, if a credential account is
missing, create or upsert an account row for that user with
providerId='credential' and the hashed password (using the same db and account
model used in the earlier update), or simply remove the fallback logic entirely;
update any related tests to reflect that only the account table (account,
providerId) is used for password storage.


await db.delete(verification).where(eq(verification.identifier, identifier));
}
Loading