Skip to content

Authentication & Security

Detailed documentation of the authentication system, token lifecycle, OAuth flows, partner auth, and authorization model.


Architecture Overview

The system uses a hybrid JWT + AES-256 encryption scheme for token security, with Passport.js handling OAuth flows for social login providers.

Layer Technology Purpose
Token Encryption JWT + CryptoJS AES Double-layer token protection
OAuth Passport.js (6 strategies) Social login/account connection
Partner Auth Plain JWT (issuer/audience) Third-party API access
Session express-session + Passport OAuth flow state
Authorization checkuseraccount() Account-level access control

Token Generation & Verification

File: helper/tokenGenerator.js

Encryption (encryptData)

1. JSON.stringify(payload)
2. CryptoJS.AES.encrypt(jsonString, JWT_SECRET_KEY) β†’ ciphertext
3. jwt.sign({ data: ciphertext }, JWT_SECRET_KEY, { expiresIn }) β†’ token

The result is a JWT whose payload contains AES-encrypted data. This provides: - Tamper detection (JWT signature) - Payload confidentiality (AES encryption)

Decryption (decryptData)

1. jwt.verify(token, JWT_SECRET_KEY) β†’ { data: ciphertext }
2. CryptoJS.AES.decrypt(ciphertext, JWT_SECRET_KEY) β†’ jsonString
3. JSON.parse(jsonString) β†’ payload object

Token Payload Structure

{
  userId: number,          // tMember.id
  role: number,            // tMember.role_type
  defaultPaymentId: number, // Payment provider (2 = Paddle)
  accountId: number,       // Active account context
  isPersonnel: boolean     // Staff/personnel flag
}

Token Lifetimes

Token Type Expiry Use Case
User login JWT_EXPIRES_IN env var (e.g., 7d) Main API access
OAuth callback 10 minutes Temporary token during OAuth redirect
Partner API 1 hour Partner endpoint access
Email verification JWT_EXPIRES_IN Account activation links

Auth Middleware

File: middlewares/auth.js

Applied globally to routes/auth.js via router.use(auth).

Flow

  1. Extract token from request header (TOKEN_HEADER_KEY env var)
  2. Check in-memory revocation set
  3. Call decryptData(token) β€” verifies JWT signature + decrypts AES payload
  4. On success: set decoded fields on res object
  5. On failure: return 401 with plain string "unauthorized access" (not the standard envelope shape β€” see error-handling.md Β§Middleware Errors)

Note: parallel auth path in methods.js. methods.js exports ensureToken, an alternative to this middleware. It hardcodes the header name to "authorization" rather than reading TOKEN_HEADER_KEY, returns 403 (not 401), and uses sendStatus (no body). The two implementations are independent β€” neither calls the other. Routes choose which to apply on a per-route basis. This dual-path pattern is itself a finding flagged in enterprise-readiness Β§4.1.

Fields Set on res

Field Source Description
res.userId JWT payload Authenticated user ID
res.role JWT payload User role type
res.defaultPaymentId JWT payload Payment provider ID
res.accountId Accountid request header Active account context
res.isPersonnel JWT payload Staff flag

Important: Auth data is set on res, not req. Route handlers destructure from res.

Token Revocation

File: helper/revokeToken.js

  • In-memory Set() checked before token decryption
  • Populated on logout
  • Not persisted (lost on server restart)
  • No TTL cleanup

Login Endpoints

Standard Login (POST /login)

File: routes/routes.js (~line 3024–3086)

  1. Query tMember by email (sid) and password
  2. Generate access token: {userId}_{timestamp}_{userId}
  3. Return user data with token

⚠ Security note β€” plaintext login token. The login token is a plaintext concatenation of the user ID and a timestamp, with no AES encryption, no JWT signature, and no integrity check. This is inconsistent with the rest of the auth model (which uses the JWT+AES encryption from Β§ Token Generation), and inconsistent with what the Auth Middleware section describes (which calls decryptData expecting a JWT-AES wrapper).

Two implications: - Anyone reading this token in any log, analytics pipeline, or cookie store can extract the user ID and forge a token by guessing a timestamp. - There is an internal inconsistency: if middlewares/auth.js runs decryptData on a plaintext token, decryption will fail. Either there is a separate code path that handles login tokens (not yet identified), or login tokens cannot be used with the standard auth middleware. Investigate before relying on this section.

Action items: treat as a security finding for the backlog. Reconcile the login-token format with the rest of the auth model β€” either encrypt login tokens like all others, or document a clear exception for whatever code path consumes them.

