Skip to content

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:

  1. Backend (adminMerchants.js:411-435, adminUsers.js:379) generates a Firebase-native password reset link via auth.generatePasswordResetLink() and emails it via Resend.
  2. Merchant clicks the link โ†’ Firebase hosted action page โ†’ sets a Firebase Auth password โ†’ redirects to admin.dev.ourlantern.app.
  3. Merchant attempts to sign in โ†’ LoginScreen calls signInWithAdminPassword โ†’ /auth/admin/signin (adminAuth.js:46-48) hard-rejects any user where customClaims.role !== 'admin' with INVALID_CREDENTIALS.
  4. App.jsx:123-127 catches the resulting Incorrect email or password. and deliberately does not fall back to Firebase legacy auth.
  5. Merchant is locked out. The "Forgot password?" flow on LoginScreen calls requestAdminPasswordReset, 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_ADMIN guards stay.

v1 Scope โ€‹

Architecture โ€‹

Two coexisting passwords per merchant user:

PasswordStoredPurposeReset destroys encryption?
Firebase Auth passwordFirebase Auth (= Lantern passphrase)Lantern consumer app sign-in; derives encryption keysYes โ€” by design
merchantPasswordHash + merchantPasswordSaltmerchantProfiles/{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) โ†’ tries signInAdmin โ†’ falls through to new signInMerchant โ†’ falls through to Firebase legacy โ†’ errors out

Every admin auth concept gets a merchant twin:

Admin (today)Merchant (new)
POST /auth/admin/signinPOST /auth/merchant/signin
POST /auth/admin/passwordPOST /auth/merchant/password
POST /auth/admin/password/resetPOST /auth/merchant/password/reset
GET /auth/admin/password/reset/:tokenGET /auth/merchant/password/reset/:token
GET /auth/admin/statusGET /auth/merchant/status
adminPasswordResetTokens/{token}merchantPasswordResetTokens/{token}
adminProfiles.adminPasswordHashmerchantProfiles.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):

