Flow: dropbet sign-in¶
Trace ID:
456b4797b61fd23db1baf685bbdcf378· Jaeger: http://localhost:16686/trace/456b4797b61fd23db1baf685bbdcf378 · E2E:tests-e2e/tests/dropbet-signin.spec.tsGenerated: 2026-04-16 · Author: signin-doc-writer · Services traced:ebit-api(+ Redis via ioredis). Browser and Postgres touched but not traced — see §7.
1. User-visible contract¶
From tests-e2e/tests/dropbet-signin.spec.ts:9-58:
- Entry point:
GET /on ebit-fe, then clickbutton[name="Login"]to open the auth modal. - Inputs: form fields matched by
placeholder="Email"andplaceholder="Password"; test useslocal@example.com/password. - Wire call:
POST /auth/sign-inon ebit-api with JSON{email,password}; status must be< 300(actual:201 Created). - Post-condition: cookie named exactly
access_tokenmust be set on the browser context within 15 s. - Cross-service trace assertion: trace window covers both
ebit-fe(pre-click SSR) andebit-api(the POST). After pw-signin-dev's helper fix in task #15, the captured trace is now root-anchored onPOST /auth/sign-in.
2. Sequence diagram¶
Built from the 21 spans in 456b4797.... bcrypt and JWT work are inferred from duration (no native-code instrumentation) and called out as comments.
sequenceDiagram
participant U as Browser
participant MW as Express middleware
participant H as AuthController.signIn (Nest handler)
participant R as Redis (ioredis)
participant BMQ as BullMQ (Redis-backed)
U->>MW: POST /auth/sign-in (cookies + JSON body)
MW->>MW: query, cors, expressInit, cookieParser, urlencodedParser
MW->>MW: authenticate (passport), session, jsonParser, global-prefix
MW->>H: route matched
Note over H: findFirstUserWithPassword (Prisma, not traced)
H->>R: GET lockout:<email>
Note over H: bcrypt.compare (~70 ms, not traced)
H->>R: UNLINK user:login-attempts:<email>
H->>R: UNLINK lockout:<email>
Note over H: JwtService.signAsync x3 (access/refresh/socket, not traced)
H->>R: SET user-auth-session:<uid>:<sKey>:<sId>
H->>R: EXPIRE user-auth-session:<uid>:<sKey>:<sId>
H->>BMQ: EVALSHA queue.add updateSessionQueue (fire-and-forget)
H-->>U: 201 + Set-Cookie access_token / refresh_token / socket_token
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 · login-attempts · user-auth-session · BullMQ updateSessionQueue")]
%% Browser SPA
subgraph web["Browser (ebit-fe)"]
ax["SignInForm → useSignInMutation → ApiClientBrowser<br/><i>react-hook-form + axios, withCredentials</i>"]
end
%% Primary api process
subgraph api["ebit-api (NestJS)"]
ctrl["AuthController.signIn<br/><i>POST /auth/sign-in</i>"]
svc["AuthService.login<br/><i>admin-origin gate + MFA short-circuit + 3× JwtService.signAsync</i>"]
usr["UserService.authenticate<br/><i>bcrypt.compare ~77 ms (dominant cost)</i>"]
sess["SessionService.createAuthSession<br/><i>SET+EXPIRE auth-session, then setTokensCookie</i>"]
prod["SessionQueueProducer.updateSession<br/><i>fire-and-forget, not awaited</i>"]
ck["setTokensCookie<br/><i>access/refresh/socket tokens</i>"]
end
%% Session worker — same process, separate BullMQ consumer
subgraph wk["Session worker (same process, separate consumer)"]
proc["SessionUpdateProcessor<br/><i>@Processor updateSessionQueue</i>"]
end
%% (1)-(2) Request enters the handler
ax -- "(1) POST /auth/sign-in (cookies + traceparent)" --> ctrl
ctrl -- "(2) login(dto, req, res)" --> svc
%% (3)-(5) UserService authenticates against Postgres + Redis
svc -- "(3) authenticate(email, password)" --> usr
usr -- "(4) findFirstUserWithPassword (case-insensitive)" --> pg
usr -- "(5) GET lockout + UNLINK attempts/lockout" --> rd
%% (6)-(7) SessionService creates the auth-session
svc -- "(6) createAuthSession(user, meta, res)" --> sess
sess -- "(7) SET + EXPIRE user-auth-session" --> rd
%% (8)-(9) Async BullMQ enqueue
sess -- "(8) updateSession (fire-and-forget)" --> prod
prod -- "(9) EVALSHA bull:updateSessionQueue" --> rd
%% (10) Cookie response back to the browser
sess -- "(10) write Set-Cookie → 201" --> ck
%% (11)-(12) Separate worker trace persists UserSession
rd -- "(11) job pulled" --> proc
proc -- "(12) UserSession upsert" --> pg
%% Style: datastores stand out
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
class pg,rd db;
4. Per-step walkthrough¶
Section headers mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Total request duration 99.975 ms. Root span POST /auth/sign-in (@opentelemetry/instrumentation-http, span 6310aeae) parents everything ebit-api-side; ioredis spans are direct children of the Nest handler span. As of task #27 the trace also extends upward into the browser (reference trace d4342af7440f0d3beff8f3e5e46a6336, 36 spans).
4.1 Step (1) — Browser fetch + Express middleware chain reaches AuthController.signIn¶
Browser root span (ebit-fe-browser, reference trace d4342af7440f0d3beff8f3e5e46a6336): @opentelemetry/instrumentation-fetch registered in ebit-fe/src/otel-client.ts via <OtelClientInit/> mounted in src/app/[locale]/layout.tsx injects W3C traceparent on the outgoing fetch (propagateTraceHeaderCorsUrls: [/.*/]). Root operation POST, duration ~226 ms (browser-perceived; includes the ~100 ms of ebit-api work plus network + JS scheduling). NestJS's app.enableCors({ origin, credentials: true }) reflects the preflight's Access-Control-Request-Headers, so traceparent/tracestate are auto-allowed without a config change on ebit-api. The exporter posts OTLP/HTTP from the browser to http://localhost:4318/v1/traces (compose env NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT); CORS for that endpoint is configured in observability/otel-collector.yml for origins http://localhost:{3000,3001,3003}. Web Vitals (CLS/LCP/INP/FCP/TTFB) are emitted as zero-duration spans on a separate web-vitals tracer — useful for RUM dashboards but not part of the sign-in causal chain.
Express middleware chain (12 sibling spans, 1.8 ms combined): registered in libs/shared/src/basic/base.main.ts:57-92 (cookieParser, session, passport.initialize, passport.session, global ValidationPipe) plus Nest's defaults (query, expressInit, jsonParser, urlencodedParser). Both middleware - <anonymous> spans (~690 µs + 230 µs) are OTel context-propagation and Nest global-prefix stubs. Span ordering reflects exit time, not registration order.
Outer Nest wrapper — AuthController.signIn span c5e8cd2c, nestjs.type: request_context, 96.98 ms. Tags: nestjs.callback = signIn, http.route = /auth/sign-in. Source: auth.controller.ts:62-69. Parents the signIn handler span (c7e4366206, nestjs.type: handler, 95.16 ms).
4.2 Step (2) — signIn handler → AuthService.login¶
NestJS instrumentation 0.43.0 still emits only request_context + handler per request, but as of trace 4384ab9dc90c8671aa60924192cdd9e3 the handler now parents manual service spans (AuthService.login, UserService.authenticate, bcrypt.compare) wired in auth.service.ts / user.service.ts, plus Prisma spans (prisma:client:operation, prisma:engine:query, prisma:engine:db_query) emitted by @prisma/instrumentation registered in libs/shared/src/basic/pre/pre-otel.main.ts. The AuthService.login manual span runs ~104 ms and parents everything below.
4.3 Step (3) — AuthService.login → UserService.authenticate¶
AuthService.login (auth.service.ts:134-162) calls userService.authenticate (manual span, ~91 ms); if request.headers.origin === APP_FE_ORIGIN_ADMIN, additionally requires Admin/SuperAdmin (see §6 bullet 4 — admin gate is Origin-header-based). Then handleMfaLogin short-circuits with { requireMfa, token } if user.mfaSecret is set. The seeded local@example.com has no MFA, so it falls through to signIn(user, meta, res) → SessionService.createAuthSession (covered in step (6)).
4.4 Step (4) — findFirstUserWithPassword against Postgres¶
UserService.authenticate (user.service.ts:714-742) calls findFirstUserWithPassword({ email }) (user.repository.ts:384-398, case-insensitive) — now visible as a prisma:client:operation (~10 ms) wrapping prisma:engine:query and a prisma:engine:db_query for the actual SELECT. The lookup hits the @unique constraint on email (hash index users_email_index at api.prisma:288). An unknown email returns USER_INVALID_CREDENTIALS before any Redis or bcrypt work runs — see SF-001.
4.5 Step (5) — Redis lockout GET + post-success UNLINK × 2 (bracketing bcrypt.compare)¶
Three Redis spans, with the ~77 ms bcrypt.compare manual span sitting between the GET and the two UNLINKs:
- Redis
get— 397 µs (053eb388).GET lockout:<email>.UserService.isUserLockedOutatuser.service.ts:912-917. Key:USER_LOCKOUT_KEY_getatuser/const.ts:18— raw email embedded verbatim (see SF-003 for casing-bypass). bcrypt.compare(manual span, ~77 ms). Confirms bcrypt is the dominant cost of the handler. AfterisUserLockedOutand theuser.isBannedguard, the password is verified here.- Redis
unlink× 2 — 422 µs + 215 µs (f59cc5f5,398cac32). Post-password-success cleanup atuser.service.ts:733-736:cache.delon both the attempts and lockout keys.ExtendedCacheClient(@bebkovan/server-core) mapsdeltoUNLINK.
4.6 Step (6) — AuthService.login → SessionService.createAuthSession (incl. 3× JWT sign)¶
Before any Redis writes, createAuthSessionTokens (session.service.ts:141-177) signs three JWTs — access, refresh, socket — all with JWT_SECRET, different TTLs, different rt claim. signAsync is not instrumented, so the cost folds into the parent SessionService.createAuthSession span.
4.7 Step (7) — Redis SET + EXPIRE of user-auth-session¶
SessionService.createAuthSessionCache at session.service.ts:179-196. Writes user-auth-session:<uid>:<sKey>:<sId> → {sId, userId, sKey}, TTL = AUTH_SESSION_TTL_SECONDS. sId/sKey are fresh randomUUID()s. The wrapper issues SET + EXPIRE separately rather than SETEX.
- Redis
set— 476 µs (64f73b23). - Redis
expire— 339 µs (fcca8366).
4.8 Steps (8)–(9) — SessionQueueProducer.updateSession enqueues onto BullMQ¶
BullMQ, not RabbitMQ. SessionQueueProducer.updateSession (session.queue-producer.ts:13-29) calls queue.add(UPDATE_SESSION_QUEUE, sessionData, …). BullMQ uses Lua for atomic enqueue; ioredis reports EVALSHA. The call is not awaited (session.service.ts:105), so it races with the HTTP response — it completes first in this trace, but not guaranteed.
- Redis
evalsha— 1.58 ms (fcf4ae58). Step (8) is the producer-side enqueue; step (9) is the sameEVALSHAviewed from the Redis side (one span in the trace, split here for diagram clarity — same pattern as bet-place §4.6/§4.8).
4.9 Step (10) — setTokensCookie writes Set-Cookie → 201 response¶
setTokensCookie (auth/cookies.ts:31-44) writes access_token (httpOnly), refresh_token (httpOnly, path: /auth/refresh), socket_token (httpOnly: false). domain defaults to process.env.BASE_DOMAIN ?? undefined. When disable_set_cookies_and_mask_tokens is off, the JSON body contains masked "cookie" strings — the E2E test ignores the body and asserts on the access_token cookie.
4.10 Steps (11)–(12) — separate session-update worker trace persists UserSession¶
Processing of the enqueued job is a separate trace rooted on BullMQJob updateSessionQueue (no traceparent is stored in the job payload — same gap as bet-place AF-2 in ../weaknesses-register.md). The job is consumed by session.update.queue-processor.ts:17, which writes the UserSession row (sessionKey PK, sessionId, userId, ip, countryCode, regionCode, lastActivity, userAgent) via session.repository.ts:23.
5. Data model¶
| Table / key | R/W | Fields touched | Schema / file |
|---|---|---|---|
User (Postgres) |
R | id, email (case-insensitive), password, isBanned, roles, mfaSecret, username |
libs/_prisma/src/schema/api.prisma:198-293 — @unique on email; hash index users_email_index at :288 |
UserSession (Postgres) |
W (async via BullMQ) | sessionKey PK, sessionId, userId, ip, countryCode, regionCode, lastActivity, userAgent |
api.prisma:480-498; writer session.repository.ts:23 |
user-auth-session:<uid>:<sKey>:<sId> (Redis) |
W | {sId, userId, sKey}, TTL = AUTH_SESSION_TTL_SECONDS |
session.service.ts:179-196 |
lockout:<email> (Redis) |
R/W | flag "1", TTL = USER_LOCKOUT_DURATION_SECONDS (1 h) |
user.service.ts:913, 936-940; key builder user/const.ts:18 |
user:login-attempts:<email> (Redis) |
R/W | integer counter | user.service.ts:921-931; key builder user/const.ts:19-20 |
BullMQ queue updateSessionQueue |
W | job payload SessionDto |
Redis-backed; session.queue-producer.ts:32-43 |
6. Failure modes¶
- Unknown-email timing oracle.
user.service.ts:715-720returnsUSER_INVALID_CREDENTIALSbefore runningisUserLockedOutorbcrypt.compare. A known email incurs Redis GET + bcrypt (~80 ms, seen as the dominant slice of the 95 ms handler span in this trace); an unknown one returns after a single Prisma hit. An attacker can enumerate registered emails by timing alone, despite superficially similar error codes — and the error code itself differs (USER_INVALID_CREDENTIALSvsUSER_INVALID_PASSWORD). Tracked: SF-001. - Lockout counter reset bug.
handleLoginAttempt(user.service.ts:935-941) deletesattemptsKeywhen the lockout is armed, and both keys shareUSER_LOCKOUT_DURATION_SECONDSas TTL. When the lockout expires, the counter is gone, so the attacker gets a freshMAX_LOGIN_ATTEMPTSwindow on the same email. Tracked: SF-002. - Thin
SignInDtoconstraints.auth/dto/user-login.dto.ts:27-40decoratesemailandpasswordwith only@IsString()— no@IsEmail, no@MaxLength, no@MinLength. The globalValidationPipeatlibs/shared/src/basic/base.main.ts:64still runs, but only enforces what the DTO declares. (@Evo.ValidateDto(false)atauth.controller.ts:61affects response serialization only — seelibs/shared/src/api/serialization/core/serializer/serializer.ts:116— it does not disable request validation.) Effect: empty strings, multi-KB payloads, and non-email shapes reach bcrypt and the Redis lockout-key writer unchecked. Tracked: SF-003. - Admin gate is Origin-header-based.
auth.service.ts:146-156admits admins only whenrequest.headers.origin === APP_FE_ORIGIN_ADMIN. Any proxy that stripsOriginsilently disables the check. - Cookie scope depends on
BASE_DOMAIN.cookies.ts:11usesprocess.env['BASE_DOMAIN'] ?? undefined. Unset → host-scoped cookie (matches E2E). SettingBASE_DOMAIN=.example.comwithout TLS (secure=false in local) leaks cookies across subdomains.
7. Unresolved¶
- Prisma / Postgres not instrumented.
processeslists only http/express/nestjs-core/ioredis onebit-api.findFirstUserWithPassword(user.repository.ts:384-398) ran — auth succeeded — but nopg.query/prisma:client:*spans. Fix: add@prisma/instrumentation+@opentelemetry/instrumentation-pg. - Service-layer methods not traced.
@opentelemetry/instrumentation-nestjs-core0.43.0 traces controller dispatch only —AuthService.login,UserService.authenticate,SessionService.createAuthSessionare opaque inside the 95 ms handler span. Wrap hot methods manually withtracer.startActiveSpanif finer granularity is needed. - Browser side has no spans.
POST /auth/sign-inis a direct browser→:4000 axios call; it never transits Next.js Node. Root-anchoring on the ebit-api HTTP span is expected after task #15's helper fix.
Appendix A — pre-sign-in home-page SSR waterfall (trace 3ab661bc97f3ca84dcda40e3e52a7b5c)¶
Kept for reference — it is the flow users hit when opening dropbet before clicking "Login", not part of the sign-in contract.
Root span GET /[locale] (ebit-fe next.js, 270.9 ms) renders the App Router page at ebit-fe/src/app/[locale]/page.tsx and fans out six parallel SSR fetch calls via @vercel/otel/fetch:
GET /live-bets?count=20&type=BigWins+ threecount=10follow-ups (LatestBets/HighRollers/LuckyWins) — all served viaLiveBetsController.getMany+ RedisLRANGE.GET /casino/games/main,/casino/games/providers,/exchange-rates?fiatCurrency=USD,/currency— cache-aside (GEThit orSET+EXPIREon miss).
Every sub-request traverses the same middleware chain as §4.1. No auth. Use this trace to document the SSR flow, not sign-in.