Web Login (POST /webauthenticate) β€” πŸ”΄ CRITICAL findings

File: routes/routes.js:6522-6691. This is the endpoint the customer SPA actually calls to log in (someli-platform/nuxt.config.js β†’ auth.strategies.local.endpoints.login.url = 'webauthenticate'). Reviewed 2026-05-22 while resolving platform [VERIFY-SEC-6]. It contains three stacked critical flaws.

F1 β€” Social-login authentication bypass β†’ account takeover by email alone (Critical)

The handler accepts a social: true flag from the request body. When set, two things change:

  1. The lookup WHERE is relaxed from AND to OR (routes/routes.js:6539-6543):

    if (req.body.social) {
      conditions += `(m.username='${reqObj.email}' OR m.sid='${reqObj.sid}') AND m.isDeleted=0`;
    } else {
      conditions += `(m.username='${reqObj.email}' AND m.sid='${reqObj.sid}') AND m.isDeleted=0`;
    }
    if (reqObj.social && reqObj.social == true) {
      conditions = `${conditions} AND m.auth_pass='Welcome!1'`   // shared sentinel
    }
    
    So a victim row is selected by username = email alone (the sid half of the OR is irrelevant), gated only by the shared constant auth_pass='Welcome!1' β€” the marker carried by social-signup / passwordless accounts (same sentinel used at routes/routes.js:2898).

  2. The password check is then bypassed unconditionally (routes/routes.js:6584-6620):

    bcrypt.compare(reqObj.password, result[0].password, function (err, checkUser) {
      ...
      if (checkUser === true) { /* success */ }
      else if (reqObj.password == result[0].password) { /* success β€” see F3 */ }
      else if (reqObj.social == true) { /* SUCCESS: token issued anyway */ }
      else { /* 403 */ }
    });
    
    When social == true, a valid session token for the matched user is returned even though bcrypt failed and the plaintext compare failed. The password and sid in the request are never actually required.

Exploit (full ATO of any social-login user):

POST /webauthenticate
Header: apptype: <base64 of an allowed role string>
Body:   { "email": "victim@example.com", "password": "x", "sid": "x", "social": true }
β†’ 200, responseObj.token = encrypted session token for victim, data = victim.sid
The only inputs are the victim's email (public) and the social flag. No password, no valid sid. Note sid is not a secret β€” elsewhere it is compared to the email / social-provider ID (routes/routes.js:2459, 2657, 7703), so it carries no entropy even on paths that do check it.

Answer to platform [VERIFY-SEC-6]: the backend does not safely validate this path. sid is not validated (OR-joined and non-secret); social:true can be replayed for an arbitrary victim email; the lone gate is a hardcoded shared password sentinel. This is independent of clickjacking and far more severe.

F2 β€” Unauthenticated SQL injection on the login endpoint (Critical)

reqObj.email and reqObj.sid are concatenated raw into the query (routes/routes.js:6529-6548); /authenticate (routes/routes.js:6401) and the personnel fallback (routes/routes.js:6630) do the same. The query SELECTs m.password (the bcrypt hash) and role.policy, so a UNION-based injection both exfiltrates password hashes and controls the row used for the auth decision β€” bypassing F1's auth_pass gate and the allow-login role check for any account, social or not. This contradicts the general assumption in security.md Β§ Input Validation that injection is "mostly mitigated by parameterized queries" β€” the primary login endpoints are not parameterized.

F3 β€” Pass-the-hash via plaintext fallback (High)

else if (reqObj.password == result[0].password) (routes/routes.js:6603, 6665) authenticates when the submitted password equals the stored value. Stored values are bcrypt hashes, so submitting the hash itself as the password authenticates β€” and F2 leaks those hashes. (If any legacy rows store plaintext passwords, this is a direct compare.)

F4 β€” Credentials/PII written to logs (Medium)

console.log("resss", req.body) (routes/routes.js:6525) logs the cleartext password; console.log("ress", result, checkUser, result?.[0].password, reqObj.password) (:6585) logs the stored hash and submitted password to stdout β†’ production logs.

F5 β€” Forgeable stored token (Medium)

On success the server sets auth_token = 'Bearer ${result[0].sid}' (routes/routes.js:6594, 6604, 6613). Since sid is non-secret (see F1), the persisted credential is predictable for anyone who learns a target's sid/email.

