Authentication & Authorization

Overview

Evergrn uses stateless JWT authentication. There is no session store โ€” every request is self-contained. The server verifies the token's signature on each request and reads the embedded id and role claim to identify the caller.

There are five distinct token types:

Role / Type Who Token expiry
user Customer accounts 7 days
provider Professional accounts 7 days
admin GodMode only [GODMODE] 24 hours
staff (type field) Internal support staff 12 hours
(staff impersonation) Staff spoofing a user/provider during elevation 15 minutes
(admin impersonation) Admin spoofing a user/provider [GODMODE] 2 hours

Token Format

Tokens are signed JWTs using HS256 (HMAC-SHA256) with JWT_SECRET from .env.

Payload structure:

{ "id": "<cuid>", "role": "user | provider | admin", "iat": ..., "exp": ... }

The id field refers to User.id, Provider.id, or Admin.id depending on role.


Signing & Verification

Signing (at login/register, in src/routes/auth.js):

jwt.sign({ id: user.id, role: 'user' }, process.env.JWT_SECRET, { expiresIn: '7d' })

Verification (in src/middleware/auth.js):

const payload = jwt.verify(token, process.env.JWT_SECRET)
req.user = { id: payload.id, role: payload.role }

The middleware extracts the token from the Authorization: Bearer <token> header. It returns 401 if missing, expired, or tampered. The decoded payload is attached to req.user for downstream route handlers.


Password Hashing

All passwords are hashed with bcryptjs at 12 salt rounds before storage. This applies to User, Provider, and Admin accounts.

const hashed = await bcrypt.hash(password, 12)
// stored in DB

const match = await bcrypt.compare(plaintext, hashed)
// verified at login

12 rounds produces ~250ms of hashing time per operation on modern hardware โ€” high enough to make brute-force impractical, low enough to not impact UX.


Customer Auth Flow

Registration โ€” POST /auth/register

Client โ†’ POST /auth/register { email, password, name, address, zip }
         โ†“
Server: hash password โ†’ prisma.user.create()
         โ†“
Response: { token, user: { id, email, name } }

Login โ€” POST /auth/login

Client โ†’ POST /auth/login { email, password }
         โ†“
Server: findUnique(email) โ†’ bcrypt.compare() โ†’ sign JWT
         โ†“
Response: { token, user: { id, email, name } }

On error (email not found, wrong password): returns 401 with { error: 'Invalid credentials' }. The same error message is returned for both cases to prevent email enumeration.


Provider Auth Flow

Identical to customer auth but uses Provider model and role 'provider'.

Registration โ€” POST /auth/provider/register

Client โ†’ POST /auth/provider/register { email, password, name, phone, serviceZips[], services[] }
         โ†“
Server: hash password โ†’ prisma.provider.create()
         โ†“
Response: { token, provider: { id, email, name } }

Login โ€” POST /auth/provider/login

Client โ†’ POST /auth/provider/login { email, password }
         โ†“
Response: { token, provider: { id, email, name } }

Token Storage

Web (React)

Tokens are stored in localStorage under the key auth_token. The user object is stored separately under auth_user.

// AuthContext.jsx
login(token, user) {
  localStorage.setItem('auth_token', token)
  localStorage.setItem('auth_user', JSON.stringify(user))
}
logout() {
  localStorage.removeItem('auth_token')
  localStorage.removeItem('auth_user')
}

The context also decodes the JWT client-side (reading the role claim from token.split('.')[1]) to determine which dashboard to render.

Security note: localStorage is vulnerable to XSS. See vulnerabilities.md for mitigation recommendations.

Mobile (React Native)

Tokens are stored in Expo SecureStore, which uses the device's secure enclave (iOS Keychain / Android Keystore). This is significantly more secure than localStorage.

// AuthContext.js
await SecureStore.setItemAsync('auth_token', token)
const token = await SecureStore.getItemAsync('auth_token')
await SecureStore.deleteItemAsync('auth_token')

Authorization on Routes

Most routes call authenticate middleware first:

router.get('/jobs', authenticate, async (req, res) => {
  // req.user = { id, role }
})

Role checks inside route handlers follow this pattern:

if (req.user.role !== 'provider') return res.status(403).json({ error: 'Forbidden' })

Access control matrix

