From 53343fa71192ab230d00918a47cd46a8dc8943b3 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sun, 26 Jul 2026 08:53:09 +0400 Subject: [PATCH] docs: create AUTHENTICATION.md Full auth contract: Telegram/QR session login (live), Ed25519 challenge/response admin auth (wired client-side, dormant - authInterceptor not registered, ed25519AuthGuard unused by any route), JWT structure, refresh, expiration, rotation, logout, session invalidation, role hierarchy, tenant isolation, permission model. 4 Mermaid sequence diagrams. Flags 9 Requires-backend-decision items and the pre-existing duplicate AdminRole definition (core/auth vs admin/users models). --- docs/AUTHENTICATION.md | 808 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 808 insertions(+) create mode 100644 docs/AUTHENTICATION.md diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 0000000..14938ae --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,808 @@ +# Authentication — Complete Spec + +Standalone, backend-implementable authentication contract for the marketplace +platform (Angular frontend, branch `B2B`). Derived directly from source — +`src/app/core/auth/**`, `src/app/core/admin-auth/**`, +`src/app/services/{auth,telegram-session-api}.service.ts`, +`src/app/components/telegram-login/**`, `src/app/guards/language.guard.ts`, +`src/app/app.routes.ts`, `src/app/app.config.ts`, +`src/app/core/config/tenant-resolver.service.ts` — plus +`docs/context/BACKEND-AUDIT.md` (this-session audit) and the prior +`docs/AUTH.md`. Where the frontend does not already imply a behavior, this +document says **"Requires backend decision"** rather than inventing one. + +Two authentication mechanisms coexist in the codebase today, at different +maturity levels: + +| Mechanism | Used by | Status | +|---|---|---| +| Telegram QR / deep-link session auth | Storefront customers **and** admin/backoffice (same API) | **LIVE** — real endpoints, in production use | +| Ed25519 challenge/response admin auth | Admin/backoffice (intended replacement) | **Frontend fully wired, backend endpoints do not exist yet** (404s today) | + +Both are documented in full below. Nothing here should be read as "the +platform has JWTs today" — it does not, except inside the not-yet-live +Ed25519 flow. + +--- + +## Table of contents + +1. [Mechanism A — Telegram QR / session login (LIVE)](#1-mechanism-a--telegram-qr--session-login-live) +2. [Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)](#2-mechanism-b--ed25519-challengeresponse-admin-auth-not-live) +3. [JWT structure](#3-jwt-structure) +4. [Refresh token](#4-refresh-token) +5. [Token expiration handling](#5-token-expiration-handling) +6. [Token rotation](#6-token-rotation) +7. [Logout](#7-logout) +8. [Session invalidation](#8-session-invalidation) +9. [Role hierarchy](#9-role-hierarchy) +10. [Tenant isolation](#10-tenant-isolation) +11. [Permission model / route guards](#11-permission-model--route-guards) +12. [Open items — "Requires backend decision"](#12-open-items--requires-backend-decision) + +--- + +## 1. Mechanism A — Telegram QR / session login (LIVE) + +Single source for **both** customer and admin login: +`TelegramSessionApiService` (`src/app/services/telegram-session-api.service.ts`). +There is no separate admin backend endpoint — the same three calls back the +customer `AuthService` (`src/app/services/auth.service.ts`) and the admin +`AdminAuthService` (`src/app/core/admin-auth/admin-auth.service.ts`). Only the +**storage** differs (cookie name, in-memory signal), so an admin QR scan +never authenticates the customer session or vice versa. + +### 1.1 Endpoints (base = `environment.authApiUrl`, e.g. `https://api.dexarmarket.ru:445`) + +| Method | Path | Request | Response | +|---|---|---|---| +| POST | `/users/sessions` | body `{ webSessionID }` (client-generated GUID), header `WebSessionID: ` | `{ webSessionID, url }` — `url` is the Telegram bot deep link | +| GET | `/users/sessions/{id}` | — | Session object, heavily field-tolerant (see §1.3) | +| DELETE | `/users/sessions/{id}` | header `WebSessionID: ` | ignored/discarded | + +### 1.2 Frontend-driven flow + +The frontend, not the backend, generates the session id. Sequence: + +1. User opens login (storefront "Sign in" or admin `/admin-login` gate). +2. Frontend generates a random GUID client-side (`generateGuid()`, + `src/app/shared/util/guid.util.ts`) — this **is** the `webSessionID`, sent + to the backend, not received from it. +3. `POST {authApiUrl}/users/sessions` with `{ webSessionID }` body and + `WebSessionID` header set to the same value. Backend response's own id + field is preferred if present (see `extractSessionId` — checks + `webSessionID/WebSessionID/webSessionId/sessionID/SessionID/sessionId/id/ID` + in that order), else the frontend's generated GUID is used as fallback. +4. Frontend builds two login links from the returned id: + - Web: `https://t.me/{bot}?start={webSessionID}` (`getBotLoginUrl`) + - App deep link: `tg://resolve?domain={bot}&start={webSessionID}` + (`getBotAppLoginUrl`) + - `bot` = `environment.telegramBot` (`'myAMLKYCBOT'` in current env config; + code fallback `'DexarSupport_bot'` if the env key is absent). +5. Frontend renders both as a QR code (external image generator + `https://api.qrserver.com/v1/create-qr-code/...` — not a backend of this + platform, purely a QR bitmap renderer for the `url`) plus the app deep + link for mobile. This is orchestrated by `QrLoginEngine` + (`src/app/shared/qr-login/qr-login.engine.ts`) shared identically by both + customer and admin modes via `TelegramLoginComponent` + (`src/app/components/telegram-login/telegram-login.component.ts`, + `[mode]="'customer' | 'admin'"`). +6. User scans the QR (or taps the deep link on mobile) and completes the + Telegram bot interaction out-of-band. The **backend** is expected to mark + that `webSessionID` as active/logged-in once the Telegram bot confirms the + user, associating a Telegram user identity with it. +7. Frontend polls `GET /users/sessions/{id}` (`checkSessionOnce`, driven by + `QrLoginEngine`'s polling loop) until the session normalizes to `active: + true`, or the user cancels/times out. +8. On an active session, the frontend calls `activateSession()` internally + (sets in-memory signal, stores the id per §1.4, schedules a re-check — + see §5) and redirects: customer → wherever the login was triggered from; + admin → `/{lang}/backoffice/dashboard` (hardcoded in + `telegram-login.component.ts`). + +### 1.3 Session response normalization (backend field tolerance) + +`TelegramSessionApiService.normalizeWebSession()` is deliberately +tolerant of multiple backend field-naming conventions (evidence the backend +contract was never fully pinned down). A backend implementation can emit any +of these; the frontend reads the **first key found** in this priority order: + +- **Active/status**: `status`, `Status`, `active`, `Active`, `loggedIn`, + `LoggedIn`, `isLoggedIn`, `IsLoggedIn`, `authenticated`, `Authenticated`. + Value is considered "active" if boolean `true`/`1`, or (case-insensitive) + one of `true, 1, active, authenticated, confirmed, success, logged_in`. +- **User object**: nested under `user`/`User`/`telegramUser`/`TelegramUser`, + else the top-level response object itself is used as the user record. +- **Username**: `username`/`Username` (user object first, then top-level). +- **First/last name**: `firstName`/`first_name`/`FirstName`/`First_name`, + `lastName`/`last_name`/`LastName`/`Last_name` — joined with a space if both + present. +- **Display name**: explicit `displayName`/`DisplayName`/`name`/`Name` (user + object or top-level) wins; else falls back to `username`; else falls back + to the joined full name; else literal `'Telegram User'`. +- **Telegram user id**: `userId`/`telegramUserId`/`telegramUserID`/ + `TelegramUserID`/`id`/`ID` (user object), else `userId`/`telegramUserId`/ + `telegramUserID`/`TelegramUserID`/`userID`/`UserID`/`UserId` (top-level). +- **Session id**: same priority list as `extractSessionId` above. +- **Expiry**: `expiresAt`/`ExpiresAt`/`expires`/`Expires` (ISO 8601 string); + if absent, the frontend fabricates `now + 3600s` client-side — **the + backend should always send a real `expiresAt`/`expires`** so the frontend's + refresh-scheduling (§5) reflects the true session lifetime rather than a + guessed one. + +Normalized shape consumed by the frontend (`AuthSession`, +`src/app/models/auth.model.ts`): + +```ts +interface AuthSession { + sessionId: string; + userId: number | null; + username: string | null; + displayName: string; + active: boolean; + expires: string; // ISO 8601 +} +``` + +### 1.4 Storage (customer vs admin — kept fully separate) + +| | Customer (`AuthService`) | Admin (`AdminAuthService`) | +|---|---|---| +| Cookie name | `webSessionID` | `adminSessionID` | +| Cookie attrs | `Max-Age=3600; Path=/; SameSite=Lax` (+`Secure` over HTTPS) | `Max-Age=3600; Path=/; SameSite=Strict` (+`Secure` over HTTPS) | +| In-memory state | `sessionSignal`, `statusSignal` (`unknown\|checking\|authenticated\|expired\|unauthenticated`) | Same shape, separate signals | +| Extra storage | — | Reserved JWT-pair slots `localStorage['adminToken']` / `localStorage['adminRefreshToken']` — **unused today**, see §12 | + +Both send a `WebSessionID` header on every marketplace API request via +`apiHeadersInterceptor` (see `docs/context/BACKEND-AUDIT.md` §3) — this is +the anonymous-or-authenticated session identity the backend correlates +requests against; there is no `Authorization: Bearer` header in this +mechanism. + +### 1.5 Session re-check / soft refresh (not a token refresh) + +Both `AuthService` and `AdminAuthService` self-schedule a re-check of +`GET /users/sessions/{id}` 60 seconds before `expires`, minimum 30s out +(`scheduleSessionRefresh`). This is **not** a refresh-token exchange — it +just re-polls the same session-status endpoint and re-activates if still +active, or clears local state if not. There is no rotation of the +`webSessionID` itself in this mechanism. + +### 1.6 Admin dev bypass (non-production only) + +`AdminAuthService.devBypassLogin()` fabricates a local session +(`sessionId: 'dev-bypass-{timestamp}'`, `active: true`, 1-hour expiry) and +activates it directly, skipping the QR flow entirely. Guarded by +`environment.production` at runtime (not just build-time) — the checked +condition is inside the function body, so it is dead code in a production +build. + +### 1.7 Sequence diagram — storefront/admin Telegram-QR login + +```mermaid +sequenceDiagram + participant User as User (browser) + participant FE as Frontend (AuthService / AdminAuthService) + participant BE as Backend (authApiUrl) + participant TG as Telegram bot + + User->>FE: Open login (customer checkout, or /admin-login gate) + FE->>FE: generate webSessionID (client GUID) + FE->>BE: POST /users/sessions { webSessionID } (header WebSessionID) + BE-->>FE: 200 { webSessionID, ... } + FE->>FE: build QR + tg:// deep link from webSessionID + FE-->>User: render QR code / "Open in Telegram" button + User->>TG: scan QR / tap deep link, confirm in bot + TG->>BE: (out of band) associate webSessionID with Telegram user + loop poll every N seconds + FE->>BE: GET /users/sessions/{webSessionID} + BE-->>FE: session (active:false while pending) + end + BE-->>FE: session (active:true, user fields, expires) + FE->>FE: activateSession(): store cookie, set signals,
schedule re-check at expires-60s + alt mode = admin + FE-->>User: redirect to /{lang}/backoffice/dashboard + else mode = customer + FE-->>User: close dialog, resume prior action (e.g. checkout) + end +``` + +--- + +## 2. Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE) + +**Status: frontend fully implemented and wired to real `HttpClient` calls; +the backend does not implement these endpoints yet — calls 404/error today.** +No route currently requires this flow (`ed25519AuthGuard` is not referenced +by any route in `app.routes.ts`; the live admin gate is still +`adminAuthGuard` / Telegram QR, §1). This is the target contract for closing +the security gap in §1: today the Telegram session API has no concept of +"admin," so the backend cannot distinguish an admin login attempt from a +customer one at the moment of login. Ed25519 closes that by requiring proof +of possession of a specific, pre-registered private key before any session +is issued. + +### 2.1 Key generation (device-local, once per device) + +`Ed25519KeypairService` (`src/app/core/auth/services/ed25519-keypair.service.ts`): + +- `getOrCreateKeyPair()`: generates a **non-extractable** Ed25519 keypair via + `crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])` + (real WebCrypto Ed25519 — RFC 8032, not a placeholder), persists the raw + `CryptoKey` handles in IndexedDB (`admin-auth-ed25519` DB, object store + `keypair`, single record `id: 'device-keypair'`). +- The private key is never exported, serialized, or transmitted — by + construction (`extractable: false`), not by convention or policy. +- `sign(message)`: signs a UTF-8-encoded string with `crypto.subtle.sign + ('Ed25519', privateKey, ...)`, returns a base64-encoded signature. +- `clear()`: deletes the IndexedDB record ("forget this device"). A new + keypair generated after this requires re-registration with the backend + (§2.2) before it can complete a login. +- **Public key registration is explicitly out of scope for the frontend.** + An Owner/Administrator must associate a new device's `publicKeyBase64` + with an admin account through some out-of-band mechanism (backend admin + tool, one-time enrollment link, etc.) — not prescribed here (see §12). + +### 2.2 Login flow, step by step + +Orchestrated by `AuthService.login()` (`src/app/core/auth/services/auth.service.ts`, +distinct from the customer/admin `AuthService` in §1 despite the identical +class name — different module, `core/auth/` vs `services/`): + +1. `GET {authApiUrl}/api/admin/auth/challenge` → `AuthChallenge { nonce, + issuedAt, expiresAt }` (all ISO 8601 except `nonce`, an opaque string). +2. `Ed25519KeypairService.getOrCreateKeyPair()` (generates on first use). +3. `Ed25519KeypairService.sign(nonce)` — signs the **raw nonce string + exactly as received**, no additional framing/prefix/hashing applied + client-side. +4. `POST {authApiUrl}/api/admin/auth/verify` with body + `VerifySignatureRequest { publicKey, signature, nonce }` (`publicKey` = + base64 raw Ed25519 public key, `signature` = base64 signature over the + nonce, `nonce` = the same value echoed back). +5. Backend must: re-derive the exact signed message from the nonce it + issued, verify the signature against its own `publicKey → admin account` + mapping, confirm the nonce hasn't expired or been used before, and only + then issue tokens. +6. On success: `200 AuthTokenPair { token, refreshToken }`. + `SessionService.activate(tokens)` decodes the JWT (§3), stores both + tokens (§4), and schedules the next refresh (§5). +7. On failure: `401`/`403` → `AuthService` maps it through + `authErrorCodeFromStatus()` to `invalid-signature` (or a more specific + code — see §2.5) and the UI routes to + `/admin-login/error/invalid-signature`. + +### 2.3 API contracts (all under `{environment.authApiUrl}/api/admin/auth`) + +| Method | Path | Request body | Response | Notes | +|---|---|---|---|---| +| GET | `/challenge` | — | `200 AuthChallenge` | `{ nonce, issuedAt, expiresAt }` | +| POST | `/verify` | `VerifySignatureRequest { publicKey, signature, nonce }` | `200 AuthTokenPair` \| `401` \| `403` | Issues `{ token, refreshToken }` | +| POST | `/refresh` | `RefreshTokenRequest { refreshToken }` | `200 AuthTokenPair` \| `401` | Rotation expected — see §6 | +| POST | `/logout` | `{ refreshToken }` | `204` (frontend clears local state regardless of response code/body) | Should revoke server-side | + +Types: `src/app/core/auth/models/auth-api.model.ts`. HTTP client: +`src/app/core/auth/services/auth-api.service.ts` (`AuthApiService`) — thin +wrapper, no retries, no fabricated mock responses. + +### 2.4 Sequence diagram — admin login with Ed25519 signing + +```mermaid +sequenceDiagram + participant Admin as Admin (browser) + participant FE as Frontend (AuthService, core/auth) + participant Key as Ed25519KeypairService (WebCrypto + IndexedDB) + participant BE as Backend + + Admin->>FE: Click "Sign in" + FE->>BE: GET /api/admin/auth/challenge + BE-->>FE: 200 { nonce, issuedAt, expiresAt } + FE->>Key: getOrCreateKeyPair() (generate on first use, non-extractable) + Key-->>FE: { publicKeyBase64 } + FE->>Key: sign(nonce) + Key-->>FE: signature (base64) + FE->>BE: POST /api/admin/auth/verify { publicKey, signature, nonce } + alt signature valid & publicKey is a provisioned admin key & nonce fresh/unused + BE-->>FE: 200 { token, refreshToken } + FE->>FE: SessionService.activate(tokens):
decode JWT claims, persist, schedule refresh + FE-->>Admin: redirect to /backoffice + else invalid signature / unknown key / expired or reused nonce + BE-->>FE: 401 / 403 + FE-->>Admin: redirect to /admin-login/error/invalid-signature + end +``` + +### 2.5 Error screens + +Single component `AuthErrorPageComponent` at route `/admin-login/error/:code` +renders all five, keyed by route param. `authErrorCodeFromStatus()` +(`src/app/core/auth/models/auth-error.model.ts`) maps HTTP status → code: +`401→unauthorized`, `403→forbidden`, `0→backend-unavailable`, +`5xx→backend-unavailable`, else `unauthorized`. + +| Code | Trigger | User action offered | +|---|---|---| +| `session-expired` | Refresh token rejected/expired | Sign in again | +| `invalid-signature` | `verify` returns 401/403 during login, or any client-side failure in the challenge→sign→verify chain that isn't a clearer HTTP-derived code | Try again | +| `unauthorized` | Route guard sees no active session | Sign in | +| `forbidden` | `permissionGuard` denies (authenticated but insufficient role) | Back to dashboard | +| `backend-unavailable` | Network error / 5xx / status 0 | Retry | + +### 2.6 Interceptor status — NOT registered + +`src/app/core/auth/interceptors/auth.interceptor.ts` exists (adds +`Authorization: Bearer` + reactive 401-refresh-and-retry, see §5) but **is +not included** in `app.config.ts`'s `withInterceptors([...])` list today. +Confirmed in `app.config.ts`: + +``` +withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, + apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor]) +``` + +`authInterceptor` is absent. Until it is registered, no request in the app +automatically attaches the Ed25519-flow JWT as a bearer token — this +confirms the mechanism is fully dormant, not partially live. + +### 2.7 Module map + +``` +src/app/core/auth/ +├── auth.routes.ts # /admin-login, /admin-login/error/:code +├── models/ +│ ├── auth-api.model.ts # AuthChallenge, VerifySignatureRequest, AuthTokenPair, JwtClaims +│ ├── auth-error.model.ts # AuthErrorCode, authErrorCodeFromStatus() +│ └── permission.model.ts # AdminRole, Permission, ROLE_PERMISSIONS +├── services/ +│ ├── ed25519-keypair.service.ts # WebCrypto keygen/sign, IndexedDB persistence +│ ├── auth-api.service.ts # HttpClient calls to the 4 endpoints in §2.3 +│ ├── jwt.service.ts # decode-only JWT parsing +│ ├── session.service.ts # token/claims state, persistence, refresh scheduling +│ ├── permission.service.ts # role -> permission set +│ ├── auth.service.ts # orchestrates challenge -> sign -> verify -> refresh -> logout +│ └── auth-facade.service.ts # public surface for components +├── interceptors/ +│ └── auth.interceptor.ts # Authorization: Bearer + 401 refresh-and-retry (NOT registered, §2.6) +├── guards/ +│ ├── ed25519-auth.guard.ts # requires SessionService.isAuthenticated() (not referenced by any route) +│ └── permission.guard.ts # permissionGuard(permission) factory +└── pages/ + ├── admin-login-page.component.* # sign-in UI + └── auth-error-page.component.* # parameterized error screen (§2.5) +``` + +`AuthFacade` (`src/app/core/auth/services/auth-facade.service.ts`) is the +only thing components/pages should depend on; `AuthService`/ +`SessionService`/`PermissionService` are internal collaborators. + +--- + +## 3. JWT structure + +Only defined for Mechanism B (Ed25519 flow) — Mechanism A (§1) issues no JWT, +only an opaque session id. Expected claims +(`src/app/core/auth/models/auth-api.model.ts::JwtClaims`): + +```ts +interface JwtClaims { + sub: string; // admin account id + role: AdminRole; // 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly' + iat: number; // seconds since epoch (standard `iat`) + exp: number; // seconds since epoch (standard `exp`) + publicKey: string; // the Ed25519 public key this token was issued for +} +``` + +`JwtService.decode()` (`src/app/core/auth/services/jwt.service.ts`) does +**decode-only** parsing (base64url payload → JSON), and validates only the +minimal shape: `sub` is a string, `role` is a string, `exp` is a number — if +any of these three checks fail, decoding returns `null` and the caller +(`SessionService`) discards the session as malformed. + +The frontend never verifies the JWT signature — it has no trusted key to +check it against; that is exclusively the backend's job on every +subsequent admin request. A decoded-but-unverified claim is UX (role-gated +menus, expiry countdowns) — never proof of authorization to any client-side +check. + +--- + +## 4. Refresh token + +Defined only for Mechanism B. `AuthTokenPair { token, refreshToken }` is +returned by both `/verify` and `/refresh`. Storage +(`SessionService`, `src/app/core/auth/services/session.service.ts`): + +- `localStorage['ed25519AdminToken']` — access token (JWT) +- `localStorage['ed25519AdminRefreshToken']` — refresh token (opaque to the + frontend; never decoded, only round-tripped) + +Both are written together in `activate()` and cleared together in `clear()`. +There is no separate expiry tracked for the refresh token client-side — +the frontend only reacts to a `401` from `/refresh` (see §5/§6). + +Separately, `AdminAuthService` (Mechanism A, Telegram) reserves +`localStorage['adminToken']` / `localStorage['adminRefreshToken']` with +`getAdminToken()`/`setAdminTokens()`/`clearAdminTokens()` methods — +**written by no code path today** ("reserved for once the backend issues +admin access/refresh tokens... unused until then," per the source comment). +These are a distinct, currently-dead pair of storage keys from the Ed25519 +ones above; do not conflate them. + +--- + +## 5. Token expiration handling + +### 5.1 Mechanism A (Telegram session) — expiry via re-poll + +See §1.5. `expires` from the session payload drives a `setTimeout` at +`max(expiresMs - now - 60_000, 30_000)` that re-calls `GET /users/sessions/ +{id}`; if the backend now reports inactive, local state is cleared to +`unauthenticated`. There is no interceptor-level reactive handling for this +mechanism — a 401/expired session surfaces only through the next explicit +`checkSessionOnce()` poll or session re-check timer, not a per-request +retry. + +### 5.2 Mechanism B (Ed25519/JWT) — proactive + reactive + +`SessionService.scheduleRefresh(claims)`: computes +`refreshInMs = max(claims.exp*1000 - now - 60_000, 5_000)` and sets a timer. +When it fires, `AuthService.refresh()` runs automatically +(`session.onRefreshDue(callback)` wiring, set up once in `AuthService`'s +constructor to avoid a circular DI dependency between the two services). + +`SessionService.restore()` (intended to run once at app bootstrap, from an +`APP_INITIALIZER` calling `AuthFacade.restoreSession()` — **not yet wired +into the bootstrap process today**, see §12): reads persisted tokens, +decodes claims, and either resumes with a scheduled refresh or marks +`expired` immediately without any network call, so a stale session is +caught before any component/guard runs. + +`authInterceptor` (present in source, not registered — §2.6) is documented +as: catch a 401 on any admin-gated request → attempt one `refresh()` → +retry the original request once on success → route to `session-expired` on +failure. Does not retry more than once; a second 401 after an +apparently-successful refresh is treated as a server-side problem, not a +transient race. + +### 5.3 Sequence diagram — token expiration / refresh (Ed25519 flow) + +```mermaid +sequenceDiagram + participant FE as Frontend (SessionService) + participant IC as authInterceptor (not yet registered, §2.6) + participant BE as Backend + + Note over FE: Timer fires ~60s before JWT exp + FE->>BE: POST /api/admin/auth/refresh { refreshToken } + alt refresh token still valid + BE-->>FE: 200 { token, refreshToken } + FE->>FE: activate(tokens) - reschedules next refresh + else refresh token expired/revoked + BE-->>FE: 401 + FE->>FE: SessionService.markExpired() + FE-->>FE: route to /admin-login/error/session-expired + end + + Note over IC: Reactive path - any 401 on an admin request
(inactive until authInterceptor is registered) + IC->>BE: Admin API request (expired token) + BE-->>IC: 401 + IC->>BE: POST /api/admin/auth/refresh (single retry) + alt refresh succeeds + BE-->>IC: 200 tokens + IC->>BE: retry original request with new token + else refresh fails + IC-->>FE: propagate error, route to session-expired + end +``` + +--- + +## 6. Token rotation + +**Mechanism A**: no token to rotate — the `webSessionID` itself is stable +for the life of the session; expiry is handled by re-polling status (§5.1), +not by issuing a new id. + +**Mechanism B**: rotation is *expected* by the frontend but not verifiable +until the backend exists. Per the source comment in `AuthApiService` and the +security notes in the prior `docs/AUTH.md`: + +- Every `POST /refresh` response is expected to include a **new** + `refreshToken`; the backend should invalidate the one just used + (single-use refresh tokens). +- The frontend always stores whatever pair it receives from `/verify` or + `/refresh` and never reuses an old refresh token after a successful + rotation — there is no client-side retry logic that would resend a stale + refresh token. +- **Requires backend decision**: refresh-token reuse detection / revocation + cascade (e.g. if a rotated-out refresh token is presented again, should the + backend revoke the entire token family as a compromise signal?). Nothing + in the frontend implies or depends on this — it is a pure backend policy + choice. + +--- + +## 7. Logout + +**Mechanism A** (`AdminAuthService.logout()` / `AuthService.logout()` in +`src/app/services/auth.service.ts`): `DELETE /users/sessions/{id}` with +`WebSessionID` header, then unconditionally clears local state (cookie, +signals, timers) regardless of the HTTP result. + +**Mechanism B** (`AuthService.logout()` in `src/app/core/auth/services/`): +clears `SessionService` state **immediately and unconditionally** (before +the network call resolves), then best-effort calls +`POST /api/admin/auth/logout { refreshToken }` if a refresh token was +present; any error from that call is swallowed (`catchError(() => +throwError(() => null))`). If no refresh token exists locally, no network +call is made at all. **Backend implication**: the frontend cannot be relied +upon to reliably deliver the logout call (network failure, tab closed +mid-request, etc.) — server-side session/token expiry must not depend on a +client-issued logout ever arriving. `AuthFacade.logout()` additionally +always navigates to `/admin-login` (default) via `finalize()`, regardless of +API outcome. + +### 7.1 Sequence diagram — logout (both mechanisms) + +```mermaid +sequenceDiagram + participant User + participant FE as Frontend + participant BE as Backend + + User->>FE: Click "Log out" + FE->>FE: Clear local session state immediately
(cookie / tokens / signals / timers) + alt Mechanism A (Telegram session) + FE->>BE: DELETE /users/sessions/{id} (header WebSessionID) + BE-->>FE: any response (ignored) + else Mechanism B (Ed25519/JWT) - only if a refresh token existed + FE->>BE: POST /api/admin/auth/logout { refreshToken } + BE-->>FE: 204 (or error, swallowed) + end + FE-->>User: redirect to login page +``` + +--- + +## 8. Session invalidation + +Client-side triggers that clear local auth state, both mechanisms: + +- Explicit logout (§7). +- Session status re-check (Mechanism A) returning `active: false` (§1.5). +- JWT decode failure on restore (Mechanism B) — a malformed/unparsable + stored token is treated as no session at all (`SessionService.restore()` + calls `clear()`). +- Refresh failure (Mechanism B) — any error from `/refresh` calls + `SessionService.markExpired()`. +- `AdminAuthService.clearAuthState()` also clears the reserved + `adminToken`/`adminRefreshToken` keys (§4) even though nothing currently + writes them, for forward-compatibility once Mechanism A gains a token + pair. + +**Requires backend decision**: server-side session/token revocation +propagation — e.g., can an Owner revoke another admin's active session +remotely (relevant given `AdminUsersGateway.revokeSession` already exists as +a **mock-only** admin-users gateway method per +`docs/context/BACKEND-AUDIT.md` §14)? If so, the frontend has no push +mechanism (no websocket, no polling of "is my token still valid" beyond the +scheduled refresh) to learn about a remote revocation before its next +refresh/request attempt — a session could remain "authenticated" client-side +for up to the refresh interval after a backend-side revocation. If real-time +revocation is required, that is new frontend work, not something already +implied by existing code. + +--- + +## 9. Role hierarchy + +**Discrepancy flagged by the backend audit — `AdminRole` is defined twice +with different meanings. Reconciliation needed before backend +implementation:** + +1. `src/app/core/auth/models/permission.model.ts` — a **string union** used + by the Ed25519/JWT flow's `role` claim and `PermissionService`: + ```ts + type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly'; + ``` + Ordered highest-to-lowest privilege by convention (not enforced in code — + `PermissionService` does not rely on ordering, only exact role → permission + set lookup). + +2. `src/app/features/admin/users/models/admin-user.model.ts` — an + **interface** describing an admin-users-management row (`{ id, name, ... + }`), unrelated in shape to #1 and used only by the mock admin-users + gateway/facade (`AdminUsersGateway`, MOCK-ONLY, no backend seam per the + audit). + +These two `AdminRole` symbols do not currently reference each other and are +imported from different modules by different features. A backend +implementer should treat #1 (the permission-model union) as the JWT/role +claim contract for auth purposes, and flag #2 for a naming rename (e.g. +`AdminUserRoleRecord`) rather than assuming they describe the same concept. +This document does not resolve the collision — it is called out so a human +reconciles it before building the backend role table. + +### 9.1 Permission-to-role mapping (from `ROLE_PERMISSIONS`) + +| Role | Permissions | +|---|---| +| `Owner` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage`, `settings.manage` | +| `Administrator` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write`, `users.manage` | +| `Editor` | `backoffice.read`, `backoffice.write`, `builder.read`, `builder.write` | +| `Support` | `backoffice.read` | +| `ReadOnly` | `backoffice.read`, `builder.read` | + +This is deliberately coarse and mirrors the bootstrap-level +`PermissionsConfig` shape (`src/app/shared/models/config/permissions.model.ts`). +Finer-grained, per-domain permissions (e.g. "can edit prices but not delete +products") do not exist anywhere client-side and stay server-side — +**Requires backend decision** if finer granularity is ever needed. + +--- + +## 10. Tenant isolation + +`TenantResolverService` (`src/app/core/config/tenant-resolver.service.ts`) +resolves tenant by **subdomain**, not by header or path prefix: + +```ts +getTenantKey(): string { + if (isLocalhost()) return environment.fallbackTenantKey ?? 'default'; + const segments = hostname.split('.').filter(Boolean); + if (segments.length === 0) return environment.fallbackTenantKey ?? 'default'; + if (segments[0] === 'www' && segments.length > 1) return segments[1]; + return segments[0]; +} +``` + +- `isLocalhost()` matches `localhost`, `127.0.0.1`, `::1`. +- On a real host, the tenant key is the **first DNS label**, skipping a + leading `www`. E.g. `dexarmarket.api.dexarmarket.ru` → tenant key + `dexarmarket`; `www.acme.com` → `acme`. +- This tenant key feeds `ApiConfigService.getBaseUrl()` + (`src/app/core/config/api-config.service.ts`, documented in + `docs/context/BACKEND-AUDIT.md` §2) to pick the marketplace API base URL: + localhost → `environment.localhostApiUrl` (`/api`); else + `environment.tenantApiBaseUrls[tenantKey]`; else + `environment.tenantApiTemplate` with `{tenant}` substituted; else + (gated by `allowBootstrapApiOverride`, off by default) a value read out of + the already-loaded bootstrap document + (`bootstrap.apiEndpoints.website.baseUrl` / + `bootstrap.tenant.apiBaseUrl`). +- **No `X-Tenant` header or `/tenant/{id}/...` path prefix is sent by the + frontend anywhere** — tenant isolation for the marketplace API is achieved + purely by **which base URL/subdomain the request is sent to**, not by a + request attribute the backend reads per-call. Auth (both mechanisms) does + **not** carry any tenant identifier in its request bodies or headers + either — `POST /users/sessions`, `/api/admin/auth/challenge`, etc. are all + called against `environment.authApiUrl`, a single fixed origin, with no + per-tenant variation in the auth-flow code today. +- **Requires backend decision**: if auth (session creation, Ed25519 + challenge/verify) must be tenant-scoped (e.g. an admin's Ed25519 public key + should only authorize them for one tenant's backoffice), the frontend + currently has no mechanism to communicate which tenant a login attempt is + for beyond whatever the backend can infer from the request's origin/ + Referer header — nothing in the auth payloads carries a tenant id + explicitly. This would be new frontend work if required. + +--- + +## 11. Permission model / route guards + +### 11.1 `adminAuthGuard` (live, Mechanism A) — `src/app/core/admin-auth/admin-auth.guard.ts` + +```ts +export const adminAuthGuard: CanActivateFn = () => { + const adminAuth = inject(AdminAuthService); + if (adminAuth.isAuthenticated()) return true; + adminAuth.requestLogin(); + return false; +}; +``` + +Checks only `AdminAuthService.isAuthenticated()` (Telegram session status +`=== 'authenticated'`) — no role/permission check at all. Applied to +`/edit`, `/edit/:section`, and `/backoffice` (and its children) in +`app.routes.ts`. This guard **cannot** distinguish admin roles from each +other — it is purely "is there an active admin Telegram session," which is +the exact gap Mechanism B is meant to close. + +### 11.2 `ed25519AuthGuard` (dormant, Mechanism B) — `src/app/core/auth/guards/ed25519-auth.guard.ts` + +Requires `SessionService.isAuthenticated()` (JWT status `=== 'authenticated'`). +Not referenced by any route in `app.routes.ts` today — confirmed by source +search. Exists purely as the cutover target (see §2's "not live" status). + +### 11.3 `permissionGuard(permission)` — `src/app/core/auth/guards/permission.guard.ts` + +Factory guard that checks `PermissionService.has(permission)` against the +Mechanism B role → permission table (§9.1). Also unused by any live route +until Mechanism B is cut over, but ready to gate specific admin sub-routes +by permission once it is (e.g. `permissionGuard('users.manage')` on a users +page). + +### 11.4 `languageGuard` — `src/app/guards/language.guard.ts` + +Not an authentication guard, but gates every localized route (`:lang` +segment wraps the entire route tree in `app.routes.ts`). Behavior: + +- If `:lang` param is a known, enabled language: preload its translation + pack (`TranslateService.preloadLanguage`), set it as current + (`LanguageService.setLanguage`), allow navigation. +- If known but **disabled**: redirect to the current default language, + preserving the rest of the path. +- If unrecognized entirely: treat the URL as a legacy no-lang-prefix URL and + redirect to `/{defaultLang}{originalUrl}`, preserving query string/fragment + via `router.parseUrl` (not a hand-built `UrlTree`, to avoid double-encoding + the query string into the path segment). + +### 11.5 `canDeactivate` guards (dirty-state guards, not auth) + +Also not authentication, but listed since the task asked for "what guards +check": `projectEditorDirtyGuard`, `adminProductDirtyGuard`, +`adminCategoryDirtyGuard` — all gate navigation *away* from an in-progress +editor (builder section, product editor, category editor) to warn about +unsaved changes. They read editor dirty-state signals, not auth state, and +are unrelated to session/token validity. + +### 11.6 What the frontend actually gates, summarized + +| Concern | Mechanism | Guard/service | +|---|---|---| +| "Is there an active admin session at all" | Telegram (A) | `adminAuthGuard` → `AdminAuthService.isAuthenticated()` | +| "Is there an active admin JWT session" | Ed25519 (B), not live | `ed25519AuthGuard` → `SessionService.isAuthenticated()` | +| "Does this role have permission X" | Ed25519 (B), not live | `permissionGuard(permission)` → `PermissionService.has()` | +| "Is `:lang` valid/enabled" | n/a | `languageGuard` | +| "Unsaved editor changes" | n/a | `*DirtyGuard` (project editor / product / category) | + +**Every one of these is a client-side UX gate only.** None of them are a +substitute for server-side authorization — the backend must independently +verify role/permission on every admin mutation regardless of what a route +guard decided, per the security note already present in the prior +`docs/AUTH.md` and repeated here: a passing client-side check is not proof +of anything to the backend. + +--- + +## 12. Open items — "Requires backend decision" + +Consolidated list of everything this document could not derive from +existing frontend code and therefore does not prescribe: + +- **Public-key enrollment mechanism** (§2.1) — how an admin's Ed25519 + `publicKeyBase64` gets associated with an account/role server-side (admin + tool? one-time enrollment link? manual DB entry?). Zero frontend code + exists for this by design. +- **Refresh-token reuse/compromise detection** (§6) — whether presenting an + already-rotated-out refresh token should revoke the whole token family. + Not implied by any frontend behavior. +- **Session/token revocation propagation** (§8) — whether/how a + remotely-revoked admin session (e.g. via the mock `AdminUsersGateway. + revokeSession`) is communicated to an already-logged-in client before its + next refresh cycle. No push/poll mechanism exists today. +- **Tenant scoping of auth requests** (§10) — whether login/challenge/verify + need an explicit tenant identifier in the payload, versus relying on + request origin. Not present in any current auth payload. +- **Relationship between the two mechanisms at cutover** — replace + `adminAuthGuard` with `ed25519AuthGuard` outright, or run both and let + role/tenant config decide? Explicitly called out in the prior + `docs/AUTH.md` as "a product decision, not made here," and nothing has + changed that. +- **`AdminAuthService`'s reserved JWT-pair slots** (`adminToken`/ + `adminRefreshToken`, §4) — whether Mechanism A is ever meant to gain its + own token pair (as the reserved-but-unused storage suggests) independent + of the Ed25519 migration, or whether that code is dead and should be + removed. Not resolved by current usage (nothing writes to it). +- **`AdminRole` naming collision** (§9) — a reconciliation/rename decision + between `core/auth/models/permission.model.ts`'s string union and + `features/admin/users/models/admin-user.model.ts`'s interface; flagged, + not resolved, by this document. +- **Fine-grained/per-domain permissions** (§9.1) — the current model is + intentionally coarse; whether a richer permission model is ever needed is + a backend/product decision. +- **`APP_INITIALIZER` wiring for `AuthFacade.restoreSession()`** (§5.2) — the + code comment says this should be wired in before the Ed25519 flow goes + live, but it is not wired in today. This is frontend follow-up work, not a + backend decision, but is listed here because it changes what "session + restored on refresh" means in practice until it lands.