js
{
  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:

js
{
  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.

EndpointAuthPurpose
POST /auth/merchant/signinnoneEmail + merchant portal password โ†’ custom token (role: 'merchant', merchantAuth: true)
POST /auth/merchant/passwordbearer (or resetToken in body)Set/change merchant portal password
POST /auth/merchant/password/resetnoneRequest reset email; safe-response, mirrors admin
GET /auth/merchant/password/reset/:tokennoneVerify 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 token

Modified endpoints โ€‹

  • POST /auth/admin/users/merchant (used by /merchants/new) โ€” replace generatePasswordResetLink invocation with logic that:
    • Generates a Firebase oobCode via generatePasswordResetLink(email) (kept โ€” fresh users still need a Lantern passphrase)
    • Creates a merchantPasswordResetTokens doc with setupKind: 'fresh' and the captured firebaseOobCode
    • Email link becomes ?mode=merchantReset&token=โ€ฆ
  • POST /auth/admin/merchants/:merchantId/users (the create-merchant-user endpoint shipped in the previous PR) โ€” same pattern, with setupKind branching on wasPromotion:
    • wasPromotion: false โ†’ setupKind: 'fresh', capture firebaseOobCode
    • wasPromotion: 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.js and the new routes/merchantAuth.js

New email template โ€‹

  • services/api/auth/src/lib/email.js adds sendMerchantPasswordResetEmail (mirrors sendAdminPasswordResetEmail)
  • sendMerchantInviteEmail is updated to use the new merchant-token URL pattern (?mode=merchantReset&token=โ€ฆ) and accepts a setupKind so 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 via confirmPasswordReset(firebaseOobCode, passphrase) (Firebase native, client-side); (2) set merchant portal password via POST /auth/merchant/password. Both must complete before the merchant token is marked used.
  • 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>
    • handleSignInWithEmail becomes a three-tier fall-through:
      1. signInWithAdminPassword โ€” success or non-401 error: stop
      2. On 401 from admin signin โ†’ signInWithMerchantPassword โ€” success or non-401 error: stop
      3. On 401 from merchant signin OR MERCHANT_PASSWORD_NOT_SET / ADMIN_PASSWORD_NOT_SET โ†’ signInWithEmail (Firebase legacy)
      4. Final failure โ†’ "Incorrect email or password"
  • src/components/LoginScreen.jsx: handlePasswordReset calls both requestAdminPasswordReset and requestMerchantPasswordReset via Promise.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: add signInWithMerchantPassword, requestMerchantPasswordReset, verifyMerchantResetToken, setMerchantPassword, checkMerchantPasswordStatus (re-exports of the new auth API client methods)
  • src/lib/authApi.js: add signInMerchant, requestMerchantPasswordReset, verifyMerchantResetToken, setMerchantPassword, checkMerchantPasswordStatus

Onboarding flow (end-to-end) โ€‹

Fresh user (brand-new email) โ€‹

  1. Admin clicks "Create Merchant User" or completes /merchants/new.
  2. Backend creates Firebase user, sets role: 'merchant' claim, writes Firestore docs, generates firebaseOobCode, creates a merchantPasswordResetTokens doc with setupKind: 'fresh', sends invite email.
  3. Email contains: https://admin.dev.ourlantern.app?mode=merchantReset&token=Y
  4. <SetMerchantPassword> renders, validates token (GET /auth/merchant/password/reset/:token โ†’ returns { valid, email, setupKind: 'fresh', firebaseOobCode }). For setupKind: 'fresh' the response includes the firebaseOobCode from the token doc so the client can run step 1; for 'promotion' and 'reset' it's null.
  5. Step 1: user enters passphrase โ†’ client-side confirmPasswordReset(oobCode, passphrase) โ†’ Firebase Auth password set.
  6. Step 2: user enters portal password โ†’ POST /auth/merchant/password with { resetToken, password } โ†’ server hashes, writes merchantProfiles, marks token used.
  7. Redirect to LoginScreen. Merchant can now sign into the portal. They can also sign into the Lantern app with the passphrase from step 5.

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) โ€‹

  1. User enters email on LoginScreen "Forgot password?" form.
  2. Both requestAdminPasswordReset and requestMerchantPasswordReset fire in parallel; each returns the safe response.
  3. If the email matches a merchant: server creates a merchantPasswordResetTokens doc with setupKind: 'reset', sends sendMerchantPasswordResetEmail.
  4. User clicks link โ†’ <SetMerchantPassword> step 2 only โ†’ portal password reset.
  5. Firebase Auth password / Lantern passphrase never touched. Encryption preserved.

Sign-in matrix โ€‹

User typeAdmin signinMerchant signinLegacy FirebaseOutcome
Admin onlysuccessn/an/asigned in as admin
Admin only, no adminPasswordHash428 NOT_SETn/asuccesssigned in as admin (legacy fallback)
Merchant only401 (not admin)successn/asigned in as merchant
Merchant only, no merchantPasswordHash401 (not admin)428 NOT_SETsuccesssigned in as merchant (legacy fallback โ€” unreachable once both create endpoints are migrated, kept as safety net)
Lantern user, no role401 (not admin)401 (not merchant)successsigned in as lantern user
Wrong password (any)401401fail"Incorrect email or password"

Permissions / filtering โ€‹

No change to existing scoping:

  • AdminDashboard.jsx:160-168 (merchantOnly flag) confines role: 'merchant' users to /merchants (index redirect routes them to their own merchant detail).
  • firestore.rules requires 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 but merchantPasswordHash is unset; client should fall through to legacy
  • MERCHANT_PASSWORD_RESET_REQUIRED (428) โ€” merchantPasswordResetRequired is true; client should prompt forgot-password flow
  • INVALID_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 no merchantPasswordHash (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 confirmPasswordReset returns auth/expired-action-code (resumed setup)
  • Submits resetToken + password to /auth/merchant/password
  • Surfaces backend errors

LoginScreen.test.jsx (create if missing):

  • handlePasswordReset calls 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 โ€‹

  1. 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.
  2. 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.
  3. Sign-in priority: admin email + admin password โ†’ success; merchant email + merchant password โ†’ success; any email + wrong password โ†’ "Incorrect."
  4. 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.js
  • services/api/auth/src/routes/__tests__/merchantAuth.test.js (with shared test helpers for Express + Firebase emulator + supertest scaffolding)
  • apps/admin/src/components/SetMerchantPassword.jsx
  • apps/admin/src/components/__tests__/SetMerchantPassword.test.jsx

Modified โ€‹

  • services/api/auth/src/index.js โ€” mount merchantAuthRouter
  • services/api/auth/src/routes/adminMerchants.js โ€” POST /:merchantId/users uses new token flow
  • services/api/auth/src/routes/adminUsers.js โ€” POST /merchant uses new token flow
  • services/api/auth/src/lib/email.js โ€” add sendMerchantPasswordResetEmail; revise sendMerchantInviteEmail
  • apps/admin/src/App.jsx โ€” add merchantReset URL handler; three-tier sign-in fall-through
  • apps/admin/src/components/LoginScreen.jsx โ€” handlePasswordReset calls both reset endpoints
  • apps/admin/src/firebase.js โ€” new merchant auth re-exports
  • apps/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 โ€” failedLoginAttempts is 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 merchant action (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.

Built with VitePress