Endpoint user provider admin unauth
POST /auth/register โœ“ โœ“ โ€” open
POST /auth/login โœ“ โœ“ โ€” open
GET /jobs โœ“ โœ“ โ€” 401
POST /jobs โœ“ only โ€” โ€” 401
GET /jobs/:id owner or matching provider if PENDING/QUOTED โ€” 401
GET /jobs/reportable โœ“ only โ€” โ€” 401
POST /jobs/:id/report owner only โ€” โ€” 401
POST /quotes โ€” โœ“ only โ€” 401
POST /quotes/:id/accept owner only โ€” โ€” 401
GET /providers/me/* โ€” โœ“ only โ€” 401
POST /providers/me/push-token โ€” โœ“ only โ€” 401
GET /providers/me/notification โ€” โœ“ only โ€” 401
POST /reviews โœ“ only โ€” โ€” 401
GET /payments/method โœ“ only โ€” โ€” 401
GET /services/available โœ“ only โ€” โ€” 401
GET /users/me โœ“ only โ€” โ€” 401
GET/POST /users/me/addresses โœ“ only โ€” โ€” 401
PATCH /users/me/addresses/:id/primary โœ“ only โ€” โ€” 401
DELETE /users/me/addresses/:id โœ“ only โ€” โ€” 401
POST /users/me/push-token โœ“ only โ€” โ€” 401
GET /users/me/notification โœ“ only โ€” โ€” 401
/admin/* โ€” โ€” โœ“ only 401

Staff Auth Flow

Overview

Staff accounts are separate from customer and provider accounts. They have their own Staff model, their own login endpoint, and JWT tokens with type: 'staff' in the payload. Four roles are supported: customer_support, professional_support, technical_support, internal_audit.

Login โ€” POST /staff/login

Client โ†’ POST /staff/login { email, password }
         โ†“
Server: prisma.staff.findUnique() โ†’ bcrypt.compare() โ†’ sign JWT { id, role, type: 'staff', exp: 12h }
         โ†“
Response: { token, staff: { id, name, email, role } }

Token Verification

Staff middleware (requireStaff) verifies both the JWT signature and the type: 'staff' claim. A regular user or provider token cannot access staff routes even with a valid signature.

// requireStaff(roles = []) โ€” used inline in src/routes/staff.js
const payload = jwt.verify(header.replace('Bearer ', ''), process.env.JWT_SECRET)
if (payload.type !== 'staff') return res.status(403).json({ error: 'Forbidden' })
if (roles.length && !roles.includes(payload.role)) return res.status(403).json({ error: 'Forbidden' })
req.staff = payload

A separate requireAdmin function checks payload.role === 'admin' (the GodMode admin token, not a staff token) for staff management endpoints (GET /staff/list, POST /staff/create).

Elevation / Impersonation Flow

When a staff member needs to access a customer or provider account to investigate an issue, they go through an elevation flow:

1. Staff submits POST /staff/elevation { targetId, targetRole, reason }
         โ†“
2. Email sent to admin with Approve / Deny links
         โ†“
3. Admin clicks Approve โ†’ ElevationRequest.status โ†’ APPROVED
         โ†“
4. Staff calls POST /staff/impersonate { elevationId }
   Server issues: jwt.sign({ id: target.id, role: target.role, elevationId }, exp: '15m')
   ElevationRequest.status โ†’ ACTIVE
         โ†“
5. Staff uses the 15-minute token as a normal Authorization header on any endpoint
   Every request logged to AuditEvent by the global auditLog middleware
         โ†“
6. Token expires (15 min) โ€” auto-deactivated on next staff portal refresh
   Staff can also manually deactivate: PATCH /staff/elevation/:id/deactivate

The impersonation token contains elevationId so the audit middleware can identify which elevation session each request belongs to. All API calls made with this token are permanently recorded in AuditEvent (elevationId, method, path, body).

Access Control โ€” Staff Routes

Endpoint customer_support professional_support technical_support internal_audit admin (GODMODE)
GET /staff/customers โœ“ โ€” โ€” โ€” โ€”
GET /staff/customers/:id โœ“ โ€” โ€” โ€” โ€”
GET /staff/providers โ€” โœ“ โ€” โ€” โ€”
GET /staff/providers/:id โ€” โœ“ โ€” โ€” โ€”
POST /staff/elevation โœ“ โœ“ โ€” โ€” โ€”
POST /staff/impersonate โœ“ โœ“ โ€” โ€” โ€”
GET /staff/tickets โ€” โ€” โœ“ โ€” โ€”
PATCH /staff/tickets/:id โ€” โ€” โœ“ โ€” โ€”
GET /staff/audit/sessions โ€” โ€” โ€” โœ“ โ€”
GET /staff/list โ€” โ€” โ€” โ€” โœ“
POST /staff/create โ€” โ€” โ€” โ€” โœ“
POST /staff/impersonate-staff โ€” โ€” โ€” โ€” โœ“
GET/PATCH elevation (own) โœ“ โœ“ โœ“ โœ“ โ€”

GodMode / Impersonation Flow [GODMODE]

This flow is dev-only and must be removed before production. See vulnerabilities.md.

Admin Login โ€” POST /admin/login

Rate-limited to 10 attempts per 15 minutes per IP.

Client โ†’ POST /admin/login { email, password }
Server: find Admin record โ†’ bcrypt.compare() โ†’ sign JWT { role: 'admin', exp: 24h }
Response: { token }

The token is stored in localStorage under godmode_admin_token (separate from normal auth).

Impersonation โ€” POST /admin/impersonate

Admin client โ†’ POST /admin/impersonate { targetId, targetRole }
               Authorization: Bearer <admin_token>
               โ†“
Server: find User/Provider โ†’ sign JWT { id: target.id, role: target.role, exp: 2h }
Response: { token, identity: { id, email, name, role } }

The admin's original token is preserved in localStorage. The impersonation token is loaded into AuthContext as a normal login. The ImpersonationBanner component detects the presence of godmode_admin_token alongside a logged-in user to show the purple exit bar.

Exit Impersonation

Exit button โ†’ logout() (clears AuthContext + auth_token) โ†’ navigate('/godmode')

The godmode_admin_token persists through impersonation and is cleared only on admin sign-out.


Password Reset Flow

Security design

Database model

PasswordResetToken {
  id        cuid PK
  tokenHash String  @unique   โ† SHA-256(rawToken)
  email     String            โ† account email
  role      String            โ† 'user' | 'provider'
  expiresAt DateTime
  createdAt DateTime
  @@index([email, role])
}

Full flow

1. User clicks "Forgot password?" on login screen
         โ†“
2. POST /auth/forgot-password { email, role }
   Server: look up account โ†’ delete old tokens โ†’ generate rawToken โ†’
           store SHA256(rawToken) + expiry โ†’ send email with link
         โ†“
3. Email arrives: "Reset password" button links to
   {APP_URL}/reset-password?token={rawToken}
         โ†“
4. User clicks link โ†’ ResetPassword page loads, reads token from URL query param
   (Mobile: user opens link in browser, which opens the web reset page)
         โ†“
5. POST /auth/reset-password { token, newPassword }
   Server: SHA256(token) โ†’ look up record โ†’ check expiry โ†’
           bcrypt.hash(newPassword, 12) โ†’ update User or Provider password โ†’
           delete PasswordResetToken โ†’ return { message, role }
         โ†“
6. Web: auto-redirects to /login or /provider/login after 3 seconds
   Mobile: user sees success state, navigates back to sign in

Required environment variables

APP_URL    = http://localhost:5173  (dev) | https://yourdomain.com (prod)
SMTP_HOST  = smtp.gmail.com
SMTP_PORT  = 587
SMTP_USER  = your@email.com
SMTP_PASS  = your-app-password-or-smtp-key

Compatible SMTP providers: Gmail (app password), SendGrid, Mailgun, Resend, Postmark, AWS SES.

New files


Stripe Payment Auth

Card save and payment flows are separate from user auth but use the same JWT to identify the customer:

  1. Save card: POST /payments/setup-intent returns a Stripe clientSecret. The web frontend uses Stripe Elements to collect card details and confirm the SetupIntent client-side. Then POST /payments/payment-method tells the backend which PaymentMethod ID was confirmed.

  2. Payment authorization: When a customer accepts a quote, the backend creates a PaymentIntent with capture_method: 'manual' and off_session: true, charging the saved card immediately but only authorizing (not capturing) the funds.

  3. Capture: When the provider marks the job COMPLETED (via PATCH /jobs/:id/status or POST /jobs/:id/complete), the backend calls stripe.paymentIntents.capture(id).

  4. Webhooks: POST /webhooks handles Stripe events (payment_intent.succeeded, payment_intent.payment_failed, etc.) to keep Payment.status in sync. The raw request body is preserved for Stripe's signature verification using STRIPE_WEBHOOK_SECRET.