Remediation (all Phase 0a β€” these are exploitable pre-auth): 1. Parameterize every query in /webauthenticate and /authenticate (bind email, sid); never interpolate request fields into SQL. 2. Delete the social:true token-grant branch (:6612-6620). Social login must complete through the Passport OAuth callback flow, not a body flag on the password endpoint. There is no scenario where a failed password compare should still issue a token. 3. Remove the shared auth_pass='Welcome!1' sentinel; social/passwordless accounts should be flagged by a column the login path treats as "password login disabled," not by a guessable shared password. 4. Remove the password == hash plaintext fallback; rely solely on bcrypt.compare. 5. Strip credential/req.body/hash console.logs. 6. Add rate limiting + lockout (cross-cuts security.md Β§ Rate Limiting).

Admin Login (POST /Adminsignin β†’ POST /Adminlogin)

  1. Find user by email
  2. Generate OTP (3 bytes hex random)
  3. Store OTP in tMember.access_token
  4. Send OTP via email
  5. User submits OTP to /Adminlogin for verification

Registration (POST /register)

  1. Check username uniqueness
  2. Create tMember record
  3. Generate encrypted token with { userId, defaultPaymentId: 2 }
  4. Send email verification link (separate token with type: 'emailVerification')
  5. Create trial subscription in tStripe (14-day default)

OAuth Flows (Passport.js)

Files: middlewares/passport.js, routes/social.js

Supported Providers

Provider Strategy Client ID Env Callback URL Scopes
Google passport-google-oauth20 GOOGLE_CLIENT_ID /social/google/callback profile, email
GitHub passport-github2 GITHUB_CLIENT_ID /social/github/callback user:email
Facebook passport-facebook FACEBOOK_APP_ID /social/facebook/callback emails, displayName, picture
LinkedIn passport-linkedin-oauth2 LINKEDIN_CLIENT_ID /social/linkedin/callback openid, profile, r_organization_social, w_member_social, etc.
Twitter @superfaceai/passport-twitter-oauth2 TWITTER_CLIENT_ID /social/twitter/callback tweet.read, users.read, offline.access
TikTok passport-tiktok-auth TIKTOK_CLIENT_ID /social/tiktok/callback user.info.basic, video.upload, video.publish

OAuth Flow Pattern

1. GET /social/{provider}
   β†’ Passport redirects to provider authorization page

2. User authorizes at provider

3. GET /social/{provider}/callback
   β†’ Passport exchanges code for tokens
   β†’ Extract profile data (name, email, photo, tokens)
   β†’ encryptData(userData, '10m')
   β†’ Redirect to: {APP_URL}/socialAuth?user={encrypted_token}

4. Frontend decrypts token and establishes session

Provider ID Constants (helper/constants.js)

Provider ID Used In
Facebook 1 tMemberAuth.provider_Id
Twitter 2 tMemberAuth.provider_Id
LinkedIn 4 tMemberAuth.provider_Id
TikTok 6 tMemberAuth.provider_Id

Token Storage for Connected Accounts

OAuth access/refresh tokens are stored in tMemberAuth: - accessToken β€” current access token - refresh_token β€” for token renewal - token_status β€” validity state - Provider-specific JSON fields: FbAccounts, IgAccounts, LnAccounts, TwtAccounts


Partner API Authentication

File: routes/partnerAuth.js

Authentication (POST /partnerAuth/partnerAuthentication)

Input: { partnerId, secretKey }

  1. Validate against tPartnerAuth table
  2. Generate plain JWT (no AES layer):
    jwt.sign(
      { partnerId, partnerName, iat },
      JWT_SECRET_KEY,
      { expiresIn: '1h', issuer: 'someli-api', audience: 'partner-auth' }
    )
    
  3. Store token and expiry in tPartnerAuth.pToken
  4. Return token with 3600s expiry

Partner User Registration (POST /partnerAuth/registerUser)

Headers: Authorization: Bearer {partner_jwt}

  1. Verify partner JWT (issuer/audience claims)
  2. Generate random 12-char password
  3. Hash with bcrypt (salt rounds: 10)
  4. Create tMember, tAccounts, tAccountMembers, tSubscriptions
  5. Return login URL: {partner.appUrl}/#login?token={encrypted_user_token}

Single Sign-On (POST /partnerAuth/singleSignOn)

Headers: Authorization: Bearer {partner_jwt}

  1. Verify partner JWT
  2. Find user, get all active accounts
  3. Generate encrypted token with { userId, role, defaultPaymentId, accountIds[] }
  4. Return login URL

Partner Sign-In (POST /partnerAuth/partnerSignIn)

  1. Find partner by name
  2. Generate JWT signed with partner's secretKey (HS256)
  3. Payload: { partner, timestamp, user_data, redirect_url }
  4. Return redirect URL with token

Alternative Auth: methods.js

Function: ensureToken

