# 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.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_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)
(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_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 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_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.