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).
809 lines
39 KiB
Markdown
809 lines
39 KiB
Markdown
# 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.
|