Files
marketplaces/docs/AUTHENTICATION.md
sdarbinyan 53343fa711 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).
2026-07-26 08:53:09 +04:00

39 KiB

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)
  2. Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)
  3. JWT structure
  4. Refresh token
  5. Token expiration handling
  6. Token rotation
  7. Logout
  8. Session invalidation
  9. Role hierarchy
  10. Tenant isolation
  11. Permission model / route guards
  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):

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

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/challengeAuthChallenge { 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/403AuthService 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

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):

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)

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)

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:

    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:

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.comacme.
  • 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

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 languageGuardsrc/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) adminAuthGuardAdminAuthService.isAuthenticated()
"Is there an active admin JWT session" Ed25519 (B), not live ed25519AuthGuardSessionService.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.