c617bc96878dd4c74f770dd22a5bf157bf1cfc22
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c617bc9687 |
M1 security: close the password backdoor, apply re-review, pin E.164
Acts on an adversarial review of the M1 auth and authorization code. Five findings fixed; the rest recorded in SECURITY-FINDINGS.md as the M1 exit criteria rather than left in a tool transcript. - auth: serve /phone-number/request-password-reset, /phone-number/ reset-password and /sign-in/phone-number as 404. better-auth's phoneNumber() registers all three unconditionally -- they are NOT gated on emailAndPassword.enabled:false. Left live they form a silent second credential path: request-password-reset stores an OTP and sends no SMS (sendPasswordResetOTP was never configured, so the owner is never told), reset-password mints a bcrypt credential row, and sign-in/phone-number then accepts it forever with no OTP. The OTP gate still applies, so this is not remote unauthenticated takeover -- it converts one momentary OTP compromise into permanent access the victim cannot see or rotate. - auth: drop bearer(). It accepts the plaintext sessions.token column as an Authorization credential, making any single leaked row a replayable login. The mobile client it was added for is hypothetical. - auth: pin E.164 via phoneNumberValidator, and add toE164/isE164 to @linkder/shared. phone is UNIQUE and bans are per-account, so "+34600111222" and "0034600111222" being separately storable meant one handset could hold two accounts and a ban was escapable by retyping. 15 tests. - auth: NEXT_PUBLIC_APP_URL now throws in production instead of falling back to localhost, which was silently dropping Secure and the __Secure- prefix from the production session cookie. - pro.upsertProfile: actually apply requiresReReview. It was computed, returned to the client and never acted on, so a verified plumber could become a verified electrician in another city by ignoring a response flag. Now demotes to pending in the same transaction and audits it. Trade changes count as material (they did not before) -- the licence is per-trade. Needed a verified -> pending edge in VERIFICATION_GRAPH, which did not exist. Removed two untracked scratch repro files. The impersonation repro depended on bearer() for transport and no longer applies as written; the underlying finding (resolveSession drops impersonatedBy, so admin actions are audited as the victim) is open and documented. typecheck, lint, build clean; 127 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cebeda7f4c |
M1: authentication with better-auth, verified end to end
Switches from the planned Auth.js v5 to better-auth 1.7.1. The plan assumed the blocker would be schema fit; it is not. @auth/drizzle-adapter accepts our tables verbatim. What rules Auth.js out is that credentials providers hardcode JWT and never call adapter.createSession, and the config assertion that would catch it only fires when EVERY provider is credentials — so adding Google suppresses the warning and the app ships silently broken. Phone OTP with database sessions is not reachable there without hand-building the whole OTP security layer. Also corrects a premise: better-auth's drizzle-orm peer is declared OPTIONAL, so no 0.38 -> 0.45 upgrade is forced. Verified on 0.38.4. - auth schema rewritten to better-auth 1.7.1's own getSchema() output: sessions/accounts/verifications reshaped, emailVerified and phoneVerified are BOOLEAN (a timestamptz there fails 100% of signups), accounts.issuer added, phone_otps dropped. Ban state now comes from the admin plugin rather than a second bannedAt column. - Session resolution is one file. Everything downstream is written against our own Session type, so the provider stays swappable. - Ban enforcement lives in the resolver because Session carries no ban field and protectedProcedure promises a non-banned user. - Phone OTP sign-in, Google, role selection, tRPC user router. - Synthetic emails for phone-first users, with isSyntheticEmail() gating every future send. Pros must supply a real address; clients need not. - Duplicate-account detection, since both signup routes stay open and nothing correlates a phone to a Google identity. Detects only — merging accounts that carry reviews and payments needs its own tooling. - SMS sender refuses to fall back to console logging in production. - declaration:false for the app, which is the actual fix for the TS2742 wall from better-auth's transitive zod under pnpm. Verified against a live server: OTP sent, code verified, uuid PK honoured, database session written, and an authenticated tRPC call resolved. A signed-in stranger gets NOT_FOUND on another client's deck; anonymous gets UNAUTHORIZED. 124 tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
66dd4ac942 |
M1 (partial): tRPC API layer, storage, and three real fixes
Stands up packages/api so the swipe path stops trusting its caller, and puts the auth library behind an interface we own. - packages/api: tRPC v11 with a Session type WE define, not one re-exported from an auth library. Swapping providers means rewriting one SessionResolver, not touching a router. - Procedure layers: public / protected / client / pro / verifiedPro / admin. Admin routes 404 rather than 403 so they cannot be probed. - deck router replaces the untrusted server action. Ownership is checked on every operation and returns NOT_FOUND, never FORBIDDEN, so job ids cannot be enumerated. 23 tests, mostly authorization. - packages/storage: presigned direct-to-R2 uploads. The server picks the key, so a caller can only write under their own user id. 14 tests. Three defects found and fixed: - The lazy db Proxy failed drizzle's is(db, PgDatabase) because it did not trap getPrototypeOf. Auth adapters dispatch on exactly that check, so this would have failed at runtime inside third-party code. Fixed and pinned with a regression test. - The open-request cap was a read-then-write race: concurrent swipes could both read 4 and both insert. Now one transaction with the job row locked. The cap is checked before the tombstone is written, so a rejected swipe leaves no trace and the card stays on the deck. - superjson was configured in two of the three required places. Without the QueryClient dehydrate/hydrate pair, RSC-prefetched data arrives as a raw envelope with no type error to warn you. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |