Add remember-me and OTP autofill, fix session persistence

Sign-in gains a "Keep me signed in" checkbox: checked issues a 30-day
token and prefills the address next launch, unchecked drops the session
to 12 hours and forgets the address. The TTL is chosen server-side in
the login route.

Emailed codes are now reachable without retyping. OtpField opts into the
iOS one-time-code keyboard suggestion and raises a paste chip when the
user returns from Gmail with a code on the clipboard. The mails put the
code first in the subject and body, which is what makes Gmail render its
"Copy code" notification action at all.

Fixes found along the way:

- Session was wiped on every launch. decodeJwtExp used atob, which
  neither RN 0.74 nor Expo SDK 51 defines, so it threw, returned null,
  and the caller read that as "expired" and deleted the token. Replaced
  with a dependency-free base64url decoder, and restore now only
  discards a session it can prove is expired.
- Verification and reset codes counted attempts but never enforced them,
  leaving a 6-digit code open to unlimited guessing. Both routes now
  charge the attempt before comparing so concurrent guesses can't race
  past the cap of five, and compare in constant time.
- A wrong verification code showed the "Verified" success screen:
  onModalHide fired unconditionally, so the failure state advanced the
  flow. Only an explicit "verified" state does that now.
- fetchAPI discarded the server's error body, so the UI substring-matched
  synthetic status strings and showed "Could not sign in" for everything.
  It now throws ApiError carrying status and the server's message.
- Login answered a missing account faster than a wrong password; it now
  runs the same scrypt work either way.
- Blank email or password is caught client-side instead of surfacing as
  an opaque 400, and a failed attempt only clears the password on a 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krikorios
