From 89ae50e9a47516eb7ece8a6c69f977576e709f93 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Mon, 20 Jul 2026 09:07:27 +0400 Subject: [PATCH] docs(auth): add docs/AUTH.md, drop unused RouterLink import Sequence diagrams, JWT claims, API contracts, error responses, permission model, refresh lifecycle, security considerations, cutover plan. ng build passes clean (pre-existing bundle-budget warnings unrelated to this change). --- docs/AUTH.md | 272 ++++++++++++++++++ .../auth/pages/auth-error-page.component.ts | 4 +- 2 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 docs/AUTH.md diff --git a/docs/AUTH.md b/docs/AUTH.md new file mode 100644 index 0000000..f4e7886 --- /dev/null +++ b/docs/AUTH.md @@ -0,0 +1,272 @@ +# 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/BACKEND-INTEGRATION.md` §2.4–2.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/BACKEND-INTEGRATION.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)
(WebCrypto, non-extractable private key) + FE->>BE: POST /api/admin/auth/verify
{ publicKey, signature, nonce } + alt signature valid & publicKey is a provisioned admin key + BE-->>FE: 200 { token, refreshToken } + FE->>FE: SessionService.activate(tokens)
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/BACKEND-INTEGRATION.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 1–4: 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/BACKEND-INTEGRATION.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. diff --git a/src/app/core/auth/pages/auth-error-page.component.ts b/src/app/core/auth/pages/auth-error-page.component.ts index 20a6ea4..4bdb679 100644 --- a/src/app/core/auth/pages/auth-error-page.component.ts +++ b/src/app/core/auth/pages/auth-error-page.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { map } from 'rxjs'; import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component'; @@ -48,7 +48,7 @@ const COPY: Record = { @Component({ selector: 'app-auth-error-page', standalone: true, - imports: [EmptyStateComponent, ButtonComponent, RouterLink], + imports: [EmptyStateComponent, ButtonComponent], templateUrl: './auth-error-page.component.html', styleUrl: './auth-error-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush