Skip to content

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-api only. 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 /login on ebit-admin-fe, then fill placeholder="Enter your email" + placeholder="Enter your password" and click button[name="Log in"].
  • Credentials: seeded admin admin-1@admin.com / admin with MFA secret O4JWQM2YBARTYJBZ (required — all admins have mfaSecret set).
  • Wire call 1: POST /auth/sign-in on ebit-api; JSON {email,password}; status must be < 300. Response short-circuits with {requireMfa: true, token} because user.mfaSecret is set (auth.service.ts:158handleMfaLogin).
  • Wire call 2: FE opens 2FA modal, user types TOTP code; POST /auth/verify-2fa on ebit-api with {token, mfaCode}; status must be < 300.
  • Post-condition: cookie named access_token set on the browser within 15 s.
  • Trace assertions: per-service only — each call's trace must contain an ebit-api span. 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.signInAuthService.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 findFirstUserWithPassword against User (Prisma not instrumented, see §7), then in-process bcrypt.compare (~60-80 ms, not instrumented — dominates the entire handler).
  • Step (5) — GET lockout:<email> (span bb08c15e8c, 411 µs) probes for a lockout flag; on successful auth two UNLINK spans (92e08f4b0e, 665481f5b8, ~840 µs) wipe user:login-attempts:<email> and lockout:<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) — AuthControllerAuthService.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).

@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) — AuthServiceSessionService.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 + EXPIRE pair (1cb9c73527 + aa603b2262, ~770 µs; TTL = AUTH_SESSION_TTL_SECONDS).
  • A second SET+EXPIRE pair at 26 ms (d5c4fde04b + ec5818e441) is createAuthSessionCache running a second time — or a related idempotency key; specifics live under the ExtendedCacheClient wrapper (see §7).
  • The GET span at 7 ms (13da60a495, 331 µs) is a findUniqueUser cache probe in UserService issued during the verify-2fa handler.
  • The ZSCORE span 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).

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)

  1. 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's middleware.ts:59-60 reads EAuthTokensType.ACCESS = 'jwt_access_token' and REFRESH = 'jwt_refresh_token' (enum at src/types/Auth.ts:36-40). They never meet. The browser also holds jwt_* cookies because setAuthCookiesAction (utils/cookies.ts:52-72) writes them from the /auth/verify-2fa JSON body — but per §4.11, the body's accessToken.token === "cookie" and refreshToken.token === "cookie" (literal strings), so the jwt_access_token cookie holds the string "cookie", not a JWT. The E2E's cookie assertion (admin-signin.spec.ts:70) polls for access_token, which exists and is valid, so the contract passes while the admin dashboard remains unreachable.

  2. admin-fe instrumentation is Sentry-only; no @vercel/otel (active bug). ebit-admin-fe/src/instrumentation.ts imports ../sentry.server.config and nothing else. Compared with ebit-fe/src/instrumentation.ts, there is no Sentry-DSN gate, no @vercel/otel registerOTel({ propagateContextUrls: [/.*/] }) fallback. Consequence: when middleware.ts:78, 99 calls apiClient.get('/user/me') SSR-side, no traceparent header 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 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.

  3. Silent middleware fall-through on bad JWT. middleware.ts:68-90: the happy-path branch parseToken(accessToken) is wrapped in try { … } catch { /* TODO */ } with the leaveFromAccount(responseLogin) redirect commented out. With Bug 1 feeding the string "cookie" into parseToken, 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.

  4. Thin VerifyMfaDto validation. auth/dto/verify-mfa.dto.ts:6-20 decorates token and mfaCode with only @IsString() — same class of problem as SF-003. No @Length(6,6) on mfaCode and no JWT-shape guard on token, so malformed input reaches jwt.verifyAsync and totp.check. Both reject, but the CPU surface widens.

  5. Admin-only gate is Origin-header-based. auth.service.ts:145-156 — mirrors failure-mode 4 of dropbet-sign-in.md. A proxy that strips Origin silently demotes the admin check.

7. Unresolved

  • Prisma / Postgres still not instrumented (processes lists only http/express/nestjs-core/ioredis). findFirstUserWithPassword and findUniqueUser ran but produced no spans. Same fix as §7 of dropbet-sign-in.md — task #20 tracks this.
  • Service-layer methods not traced. NestJS instrumentation 0.43.0 emits only request_context + handler spans; AuthService.login, UserService.authenticate, verifyMfaAndCompleteLogin, JWT signs, bcrypt, and TOTP are all inside the handler span. Task #20 covers adding @prisma/instrumentation + manual tracer.startActiveSpan wrappers.
  • Three Redis ops not positively identified. ZSCORE at 19 ms (4202111467) and the second SET+EXPIRE pair at 26 ms (d5c4fde04b + ec5818e441) during verify-2fa come from inside @bebkovan/server-core's ExtendedCacheClient. WaitMutex setnx/del and createAuthSessionCache are 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 to waitForCrossServiceTrace and this section can be deleted.