Flow: admin-fe sign-in (with 2FA)¶
Sign-in Trace:
6aad4fe5ddf41fe8978d2a4180dc5395· Verify-2FA Trace:5c7094f97bda21946c8ed496cbe2b9b9· Jaeger: http://localhost:16686 E2E:tests-e2e/tests/admin-signin.spec.ts· Generated: 2026-04-16 · Author: signin-doc-writer Services traced:ebit-apionly. Admin-fe browser and Postgres touched but not traced — see §6 and §7.
1. User-visible contract¶
From tests-e2e/tests/admin-signin.spec.ts:14-112:
- Entry point:
GET /loginon ebit-admin-fe, then fillplaceholder="Enter your email"+placeholder="Enter your password"and clickbutton[name="Log in"]. - Credentials: seeded admin
admin-1@admin.com/adminwith MFA secretO4JWQM2YBARTYJBZ(required — all admins havemfaSecretset). - Wire call 1:
POST /auth/sign-inon ebit-api; JSON{email,password}; status must be< 300. Response short-circuits with{requireMfa: true, token}becauseuser.mfaSecretis set (auth.service.ts:158→handleMfaLogin). - Wire call 2: FE opens 2FA modal, user types TOTP code;
POST /auth/verify-2faon ebit-api with{token, mfaCode}; status must be< 300. - Post-condition: cookie named
access_tokenset on the browser within 15 s. - Trace assertions: per-service only — each call's trace must contain an
ebit-apispan. No cross-service assertion (see §6 for why).
2. Sequence diagram¶
Merged from the sign-in trace (18 spans, 93 ms) and verify-2fa trace (24 spans, 31 ms). Admin-fe browser actions are drawn but not traced.
sequenceDiagram
participant U as Admin (Browser)
participant AFE as admin-fe (Vite SPA)
participant MW as Express middleware
participant API as AuthController
participant SVC as AuthService / SessionService
participant R as Redis (ioredis)
participant BMQ as BullMQ (Redis)
U->>AFE: GET /login (page renders LoginForm)
AFE->>API: POST /auth/sign-in (email, password)
MW->>MW: cors / cookieParser / session / passport / json
MW->>API: route /auth/sign-in
API->>SVC: AuthService.login
Note over SVC: UserService.authenticate: Prisma findFirst + bcrypt.compare (not traced)
SVC->>R: GET lockout:<email>
SVC->>R: UNLINK attempts / lockout keys
Note over SVC: handleMfaLogin: createTempMfaSession signs JWT (5 min TTL)
API-->>AFE: 201 { requireMfa: true, token }
AFE->>AFE: useSignInMutation: skip setAuthCookiesAction<br/>open TwoFAModal
U->>AFE: types 6-digit TOTP
AFE->>API: POST /auth/verify-2fa (token, mfaCode)
MW->>MW: (same middleware chain, ~1.9 ms)
API->>SVC: verifyMfaAndCompleteLogin (@WaitMutex key=sha256(token))
SVC->>R: SETNX auth:verify-mfa:<hash> (WaitMutex acquire)
Note over SVC: JWT verify tempToken, findUniqueUser, totp check (not traced)
SVC->>R: SET + EXPIRE user-auth-session:<uid>:<sKey>:<sId>
SVC->>R: SET + EXPIRE (second session cache)
SVC->>BMQ: EVALSHA queue.add updateSession (fire-and-forget)
SVC->>R: UNLINK (WaitMutex release)
Note over SVC: setTokensCookie: Set-Cookie access_token / refresh_token / socket_token
API-->>AFE: 200 { accessToken:{token:"cookie"}, refreshToken:{token:"cookie"}, socketToken:{token:<jwt>} }
AFE->>AFE: setAuthCookiesAction → writes jwt_access_token / jwt_refresh_token / jwt_socket_token<br/>(values are literal strings "cookie" / "cookie" / real JWT — see §6 Bug 1)
AFE->>AFE: router.replace('/') — dashboard request hits middleware.ts
AFE-->>U: middleware reads jwt_access_token="cookie", parseToken fails silently, page 401s via /user/me (see §6 Bug 2+3)
3. Component diagram¶
Edges are numbered in request-flow order. Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through.
flowchart TD
%% Datastores
pg[("Postgres<br/>User · UserSession")]
rd[("Redis (cache)<br/>lockout · attempts · user-auth-session · auth:verify-mfa mutex · BullMQ updateSessionQueue")]
%% admin-fe (browser SPA + Node middleware)
subgraph adm["admin-fe (Vite SPA + Node middleware, different deploy unit)"]
form["LoginForm + useSignInMutation + useVerify2FAMutation<br/><i>react-query, opens TwoFAModal on requireMfa</i>"]
mw["middleware.ts + setAuthCookiesAction<br/><i>writes jwt_* cookies, SSR /user/me (no @vercel/otel)</i>"]
end
%% ebit-api (NestJS, :4000)
subgraph api["ebit-api :4000 (NestJS)"]
ctrl["AuthController<br/><i>POST /auth/sign-in + /auth/verify-2fa</i>"]
auth["AuthService<br/><i>login + verifyMfaAndCompleteLogin (@WaitMutex)</i>"]
usrSvc["UserService.authenticate<br/><i>Prisma findFirst + bcrypt.compare</i>"]
sess["SessionService<br/><i>createTempMfaSession / createAuthSession</i>"]
prod["SessionQueueProducer.updateSession<br/><i>BullMQ updateSessionQueue</i>"]
ck["setTokensCookie<br/><i>Set-Cookie access_token / refresh_token / socket_token</i>"]
end
%% (1)-(6) Sign-in happy path
form -- "(1) POST /auth/sign-in" --> ctrl
ctrl -- "(2) login(dto)" --> auth
auth -- "(3) authenticate(email,password)" --> usrSvc
usrSvc -- "(4) findFirstUserWithPassword" --> pg
usrSvc -- "(5) lockout/attempts probes + UNLINK" --> rd
auth -- "(6) handleMfaLogin → createTempMfaSession" --> sess
%% (7)-(15) Verify-2fa happy path
form -- "(7) POST /auth/verify-2fa" --> ctrl
ctrl -- "(8) verifyMfaAndCompleteLogin(dto)" --> auth
auth -- "(9) @WaitMutex SETNX + UNLINK" --> rd
auth -- "(10) createAuthSession(user, meta)" --> sess
sess -- "(11) session cache writes + findUniqueUser GET" --> rd
sess -- "(12) updateSession(payload)" --> prod
prod -- "(13) EVALSHA bull:updateSessionQueue" --> rd
sess -- "(14) setTokensCookie(tokens, res)" --> ck
ck -- "(15) Set-Cookie access_token/refresh_token/socket_token" --> form
%% (16)-(17) admin-fe post-response branch (bugs)
form -- "(16) setAuthCookiesAction writes jwt_* (Bug 1)" --> mw
mw -- "(17) SSR /user/me — no traceparent, sends jwt_access_token=cookie (Bug 2+3)" --> ctrl
%% Style: datastores stand out
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
class pg,rd db;
4. Per-step walkthrough¶
Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. The flow spans two traces: sign-in (6aad4fe5…, 18 spans, 93.27 ms total, root db29c09a43) and verify-2fa (5c7094f9…, 24 spans, 31.33 ms total, root cd7d7686ed). Both calls traverse the same 9-span Express middleware chain registered in libs/shared/src/basic/base.main.ts:57-92 (query, cors, cookieParser, session, expressInit, passport.initialize, passport.authenticate, json, urlencodedParser, plus two OTel/global-prefix <anonymous> stubs) — ~1.8 ms per call, span order reflects exit time not registration order, session middleware is usually first into Redis.
4.1 Step (1) — POST /auth/sign-in reaches AuthController¶
useSignInMutation in ebit-admin-fe/src/queries/auth/index.ts:38 POSTs {email, password} to ebit-api. The request lands on the outer AuthController.signIn wrapper span 5eeb5bd2b0 (90 ms) which parents the signIn handler span 35a159b721 (87 ms). auth.controller.ts:60-69 — body SignInDto, same permissive validation noted in SF-003.
4.2 Step (2) — AuthController.signIn → AuthService.login¶
Controller is a one-liner that hands the DTO to AuthService.login (auth.service.ts:134-162). The handler span parents everything downstream: userService.authenticate, the fromBackoffice origin check (auth.service.ts:145-156 — only passes when request.headers.origin === APP_FE_ORIGIN_ADMIN AND user.roles includes Admin or SuperAdmin; non-admins hitting admin-fe get USER_INVALID_CREDENTIALS), and the handleMfaLogin branch.
4.3 Steps (3)–(5) — UserService.authenticate: Prisma + bcrypt + Redis lockout¶
user.service.ts:714-742. In order:
- Step (4) — Prisma
findFirstUserWithPasswordagainstUser(Prisma not instrumented, see §7), then in-processbcrypt.compare(~60-80 ms, not instrumented — dominates the entire handler). - Step (5) — GET
lockout:<email>(spanbb08c15e8c, 411 µs) probes for a lockout flag; on successful auth twoUNLINKspans (92e08f4b0e,665481f5b8, ~840 µs) wipeuser:login-attempts:<email>andlockout:<email>.
Step (3) is the outer authenticate(email,password) call from AuthService.login — its work decomposes into (4) and (5) above, so all three numbers share this sub-section.
4.4 Step (6) — handleMfaLogin + createTempMfaSession (stateless JWT)¶
auth.service.ts:435-450: because user.mfaSecret is set on seeded admin-1@admin.com, the login short-circuits with {requireMfa: true, token}. The token is minted by SessionService.createTempMfaSession at session.service.ts:239-259 — a 5-minute JWT signed with JWT_MFA_TEMP_SECRET carrying {sub: userId, sKey, sId}. No Redis write for the temp MFA token — it's a stateless JWT.
setTokensCookie is not called on this path; no access_token cookie yet. The {requireMfa:true, token} response body is parsed by useSignInMutation at queries/auth/index.ts:38 — on that branch it returns before setAuthCookiesAction runs, so no admin-fe-side cookie write yet either. The SPA opens the TwoFAModal instead.
4.5 Step (7) — POST /auth/verify-2fa reaches AuthController¶
After the user types the 6-digit TOTP, useVerify2FAMutation POSTs {token, mfaCode} to ebit-api. Controller at auth.controller.ts:197-217. Handler span 521a738c2e (24 ms) parents nine ioredis children.
4.6 Step (8) — AuthController → AuthService.verifyMfaAndCompleteLogin¶
auth.service.ts:397-433. The handler decomposes into the mutex acquire/release (step 9), JWT verify of the temp token via verifyTempMfaSession (session.service.ts:261-272, pure JWT verify, no Redis, not traced), verifyMfaCode(mfaCode, user.mfaSecret) TOTP check (not traced), and createAuthSession (step 10).
4.7 Step (9) — @WaitMutex SETNX + UNLINK on auth:verify-mfa:<sha256(token)>¶
@WaitMutex({ key: … }) decorator from @bebkovan/server-core wraps verifyMfaAndCompleteLogin. Mutex acquire runs SETNX auth:verify-mfa:<sha256(token)> (span 6999462b28, SET 460 µs) and release runs UNLINK at the end (fad701e99f, 338 µs). Prevents concurrent TOTP brute-force against the same temp token.
4.8 Step (10) — AuthService → SessionService.createAuthSession¶
session.service.ts:86-135. Signs three JWTs (access/refresh/socket) via JwtService.signAsync (~3-5 ms, not traced) for the authenticated session.
4.9 Step (11) — SessionService Redis writes (auth session cache + findUniqueUser probe)¶
- Writes
user-auth-session:<uid>:<sKey>:<sId>→SET+EXPIREpair (1cb9c73527+aa603b2262, ~770 µs; TTL =AUTH_SESSION_TTL_SECONDS). - A second
SET+EXPIREpair at 26 ms (d5c4fde04b+ec5818e441) iscreateAuthSessionCacherunning a second time — or a related idempotency key; specifics live under theExtendedCacheClientwrapper (see §7). - The GET span at 7 ms (
13da60a495, 331 µs) is afindUniqueUsercache probe inUserServiceissued during the verify-2fa handler. - The
ZSCOREspan at 19 ms (4202111467) comes from inside the cache wrapper — not positively identified (§7).
4.10 Steps (12)–(13) — SessionQueueProducer.updateSession enqueues to BullMQ¶
session.queue-producer.ts:13-29. Step (12) is the in-process call from SessionService; step (13) is the same call viewed Redis-side as an EVALSHA against bull:updateSessionQueue (span 6f2e098561, 2.9 ms). Not RabbitMQ — see CLAUDE.md "Async queues — BullMQ, not RabbitMQ" for the full routing map. The processor at session.update.queue-processor.ts:17 writes UserSession to Postgres asynchronously (out of scope for this trace).
4.11 Steps (14)–(15) — setTokensCookie writes Set-Cookie back to admin-fe¶
cookies.ts:31-44. Step (14) is the in-process call from SessionService to the cookie helper, which writes Set-Cookie for access_token, refresh_token (path /auth/refresh), socket_token. Step (15) is the HTTP response carrying those headers back to the SPA.
Because feature flag disable_set_cookies_and_mask_tokens defaults to false (libs/integrations/src/feature-flag/features.ts:14), the JSON body returned to admin-fe contains placeholder strings: accessToken.token === "cookie", refreshToken.token === "cookie"; only socketToken.token is the real JWT (session.service.ts:116-131). Load-bearing for Bug 1 below.
4.12 Step (16) — setAuthCookiesAction writes jwt_* cookies from the response body (Bug 1)¶
utils/cookies.ts:52-72 (Next.js-style server action in the admin-fe). It reads accessToken.token / refreshToken.token / socketToken.token from the verify-2fa JSON body and writes them as cookies named jwt_access_token / jwt_refresh_token / jwt_socket_token. Per §4.11 the first two values are the literal string "cookie" — only jwt_socket_token holds a real JWT. This is the cookie-name + cookie-value mismatch documented as Bug 1 in §6 — the browser ends up holding both ebit-api's real access_token and admin-fe's bogus jwt_access_token="cookie".
4.13 Step (17) — admin-fe middleware.ts SSR /user/me (Bug 2 + Bug 3)¶
After router.replace('/') the dashboard request hits middleware.ts:59-90. It reads EAuthTokensType.ACCESS = 'jwt_access_token' (enum at src/types/Auth.ts:36-40), gets the string "cookie", and the parseToken(accessToken) call is wrapped in try { … } catch { /* TODO */ } with leaveFromAccount(responseLogin) commented out — the catch fires, the function falls through, Next.js lets the request continue. The middleware then calls apiClient.get('/user/me') SSR-side (middleware.ts:78, 99), but because ebit-admin-fe/src/instrumentation.ts is Sentry-only (no @vercel/otel registerOTel({ propagateContextUrls: [/.*/] }) fallback like ebit-fe), no traceparent header is injected — the ebit-api span this produces is not linked to any admin-fe parent (Bug 2). The /user/me call additionally sends the bogus jwt_access_token="cookie", so ebit-api returns 401 and the dashboard renders empty (Bug 3). This is why all recent admin sign-in traces are single-service (procs: ['ebit-api']) and why admin-signin.spec.ts:83-100 asserts per-service presence only — a cross-service waitForCrossServiceTrace would time out. User constraint: do not edit admin-fe source to fix this.
5. Data model¶
| Table / key | R/W | Fields / payload | Schema / file |
|---|---|---|---|
User (Postgres) |
R | id, email (case-insensitive), password, mfaSecret, roles, isBanned |
libs/_prisma/src/schema/api.prisma:198-293 |
UserSession (Postgres) |
W (async via BullMQ) | sessionKey PK, sessionId, userId, ip, userAgent, countryCode, regionCode, lastActivity |
api.prisma:480-498; writer session.update.queue-processor.ts:17 |
user-auth-session:<uid>:<sKey>:<sId> (Redis) |
W | {sId, userId, sKey}, TTL AUTH_SESSION_TTL_SECONDS |
session.service.ts:179-196 |
lockout:<email> / user:login-attempts:<email> (Redis) |
R/W | lockout flag; integer counter | user.service.ts:912-947; user/const.ts:18-22 |
auth:verify-mfa:<sha256(token)> (Redis) |
W (mutex SETNX) | boolean lock, default TTL | @bebkovan/server-core WaitMutex; decorator at auth.service.ts:397-399 |
BullMQ updateSessionQueue |
W | job payload SessionDto |
Redis-backed; session.queue-producer.ts:32-43 |
| Temp MFA token | — (stateless JWT) | {sub, sKey, sId}, 5-minute TTL, secret JWT_MFA_TEMP_SECRET |
session.service.ts:239-259 |
6. Failure modes (two active production bugs)¶
-
Cookie-name mismatch between ebit-api and admin-fe (active bug, not traced by design). ebit-api sets response cookies named
access_token/refresh_token/socket_token(auth/cookies.ts:8-23). admin-fe'smiddleware.ts:59-60readsEAuthTokensType.ACCESS = 'jwt_access_token'andREFRESH = 'jwt_refresh_token'(enum atsrc/types/Auth.ts:36-40). They never meet. The browser also holdsjwt_*cookies becausesetAuthCookiesAction(utils/cookies.ts:52-72) writes them from the/auth/verify-2faJSON body — but per §4.11, the body'saccessToken.token === "cookie"andrefreshToken.token === "cookie"(literal strings), so thejwt_access_tokencookie holds the string"cookie", not a JWT. The E2E's cookie assertion (admin-signin.spec.ts:70) polls foraccess_token, which exists and is valid, so the contract passes while the admin dashboard remains unreachable. -
admin-fe instrumentation is Sentry-only; no
@vercel/otel(active bug).ebit-admin-fe/src/instrumentation.tsimports../sentry.server.configand nothing else. Compared withebit-fe/src/instrumentation.ts, there is no Sentry-DSN gate, no@vercel/otelregisterOTel({ propagateContextUrls: [/.*/] })fallback. Consequence: whenmiddleware.ts:78, 99callsapiClient.get('/user/me')SSR-side, notraceparentheader is injected; the ebit-api span it produces is not linked to any admin-fe parent. That is why all recent admin sign-in traces are single-service (procs: ['ebit-api']) and whyadmin-signin.spec.ts:83-100asserts per-service presence only — a cross-servicewaitForCrossServiceTracewould time out. User constraint: do not edit admin-fe source to fix this. -
Silent middleware fall-through on bad JWT.
middleware.ts:68-90: the happy-path branchparseToken(accessToken)is wrapped intry { … } catch { /* TODO */ }with theleaveFromAccount(responseLogin)redirect commented out. With Bug 1 feeding the string"cookie"intoparseToken, the catch fires on every request, the function falls through without returning, and Next.js lets the request continue. The dashboard then 401s on SSR/user/me. No visible redirect to/login, no visible error — just an empty page. Tracked here; no SF-* opened because the root cause is Bug 1. -
Thin
VerifyMfaDtovalidation.auth/dto/verify-mfa.dto.ts:6-20decoratestokenandmfaCodewith only@IsString()— same class of problem as SF-003. No@Length(6,6)onmfaCodeand no JWT-shape guard ontoken, so malformed input reachesjwt.verifyAsyncandtotp.check. Both reject, but the CPU surface widens. -
Admin-only gate is Origin-header-based.
auth.service.ts:145-156— mirrors failure-mode 4 ofdropbet-sign-in.md. A proxy that stripsOriginsilently demotes the admin check.
7. Unresolved¶
- Prisma / Postgres still not instrumented (
processeslists only http/express/nestjs-core/ioredis).findFirstUserWithPasswordandfindUniqueUserran but produced no spans. Same fix as §7 ofdropbet-sign-in.md— task #20 tracks this. - Service-layer methods not traced. NestJS instrumentation 0.43.0 emits only
request_context+handlerspans;AuthService.login,UserService.authenticate,verifyMfaAndCompleteLogin, JWT signs, bcrypt, and TOTP are all inside the handler span. Task #20 covers adding@prisma/instrumentation+ manualtracer.startActiveSpanwrappers. - Three Redis ops not positively identified.
ZSCOREat 19 ms (4202111467) and the secondSET+EXPIREpair at 26 ms (d5c4fde04b+ec5818e441) during verify-2fa come from inside@bebkovan/server-core'sExtendedCacheClient. WaitMutexsetnx/delandcreateAuthSessionCacheare located; these three are not. Service-method instrumentation (task #20) should make the origin obvious. - Cross-service browser → ebit-api trace. Blocked by §6 Bug 2. Once admin-fe gains
@vercel/otel, the E2E can be upgraded towaitForCrossServiceTraceand this section can be deleted.