2026-08-23 23:33:51 +03:00
co-authored by Claude Opus 5
parent eceb6b45d5
commit bc23c94ea2
15 changed files with 558 additions and 148 deletions
+22 -2
View File
@@ -12,6 +12,17 @@ export const clearAuthToken = () => {
authToken = null;
};
/** Carries the HTTP status so callers can branch on it instead of on text. */
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export const fetchAPI = async (url: string, options?: RequestInit) => {
try {
const headers = new Headers(options?.headers);
@@ -20,12 +31,21 @@ export const fetchAPI = async (url: string, options?: RequestInit) => {
}
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
// Every route answers with `{ error }`; keep that text so the UI can show
// what actually went wrong instead of guessing from a status code.
const body = await response.json().catch(() => null);
throw new ApiError(
response.status,
body?.error ?? `Request failed with status ${response.status}.`,
);
}
return await response.json();
} catch (error) {
console.error("Fetch error:", error);
console.error(`Fetch error: ${url}`, error);
throw error;
}
};
+7 -1
View File
@@ -23,9 +23,15 @@ const secret = (): string => {
return value;
};
/** "Remember me" sessions: long enough that a rider rarely signs in again. */
export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
/** Sessions on a shared or borrowed phone: survives a day, then expires. */
export const SHORT_SESSION_TTL_SECONDS = 12 * 60 * 60;
export const signJwt = (
payload: { sub: string; email: string },
expiresInSeconds = 30 * 24 * 60 * 60,
expiresInSeconds = SESSION_TTL_SECONDS,
): string => {
const iat = Math.floor(Date.now() / 1000);
const body: JwtPayload = { ...payload, iat, exp: iat + expiresInSeconds };
+49
View File
@@ -0,0 +1,49 @@
// Shared helpers for the 6-digit email codes used by sign-up verification and
// password reset. Both flows store a salted hash keyed by email, so the code
// itself only ever lives in the outgoing mail.
import { createHash, randomInt, timingSafeEqual } from "crypto";
export const CODE_TTL_MINUTES = 15;
// A 6-digit code is only 1,000,000 wide, so the attempt cap is what actually
// makes it safe to email. Both code tables carry an `attempts` column.
export const MAX_CODE_ATTEMPTS = 5;
export const generateCode = (): string =>
String(randomInt(0, 1_000_000)).padStart(6, "0");
export const hashCode = (email: string, code: string): string =>
createHash("sha256").update(`${email}:${code}`).digest("hex");
export const codeMatches = (
storedHash: string,
email: string,
code: string,
): boolean => {
const expected = Buffer.from(storedHash, "hex");
const actual = Buffer.from(hashCode(email, code), "hex");
return expected.length === actual.length && timingSafeEqual(expected, actual);
};
// Gmail and iOS Mail only offer a one-tap "copy code" / keyboard suggestion
// when the code leads the subject line and the body opens with a recognised
// phrasing. Keep both formats intact when editing this copy.
export const verificationEmail = (code: string) => ({
subject: `${code} is your Waseel verification code`,
text:
`${code} is your Waseel verification code.\n\n` +
`Welcome to Waseel! Enter this code in the app to finish signing up.\n` +
`It expires in ${CODE_TTL_MINUTES} minutes.\n`,
});
export const resetEmail = (code: string) => ({
subject: `${code} is your Waseel password reset code`,
text:
`${code} is your Waseel password reset code.\n\n` +
`We received a request to reset your Waseel password. Enter this code in ` +
`the app to choose a new one.\n` +
`It expires in ${CODE_TTL_MINUTES} minutes. If you didn't ask for this, ` +
`you can ignore this email.\n`,
});
+62 -5
View File
@@ -13,8 +13,25 @@ import { setAuthToken, clearAuthToken } from "./fetch";
const TOKEN_KEY = "waseel_auth_token";
const USER_KEY = "waseel_auth_user";
const REMEMBERED_EMAIL_KEY = "waseel_remembered_email";
const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
/**
* The address to prefill on the sign-in screen, or null when the last sign-in
* cleared "remember me". The token itself is stored either way — what
* "remember me" changes is how long the server makes it live.
*/
export const getRememberedEmail = (): Promise<string | null> =>
SecureStore.getItemAsync(REMEMBERED_EMAIL_KEY);
export const rememberEmail = async (email: string | null): Promise<void> => {
if (email) {
await SecureStore.setItemAsync(REMEMBERED_EMAIL_KEY, email);
} else {
await SecureStore.deleteItemAsync(REMEMBERED_EMAIL_KEY);
}
};
export type SessionUser = {
id: string;
name: string;
@@ -37,12 +54,46 @@ type SessionContextValue = {
const SessionContext = createContext<SessionContextValue | null>(null);
const BASE64_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/**
* Decodes a base64url segment without `atob`.
*
* Neither React Native 0.74 nor Expo SDK 51 defines a global `atob`, so the
* previous implementation threw on every call — which silently expired the
* stored session on each launch (see decodeJwtExp). Skipping unknown
* characters also makes the missing "=" padding in a JWT a non-issue.
*/
const decodeBase64Url = (segment: string): string => {
const normalized = segment.replace(/-/g, "+").replace(/_/g, "/");
let output = "";
let buffer = 0;
let bits = 0;
for (const character of normalized) {
const value = BASE64_ALPHABET.indexOf(character);
if (value === -1) continue;
buffer = (buffer << 6) | value;
bits += 6;
if (bits >= 8) {
bits -= 8;
output += String.fromCharCode((buffer >> bits) & 0xff);
}
}
return output;
};
// Mirrors lib/jwt.ts payload decoding (no signature check needed client-side).
export const decodeJwtExp = (token: string): number | null => {
try {
const claims = JSON.parse(
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
);
const claims = JSON.parse(decodeBase64Url(token.split(".")[1]));
return typeof claims.exp === "number" ? claims.exp : null;
} catch {
return null;
@@ -65,8 +116,14 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => {
if (!token || !storedUser) return;
const exp = decodeJwtExp(token) ?? 0;
if (exp * 1000 < Date.now()) {
const exp = decodeJwtExp(token);
// Only discard a session we can positively prove is expired. Treating
// an unreadable token as expired is what made every launch sign the
// user back out; if it really is bad, the next request gets a 401.
if (exp === null) {
console.warn("[SESSION_RESTORE]: could not read token expiry");
} else if (exp * 1000 < Date.now()) {
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
return;
+2 -1
View File
@@ -24,8 +24,9 @@ export const toProfile = (row: UserRow): UserProfile => ({
export const issueSession = (
row: UserRow,
expiresInSeconds?: number,
): { token: string; user: UserProfile } => ({
token: signJwt({ sub: row.id, email: row.email }),
token: signJwt({ sub: row.id, email: row.email }, expiresInSeconds),
user: toProfile(row),
});