diff --git a/app/(api)/auth/forgot-password+api.ts b/app/(api)/auth/forgot-password+api.ts index 5569eb3..ce70c23 100644 --- a/app/(api)/auth/forgot-password+api.ts +++ b/app/(api)/auth/forgot-password+api.ts @@ -1,10 +1,11 @@ -import { createHash, randomInt } from "crypto"; - import { sql } from "@/lib/db"; import { sendEmail } from "@/lib/mailer"; - -const hashCode = (email: string, code: string): string => - createHash("sha256").update(`${email}:${code}`).digest("hex"); +import { + CODE_TTL_MINUTES, + generateCode, + hashCode, + resetEmail, +} from "@/lib/otp"; export async function POST(req: Request) { const { email } = await req.json(); @@ -25,14 +26,14 @@ export async function POST(req: Request) { return Response.json({ data: { sent: false } }); } - const code = String(randomInt(0, 1_000_000)).padStart(6, "0"); + const code = generateCode(); await sql` INSERT INTO password_reset_codes (email, code_hash, expires_at) VALUES ( ${normalized}, ${hashCode(normalized, code)}, - CURRENT_TIMESTAMP + INTERVAL '15 minutes' + CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES}) ) ON CONFLICT (email) DO UPDATE SET code_hash = EXCLUDED.code_hash, @@ -40,11 +41,8 @@ export async function POST(req: Request) { attempts = 0 `; - const delivered = await sendEmail( - normalized, - "Reset your Waseel password", - `We received a request to reset your Waseel password.\n\nYour reset code is: ${code}\n\nIt expires in 15 minutes. If you didn't ask for this, you can ignore this email.`, - ); + const mail = resetEmail(code); + const delivered = await sendEmail(normalized, mail.subject, mail.text); return Response.json({ data: { diff --git a/app/(api)/auth/login+api.ts b/app/(api)/auth/login+api.ts index 9a1b0a9..0263237 100644 --- a/app/(api)/auth/login+api.ts +++ b/app/(api)/auth/login+api.ts @@ -1,13 +1,25 @@ -import { createHash } from "crypto"; - import { sql } from "@/lib/db"; -import { verifyPassword } from "@/lib/password"; +import { SESSION_TTL_SECONDS, SHORT_SESSION_TTL_SECONDS } from "@/lib/jwt"; +import { hashPassword, verifyPassword } from "@/lib/password"; import { issueSession, toProfile } from "@/lib/users"; +// Compared against when no account matches, so a wrong email costs the same +// scrypt work as a wrong password instead of answering noticeably faster. +const DUMMY_HASH = hashPassword("waseel-no-such-account"); + export async function POST(req: Request) { - const { email, password } = await req.json(); + const { email, password, remember } = await req.json(); if (!email?.trim() || !password) { + // Names only, never values: this is the one 400 that looks like a server + // fault from the app, so say which field arrived empty. + console.warn( + "[LOGIN]: rejected empty", + [!email?.trim() && "email", !password && "password"] + .filter(Boolean) + .join(" + "), + ); + return Response.json( { error: "Email and password are required." }, { status: 400 }, @@ -29,8 +41,9 @@ export async function POST(req: Request) { `; const user = rows[0]; + const valid = verifyPassword(password, user?.password_hash ?? DUMMY_HASH); - if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { + if (!user || !user.password_hash || !valid) { return Response.json( { error: "Invalid email or password." }, { status: 401 }, @@ -44,7 +57,10 @@ export async function POST(req: Request) { ); } - const session = issueSession(user); + const session = issueSession( + user, + remember === false ? SHORT_SESSION_TTL_SECONDS : SESSION_TTL_SECONDS, + ); return Response.json({ data: { token: session.token, user: toProfile(user) }, diff --git a/app/(api)/auth/register+api.ts b/app/(api)/auth/register+api.ts index 9e302ca..146dd03 100644 --- a/app/(api)/auth/register+api.ts +++ b/app/(api)/auth/register+api.ts @@ -1,8 +1,12 @@ -import { createHash, randomInt } from "crypto"; - import { sql } from "@/lib/db"; import { hashPassword } from "@/lib/password"; import { sendEmail } from "@/lib/mailer"; +import { + CODE_TTL_MINUTES, + generateCode, + hashCode, + verificationEmail, +} from "@/lib/otp"; const normalizePhone = (raw: string): string => { const cleaned = raw.replace(/[^\d+]/g, ""); @@ -10,9 +14,6 @@ const normalizePhone = (raw: string): string => { return `+961${cleaned.replace(/^0+/, "")}`; }; -const hashCode = (email: string, code: string): string => - createHash("sha256").update(`${email}:${code}`).digest("hex"); - export async function POST(req: Request) { const { name, email, phone, password, role } = await req.json(); @@ -62,14 +63,14 @@ export async function POST(req: Request) { role = EXCLUDED.role `; - const code = String(randomInt(0, 1_000_000)).padStart(6, "0"); + const code = generateCode(); await sql` INSERT INTO email_verification_codes (email, code_hash, expires_at) VALUES ( ${email.trim().toLowerCase()}, ${hashCode(email.trim().toLowerCase(), code)}, - CURRENT_TIMESTAMP + INTERVAL '15 minutes' + CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES}) ) ON CONFLICT (email) DO UPDATE SET code_hash = EXCLUDED.code_hash, @@ -77,10 +78,11 @@ export async function POST(req: Request) { attempts = 0 `; + const mail = verificationEmail(code); const delivered = await sendEmail( email.trim().toLowerCase(), - "Your Waseel verification code", - `Welcome to Waseel!\n\nYour verification code is: ${code}\n\nIt expires in 15 minutes.`, + mail.subject, + mail.text, ); return Response.json( diff --git a/app/(api)/auth/reset-password+api.ts b/app/(api)/auth/reset-password+api.ts index de16d28..834648d 100644 --- a/app/(api)/auth/reset-password+api.ts +++ b/app/(api)/auth/reset-password+api.ts @@ -1,12 +1,8 @@ -import { createHash } from "crypto"; - import { sql } from "@/lib/db"; +import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp"; import { hashPassword } from "@/lib/password"; import { issueSession, toProfile } from "@/lib/users"; -const hashCode = (email: string, code: string): string => - createHash("sha256").update(`${email}:${code}`).digest("hex"); - export async function POST(req: Request) { const { email, code, password } = await req.json(); @@ -27,19 +23,22 @@ export async function POST(req: Request) { const normalized = email.trim().toLowerCase(); try { - const valid = await sql<{ email: string }>` - SELECT email FROM password_reset_codes - WHERE email = ${normalized} - AND code_hash = ${hashCode(normalized, code)} - AND expires_at > CURRENT_TIMESTAMP + // Charge the attempt before comparing so concurrent guesses can't race + // past the cap, and so a correct guess still costs one of the five. + const attempts = await sql<{ code_hash: string; attempts: number }>` + UPDATE password_reset_codes + SET attempts = attempts + 1 + WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP + RETURNING code_hash, attempts `; - if (!valid[0]) { - await sql` - UPDATE password_reset_codes SET attempts = attempts + 1 - WHERE email = ${normalized} - `; + const record = attempts[0]; + if ( + !record || + record.attempts > MAX_CODE_ATTEMPTS || + !codeMatches(record.code_hash, normalized, code) + ) { return Response.json( { error: "Invalid or expired reset code." }, { status: 400 }, diff --git a/app/(api)/auth/verify+api.ts b/app/(api)/auth/verify+api.ts index 47b09e0..9647b5b 100644 --- a/app/(api)/auth/verify+api.ts +++ b/app/(api)/auth/verify+api.ts @@ -1,11 +1,10 @@ -import { createHash } from "crypto"; - import { sql } from "@/lib/db"; +import { + MAX_CODE_ATTEMPTS, + codeMatches, +} from "@/lib/otp"; import { issueSession, toProfile } from "@/lib/users"; -const hashCode = (email: string, code: string): string => - createHash("sha256").update(`${email}:${code}`).digest("hex"); - export async function POST(req: Request) { const { email, code } = await req.json(); @@ -19,6 +18,28 @@ export async function POST(req: Request) { const normalized = email.trim().toLowerCase(); try { + // Charge the attempt before comparing so concurrent guesses can't race + // past the cap, and so a correct guess still costs one of the five. + const attempts = await sql<{ code_hash: string; attempts: number }>` + UPDATE email_verification_codes + SET attempts = attempts + 1 + WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP + RETURNING code_hash, attempts + `; + + const record = attempts[0]; + + if ( + !record || + record.attempts > MAX_CODE_ATTEMPTS || + !codeMatches(record.code_hash, normalized, code) + ) { + return Response.json( + { error: "Invalid or expired verification code." }, + { status: 400 }, + ); + } + const rows = await sql<{ id: string; name: string; @@ -27,27 +48,13 @@ export async function POST(req: Request) { }>` UPDATE users SET email_verified = TRUE WHERE email = ${normalized} - AND EXISTS ( - SELECT 1 FROM email_verification_codes - WHERE email = ${normalized} - AND code_hash = ${hashCode(normalized, code)} - AND expires_at > CURRENT_TIMESTAMP - ) RETURNING id, name, email, role `; const user = rows[0]; if (!user) { - await sql` - UPDATE email_verification_codes SET attempts = attempts + 1 - WHERE email = ${normalized} - `; - - return Response.json( - { error: "Invalid or expired verification code." }, - { status: 400 }, - ); + return Response.json({ error: "User not found." }, { status: 404 }); } await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`; diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx index 68041ca..56dc57e 100644 --- a/app/(auth)/sign-in.tsx +++ b/app/(auth)/sign-in.tsx @@ -1,5 +1,5 @@ import { Link, useRouter } from "expo-router"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Alert, Image, @@ -15,17 +15,46 @@ import ReactNativeModal from "react-native-modal"; import { CustomButton } from "@/components/custom-button"; import { InputField } from "@/components/input-field"; import { OAuth } from "@/components/oauth"; +import { OtpField } from "@/components/otp-field"; import { icons, images } from "@/constants"; -import { fetchAPI } from "@/lib/fetch"; -import { useSession } from "@/lib/session"; +import { ApiError, fetchAPI } from "@/lib/fetch"; +import { getRememberedEmail, rememberEmail, useSession } from "@/lib/session"; const SignIn = () => { const router = useRouter(); - const { isLoaded, setSession } = useSession(); + const { setSession } = useSession(); const [form, setForm] = useState({ email: "", password: "", }); + const [busy, setBusy] = useState(false); + + // On by default: a rider signing in on their own phone shouldn't have to + // opt into staying signed in. Unchecking it shortens the session to 12h and + // stops the address being prefilled next time. + const [remember, setRemember] = useState(true); + + useEffect(() => { + let cancelled = false; + + getRememberedEmail() + .then((email) => { + if (!email || cancelled) return; + + // SecureStore can resolve after the user has started typing, so only + // fill a field that's still untouched. + setForm((prevForm) => + prevForm.email ? prevForm : { ...prevForm, email }, + ); + }) + .catch(() => { + // Nothing stored, or the keychain is unavailable: start blank. + }); + + return () => { + cancelled = true; + }; + }, []); // Forgot-password flow: "request" collects the email, "reset" collects the // emailed code and a new password. @@ -109,20 +138,36 @@ const SignIn = () => { }); await setSession(response.data); + await rememberEmail(remember ? reset.email.trim() : null); setReset((prev) => ({ ...prev, state: "closed", busy: false })); router.replace("/"); } catch (err: any) { setReset((prev) => ({ ...prev, busy: false, - error: String(err?.message ?? "").includes("400") - ? "Invalid or expired reset code." - : "Could not reset your password. Please try again.", + error: + err instanceof ApiError && err.status < 500 + ? err.message + : "Could not reset your password. Please try again.", })); } }; const onSignInPress = useCallback(async () => { + if (busy) return; + + // Catch the blank-field case here: the server answers 400 for it, which + // otherwise surfaces as a generic "could not sign in". + if (!form.email.trim() || !form.password) { + Alert.alert( + "Missing information", + "Enter both your email and your password.", + ); + return; + } + + setBusy(true); + try { const response = await fetchAPI("/(api)/auth/login", { method: "POST", @@ -130,26 +175,30 @@ const SignIn = () => { body: JSON.stringify({ email: form.email, password: form.password, + remember, }), }); await setSession(response.data); + await rememberEmail(remember ? form.email.trim() : null); router.replace("/"); } catch (err: any) { - const status = String(err?.message ?? ""); - const message = status.includes("403") - ? "Please verify your email first." - : status.includes("401") - ? "Invalid email or password." + const message = + err instanceof ApiError && err.status < 500 + ? err.message : "Could not sign in. Please try again."; Alert.alert("Error", message); - setForm((prevForm) => ({ - ...prevForm, - password: "", - })); + + // Only a rejected password is worth retyping. Clearing it after a + // network blip or a 403 just makes the next attempt fail differently. + if (err instanceof ApiError && err.status === 401) { + setForm((prevForm) => ({ ...prevForm, password: "" })); + } + } finally { + setBusy(false); } - }, [isLoaded, form.email, form.password, setSession, router]); + }, [busy, form.email, form.password, remember, setSession, router]); return ( { })) } keyboardType="email-address" + autoComplete="email" + textContentType="username" /> { password: value, })) } + autoComplete="current-password" + textContentType="password" /> + setRemember((current) => !current)} + activeOpacity={0.7} + accessibilityRole="checkbox" + accessibilityState={{ checked: remember }} + className="flex-row items-center mt-4" + > + + {remember ? ( + + ) : null} + + + + Keep me signed in + + + @@ -248,6 +326,8 @@ const SignIn = () => { icon={icons.email} value={reset.email} keyboardType="email-address" + autoComplete="email" + textContentType="username" onChangeText={(email) => setReset((prev) => ({ ...prev, email })) } @@ -288,14 +368,11 @@ const SignIn = () => { ) : null} - setReset((prev) => ({ ...prev, code }))} + onChange={(code) => + setReset((prev) => ({ ...prev, code, error: "" })) + } /> { onChangeText={(password) => setReset((prev) => ({ ...prev, password })) } + autoComplete="new-password" + textContentType="newPassword" /> {reset.error ? ( diff --git a/app/(auth)/sign-up.tsx b/app/(auth)/sign-up.tsx index 33f7ba5..6235a7c 100644 --- a/app/(auth)/sign-up.tsx +++ b/app/(auth)/sign-up.tsx @@ -1,5 +1,5 @@ import { Link, router } from "expo-router"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Alert, Image, @@ -15,8 +15,9 @@ import ReactNativeModal from "react-native-modal"; import { CustomButton } from "@/components/custom-button"; import { InputField } from "@/components/input-field"; import { OAuth } from "@/components/oauth"; +import { OtpField } from "@/components/otp-field"; import { icons, images } from "@/constants"; -import { fetchAPI } from "@/lib/fetch"; +import { ApiError, fetchAPI } from "@/lib/fetch"; import { useSession } from "@/lib/session"; const ROLES = [ @@ -45,11 +46,14 @@ const SignUp = () => { password: "", }); + // "verified" is a hand-off state: it hides the code modal so its onModalHide + // can bring up the success modal, since two modals can't cross-fade. const [verification, setVerification] = useState({ - state: "default", + state: "default" as "default" | "pending" | "verified" | "success", error: "", code: "", devCode: "", + busy: false, }); const onSignUpPress = async () => { @@ -82,50 +86,80 @@ const SignUp = () => { }), }); - setVerification((prevVerification) => ({ - ...prevVerification, + setVerification({ state: "pending", + error: "", + code: "", + busy: false, devCode: (response as { data?: { devCode?: string } })?.data?.devCode ?? "", - })); - - setForm((prevForm) => ({ - ...prevForm, - password: "", - })); - } catch (err: any) { - setForm((prevForm) => ({ - ...prevForm, - password: "", - })); - Alert.alert("Error", err?.message ?? "Could not create your account."); - } - }; - - const onPressVerify = async () => { - try { - const response = await fetchAPI("/(api)/auth/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: form.email, code: verification.code }), }); - await setSession(response.data); - setVerification((prevVerification) => ({ - ...prevVerification, - state: "success", + setForm((prevForm) => ({ + ...prevForm, + password: "", })); } catch (err: any) { - setVerification((prevVerification) => ({ - ...prevVerification, - error: err?.message?.includes("400") - ? "Invalid or expired verification code." - : err?.message ?? "Verification failed.", - state: "failed", + setForm((prevForm) => ({ + ...prevForm, + password: "", })); + Alert.alert( + "Error", + err instanceof ApiError && err.status < 500 + ? err.message + : "Could not create your account.", + ); } }; + const onPressVerify = useCallback( + async (code: string) => { + if (!/^\d{6}$/.test(code)) { + setVerification((prevVerification) => ({ + ...prevVerification, + error: "Enter the 6-digit code.", + })); + return; + } + + setVerification((prevVerification) => + // Guard the double submit that auto-verify + a button tap would cause. + prevVerification.busy + ? prevVerification + : { ...prevVerification, busy: true, error: "" }, + ); + + try { + const response = await fetchAPI("/(api)/auth/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: form.email, code }), + }); + + await setSession(response.data); + setVerification((prevVerification) => ({ + ...prevVerification, + state: "verified", + busy: false, + })); + } catch (err: any) { + // Stay on the code modal so the user can retry; only a real success + // advances the flow. + setVerification((prevVerification) => ({ + ...prevVerification, + code: "", + busy: false, + error: + err instanceof ApiError && err.status < 500 + ? err.message + : "Verification failed. Please try again.", + })); + } + }, + [form.email, setSession], + ); + return ( { - setVerification((prevVerification) => ({ - ...prevVerification, - state: "success", - })) + setVerification((prevVerification) => + prevVerification.state === "verified" + ? { ...prevVerification, state: "success" } + : prevVerification, + ) } isVisible={verification.state === "pending"} > @@ -292,31 +327,28 @@ const SignUp = () => { ) : null} - + onChange={(code) => setVerification((prevVerification) => ({ ...prevVerification, code, + error: "", })) } + onComplete={onPressVerify} /> - {verification.error && ( + {verification.error ? ( {verification.error} - )} + ) : null} onPressVerify(verification.code)} + disabled={verification.busy} className="mt-5 bg-emerald-500" /> diff --git a/components/otp-field.tsx b/components/otp-field.tsx new file mode 100644 index 0000000..855e179 --- /dev/null +++ b/components/otp-field.tsx @@ -0,0 +1,134 @@ +import * as Clipboard from "expo-clipboard"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppState, Text, TouchableOpacity, View } from "react-native"; + +import { InputField } from "@/components/input-field"; +import { icons } from "@/constants"; + +// `\b` won't match between two digits, so a longer run like an order number +// never yields a false positive. +const CODE_PATTERN = /\b\d{6}\b/; + +/** Pulls the 6-digit code out of whatever the user copied from the email. */ +export const extractCode = (raw: string | null | undefined): string | null => + raw ? (CODE_PATTERN.exec(raw)?.[0] ?? null) : null; + +type OtpFieldProps = { + label?: string; + value: string; + onChange: (code: string) => void; + /** Fired once the field holds a complete 6-digit code. */ + onComplete?: (code: string) => void; +}; + +/** + * Code entry for the emailed verification/reset codes. + * + * Three ways in, cheapest first: + * 1. iOS surfaces the code above the keyboard once Mail has it — + * `textContentType="oneTimeCode"` is what opts the field into that. + * 2. Gmail's notification carries a "Copy code" action (Android) and the + * code is one long-press away on any platform: coming back to the app + * with a code on the clipboard raises the paste chip below. + * 3. Typing it. + */ +export const OtpField = ({ + label = "Code", + value, + onChange, + onComplete, +}: OtpFieldProps) => { + const [pasteReady, setPasteReady] = useState(false); + const completedFor = useRef(null); + + // `hasStringAsync` inspects the clipboard without reading it, so it never + // trips the iOS paste prompt — that only fires on the explicit tap below. + const refreshPasteChip = useCallback(async () => { + try { + setPasteReady(await Clipboard.hasStringAsync()); + } catch { + setPasteReady(false); + } + }, []); + + useEffect(() => { + void refreshPasteChip(); + + // The user leaves for Gmail and comes back with the code copied. + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") void refreshPasteChip(); + }); + + return () => subscription.remove(); + }, [refreshPasteChip]); + + const handleChange = useCallback( + (next: string) => { + // Paste of a whole line ("123456 is your Waseel…") still lands the code. + const digits = + next.length > 6 + ? (extractCode(next) ?? next.replace(/\D/g, "").slice(0, 6)) + : next.replace(/\D/g, ""); + + onChange(digits); + }, + [onChange], + ); + + const onPastePress = useCallback(async () => { + try { + const code = extractCode(await Clipboard.getStringAsync()); + + if (code) { + onChange(code); + return; + } + } catch { + // Fall through to the hint below. + } + + setPasteReady(false); + }, [onChange]); + + // Auto-submit on a complete code, but only once per distinct code so a + // rejected code isn't resubmitted on every re-render. + useEffect(() => { + if (value.length !== 6 || !onComplete) return; + if (completedFor.current === value) return; + + completedFor.current = value; + onComplete(value); + }, [value, onComplete]); + + return ( + + + + {pasteReady && value.length < 6 ? ( + + + Paste code from Gmail + + + ) : null} + + ); +}; diff --git a/lib/fetch.ts b/lib/fetch.ts index e7cd942..3f89cdf 100644 --- a/lib/fetch.ts +++ b/lib/fetch.ts @@ -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; } }; diff --git a/lib/jwt.ts b/lib/jwt.ts index 8eaf456..df37895 100644 --- a/lib/jwt.ts +++ b/lib/jwt.ts @@ -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 }; diff --git a/lib/otp.ts b/lib/otp.ts new file mode 100644 index 0000000..6627342 --- /dev/null +++ b/lib/otp.ts @@ -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`, +}); diff --git a/lib/session.tsx b/lib/session.tsx index 1afa3fb..4c0c484 100644 --- a/lib/session.tsx +++ b/lib/session.tsx @@ -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 => + SecureStore.getItemAsync(REMEMBERED_EMAIL_KEY); + +export const rememberEmail = async (email: string | null): Promise => { + 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(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; diff --git a/lib/users.ts b/lib/users.ts index a2610e8..3dee62c 100644 --- a/lib/users.ts +++ b/lib/users.ts @@ -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), }); diff --git a/package-lock.json b/package-lock.json index 69e45a2..28f0dd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "eslint-plugin-prettier": "^5.2.1", "expo": "~51.0.28", "expo-auth-session": "~5.5.2", + "expo-clipboard": "~6.0.3", "expo-constants": "~16.0.2", "expo-crypto": "^57.0.1", "expo-font": "~12.0.9", @@ -9347,6 +9348,14 @@ "expo": "*" } }, + "node_modules/expo-clipboard": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-6.0.3.tgz", + "integrity": "sha512-RIKDsuHkYfaspifbFpVC8sBVFKR05L7Pj7mU2/XkbrW9m01OBNvdpGraXEMsTFCx97xMGsZpEw9pPquL4j4xVg==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-constants": { "version": "16.0.2", "license": "MIT", diff --git a/package.json b/package.json index dd20fb6..fc040cc 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "eslint-plugin-prettier": "^5.2.1", "expo": "~51.0.28", "expo-auth-session": "~5.5.2", + "expo-clipboard": "~6.0.3", "expo-constants": "~16.0.2", "expo-crypto": "^57.0.1", "expo-font": "~12.0.9",