# 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.