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).
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
- Mechanism A — Telegram QR / session login (LIVE)
- Mechanism B — Ed25519 challenge/response admin auth (NOT LIVE)
- JWT structure
- Refresh token
- Token expiration handling
- Token rotation
- Logout
- Session invalidation
- Role hierarchy
- Tenant isolation
- Permission model / route guards
- 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:
- User opens login (storefront "Sign in" or admin
/admin-logingate). - Frontend generates a random GUID client-side (
generateGuid(),src/app/shared/util/guid.util.ts) — this is thewebSessionID, sent to the backend, not received from it. POST {authApiUrl}/users/sessionswith{ webSessionID }body andWebSessionIDheader set to the same value. Backend response's own id field is preferred if present (seeextractSessionId— checkswebSessionID/WebSessionID/webSessionId/sessionID/SessionID/sessionId/id/IDin that order), else the frontend's generated GUID is used as fallback.- 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).
- Web:
- 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 theurl) plus the app deep link for mobile. This is orchestrated byQrLoginEngine(src/app/shared/qr-login/qr-login.engine.ts) shared identically by both customer and admin modes viaTelegramLoginComponent(src/app/components/telegram-login/telegram-login.component.ts,[mode]="'customer' | 'admin'"). - 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
webSessionIDas active/logged-in once the Telegram bot confirms the user, associating a Telegram user identity with it. - Frontend polls
GET /users/sessions/{id}(checkSessionOnce, driven byQrLoginEngine's polling loop) until the session normalizes toactive: true, or the user cancels/times out. - 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 intelegram-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 booleantrue/1, or (case-insensitive) one oftrue, 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 tousername; else falls back to the joined full name; else literal'Telegram User'. - Telegram user id:
userId/telegramUserId/telegramUserID/TelegramUserID/id/ID(user object), elseuserId/telegramUserId/telegramUserID/TelegramUserID/userID/UserID/UserId(top-level). - Session id: same priority list as
extractSessionIdabove. - Expiry:
expiresAt/ExpiresAt/expires/Expires(ISO 8601 string); if absent, the frontend fabricatesnow + 3600sclient-side — the backend should always send a realexpiresAt/expiresso 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 viacrypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])(real WebCrypto Ed25519 — RFC 8032, not a placeholder), persists the rawCryptoKeyhandles in IndexedDB (admin-auth-ed25519DB, object storekeypair, single recordid: '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 withcrypto.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
publicKeyBase64with 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/):
GET {authApiUrl}/api/admin/auth/challenge→AuthChallenge { nonce, issuedAt, expiresAt }(all ISO 8601 exceptnonce, an opaque string).Ed25519KeypairService.getOrCreateKeyPair()(generates on first use).Ed25519KeypairService.sign(nonce)— signs the raw nonce string exactly as received, no additional framing/prefix/hashing applied client-side.POST {authApiUrl}/api/admin/auth/verifywith bodyVerifySignatureRequest { publicKey, signature, nonce }(publicKey= base64 raw Ed25519 public key,signature= base64 signature over the nonce,nonce= the same value echoed back).- Backend must: re-derive the exact signed message from the nonce it
issued, verify the signature against its own
publicKey → admin accountmapping, confirm the nonce hasn't expired or been used before, and only then issue tokens. - On success:
200 AuthTokenPair { token, refreshToken }.SessionService.activate(tokens)decodes the JWT (§3), stores both tokens (§4), and schedules the next refresh (§5). - On failure:
401/403→AuthServicemaps it throughauthErrorCodeFromStatus()toinvalid-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 /refreshresponse is expected to include a newrefreshToken; the backend should invalidate the one just used (single-use refresh tokens). - The frontend always stores whatever pair it receives from
/verifyor/refreshand 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()callsclear()). - Refresh failure (Mechanism B) — any error from
/refreshcallsSessionService.markExpired(). AdminAuthService.clearAuthState()also clears the reservedadminToken/adminRefreshTokenkeys (§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:
-
src/app/core/auth/models/permission.model.ts— a string union used by the Ed25519/JWT flow'sroleclaim andPermissionService:type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';Ordered highest-to-lowest privilege by convention (not enforced in code —
PermissionServicedoes not rely on ordering, only exact role → permission set lookup). -
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()matcheslocalhost,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 keydexarmarket;www.acme.com→acme. - This tenant key feeds
ApiConfigService.getBaseUrl()(src/app/core/config/api-config.service.ts, documented indocs/context/BACKEND-AUDIT.md§2) to pick the marketplace API base URL: localhost →environment.localhostApiUrl(/api); elseenvironment.tenantApiBaseUrls[tenantKey]; elseenvironment.tenantApiTemplatewith{tenant}substituted; else (gated byallowBootstrapApiOverride, off by default) a value read out of the already-loaded bootstrap document (bootstrap.apiEndpoints.website.baseUrl/bootstrap.tenant.apiBaseUrl). - No
X-Tenantheader 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 againstenvironment.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 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
:langparam 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 viarouter.parseUrl(not a hand-builtUrlTree, 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
publicKeyBase64gets 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
adminAuthGuardwithed25519AuthGuardoutright, or run both and let role/tenant config decide? Explicitly called out in the priordocs/AUTH.mdas "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).AdminRolenaming collision (§9) — a reconciliation/rename decision betweencore/auth/models/permission.model.ts's string union andfeatures/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_INITIALIZERwiring forAuthFacade.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.