From 6510df65661eaebb8ff875c21a3b08c5162d4ac5 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Mon, 20 Jul 2026 09:00:41 +0400 Subject: [PATCH] feat(auth): add Ed25519 admin auth core services, interceptor, guards AuthService/AuthFacade orchestrate GET challenge -> sign -> POST verify -> JWT+refresh, SessionService/PermissionService hold state, real WebCrypto Ed25519 keypair (non-extractable), authInterceptor + ed25519AuthGuard/permissionGuard prepared but not yet wired onto live routes - backend endpoints (docs/AUTH.md) do not exist yet. --- .../core/auth/guards/ed25519-auth.guard.ts | 25 ++++ src/app/core/auth/guards/permission.guard.ts | 25 ++++ .../auth/interceptors/auth.interceptor.ts | 46 ++++++ src/app/core/auth/models/auth-api.model.ts | 46 ++++++ src/app/core/auth/models/auth-error.model.ts | 32 +++++ src/app/core/auth/models/permission.model.ts | 34 +++++ .../core/auth/services/auth-api.service.ts | 36 +++++ .../core/auth/services/auth-facade.service.ts | 56 ++++++++ src/app/core/auth/services/auth.service.ts | 124 ++++++++++++++++ .../auth/services/ed25519-keypair.service.ts | 125 ++++++++++++++++ src/app/core/auth/services/jwt.service.ts | 44 ++++++ .../core/auth/services/permission.service.ts | 26 ++++ src/app/core/auth/services/session.service.ts | 133 ++++++++++++++++++ 13 files changed, 752 insertions(+) create mode 100644 src/app/core/auth/guards/ed25519-auth.guard.ts create mode 100644 src/app/core/auth/guards/permission.guard.ts create mode 100644 src/app/core/auth/interceptors/auth.interceptor.ts create mode 100644 src/app/core/auth/models/auth-api.model.ts create mode 100644 src/app/core/auth/models/auth-error.model.ts create mode 100644 src/app/core/auth/models/permission.model.ts create mode 100644 src/app/core/auth/services/auth-api.service.ts create mode 100644 src/app/core/auth/services/auth-facade.service.ts create mode 100644 src/app/core/auth/services/auth.service.ts create mode 100644 src/app/core/auth/services/ed25519-keypair.service.ts create mode 100644 src/app/core/auth/services/jwt.service.ts create mode 100644 src/app/core/auth/services/permission.service.ts create mode 100644 src/app/core/auth/services/session.service.ts diff --git a/src/app/core/auth/guards/ed25519-auth.guard.ts b/src/app/core/auth/guards/ed25519-auth.guard.ts new file mode 100644 index 0000000..a7e31da --- /dev/null +++ b/src/app/core/auth/guards/ed25519-auth.guard.ts @@ -0,0 +1,25 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { SessionService } from '../services/session.service'; + +/** + * Guards routes under the Ed25519 JWT flow. Not wired onto any live route + * yet (see docs/AUTH.md cutover plan) - `adminAuthGuard` + * (`core/admin-auth/admin-auth.guard.ts`) remains the active guard for + * `/backoffice` and `/edit` until the backend ships the challenge/verify + * endpoints this depends on. + */ +export const ed25519AuthGuard: CanActivateFn = () => { + const session = inject(SessionService); + const router = inject(Router); + + if (session.isAuthenticated()) { + return true; + } + + if (session.status() === 'expired') { + return router.parseUrl('/admin-login/error/session-expired'); + } + + return router.parseUrl('/admin-login'); +}; diff --git a/src/app/core/auth/guards/permission.guard.ts b/src/app/core/auth/guards/permission.guard.ts new file mode 100644 index 0000000..bddaaa9 --- /dev/null +++ b/src/app/core/auth/guards/permission.guard.ts @@ -0,0 +1,25 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { Permission } from '../models/permission.model'; +import { PermissionService } from '../services/permission.service'; +import { SessionService } from '../services/session.service'; + +/** + * Factory guard: `permissionGuard('users.manage')` in a route's + * `canActivate`. Composes with `ed25519AuthGuard` - route to this only after + * confirming authentication, since an unauthenticated user has no role and + * would otherwise always be routed to `forbidden` instead of the login page. + */ +export function permissionGuard(required: Permission): CanActivateFn { + return () => { + const session = inject(SessionService); + const permissions = inject(PermissionService); + const router = inject(Router); + + if (!session.isAuthenticated()) { + return router.parseUrl('/admin-login'); + } + + return permissions.has(required) ? true : router.parseUrl('/admin-login/error/forbidden'); + }; +} diff --git a/src/app/core/auth/interceptors/auth.interceptor.ts b/src/app/core/auth/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..b99e204 --- /dev/null +++ b/src/app/core/auth/interceptors/auth.interceptor.ts @@ -0,0 +1,46 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, switchMap, throwError } from 'rxjs'; +import { SessionService } from '../services/session.service'; +import { AuthService } from '../services/auth.service'; + +/** Paths gated by the Ed25519 JWT once it is the live admin auth mechanism. Kept identical to adminAuthHeadersInterceptor's list for consistency. */ +const ADMIN_GATED_PATH_SEGMENTS = ['/admin/', '/backoffice/', '/builder/', '/media/']; + +/** + * Attaches `Authorization: Bearer ` to admin API requests and, on a 401, + * attempts a single silent refresh-then-retry before giving up and routing + * to the session-expired screen. Not registered in app.config.ts yet - this + * activates once the Ed25519 flow replaces (or runs alongside) + * adminAuthHeadersInterceptor; see docs/AUTH.md for the cutover plan. + */ +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const isAdminRequest = ADMIN_GATED_PATH_SEGMENTS.some(segment => req.url.includes(segment)); + if (!isAdminRequest) { + return next(req); + } + + const session = inject(SessionService); + const auth = inject(AuthService); + const router = inject(Router); + + const token = session.token(); + const authedReq = token ? req.clone({ headers: req.headers.set('Authorization', `Bearer ${token}`) }) : req; + + return next(authedReq).pipe( + catchError((error: unknown) => { + if (!(error instanceof HttpErrorResponse) || error.status !== 401 || !session.getRefreshToken()) { + return throwError(() => error); + } + + return auth.refresh().pipe( + switchMap(refreshed => next(req.clone({ headers: req.headers.set('Authorization', `Bearer ${refreshed.token}`) }))), + catchError(refreshError => { + router.navigate(['/admin-login/error', 'session-expired']); + return throwError(() => refreshError); + }) + ); + }) + ); +}; diff --git a/src/app/core/auth/models/auth-api.model.ts b/src/app/core/auth/models/auth-api.model.ts new file mode 100644 index 0000000..9f7090a --- /dev/null +++ b/src/app/core/auth/models/auth-api.model.ts @@ -0,0 +1,46 @@ +import { AdminRole } from './permission.model'; + +/** + * Wire contracts for the Ed25519 challenge/response admin auth flow. These + * are documented in docs/AUTH.md and match the endpoints listed there + * exactly - none of this is invented beyond what's documented as FUTURE + * there and in docs/backend/BACKEND-INTEGRATION.md §2.5. + */ +export interface AuthChallenge { + nonce: string; + /** ISO 8601 issue time of the challenge. */ + issuedAt: string; + /** ISO 8601 - challenge must be used before this or the backend rejects it. */ + expiresAt: string; +} + +export interface VerifySignatureRequest { + publicKey: string; + signature: string; + nonce: string; +} + +export interface AuthTokenPair { + token: string; + refreshToken: string; +} + +export interface RefreshTokenRequest { + refreshToken: string; +} + +/** + * Claims expected in the JWT `token`. Decoded client-side for display/UX + * only (role-gating UI, expiry countdown) - the frontend never treats this + * as proof of authorization; every admin request is still re-checked + * server-side per docs/AUTH.md security considerations. + */ +export interface JwtClaims { + sub: string; + role: AdminRole; + /** Issued-at, seconds since epoch (standard `iat` claim). */ + iat: number; + /** Expiry, seconds since epoch (standard `exp` claim). */ + exp: number; + publicKey: string; +} diff --git a/src/app/core/auth/models/auth-error.model.ts b/src/app/core/auth/models/auth-error.model.ts new file mode 100644 index 0000000..f2e6b03 --- /dev/null +++ b/src/app/core/auth/models/auth-error.model.ts @@ -0,0 +1,32 @@ +/** + * Error codes the Ed25519 admin auth flow can surface to the UI. Each maps to + * a dedicated screen (see `core/auth/pages`) rather than a generic toast, + * because the recovery action differs per code (re-login vs. retry vs. wait). + */ +export type AuthErrorCode = + | 'session-expired' + | 'invalid-signature' + | 'unauthorized' + | 'forbidden' + | 'backend-unavailable'; + +export interface AuthError { + code: AuthErrorCode; + message: string; + /** HTTP status that produced this error, when known (absent for client-side errors, e.g. no Ed25519 support). */ + status?: number; +} + +/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */ +export function authErrorCodeFromStatus(status: number): AuthErrorCode { + switch (status) { + case 401: + return 'unauthorized'; + case 403: + return 'forbidden'; + case 0: + return 'backend-unavailable'; + default: + return status >= 500 ? 'backend-unavailable' : 'unauthorized'; + } +} diff --git a/src/app/core/auth/models/permission.model.ts b/src/app/core/auth/models/permission.model.ts new file mode 100644 index 0000000..c7380ce --- /dev/null +++ b/src/app/core/auth/models/permission.model.ts @@ -0,0 +1,34 @@ +/** + * Roles the Ed25519 JWT `role` claim is expected to carry (see docs/AUTH.md + * §JWT Claims). Ordered highest-to-lowest privilege; PermissionService does + * not rely on the order, it is documentation only. + */ +export type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly'; + +/** + * Coarse-grained permission keys. Intentionally small and domain-agnostic + * (mirrors the existing bootstrap-level `PermissionsConfig` shape in + * `shared/models/config/permissions.model.ts`) - fine-grained, per-domain + * permissions stay server-side until the backend ships a real permission + * model; the frontend only needs enough to hide/disable UI, never to be the + * source of truth for authorization. + */ +export type Permission = + | 'backoffice.read' + | 'backoffice.write' + | 'builder.read' + | 'builder.write' + | 'users.manage' + | 'settings.manage'; + +export const ROLE_PERMISSIONS: Readonly> = { + 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'] +}; + +export function isAdminRole(value: unknown): value is AdminRole { + return typeof value === 'string' && value in ROLE_PERMISSIONS; +} diff --git a/src/app/core/auth/services/auth-api.service.ts b/src/app/core/auth/services/auth-api.service.ts new file mode 100644 index 0000000..a1d18c9 --- /dev/null +++ b/src/app/core/auth/services/auth-api.service.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { AuthChallenge, AuthTokenPair, RefreshTokenRequest, VerifySignatureRequest } from '../models/auth-api.model'; + +/** + * Thin HTTP client for the Ed25519 admin auth endpoints documented in + * docs/AUTH.md. These endpoints do not exist on the backend yet (FUTURE - + * see docs/backend/BACKEND-INTEGRATION.md §2.5) - calling them today 404s + * or connection-errors, which AuthService maps to the + * `backend-unavailable` error screen. No mock/fake responses are fabricated + * here; this is real HttpClient wiring against the real contract, ready for + * the moment the backend ships. + */ +@Injectable({ providedIn: 'root' }) +export class AuthApiService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.authApiUrl}/api/admin/auth`; + + requestChallenge(): Observable { + return this.http.get(`${this.baseUrl}/challenge`); + } + + verifySignature(request: VerifySignatureRequest): Observable { + return this.http.post(`${this.baseUrl}/verify`, request); + } + + refresh(request: RefreshTokenRequest): Observable { + return this.http.post(`${this.baseUrl}/refresh`, request); + } + + logout(refreshToken: string): Observable { + return this.http.post(`${this.baseUrl}/logout`, { refreshToken } satisfies RefreshTokenRequest); + } +} diff --git a/src/app/core/auth/services/auth-facade.service.ts b/src/app/core/auth/services/auth-facade.service.ts new file mode 100644 index 0000000..e3f42d4 --- /dev/null +++ b/src/app/core/auth/services/auth-facade.service.ts @@ -0,0 +1,56 @@ +import { Injectable, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { finalize } from 'rxjs'; +import { AuthService } from './auth.service'; +import { PermissionService } from './permission.service'; +import { SessionService } from './session.service'; +import { Permission } from '../models/permission.model'; + +/** + * Public surface for components/pages. Components should depend on this, + * not on AuthService/SessionService/PermissionService directly, so the + * orchestration details (which service owns what) can change without + * touching UI code. + */ +@Injectable({ providedIn: 'root' }) +export class AuthFacade { + private readonly auth = inject(AuthService); + private readonly session = inject(SessionService); + private readonly permissions = inject(PermissionService); + private readonly router = inject(Router); + + readonly isAuthenticated = this.session.isAuthenticated; + readonly status = this.session.status; + readonly role = this.session.role; + readonly loginPhase = this.auth.loginPhase; + readonly lastError = this.auth.lastError; + + restoreSession(): void { + this.auth.restoreSession(); + } + + login(onSuccessRedirectTo?: string): void { + this.auth.login().subscribe({ + next: () => { + if (onSuccessRedirectTo) { + this.router.navigateByUrl(onSuccessRedirectTo); + } + }, + error: () => { + const code = this.auth.lastError()?.code ?? 'unauthorized'; + this.router.navigate(['/admin-login/error', code]); + } + }); + } + + logout(redirectTo = '/admin-login'): void { + this.auth + .logout() + .pipe(finalize(() => this.router.navigateByUrl(redirectTo))) + .subscribe({ error: () => undefined }); + } + + can(permission: Permission): boolean { + return this.permissions.has(permission); + } +} diff --git a/src/app/core/auth/services/auth.service.ts b/src/app/core/auth/services/auth.service.ts new file mode 100644 index 0000000..55fd221 --- /dev/null +++ b/src/app/core/auth/services/auth.service.ts @@ -0,0 +1,124 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { catchError, switchMap, tap, throwError } from 'rxjs'; +import { Observable } from 'rxjs'; +import { AuthTokenPair } from '../models/auth-api.model'; +import { AuthError, authErrorCodeFromStatus } from '../models/auth-error.model'; +import { AuthApiService } from './auth-api.service'; +import { Ed25519KeypairService } from './ed25519-keypair.service'; +import { SessionService } from './session.service'; + +export type LoginPhase = 'idle' | 'requesting-challenge' | 'signing' | 'verifying' | 'done'; + +/** + * Orchestrates the Ed25519 challenge/response admin auth flow end to end: + * + * GET /api/admin/auth/challenge -> { nonce } + * sign(nonce) with local Ed25519 key -> signature + * POST /api/admin/auth/verify -> { token, refreshToken } + * + * This is the lowest-level orchestrator; components should go through + * AuthFacade rather than calling this directly. + */ +@Injectable({ providedIn: 'root' }) +export class AuthService { + private readonly api = inject(AuthApiService); + private readonly keypair = inject(Ed25519KeypairService); + private readonly session = inject(SessionService); + + private readonly loginPhaseSignal = signal('idle'); + private readonly lastErrorSignal = signal(null); + + readonly loginPhase = this.loginPhaseSignal.asReadonly(); + readonly lastError = this.lastErrorSignal.asReadonly(); + + constructor() { + this.session.onRefreshDue(() => this.refresh().subscribe()); + } + + /** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */ + restoreSession(): void { + this.session.restore(); + } + + login(): Observable { + this.lastErrorSignal.set(null); + this.loginPhaseSignal.set('requesting-challenge'); + + return this.api.requestChallenge().pipe( + switchMap(challenge => + this.signChallenge(challenge.nonce).pipe( + switchMap(({ publicKeyBase64, signature }) => { + this.loginPhaseSignal.set('verifying'); + return this.api.verifySignature({ publicKey: publicKeyBase64, signature, nonce: challenge.nonce }); + }) + ) + ), + tap(tokens => { + this.session.activate(tokens); + this.loginPhaseSignal.set('done'); + }), + catchError(error => this.handleAuthError(error, 'invalid-signature')) + ); + } + + refresh(): Observable { + const refreshToken = this.session.getRefreshToken(); + if (!refreshToken) { + this.session.markExpired(); + return throwError(() => this.toAuthError({ code: 'session-expired', message: 'No refresh token available.' })); + } + + return this.api.refresh({ refreshToken }).pipe( + tap(tokens => this.session.activate(tokens)), + catchError(error => this.handleAuthError(error, 'session-expired', () => this.session.markExpired())) + ); + } + + logout(): Observable { + const refreshToken = this.session.getRefreshToken(); + this.session.clear(); + if (!refreshToken) { + return new Observable(subscriber => { + subscriber.next(); + subscriber.complete(); + }); + } + return this.api.logout(refreshToken).pipe(catchError(() => throwError(() => null))); + } + + private signChallenge(nonce: string): Observable<{ publicKeyBase64: string; signature: string }> { + this.loginPhaseSignal.set('signing'); + return new Observable<{ publicKeyBase64: string; signature: string }>(subscriber => { + this.keypair + .getOrCreateKeyPair() + .then(({ publicKeyBase64 }) => + this.keypair.sign(nonce).then(signature => { + subscriber.next({ publicKeyBase64, signature }); + subscriber.complete(); + }) + ) + .catch(error => subscriber.error(error)); + }); + } + + private handleAuthError(error: unknown, fallbackCode: AuthError['code'], onError?: () => void): Observable { + onError?.(); + return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode))); + } + + private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError { + if (error instanceof HttpErrorResponse) { + return { code: authErrorCodeFromStatus(error.status), message: error.message, status: error.status }; + } + if (error instanceof Error) { + return { code: fallbackCode, message: error.message }; + } + return { code: fallbackCode, message: 'Unknown authentication error.' }; + } + + private toAuthError(error: AuthError): AuthError { + this.lastErrorSignal.set(error); + return error; + } +} diff --git a/src/app/core/auth/services/ed25519-keypair.service.ts b/src/app/core/auth/services/ed25519-keypair.service.ts new file mode 100644 index 0000000..1ea3ab7 --- /dev/null +++ b/src/app/core/auth/services/ed25519-keypair.service.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@angular/core'; + +/** + * Manages the browser-local Ed25519 keypair used to sign admin auth + * challenges. Real WebCrypto Ed25519 (RFC 8032 support landed in evergreen + * browsers) - not a placeholder. The private key is generated + * non-extractable and kept only in IndexedDB as a CryptoKey handle; it is + * never serialized, never sent anywhere, and cannot be exported by design. + * + * Registering `publicKey` with an admin's account (associating it with a + * role) is a backend-side, out-of-band operation (e.g. an Owner approving a + * new admin's public key) - entirely outside this frontend's scope. + */ +const DB_NAME = 'admin-auth-ed25519'; +const DB_VERSION = 1; +const STORE_NAME = 'keypair'; +const KEY_RECORD_ID = 'device-keypair'; + +interface StoredKeyPair { + id: string; + publicKey: CryptoKey; + privateKey: CryptoKey; + publicKeyBase64: string; +} + +@Injectable({ providedIn: 'root' }) +export class Ed25519KeypairService { + private cached: StoredKeyPair | null = null; + + isSupported(): boolean { + return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined'; + } + + /** Returns the device's Ed25519 keypair, generating and persisting one on first use. */ + async getOrCreateKeyPair(): Promise<{ publicKeyBase64: string }> { + if (!this.isSupported()) { + throw new Error('Ed25519 is not supported in this browser (requires WebCrypto + IndexedDB).'); + } + const existing = await this.loadFromStore(); + if (existing) { + this.cached = existing; + return { publicKeyBase64: existing.publicKeyBase64 }; + } + + const generated = await this.generateAndPersist(); + this.cached = generated; + return { publicKeyBase64: generated.publicKeyBase64 }; + } + + async sign(message: string): Promise { + const keyPair = this.cached ?? (await this.loadFromStore()); + if (!keyPair) { + throw new Error('No Ed25519 keypair available - call getOrCreateKeyPair() first.'); + } + + const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, new TextEncoder().encode(message)); + return this.toBase64(new Uint8Array(signatureBuffer)); + } + + /** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */ + async clear(): Promise { + this.cached = null; + const db = await this.openDatabase(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).delete(KEY_RECORD_ID); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + private async generateAndPersist(): Promise { + const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])) as CryptoKeyPair; + const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); + const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw)); + + const record: StoredKeyPair = { + id: KEY_RECORD_ID, + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + publicKeyBase64 + }; + + const db = await this.openDatabase(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(record); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + + return record; + } + + private async loadFromStore(): Promise { + const db = await this.openDatabase(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const request = tx.objectStore(STORE_NAME).get(KEY_RECORD_ID); + request.onsuccess = () => resolve((request.result as StoredKeyPair | undefined) ?? null); + request.onerror = () => reject(request.error); + }); + } + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + private toBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); + } +} diff --git a/src/app/core/auth/services/jwt.service.ts b/src/app/core/auth/services/jwt.service.ts new file mode 100644 index 0000000..59fe7b0 --- /dev/null +++ b/src/app/core/auth/services/jwt.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { JwtClaims } from '../models/auth-api.model'; + +/** + * Client-side JWT *decoding* only - never verification. The signature is + * meaningless to check here because the frontend has no trusted key to check + * it against; verifying a JWT's signature is the backend's job on every + * request. This service exists purely so the UI can read `role`/`exp` for + * display and route-gating UX (e.g. "session expires in 4m"). + */ +@Injectable({ providedIn: 'root' }) +export class JwtService { + decode(token: string): JwtClaims | null { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + try { + const payload = this.base64UrlDecode(parts[1]); + const claims = JSON.parse(payload) as JwtClaims; + return this.isJwtClaims(claims) ? claims : null; + } catch { + return null; + } + } + + isExpired(claims: JwtClaims, skewSeconds = 0): boolean { + return claims.exp * 1000 <= Date.now() + skewSeconds * 1000; + } + + private isJwtClaims(value: unknown): value is JwtClaims { + if (!value || typeof value !== 'object') { + return false; + } + const claims = value as Partial; + return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number'; + } + + private base64UrlDecode(input: string): string { + const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '='); + return decodeURIComponent(escape(atob(base64))); + } +} diff --git a/src/app/core/auth/services/permission.service.ts b/src/app/core/auth/services/permission.service.ts new file mode 100644 index 0000000..2f8bff9 --- /dev/null +++ b/src/app/core/auth/services/permission.service.ts @@ -0,0 +1,26 @@ +import { Injectable, computed, inject } from '@angular/core'; +import { Permission, ROLE_PERMISSIONS } from '../models/permission.model'; +import { SessionService } from './session.service'; + +/** + * Derives the current admin's permission set from their JWT `role` claim. + * UI-only gate (hide/disable) - the backend must independently enforce + * every mutation server-side; see docs/AUTH.md security considerations. + */ +@Injectable({ providedIn: 'root' }) +export class PermissionService { + private readonly session = inject(SessionService); + + readonly permissions = computed(() => { + const role = this.session.role(); + return role ? ROLE_PERMISSIONS[role] : []; + }); + + has(permission: Permission): boolean { + return this.permissions().includes(permission); + } + + hasAny(permissions: readonly Permission[]): boolean { + return permissions.some(permission => this.has(permission)); + } +} diff --git a/src/app/core/auth/services/session.service.ts b/src/app/core/auth/services/session.service.ts new file mode 100644 index 0000000..85536dd --- /dev/null +++ b/src/app/core/auth/services/session.service.ts @@ -0,0 +1,133 @@ +import { Injectable, computed, signal } from '@angular/core'; +import { AuthTokenPair, JwtClaims } from '../models/auth-api.model'; +import { JwtService } from './jwt.service'; + +export type SessionStatus = 'unknown' | 'restoring' | 'authenticated' | 'unauthenticated' | 'expired'; + +const TOKEN_STORAGE_KEY = 'ed25519AdminToken'; +const REFRESH_STORAGE_KEY = 'ed25519AdminRefreshToken'; +/** Refresh this long before actual expiry, so a request never races an expiring token. */ +const REFRESH_SKEW_MS = 60_000; + +/** + * Holds the Ed25519-flow JWT/refresh-token pair and derived claims. Separate + * from AdminAuthService (Telegram-session state) by design - the two auth + * mechanisms are not merged until the backend actually ships the Ed25519 + * endpoints and a migration decision is made (see docs/AUTH.md). + */ +@Injectable({ providedIn: 'root' }) +export class SessionService { + private readonly jwt = new JwtService(); + + private readonly tokenSignal = signal(null); + private readonly refreshTokenSignal = signal(null); + private readonly claimsSignal = signal(null); + private readonly statusSignal = signal('unknown'); + + readonly token = this.tokenSignal.asReadonly(); + readonly claims = this.claimsSignal.asReadonly(); + readonly status = this.statusSignal.asReadonly(); + readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); + readonly role = computed(() => this.claimsSignal()?.role ?? null); + + private refreshTimer?: ReturnType; + private refreshCallback?: () => void; + + /** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */ + onRefreshDue(callback: () => void): void { + this.refreshCallback = callback; + } + + /** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */ + restore(): boolean { + this.statusSignal.set('restoring'); + const token = this.readStorage(TOKEN_STORAGE_KEY); + const refreshToken = this.readStorage(REFRESH_STORAGE_KEY); + if (!token || !refreshToken) { + this.statusSignal.set('unauthenticated'); + return false; + } + + const claims = this.jwt.decode(token); + if (!claims) { + this.clear(); + return false; + } + + this.tokenSignal.set(token); + this.refreshTokenSignal.set(refreshToken); + this.claimsSignal.set(claims); + + if (this.jwt.isExpired(claims)) { + this.statusSignal.set('expired'); + } else { + this.statusSignal.set('authenticated'); + this.scheduleRefresh(claims); + } + return true; + } + + activate(tokens: AuthTokenPair): void { + const claims = this.jwt.decode(tokens.token); + if (!claims) { + throw new Error('Received a malformed JWT from the auth backend.'); + } + + this.tokenSignal.set(tokens.token); + this.refreshTokenSignal.set(tokens.refreshToken); + this.claimsSignal.set(claims); + this.statusSignal.set('authenticated'); + this.writeStorage(TOKEN_STORAGE_KEY, tokens.token); + this.writeStorage(REFRESH_STORAGE_KEY, tokens.refreshToken); + this.scheduleRefresh(claims); + } + + getRefreshToken(): string | null { + return this.refreshTokenSignal(); + } + + markExpired(): void { + this.statusSignal.set('expired'); + this.clearRefreshTimer(); + } + + clear(): void { + this.tokenSignal.set(null); + this.refreshTokenSignal.set(null); + this.claimsSignal.set(null); + this.statusSignal.set('unauthenticated'); + this.removeStorage(TOKEN_STORAGE_KEY); + this.removeStorage(REFRESH_STORAGE_KEY); + this.clearRefreshTimer(); + } + + private scheduleRefresh(claims: JwtClaims): void { + this.clearRefreshTimer(); + const expiresInMs = claims.exp * 1000 - Date.now(); + const refreshInMs = Math.max(expiresInMs - REFRESH_SKEW_MS, 5_000); + this.refreshTimer = setTimeout(() => this.refreshCallback?.(), refreshInMs); + } + + private clearRefreshTimer(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + } + } + + private readStorage(key: string): string | null { + return typeof localStorage === 'undefined' ? null : localStorage.getItem(key); + } + + private writeStorage(key: string, value: string): void { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(key, value); + } + } + + private removeStorage(key: string): void { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(key); + } + } +}