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).
This commit is contained in:
sdarbinyan
2026-07-26 08:53:09 +04:00
parent fd7ffc8668
commit 53343fa711

808
docs/AUTHENTICATION.md Normal file
View File

@@ -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: <same guid>` | `{ 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: <id>` | 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,<br/>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):<br/>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<br/>(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<br/>(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.