Files
marketplaces/docs/archive/AUTH.md
sdarbinyan 99f7bace2d docs: assemble BACKEND_INTEGRATION.md, single canonical backend spec
4349 lines, 9 numbered sections per the Backend Finalization Sprint
spec: Bootstrap, Endpoint Framework, CRUD Contracts (~102 endpoints
across 20 domains), Authentication (spliced from AUTHENTICATION.md),
Security, Error Model (spliced from ERROR_CONTRACT.md), Uploads, Real
Backend Implementation Guide, Backend Checklist (34 items).

Everything traced to docs/context/BACKEND-AUDIT.md and actual current
source - proposed (unverified) paths explicitly marked as such,
everything the frontend has no opinion on marked "Requires backend
decision" rather than invented.

Archived the three docs this supersedes (BACKEND_API.md, AUTH.md,
BACKEND_API_REMAINING_WORK.md) to docs/archive/ with pointers back to
this file. AUTHENTICATION.md, ERROR_CONTRACT.md, MAINTENANCE_MODE.md
kept in place as standalone companion references (their content is
also inlined/cross-referenced here). ADMIN.md left untouched - it's a
frontend admin-UI sprint doc, not a backend spec, no overlap.

Verified via repo-wide search: no other backend/API spec docs remain
outside archive/ and this canonical file.
2026-07-26 12:17:51 +04:00

