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

14 KiB
Raw Blame History

ARCHIVED 2026-07-26. Superseded by docs/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

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

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

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.