Merchant Auth Decoupling โ Design Spec โ
Date: 2026-04-27 Status: Approved (pending implementation plan) Scope: Decouple merchant portal sign-in from Firebase Auth so resetting a merchant's portal password never destroys their Lantern encryption keys. Mirror the admin auth pattern (Issue #245) end-to-end.
Problem โ
A merchant user created via the admin portal today cannot sign in, end-to-end:
- Backend (
adminMerchants.js:411-435,adminUsers.js:379) generates a Firebase-native password reset link viaauth.generatePasswordResetLink()and emails it via Resend. - Merchant clicks the link โ Firebase hosted action page โ sets a Firebase Auth password โ redirects to
admin.dev.ourlantern.app. - Merchant attempts to sign in โ
LoginScreencallssignInWithAdminPasswordโ/auth/admin/signin(adminAuth.js:46-48) hard-rejects any user wherecustomClaims.role !== 'admin'withINVALID_CREDENTIALS. App.jsx:123-127catches the resultingIncorrect email or password.and deliberately does not fall back to Firebase legacy auth.- Merchant is locked out. The "Forgot password?" flow on
LoginScreencallsrequestAdminPasswordReset, which silently no-ops for non-admin roles (adminAuth.js:161โSAFE_RESPONSE).
Even if we permitted the legacy fallback, the deeper problem is that a merchant's Firebase Auth password is also their Lantern app passphrase, which derives their encryption keys. Resetting the merchant portal password via Firebase native flow would destroy their encryption โ exactly the Issue #245 problem that motivated the admin password decoupling.
The fix is to mirror the admin pattern: a separate merchantPasswordHash on merchantProfiles, separate sign-in / reset endpoints, separate setup screen.
Constraints โ
- Greenfield merchant accounts. No real merchant accounts exist yet โ no migration constraints.
- Encryption isolation. A merchant who is also a Lantern user must be able to reset their merchant portal password without affecting their Lantern passphrase or encrypted personal data.
- Two surfaces, one user. A merchant may need to access both the merchant portal (
apps/admin, role-gated to/merchants/:id/*) and the Lantern consumer app (apps/web). They get two passwords end-state โ one per surface โ by design. - No multi-role users. A given uid is either an admin or a merchant or neither, never both. Existing
EMAIL_IN_USE_AS_ADMINguards stay.
v1 Scope โ
Architecture โ
Two coexisting passwords per merchant user:
| Password | Stored | Purpose | Reset destroys encryption? |
|---|---|---|---|
| Firebase Auth password | Firebase Auth (= Lantern passphrase) | Lantern consumer app sign-in; derives encryption keys | Yes โ by design |
merchantPasswordHash + merchantPasswordSalt | merchantProfiles/{uid} | Merchant portal sign-in (admin app) | No โ independent of Firebase |
Auth surfaces:
- Lantern app (
apps/web) โ Firebase Auth (untouched) - Admin/merchant portal (
apps/admin) โ triessignInAdminโ falls through to newsignInMerchantโ falls through to Firebase legacy โ errors out
Every admin auth concept gets a merchant twin:
| Admin (today) | Merchant (new) |
|---|---|
POST /auth/admin/signin | POST /auth/merchant/signin |
POST /auth/admin/password | POST /auth/merchant/password |
POST /auth/admin/password/reset | POST /auth/merchant/password/reset |
GET /auth/admin/password/reset/:token | GET /auth/merchant/password/reset/:token |
GET /auth/admin/status | GET /auth/merchant/status |
adminPasswordResetTokens/{token} | merchantPasswordResetTokens/{token} |
adminProfiles.adminPasswordHash | merchantProfiles.merchantPasswordHash |
<SetAdminPassword> (mode=adminReset) | <SetMerchantPassword> (mode=merchantReset) |
The hashing service (services/adminAuth.service.js) is renamed/generalized to services/passwordHash.service.js and reused by both admin and merchant route files. Single source of truth for hashPassword / verifyPassword.
Data model โ
merchantProfiles/{uid} โ adds these fields (alongside today's contactName, phone, notes, status, createdAt, createdBy):
{
merchantPasswordHash: string, // scrypt hash, base64 (same algo as admin: N=16384, r=8, p=1, keyLen=64)
merchantPasswordSalt: string, // base64-encoded 32-byte salt
merchantPasswordResetRequired: boolean, // forces reset on next sign-in
failedLoginAttempts: number, // increments on bad password (no lockout enforcement v1)
lastFailedLoginAt: serverTimestamp,
lastMerchantLoginAt: serverTimestamp,
}merchantPasswordResetTokens/{token} โ new collection, mirrors adminPasswordResetTokens:
{
userId: string,
email: string,
setupKind: 'fresh' | 'promotion' | 'reset', // drives setup-screen branching
firebaseOobCode: string | null, // present only when setupKind === 'fresh'
createdAt: serverTimestamp,
used: boolean,
}24-hour expiry (mirrors admin), enforced server-side in GET /auth/merchant/password/reset/:token.
Backend changes (services/api/auth/) โ
New: services/api/auth/src/routes/merchantAuth.js โ
Mirror of adminAuth.js. Mounted at /auth/merchant in src/index.js.
| Endpoint | Auth | Purpose |
|---|---|---|
POST /auth/merchant/signin | none | Email + merchant portal password โ custom token (role: 'merchant', merchantAuth: true) |
POST /auth/merchant/password | bearer (or resetToken in body) | Set/change merchant portal password |
POST /auth/merchant/password/reset | none | Request reset email; safe-response, mirrors admin |
GET /auth/merchant/password/reset/:token | none | Verify reset token validity (returns email, setupKind, expiry status) |
GET /auth/merchant/status?email=โฆ | none | { isMerchant, passwordSet, resetRequired } |
POST /auth/merchant/signin semantics:
1. getUserByEmail โ 401 if not found
2. role !== 'merchant' โ 401 INVALID_CREDENTIALS
3. merchantProfiles missing โ 401 INVALID_CREDENTIALS
4. merchantPasswordHash unset โ 428 MERCHANT_PASSWORD_NOT_SET
5. password mismatch โ increment failedLoginAttempts, 401 INVALID_CREDENTIALS
6. merchantPasswordResetRequired โ 428 MERCHANT_PASSWORD_RESET_REQUIRED
7. Mint custom token { role: 'merchant', merchantAuth: true }, reset failedLoginAttempts,
set lastMerchantLoginAt, audit to adminActions, return tokenModified endpoints โ
POST /auth/admin/users/merchant(used by/merchants/new) โ replacegeneratePasswordResetLinkinvocation with logic that:- Generates a Firebase oobCode via
generatePasswordResetLink(email)(kept โ fresh users still need a Lantern passphrase) - Creates a
merchantPasswordResetTokensdoc withsetupKind: 'fresh'and the capturedfirebaseOobCode - Email link becomes
?mode=merchantReset&token=โฆ
- Generates a Firebase oobCode via
POST /auth/admin/merchants/:merchantId/users(the create-merchant-user endpoint shipped in the previous PR) โ same pattern, withsetupKindbranching onwasPromotion:wasPromotion: falseโsetupKind: 'fresh', capturefirebaseOobCodewasPromotion: trueโsetupKind: 'promotion',firebaseOobCode: null(existing Lantern passphrase preserved)
Renamed for reuse โ
services/api/auth/src/services/adminAuth.service.jsโservices/api/auth/src/services/passwordHash.service.js- Rename only; same exports (
hashPassword,verifyPassword) - Update imports in
routes/adminAuth.jsand the newroutes/merchantAuth.js
- Rename only; same exports (
New email template โ
services/api/auth/src/lib/email.jsaddssendMerchantPasswordResetEmail(mirrorssendAdminPasswordResetEmail)sendMerchantInviteEmailis updated to use the new merchant-token URL pattern (?mode=merchantReset&token=โฆ) and accepts asetupKindso subject/copy can vary slightly between fresh and promotion
Frontend changes (apps/admin/) โ
New: src/components/SetMerchantPassword.jsx โ
Mirror of SetAdminPassword.jsx. Handles all three setupKind values:
fresh: two steps โ (1) set Lantern passphrase viaconfirmPasswordReset(firebaseOobCode, passphrase)(Firebase native, client-side); (2) set merchant portal password viaPOST /auth/merchant/password. Both must complete before the merchant token is markedused.promotion: one step โ only step 2. Existing Firebase password / Lantern passphrase untouched.reset: one step โ only step 2. Same as promotion. Forgot-password flow.
If a fresh setup is resumed after step 1 already completed (oobCode consumed but token unused), the screen catches auth/expired-action-code / auth/invalid-action-code from confirmPasswordReset and skips straight to step 2. Token still works.
Modified โ
src/App.jsx:- Add a
mode === 'merchantReset'URL handler near the'adminReset'one, routing to<SetMerchantPassword> handleSignInWithEmailbecomes a three-tier fall-through:signInWithAdminPasswordโ success or non-401 error: stop- On 401 from admin signin โ
signInWithMerchantPasswordโ success or non-401 error: stop - On 401 from merchant signin OR
MERCHANT_PASSWORD_NOT_SET/ADMIN_PASSWORD_NOT_SETโsignInWithEmail(Firebase legacy) - Final failure โ "Incorrect email or password"
- Add a
src/components/LoginScreen.jsx:handlePasswordResetcalls bothrequestAdminPasswordResetandrequestMerchantPasswordResetviaPromise.allSettled. Each endpoint independently safe-responds; the user sees one generic confirmation, the email arrives if and only if their account matches one of the roles.src/firebase.js: addsignInWithMerchantPassword,requestMerchantPasswordReset,verifyMerchantResetToken,setMerchantPassword,checkMerchantPasswordStatus(re-exports of the new auth API client methods)src/lib/authApi.js: addsignInMerchant,requestMerchantPasswordReset,verifyMerchantResetToken,setMerchantPassword,checkMerchantPasswordStatus
Onboarding flow (end-to-end) โ
Fresh user (brand-new email) โ
- Admin clicks "Create Merchant User" or completes
/merchants/new. - Backend creates Firebase user, sets
role: 'merchant'claim, writes Firestore docs, generatesfirebaseOobCode, creates amerchantPasswordResetTokensdoc withsetupKind: 'fresh', sends invite email. - Email contains:
https://admin.dev.ourlantern.app?mode=merchantReset&token=Y <SetMerchantPassword>renders, validates token (GET /auth/merchant/password/reset/:tokenโ returns{ valid, email, setupKind: 'fresh', firebaseOobCode }). ForsetupKind: 'fresh'the response includes thefirebaseOobCodefrom the token doc so the client can run step 1; for'promotion'and'reset'it'snull.- Step 1: user enters passphrase โ client-side
confirmPasswordReset(oobCode, passphrase)โ Firebase Auth password set. - Step 2: user enters portal password โ
POST /auth/merchant/passwordwith{ resetToken, password }โ server hashes, writesmerchantProfiles, marks tokenused. - Redirect to
LoginScreen. Merchant can now sign into the portal. They can also sign into the Lantern app with the passphrase from step 5.
Promoted user (existing Lantern user) โ
Same as above, but step 2 of the backend skips generatePasswordResetLink, emits setupKind: 'promotion' with firebaseOobCode: null. The setup screen shows only step 2. Existing Firebase password / Lantern passphrase preserved.
Forgot password (any merchant) โ
- User enters email on
LoginScreen"Forgot password?" form. - Both
requestAdminPasswordResetandrequestMerchantPasswordResetfire in parallel; each returns the safe response. - If the email matches a merchant: server creates a
merchantPasswordResetTokensdoc withsetupKind: 'reset', sendssendMerchantPasswordResetEmail. - User clicks link โ
<SetMerchantPassword>step 2 only โ portal password reset. - Firebase Auth password / Lantern passphrase never touched. Encryption preserved.
Sign-in matrix โ
| User type | Admin signin | Merchant signin | Legacy Firebase | Outcome |
|---|---|---|---|---|
| Admin only | success | n/a | n/a | signed in as admin |
Admin only, no adminPasswordHash | 428 NOT_SET | n/a | success | signed in as admin (legacy fallback) |
| Merchant only | 401 (not admin) | success | n/a | signed in as merchant |
Merchant only, no merchantPasswordHash | 401 (not admin) | 428 NOT_SET | success | signed in as merchant (legacy fallback โ unreachable once both create endpoints are migrated, kept as safety net) |
| Lantern user, no role | 401 (not admin) | 401 (not merchant) | success | signed in as lantern user |
| Wrong password (any) | 401 | 401 | fail | "Incorrect email or password" |
Permissions / filtering โ
No change to existing scoping:
AdminDashboard.jsx:160-168(merchantOnlyflag) confinesrole: 'merchant'users to/merchants(index redirect routes them to their own merchant detail).firestore.rulesrequires no changes; the new collections live under existing admin-readable paths and are written only by the auth API service account.
Error handling โ
Backend โ structured error codes returned as 4xx:
INVALID_CREDENTIALS(401) โ covers wrong password, wrong role, missing user, missing profile (deliberately ambiguous to prevent enumeration)MERCHANT_PASSWORD_NOT_SET(428) โ merchant account exists butmerchantPasswordHashis unset; client should fall through to legacyMERCHANT_PASSWORD_RESET_REQUIRED(428) โmerchantPasswordResetRequiredis true; client should prompt forgot-password flowINVALID_TOKEN(404),TOKEN_EXPIRED(410),TOKEN_USED(409) โ for setup/reset token verification
Client: LoginScreen surfaces the standard error UX. SetMerchantPassword surfaces token-validation errors with a "Request a new link" CTA that triggers the forgot-password flow.
Audit logging โ
All merchant auth events append to the existing adminActions collection (consistent with admin auth events):
merchantLogin,merchantLoginFailed,requestMerchantPasswordReset,setMerchantPassword
Testing โ
Backend (auth-API) โ new vitest tests โ
services/api/auth/src/routes/__tests__/merchantAuth.test.js covering:
- signin: happy path; wrong password (increments
failedLoginAttempts); user doesn't exist; user is admin (returns 401, not 428); user is merchant but nomerchantPasswordHash(428);merchantPasswordResetRequired(428) - password (set/change): valid resetToken; expired/used token (410/409); password too short (400); successful change increments timestamp + clears
merchantPasswordResetRequired - password/reset (request): unknown email returns SAFE_RESPONSE; admin email returns SAFE_RESPONSE (no email sent); merchant email creates token + sends email
- password/reset/:token (verify): valid; expired (410); already-used (410); not-found (404)
- status: all five branches (
isMerchant: false,isMerchant: trueรpasswordSetรresetRequired)
The auth-API workspace currently lacks route-level test infrastructure. This spec includes scaffolding (Express + Firebase emulator + supertest) as part of the implementation. If the implementation plan judges this too large to bundle, the fallback is unit tests around extracted helpers (token validation, password hashing) plus manual smoke for endpoint coverage โ but the recommendation is full integration tests since this is auth surface area.
Frontend (admin app) โ new component tests โ
SetMerchantPassword.test.jsx:
- Renders step 1 + step 2 for
setupKind: 'fresh' - Renders only step 2 for
setupKind: 'promotion'and'reset' - Skips step 1 if
confirmPasswordResetreturnsauth/expired-action-code(resumed setup) - Submits resetToken + password to
/auth/merchant/password - Surfaces backend errors
LoginScreen.test.jsx (create if missing):
handlePasswordResetcalls both admin + merchant reset endpoints- Sign-in fall-through: admin error 401 โ merchant signin called โ success
- Sign-in fall-through: both 401 โ legacy Firebase called
Manual smoke โ
- Fresh merchant onboarding end-to-end: create user โ email arrives โ click link โ set Lantern passphrase + portal password โ sign in โ land on
/merchants/:idโ forgot-password โ click link โ reset only portal password โ confirm encryption canary still readable in Lantern app. - Promoted merchant onboarding: create with email of existing Lantern user โ email arrives โ click link โ set only portal password โ sign in โ confirm Firebase password / passphrase still works in Lantern app.
- Sign-in priority: admin email + admin password โ success; merchant email + merchant password โ success; any email + wrong password โ "Incorrect."
- Forgot password: request reset for a merchant email โ only merchant reset email arrives โ reset โ portal password works, Firebase password unchanged.
Files Touched โ
New โ
services/api/auth/src/routes/merchantAuth.jsservices/api/auth/src/routes/__tests__/merchantAuth.test.js(with shared test helpers for Express + Firebase emulator + supertest scaffolding)apps/admin/src/components/SetMerchantPassword.jsxapps/admin/src/components/__tests__/SetMerchantPassword.test.jsx
Modified โ
services/api/auth/src/index.jsโ mountmerchantAuthRouterservices/api/auth/src/routes/adminMerchants.jsโPOST /:merchantId/usersuses new token flowservices/api/auth/src/routes/adminUsers.jsโPOST /merchantuses new token flowservices/api/auth/src/lib/email.jsโ addsendMerchantPasswordResetEmail; revisesendMerchantInviteEmailapps/admin/src/App.jsxโ addmerchantResetURL handler; three-tier sign-in fall-throughapps/admin/src/components/LoginScreen.jsxโhandlePasswordResetcalls both reset endpointsapps/admin/src/firebase.jsโ new merchant auth re-exportsapps/admin/src/lib/authApi.jsโ new merchant auth API client methods
Renamed โ
services/api/auth/src/services/adminAuth.service.jsโservices/api/auth/src/services/passwordHash.service.js- Update imports in
services/api/auth/src/routes/adminAuth.js
Intentionally untouched โ
apps/web/(Lantern consumer app) โ no changes; merchants who are also Lantern users use the existing Firebase Auth flow there.firestore.rulesโ no rule changes needed.apps/admin/src/components/SetAdminPassword.jsx,apps/admin/src/components/CreateAdminForm.jsx, etc. โ admin flow stays as-is.
Out of Scope (deferred) โ
apps/merchant/separate app split โ still deferred per the previous spec./auth/merchant/*namespace beyond auth โ endpoints derived from the auth claim rather than URL params; defer until a second consumer feels the duplication.- Multi-role users (a uid being both admin and merchant) โ already prevented by guard rails; not enabling here.
- Lockout policy โ
failedLoginAttemptsis tracked but no enforcement (mirrors admin). Add a lockout follow-up if abuse is observed. - Multi-factor auth on merchant portal โ not in this pass.
- Email change flow for merchants โ not in this pass; admin would have to delete + recreate.
Detach merchantaction (the inverse of attach) โ still deferred per the previous spec.
If the implementer thinks any of these are needed for v1, stop and re-confirm with the user.