275 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> **ARCHIVED 2026-07-26.** Superseded by [`docs/BACKEND_INTEGRATION.md`](../BACKEND_INTEGRATION.md), the single canonical backend integration document. Kept for history only — do not implement against this file.
# Admin Authentication — Ed25519 Foundation
Status: **FRONTEND PREPARED, NOT LIVE.** Everything in this document describes
code that exists in `src/app/core/auth/` today, wired to endpoints that do
not exist on the backend yet. No route currently requires this flow — the
live admin gate remains the Telegram-QR-based `AdminAuthService` /
`adminAuthGuard` (`src/app/core/admin-auth/`, documented in
`docs/BACKEND_API.md` §2.42.5). This module is the
integration target once the backend ships the endpoints below.
Do not point any live route's `canActivate` at `ed25519AuthGuard` until the
backend endpoints in §API Contracts exist and have been verified — doing so
before then would lock every admin out.
## 1. Why this exists
`docs/BACKEND_API.md` §2.5 documents the current system's
biggest security gap: admin and customer login hit the *same* Telegram
session endpoint, so the backend has no way to distinguish an admin login
attempt from a customer one at the moment of login — authorization is
effectively unenforced. Ed25519 challenge/response auth closes this by
requiring proof of possession of a specific, pre-registered private key
before a session is ever issued, instead of "any Telegram account that
happened to scan the right QR code."
## 2. Sequence diagram
```mermaid
sequenceDiagram
participant Admin as Admin (browser)
participant FE as Frontend (AuthService)
participant BE as Backend
Admin->>FE: Click "Sign in"
FE->>BE: GET /api/admin/auth/challenge
BE-->>FE: { nonce, issuedAt, expiresAt }
FE->>FE: Ed25519KeypairService.sign(nonce)<br/>(WebCrypto, non-extractable private key)
FE->>BE: POST /api/admin/auth/verify<br/>{ publicKey, signature, nonce }
alt signature valid & publicKey is a provisioned admin key
BE-->>FE: 200 { token, refreshToken }
FE->>FE: SessionService.activate(tokens)<br/>decode JWT claims, schedule refresh
FE-->>Admin: Redirect to /backoffice
else invalid signature / unknown key / expired nonce
BE-->>FE: 401/403
FE-->>Admin: Redirect to /admin-login/error/invalid-signature
end
```
### Refresh sequence
```mermaid
sequenceDiagram
participant FE as Frontend (SessionService)
participant IC as authInterceptor
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
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
```
## 3. Ed25519 flow, step by step
1. **Key generation (once per device):** `Ed25519KeypairService.getOrCreateKeyPair()`
generates a non-extractable Ed25519 keypair via `crypto.subtle.generateKey`
and persists the `CryptoKey` handles in IndexedDB (`admin-auth-ed25519` DB).
The private key is never exported, serialized, or transmitted — by
construction, not by convention.
2. **Registering the public key with the backend is out of scope for this
frontend.** An Owner/Administrator must associate a new device's
`publicKeyBase64` with an admin account through some out-of-band
mechanism (e.g. a backend admin tool, a one-time enrollment link) before
that device can complete step 4. This document does not prescribe that
mechanism — it is a backend/ops concern.
3. **Challenge:** `GET /api/admin/auth/challenge` returns a fresh `nonce` the
client must sign before `expiresAt`.
4. **Sign:** the raw `nonce` string is signed with the device's private key
(`Ed25519KeypairService.sign`), producing a base64 signature.
5. **Verify:** `POST /api/admin/auth/verify` sends `{ publicKey, signature,
nonce }`. The backend re-derives the signed message from the nonce it
issued, verifies the signature against its own record of that
`publicKey → admin account` mapping, and only then issues tokens.
6. **Session:** the returned `{ token, refreshToken }` pair is stored
(`SessionService`, `localStorage: ed25519AdminToken` /
`ed25519AdminRefreshToken`) and the JWT is decoded client-side for
`role`/`exp` — decoding only, never signature verification (the frontend
has no trusted key to check it against).
## 4. API contracts
All under `{environment.authApiUrl}/api/admin/auth` (see
`src/environments/environment.ts`). None of these exist on the backend
today — this is the contract the frontend was built against, not a
confirmed backend spec.
| Method | Path | Request body | Response | Notes |
|---|---|---|---|---|
| GET | `/challenge` | — | `200 AuthChallenge` | `{ nonce, issuedAt, expiresAt }`, all ISO 8601 except `nonce` |
| POST | `/verify` | `VerifySignatureRequest` | `200 AuthTokenPair` \| `401` \| `403` | `{ publicKey, signature, nonce }` → `{ token, refreshToken }` |
| POST | `/refresh` | `RefreshTokenRequest` | `200 AuthTokenPair` \| `401` | `{ refreshToken }` → new pair (rotation expected — old refresh token should be invalidated server-side) |
| POST | `/logout` | `{ refreshToken }` | `204` | Should revoke the refresh token server-side; frontend clears local state regardless of response |
Types: `src/app/core/auth/models/auth-api.model.ts`.
## 5. JWT claims
```ts
interface JwtClaims {
sub: string; // admin account id
role: AdminRole; // 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly'
iat: number; // seconds since epoch
exp: number; // seconds since epoch
publicKey: string; // the Ed25519 public key this token was issued for
}
```
The frontend decodes these (`JwtService.decode`) for UX only — role-based UI
gating, expiry countdowns, refresh scheduling. **Every admin API request
must be independently authorized server-side**; a decoded-but-unverified
claim is not proof of anything to the backend.
## 6. Permission model
Five roles, coarse-grained permission keys (`src/app/core/auth/models/permission.model.ts`):
| 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 existing 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") stay server-side until a real permission model exists there —
see `docs/BACKEND_API.md` §"Admin-role-required". Use
`PermissionService.has(permission)` / `permissionGuard(permission)` to gate
UI and routes; never treat a passing client-side check as authorization by
itself.
## 7. Error screens
Single component (`AuthErrorPageComponent`, `/admin-login/error/:code`)
renders all five, keyed by route param:
| Code | Trigger | User action offered |
|---|---|---|
| `session-expired` | Refresh token rejected/expired | Sign in again |
| `invalid-signature` | `verify` returns 401/403 during login | 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 |
`authErrorCodeFromStatus` (`models/auth-error.model.ts`) maps HTTP status →
code: `401→unauthorized`, `403→forbidden`, `0→backend-unavailable`,
`5xx→backend-unavailable`, else `unauthorized`. `AuthService.login()`
additionally maps any failure during the challenge/sign/verify sequence to
`invalid-signature` when it isn't a clearer HTTP-status-derived code.
## 8. Refresh lifecycle
- On `SessionService.activate(tokens)`, a timer is scheduled for
`max(exp - now - 60s, 5s)` — refresh fires ~60 seconds before expiry so a
concurrent request never races an expiring token.
- **Proactive path:** the timer fires `AuthService.refresh()` directly.
- **Reactive path:** `authInterceptor` catches a 401 on any admin-gated
request, attempts one `refresh()`, retries the original request once on
success, and routes to `session-expired` on failure. It does not retry
more than once — a second 401 after a successful-looking refresh means
something is wrong server-side, not a transient race.
- `SessionService.restore()` runs on app bootstrap (call
`AuthFacade.restoreSession()` from an app initializer once this flow goes
live) — reads persisted tokens, decodes claims, and either resumes with a
scheduled refresh or marks `expired` without any network call, so a stale
session is caught before it reaches any component.
## 9. 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 §4
│ ├── 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
├── guards/
│ ├── ed25519-auth.guard.ts # requires SessionService.isAuthenticated()
│ └── permission.guard.ts # permissionGuard(permission) factory
└── pages/
├── admin-login-page.component.* # sign-in UI
└── auth-error-page.component.* # parameterized error screen (§7)
```
`AuthFacade` is the only thing components/pages should depend on;
`AuthService`/`SessionService`/`PermissionService` are internal
collaborators reachable through it.
## 10. Cutover plan (when the backend ships)
1. Verify the four endpoints in §4 against a real backend, including error
shapes.
2. Register `authInterceptor` in `app.config.ts`'s `withInterceptors([...])`
list (currently not registered).
3. Decide the relationship to the existing Telegram flow: replace
`adminAuthGuard` with `ed25519AuthGuard` outright, or run both and let
role/tenant config pick — this is a product decision, not made here.
4. Wire `AuthFacade.restoreSession()` into an `APP_INITIALIZER` (or root
component `ngOnInit`) so a page refresh restores state before any guard
runs.
5. Only after 14: point `/backoffice` and `/edit`'s `canActivate` at
`ed25519AuthGuard` (and `permissionGuard(...)` where a route needs a
specific role).
## 11. Security considerations
- **Private key never leaves the device.** Generated non-extractable via
WebCrypto; `Ed25519KeypairService` has no export path. Losing the device
means losing the key — key rotation/recovery (revoking a lost device's
public key, provisioning a new one) is a backend/ops process, not
implemented here.
- **The frontend is not the authorization boundary.** Every admin
request must be independently checked server-side against the caller's
actual role, exactly as `docs/BACKEND_API.md` §2.5
already states for the Telegram flow. A decoded JWT claim or a passing
`PermissionService.has()` check is UX, not proof.
- **Refresh tokens should rotate.** Every `POST /refresh` response is
expected to include a *new* refresh token; the backend should invalidate
the one just used. The frontend always stores whatever pair it receives
and never reuses an old refresh token after a successful rotation.
- **CSRF/replay:** the nonce from `/challenge` must be single-use and
time-boxed server-side (`expiresAt`) — the frontend enforces nothing here
beyond passing the nonce back unmodified; replay protection is the
backend's responsibility.
- **No fallback to unsigned auth.** There is no code path in this module
that issues a session without a valid signature. If the backend is
unreachable, the user sees `backend-unavailable`, never a degraded or
bypassed login.
- **Dev bypass exclusion:** unlike `AdminAuthService.devBypassLogin()` in
the Telegram flow, this module intentionally has no dev bypass — an
Ed25519 keypair is cheap to generate locally, so local testing should
point at a real (even if mocked-in-dev) `/challenge`/`/verify` pair
rather than fabricating a session.