Simpler JWT verification used on GET / in routes/routes.js: - Extracts Bearer token from Authorization header - Verifies JWT signature only (no AES decryption) - Returns 403 on invalid token


Authorization Model

Account Access Check

Function: checkuseraccount(memberId, accountId) in helper/helper.js

Two-tier authorization:

  1. Regular members: Check tAccountMembers where memberId + accountId + isDeleted=0 + active=1
  2. Personnel: If not found above, check tPersonnelAccounts where personnelId + accountId + isDeleted=0 + active=1

Returns true if user has access, false otherwise. Called by most authenticated route handlers.

Role Model

Role ID Name Context
0 Admin Full account access
1 Owner Equivalent to admin
2 Manager/Collaborator Reduced permissions
3 Contributor Limited permissions
4 Designer Design-specific access (env: DESIGNER)
5 User Member Standard member (env: USER_MEMBER)
6 Account Manager Management role (env: ACCOUNT_MANAGER)
11 Disabled/Archived Excluded from operations

Role fields: - tMember.role_type β€” User's base role - tAccountMembers.roleType β€” Role within specific account

Personnel System

The isPersonnel flag distinguishes staff from regular users: - Set in JWT payload during token generation - Personnel access tracked in tPersonnelAccounts (separate from tAccountMembers) - Allows cross-account access for support/management


Session Configuration

File: server.js

app.use(session({
  secret: SESSION_SECRET,  // env var, default: "change-me"
  resave: false,
  saveUninitialized: true
}));
app.use(passport.initialize());
app.use(passport.session());

Passport serialization stores the entire user object in session (simple approach, no database lookup on deserialization).


Security Environment Variables

Variable Purpose
JWT_SECRET_KEY Signs JWT + encrypts AES payload
JWT_EXPIRES_IN Default token expiry (e.g., 7d)
TOKEN_HEADER_KEY Header name for token extraction
SESSION_SECRET Express session secret

Request Authentication Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Client  β”‚         β”‚ Auth Middlewareβ”‚         β”‚Route Handlerβ”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
     β”‚                       β”‚                        β”‚
     │── Request + Token ──▢│                        β”‚
     β”‚   (Authorization hdr) β”‚                        β”‚
     β”‚                       │── Check revocation ──▢ β”‚
     β”‚                       │── JWT verify ────────▢ β”‚
     β”‚                       │── AES decrypt ───────▢ β”‚
     β”‚                       β”‚                        β”‚
     β”‚                       │── Set res.userId ─────▢│
     β”‚                       │── Set res.accountId ──▢│
     β”‚                       │── Set res.role ───────▢│
     β”‚                       │── next() ────────────▢│
     β”‚                       β”‚                        β”‚
     β”‚                       β”‚                     β”Œβ”€β”€β”΄β”€β”€β”
     β”‚                       β”‚                     β”‚Check β”‚
     β”‚                       β”‚                     β”‚accountβ”‚
     β”‚                       β”‚                     β”‚access β”‚
     β”‚                       β”‚                     β””β”€β”€β”¬β”€β”€β”˜
     │◀──────────── Response ─────────────────────────│

Known Security Considerations

Issue Location Impact
πŸ”΄ Social-login auth bypass (ATO by email) POST /webauthenticate routes/routes.js:6539-6620 Critical β€” social:true issues a token for any social account given only the victim's email. See Β§ Web Login F1.
πŸ”΄ Unauthenticated SQL injection POST /webauthenticate :6529-6548, POST /authenticate :6401 Critical β€” raw ${email}/${sid} interpolation; UNION leaks password hashes + bypasses auth. See F2.
Pass-the-hash plaintext fallback /webauthenticate :6603, :6665 High β€” password == stored hash authenticates; F2 leaks the hash. See F3.
Credentials/PII logged /webauthenticate :6525, :6585 Medium β€” cleartext password + stored hash to stdout/logs. See F4.
Forgeable stored token /webauthenticate :6594+ Medium β€” auth_token='Bearer '+sid, sid non-secret. See F5.
Plaintext password storage POST /register Passwords readable if DB compromised
Plaintext password comparison POST /login No timing-attack protection
SQL string interpolation Multiple route handlers (incl. login endpoints β€” see above) Injection risk
In-memory token revocation helper/revokeToken.js Revocations lost on restart
No rate limiting Login endpoints Brute-force vulnerable
Predictable network token format POST /login (network) {userId}_{timestamp}_{userId} pattern

Note: Partner auth (routes/partnerAuth.js) correctly uses bcrypt for password hashing β€” this pattern should be extended to all user registration/login flows.