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¶
- Extract token from request header (
TOKEN_HEADER_KEYenv var) - Check in-memory revocation set
- Call
decryptData(token)β verifies JWT signature + decrypts AES payload - On success: set decoded fields on
resobject - 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.jsexportsensureToken, an alternative to this middleware. It hardcodes the header name to"authorization"rather than readingTOKEN_HEADER_KEY, returns 403 (not 401), and usessendStatus(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)
- Query
tMemberby email (sid) and password - Generate access token:
{userId}_{timestamp}_{userId} - 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
decryptDataexpecting 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.jsrunsdecryptDataon 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:
-
The lookup
WHEREis relaxed from AND to OR (routes/routes.js:6539-6543):So a victim row is selected byif (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 }username = emailalone (thesidhalf of the OR is irrelevant), gated only by the shared constantauth_pass='Welcome!1'β the marker carried by social-signup / passwordless accounts (same sentinel used atroutes/routes.js:2898). -
The password check is then bypassed unconditionally (
routes/routes.js:6584-6620):Whenbcrypt.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 */ } });social == true, a valid session token for the matched user is returned even though bcrypt failed and the plaintext compare failed. Thepasswordandsidin 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
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)¶
- Find user by email
- Generate OTP (3 bytes hex random)
- Store OTP in
tMember.access_token - Send OTP via email
- User submits OTP to
/Adminloginfor verification
Registration (POST /register)¶
- Check username uniqueness
- Create
tMemberrecord - Generate encrypted token with
{ userId, defaultPaymentId: 2 } - Send email verification link (separate token with type:
'emailVerification') - 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 |
|---|---|---|---|---|
passport-google-oauth20 |
GOOGLE_CLIENT_ID |
/social/google/callback |
profile, email | |
| GitHub | passport-github2 |
GITHUB_CLIENT_ID |
/social/github/callback |
user:email |
passport-facebook |
FACEBOOK_APP_ID |
/social/facebook/callback |
emails, displayName, picture | |
passport-linkedin-oauth2 |
LINKEDIN_CLIENT_ID |
/social/linkedin/callback |
openid, profile, r_organization_social, w_member_social, etc. | |
@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 |
|---|---|---|
| 1 | tMemberAuth.provider_Id |
|
| 2 | tMemberAuth.provider_Id |
|
| 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 }
- Validate against
tPartnerAuthtable - Generate plain JWT (no AES layer):
- Store token and expiry in
tPartnerAuth.pToken - Return token with 3600s expiry
Partner User Registration (POST /partnerAuth/registerUser)¶
Headers: Authorization: Bearer {partner_jwt}
- Verify partner JWT (issuer/audience claims)
- Generate random 12-char password
- Hash with bcrypt (salt rounds: 10)
- Create
tMember,tAccounts,tAccountMembers,tSubscriptions - Return login URL:
{partner.appUrl}/#login?token={encrypted_user_token}
Single Sign-On (POST /partnerAuth/singleSignOn)¶
Headers: Authorization: Bearer {partner_jwt}
- Verify partner JWT
- Find user, get all active accounts
- Generate encrypted token with
{ userId, role, defaultPaymentId, accountIds[] } - Return login URL
Partner Sign-In (POST /partnerAuth/partnerSignIn)¶
- Find partner by name
- Generate JWT signed with partner's
secretKey(HS256) - Payload:
{ partner, timestamp, user_data, redirect_url } - 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:
- Regular members: Check
tAccountMemberswherememberId+accountId+isDeleted=0+active=1 - Personnel: If not found above, check
tPersonnelAccountswherepersonnelId+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.