From c628b1d8a956de30fdadb773978acc89656d31b3 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 18 Aug 2026 00:52:00 +0400 Subject: [PATCH] feat: migrate telegram + ed25519 auth implementation into @marketplaces/auth --- packages/auth/src/config.ts | 7 + packages/auth/src/ed25519/auth-api.service.ts | 34 +++ .../auth/src/ed25519/auth-facade.service.ts | 56 +++++ packages/auth/src/ed25519/auth.service.ts | 128 +++++++++++ .../src/ed25519/ed25519-keypair.service.ts | 125 +++++++++++ .../src/ed25519/ed25519-verification.model.ts | 31 +++ packages/auth/src/ed25519/jwt.service.ts | 44 ++++ .../auth/src/ed25519/models/auth-api.model.ts | 41 ++++ .../src/ed25519/models/auth-error.model.ts | 45 ++++ .../src/ed25519/models/permission.model.ts | 24 ++ .../noop-ed25519-verification.service.ts | 20 ++ .../auth/src/ed25519/permission.service.ts | 26 +++ packages/auth/src/ed25519/session.service.ts | 133 +++++++++++ packages/auth/src/index.ts | 46 +++- .../admin-auth-headers.interceptor.ts | 32 +++ .../auth/src/telegram/admin-auth.guard.ts | 15 ++ .../auth/src/telegram/admin-auth.service.ts | 210 ++++++++++++++++++ packages/auth/src/telegram/auth.service.ts | 190 ++++++++++++++++ .../auth/src/telegram/models/session.model.ts | 16 ++ .../telegram/telegram-session-api.service.ts | 157 +++++++++++++ packages/auth/src/util/guid.util.ts | 21 ++ 21 files changed, 1397 insertions(+), 4 deletions(-) create mode 100644 packages/auth/src/config.ts create mode 100644 packages/auth/src/ed25519/auth-api.service.ts create mode 100644 packages/auth/src/ed25519/auth-facade.service.ts create mode 100644 packages/auth/src/ed25519/auth.service.ts create mode 100644 packages/auth/src/ed25519/ed25519-keypair.service.ts create mode 100644 packages/auth/src/ed25519/ed25519-verification.model.ts create mode 100644 packages/auth/src/ed25519/jwt.service.ts create mode 100644 packages/auth/src/ed25519/models/auth-api.model.ts create mode 100644 packages/auth/src/ed25519/models/auth-error.model.ts create mode 100644 packages/auth/src/ed25519/models/permission.model.ts create mode 100644 packages/auth/src/ed25519/noop-ed25519-verification.service.ts create mode 100644 packages/auth/src/ed25519/permission.service.ts create mode 100644 packages/auth/src/ed25519/session.service.ts create mode 100644 packages/auth/src/telegram/admin-auth-headers.interceptor.ts create mode 100644 packages/auth/src/telegram/admin-auth.guard.ts create mode 100644 packages/auth/src/telegram/admin-auth.service.ts create mode 100644 packages/auth/src/telegram/auth.service.ts create mode 100644 packages/auth/src/telegram/models/session.model.ts create mode 100644 packages/auth/src/telegram/telegram-session-api.service.ts create mode 100644 packages/auth/src/util/guid.util.ts diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts new file mode 100644 index 0000000..f981e75 --- /dev/null +++ b/packages/auth/src/config.ts @@ -0,0 +1,7 @@ +import { InjectionToken } from '@angular/core'; + +/** Base URL for the auth backend, e.g. `https://api.example.com`. Provide from the consuming app's environment config. */ +export const AUTH_API_URL = new InjectionToken('@marketplaces/auth AUTH_API_URL'); + +/** Telegram bot username used to build QR/deep-link login URLs. Optional — falls back to a default if not provided. */ +export const TELEGRAM_BOT_USERNAME = new InjectionToken('@marketplaces/auth TELEGRAM_BOT_USERNAME'); diff --git a/packages/auth/src/ed25519/auth-api.service.ts b/packages/auth/src/ed25519/auth-api.service.ts new file mode 100644 index 0000000..803eb6f --- /dev/null +++ b/packages/auth/src/ed25519/auth-api.service.ts @@ -0,0 +1,34 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AUTH_API_URL } from '../config'; +import { AuthChallenge, AuthTokenPair, RefreshTokenRequest, VerifySignatureRequest } from './models/auth-api.model'; + +/** + * Thin HTTP client for the Ed25519 admin auth endpoints. These endpoints may + * not exist on every backend yet - calling them before the backend ships + * 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. + */ +@Injectable({ providedIn: 'root' }) +export class AuthApiService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${inject(AUTH_API_URL)}/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/packages/auth/src/ed25519/auth-facade.service.ts b/packages/auth/src/ed25519/auth-facade.service.ts new file mode 100644 index 0000000..dc71c2e --- /dev/null +++ b/packages/auth/src/ed25519/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/packages/auth/src/ed25519/auth.service.ts b/packages/auth/src/ed25519/auth.service.ts new file mode 100644 index 0000000..3c7cb02 --- /dev/null +++ b/packages/auth/src/ed25519/auth.service.ts @@ -0,0 +1,128 @@ +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, authErrorCodeFromBackendCode, 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. Exported from the package + * barrel as `Ed25519AuthService` to avoid colliding with the telegram + * module's `AuthService`. + */ +@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) { + const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.error?.code; + const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status); + return { code, 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/packages/auth/src/ed25519/ed25519-keypair.service.ts b/packages/auth/src/ed25519/ed25519-keypair.service.ts new file mode 100644 index 0000000..1ea3ab7 --- /dev/null +++ b/packages/auth/src/ed25519/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/packages/auth/src/ed25519/ed25519-verification.model.ts b/packages/auth/src/ed25519/ed25519-verification.model.ts new file mode 100644 index 0000000..989636c --- /dev/null +++ b/packages/auth/src/ed25519/ed25519-verification.model.ts @@ -0,0 +1,31 @@ +import { Observable } from 'rxjs'; + +/** + * Prep interfaces for a future Ed25519 challenge/response admin auth flow. + * No crypto is implemented here - verification is delegated to an injectable + * service so the real implementation (native WebCrypto Ed25519 support, or a + * backend verification call) can be swapped in once the backend API exists, + * without touching AdminAuthService or components. + */ +export interface Ed25519Challenge { + nonce: string; + timestamp: string; + /** Opaque challenge payload the client must sign with its private key. */ + payload: string; +} + +export interface Ed25519SignedResponse { + challenge: Ed25519Challenge; + publicKey: string; + signature: string; +} + +export interface Ed25519VerificationResult { + valid: boolean; + reason?: string; +} + +export abstract class Ed25519VerificationService { + abstract requestChallenge(): Observable; + abstract verify(response: Ed25519SignedResponse): Observable; +} diff --git a/packages/auth/src/ed25519/jwt.service.ts b/packages/auth/src/ed25519/jwt.service.ts new file mode 100644 index 0000000..ca24a08 --- /dev/null +++ b/packages/auth/src/ed25519/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/packages/auth/src/ed25519/models/auth-api.model.ts b/packages/auth/src/ed25519/models/auth-api.model.ts new file mode 100644 index 0000000..76ae54f --- /dev/null +++ b/packages/auth/src/ed25519/models/auth-api.model.ts @@ -0,0 +1,41 @@ +import { AdminRole } from './permission.model'; + +/** Wire contracts for the Ed25519 challenge/response admin auth flow. */ +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. + */ +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/packages/auth/src/ed25519/models/auth-error.model.ts b/packages/auth/src/ed25519/models/auth-error.model.ts new file mode 100644 index 0000000..6527791 --- /dev/null +++ b/packages/auth/src/ed25519/models/auth-error.model.ts @@ -0,0 +1,45 @@ +/** + * Error codes the Ed25519 admin auth flow can surface to the UI. Each maps to + * a dedicated screen 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 error envelope's `error.code` to the client's AuthErrorCode screens. Only codes with a dedicated screen are mapped; anything else falls back to the HTTP-status-derived code via authErrorCodeFromStatus. */ +const BACKEND_ERROR_CODE_MAP: Record = { + TOKEN_EXPIRED: 'session-expired', + INVALID_SIGNATURE: 'invalid-signature', + UNAUTHENTICATED: 'unauthorized', + FORBIDDEN: 'forbidden', + SERVICE_UNAVAILABLE: 'backend-unavailable', +}; + +export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined { + return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined; +} + +/** 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/packages/auth/src/ed25519/models/permission.model.ts b/packages/auth/src/ed25519/models/permission.model.ts new file mode 100644 index 0000000..a4ba9a8 --- /dev/null +++ b/packages/auth/src/ed25519/models/permission.model.ts @@ -0,0 +1,24 @@ +/** Roles the Ed25519 JWT `role` claim is expected to carry. 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 - + * fine-grained, per-domain permissions stay server-side; 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'] +}; diff --git a/packages/auth/src/ed25519/noop-ed25519-verification.service.ts b/packages/auth/src/ed25519/noop-ed25519-verification.service.ts new file mode 100644 index 0000000..f57a84f --- /dev/null +++ b/packages/auth/src/ed25519/noop-ed25519-verification.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Observable, throwError } from 'rxjs'; +import { Ed25519Challenge, Ed25519SignedResponse, Ed25519VerificationResult, Ed25519VerificationService } from './ed25519-verification.model'; + +/** + * Default DI binding for Ed25519VerificationService until the backend ships + * the real challenge/verify endpoints. Intentionally fails closed (throws) + * rather than pretending to verify anything, so accidental use in a login + * path is loud instead of silently accepting unsigned sessions. + */ +@Injectable({ providedIn: 'root' }) +export class NoopEd25519VerificationService implements Ed25519VerificationService { + requestChallenge(): Observable { + return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.')); + } + + verify(_response: Ed25519SignedResponse): Observable { + return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.')); + } +} diff --git a/packages/auth/src/ed25519/permission.service.ts b/packages/auth/src/ed25519/permission.service.ts new file mode 100644 index 0000000..2d46023 --- /dev/null +++ b/packages/auth/src/ed25519/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. + */ +@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/packages/auth/src/ed25519/session.service.ts b/packages/auth/src/ed25519/session.service.ts new file mode 100644 index 0000000..e842f9f --- /dev/null +++ b/packages/auth/src/ed25519/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 the telegram module's AdminAuthService (Telegram-session state) by + * design - the two auth mechanisms are not merged until both ship on the + * same backend and a migration decision is made. + */ +@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); + } + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 65b6866..cc668f8 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,5 +1,43 @@ // @marketplaces/auth — public API barrel. -// Scaffold only: code migrates here from src/app/core/auth and src/app/core/admin-auth -// per ADR-0001 (marketplaces repo: docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md). -// Nothing is exported yet — the marketplaces repo still owns the live implementation until migration lands. -export {}; +// Two independent auth mechanisms, per ADR-0001 (marketplaces repo: +// docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md): +// - telegram/ — live Telegram QR/session auth (customer + admin) +// - ed25519/ — future Ed25519 challenge/response admin auth (backend not shipped yet) +// Provide AUTH_API_URL (and optionally TELEGRAM_BOT_USERNAME) from the consuming app's config. + +export { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from './config'; + +// Telegram module +export { AuthSession, WebSessionStart, AuthStatus, AdminAuthStatus } from './telegram/models/session.model'; +export { TelegramSessionApiService } from './telegram/telegram-session-api.service'; +export { AuthService } from './telegram/auth.service'; +export { AdminAuthService } from './telegram/admin-auth.service'; +export { adminAuthGuard } from './telegram/admin-auth.guard'; +export { adminAuthHeadersInterceptor } from './telegram/admin-auth-headers.interceptor'; + +// Ed25519 module (namespaced re-exports to avoid colliding with the telegram module's AuthService) +export { AuthService as Ed25519AuthService } from './ed25519/auth.service'; +export { AuthFacade } from './ed25519/auth-facade.service'; +export { AuthApiService } from './ed25519/auth-api.service'; +export { SessionService } from './ed25519/session.service'; +export { JwtService } from './ed25519/jwt.service'; +export { Ed25519KeypairService } from './ed25519/ed25519-keypair.service'; +export { PermissionService } from './ed25519/permission.service'; +export { + Ed25519VerificationService, + Ed25519Challenge, + Ed25519SignedResponse, + Ed25519VerificationResult +} from './ed25519/ed25519-verification.model'; +export { NoopEd25519VerificationService } from './ed25519/noop-ed25519-verification.service'; +export { + AuthChallenge, + VerifySignatureRequest, + AuthTokenPair, + RefreshTokenRequest, + JwtClaims +} from './ed25519/models/auth-api.model'; +export { AuthErrorCode, AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from './ed25519/models/auth-error.model'; +export { AdminRole, Permission, ROLE_PERMISSIONS } from './ed25519/models/permission.model'; +export type { LoginPhase } from './ed25519/auth.service'; +export type { SessionStatus } from './ed25519/session.service'; diff --git a/packages/auth/src/telegram/admin-auth-headers.interceptor.ts b/packages/auth/src/telegram/admin-auth-headers.interceptor.ts new file mode 100644 index 0000000..31adb4d --- /dev/null +++ b/packages/auth/src/telegram/admin-auth-headers.interceptor.ts @@ -0,0 +1,32 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { AdminAuthService } from './admin-auth.service'; + +/** Backend paths that require an active AdminWebSessionID. Adjust to match your API surface if consuming this outside marketplaces. */ +const ADMIN_GATED_PATH_SEGMENTS = ['/admin/', '/backoffice/', '/builder/', '/media/']; + +/** + * Attaches admin session/token headers only to admin API requests. Scoped to + * admin-gated paths so it never touches customer requests and never reads + * the customer AuthService's session. + */ +export const adminAuthHeadersInterceptor: HttpInterceptorFn = (req, next) => { + const isAdminRequest = ADMIN_GATED_PATH_SEGMENTS.some(segment => req.url.includes(segment)); + if (!isAdminRequest) { + return next(req); + } + + const adminAuth = inject(AdminAuthService); + const session = adminAuth.session(); + const token = adminAuth.getAdminToken(); + + let headers = req.headers; + if (session?.sessionId) { + headers = headers.set('AdminWebSessionID', session.sessionId); + } + if (token) { + headers = headers.set('Authorization', `Bearer ${token}`); + } + + return next(req.clone({ headers })); +}; diff --git a/packages/auth/src/telegram/admin-auth.guard.ts b/packages/auth/src/telegram/admin-auth.guard.ts new file mode 100644 index 0000000..64bcdf7 --- /dev/null +++ b/packages/auth/src/telegram/admin-auth.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { AdminAuthService } from './admin-auth.service'; + +/** Guards `/admin/**`-style routes. Never shares state with the customer auth guard/service. */ +export const adminAuthGuard: CanActivateFn = () => { + const adminAuth = inject(AdminAuthService); + + if (adminAuth.isAuthenticated()) { + return true; + } + + adminAuth.requestLogin(); + return false; +}; diff --git a/packages/auth/src/telegram/admin-auth.service.ts b/packages/auth/src/telegram/admin-auth.service.ts new file mode 100644 index 0000000..4c30833 --- /dev/null +++ b/packages/auth/src/telegram/admin-auth.service.ts @@ -0,0 +1,210 @@ +import { Injectable, signal, computed, inject, isDevMode } from '@angular/core'; +import { Observable, tap } from 'rxjs'; +import { AdminAuthStatus, AuthSession, WebSessionStart } from './models/session.model'; +import { TelegramSessionApiService } from './telegram-session-api.service'; + +/** + * Admin login uses the exact same Telegram QR/session API as the customer + * login (TelegramSessionApiService) - there is no separate admin backend + * endpoint, and none should be invented client-side. Only the *storage* is + * kept separate from AuthService, so an admin QR scan never authenticates + * the customer session or vice versa: distinct cookie name, distinct + * signals, distinct guard/interceptor. + * + * Since the session API itself has no concept of "admin", the frontend + * cannot tell an admin Telegram session from a regular one. Actual admin + * authorization must be enforced server-side when admin API calls are made + * with the resulting session id - the frontend only decides where to + * *store* the result. + */ +const ADMIN_SESSION_COOKIE = 'adminSessionID'; +const ADMIN_TOKEN_STORAGE_KEY = 'adminToken'; +const ADMIN_REFRESH_STORAGE_KEY = 'adminRefreshToken'; +const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; + +@Injectable({ providedIn: 'root' }) +export class AdminAuthService { + private readonly api = inject(TelegramSessionApiService); + + private readonly sessionSignal = signal(null); + private readonly statusSignal = signal('unknown'); + private readonly showLoginSignal = signal(false); + + readonly session = this.sessionSignal.asReadonly(); + readonly status = this.statusSignal.asReadonly(); + readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); + readonly showLoginDialog = this.showLoginSignal.asReadonly(); + readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null); + + private sessionCheckTimer?: ReturnType; + + constructor() { + this.checkSession(); + } + + checkSession(): void { + const webSessionID = this.getStoredAdminSessionID(); + if (!webSessionID) { + this.clearAuthState('unauthenticated'); + return; + } + + this.statusSignal.set('checking'); + this.checkSessionOnce(webSessionID).subscribe(session => { + if (!session?.active) { + this.clearAuthState('unauthenticated'); + } + }); + } + + /** Check session without mutating internal state beyond activating on success (used for polling). */ + checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable { + return this.api.checkSessionOnce(webSessionID).pipe( + tap(session => { + if (session?.active) { + this.activateSession(session); + } + }) + ); + } + + /** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */ + createWebSession(): Observable { + return this.api.createSession(); + } + + getAdminAppLoginUrl(webSessionID: string): string { + return this.api.getBotAppLoginUrl(webSessionID); + } + + onLoginComplete(): void { + this.hideLogin(); + if (!this.isAuthenticated()) { + this.checkSession(); + } + } + + requestLogin(): void { + this.showLoginSignal.set(true); + } + + /** + * Dev-only shortcut for local testing without a reachable Telegram/session + * backend: fabricates a local session and activates it directly, skipping + * the QR flow entirely. No-ops in production builds (checked via Angular's + * isDevMode() at runtime, not just build-time, so it is safe even if this + * code ships). Never call this from anywhere reachable in a production build. + */ + devBypassLogin(): void { + if (!isDevMode()) { + return; + } + this.hideLogin(); + this.activateSession({ + sessionId: `dev-bypass-${Date.now()}`, + userId: 0, + username: 'dev-admin', + displayName: 'Dev Admin (local bypass)', + active: true, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + } + + hideLogin(): void { + this.showLoginSignal.set(false); + } + + logout(): void { + const webSessionID = this.sessionSignal()?.sessionId || this.getStoredAdminSessionID(); + if (!webSessionID) { + this.clearAuthState('unauthenticated'); + return; + } + + this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated')); + } + + /** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */ + getAdminToken(): string | null { + return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY); + } + + setAdminTokens(token: string, refreshToken: string): void { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token); + localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken); + } + + clearAdminTokens(): void { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); + localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY); + } + + private activateSession(session: AuthSession): void { + this.sessionSignal.set(session); + this.statusSignal.set('authenticated'); + this.setStoredAdminSessionID(session.sessionId); + this.scheduleSessionRefresh(session.expires); + } + + private clearAuthState(status: AdminAuthStatus): void { + this.sessionSignal.set(null); + this.statusSignal.set(status); + this.clearStoredAdminSessionID(); + this.clearAdminTokens(); + this.clearSessionRefresh(); + } + + private scheduleSessionRefresh(expiresAt: string): void { + this.clearSessionRefresh(); + const expiresMs = new Date(expiresAt).getTime(); + const nowMs = Date.now(); + const refreshIn = Number.isFinite(expiresMs) + ? Math.max(expiresMs - nowMs - 60_000, 30_000) + : ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000; + + this.sessionCheckTimer = setTimeout(() => this.checkSession(), refreshIn); + } + + private clearSessionRefresh(): void { + if (this.sessionCheckTimer) { + clearTimeout(this.sessionCheckTimer); + this.sessionCheckTimer = undefined; + } + } + + private getStoredAdminSessionID(): string | null { + if (typeof document === 'undefined') { + return null; + } + const cookie = document.cookie.split('; ').find(row => row.startsWith(`${ADMIN_SESSION_COOKIE}=`)); + if (!cookie) { + return null; + } + try { + return decodeURIComponent(cookie.substring(ADMIN_SESSION_COOKIE.length + 1)); + } catch { + return null; + } + } + + private setStoredAdminSessionID(webSessionID: string): void { + if (typeof document === 'undefined') { + return; + } + const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Strict${secure}`; + } + + private clearStoredAdminSessionID(): void { + if (typeof document === 'undefined') { + return; + } + document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`; + } +} diff --git a/packages/auth/src/telegram/auth.service.ts b/packages/auth/src/telegram/auth.service.ts new file mode 100644 index 0000000..6703674 --- /dev/null +++ b/packages/auth/src/telegram/auth.service.ts @@ -0,0 +1,190 @@ +import { Injectable, signal, computed, inject } from '@angular/core'; +import { Observable, tap } from 'rxjs'; +import { AuthSession, AuthStatus, WebSessionStart } from './models/session.model'; +import { TelegramSessionApiService } from './telegram-session-api.service'; + +const WEB_SESSION_COOKIE = 'webSessionID'; +const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60; + +/** Customer-facing Telegram QR/session auth. Distinct storage/state from AdminAuthService by design. */ +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private readonly api = inject(TelegramSessionApiService); + + private sessionSignal = signal(null); + private statusSignal = signal('unknown'); + private showLoginSignal = signal(false); + + /** Current auth session */ + readonly session = this.sessionSignal.asReadonly(); + /** Current auth status */ + readonly status = this.statusSignal.asReadonly(); + /** Whether user is fully authenticated */ + readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); + /** Whether to show login dialog */ + readonly showLoginDialog = this.showLoginSignal.asReadonly(); + /** Display name of authenticated user */ + readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null); + + private sessionCheckTimer?: ReturnType; + + constructor() { + // On init, check existing session via cookie + this.checkSession(); + } + + /** Check the current webSessionID cookie against the auth backend. */ + checkSession(): void { + const webSessionID = this.getStoredWebSessionID(); + + if (!webSessionID) { + this.clearAuthState('unauthenticated'); + return; + } + + this.statusSignal.set('checking'); + + this.checkSessionOnce(webSessionID).subscribe(session => { + if (!session?.active) { + this.clearAuthState('unauthenticated'); + } + }); + } + + /** Check session without updating internal state beyond activating on success (used for polling). */ + checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable { + return this.api.checkSessionOnce(webSessionID).pipe( + tap(session => { + if (session?.active) { + this.activateSession(session); + } + }) + ); + } + + /** + * Called after user completes Telegram login. + */ + onTelegramLoginComplete(): void { + this.hideLogin(); + + if (!this.isAuthenticated()) { + this.checkSession(); + } + } + + /** Generate the Telegram login URL for bot-based auth */ + getTelegramLoginUrl(webSessionID: string): string { + return this.api.getBotLoginUrl(webSessionID); + } + + /** Generate a Telegram app deep link for mobile login without opening a browser tab. */ + getTelegramAppLoginUrl(webSessionID: string): string { + return this.api.getBotAppLoginUrl(webSessionID); + } + + /** Create a backend web session and return the Telegram start link for it. */ + createWebSession(): Observable { + return this.api.createSession(); + } + + /** Show login dialog (called when user tries to pay without being logged in) */ + requestLogin(): void { + this.showLoginSignal.set(true); + } + + /** Hide login dialog */ + hideLogin(): void { + this.showLoginSignal.set(false); + } + + /** Logout — clears session on backend and locally */ + logout(): void { + const webSessionID = this.sessionSignal()?.sessionId || this.getStoredWebSessionID(); + + if (!webSessionID) { + this.clearAuthState('unauthenticated'); + return; + } + + this.api.logout(webSessionID).subscribe(() => { + this.clearAuthState('unauthenticated'); + }); + } + + private activateSession(session: AuthSession): void { + this.sessionSignal.set(session); + this.statusSignal.set('authenticated'); + this.setStoredWebSessionID(session.sessionId); + this.scheduleSessionRefresh(session.expires); + } + + private clearAuthState(status: AuthStatus): void { + this.sessionSignal.set(null); + this.statusSignal.set(status); + this.clearStoredWebSessionID(); + this.clearSessionRefresh(); + } + + /** Schedule a session re-check before it expires */ + private scheduleSessionRefresh(expiresAt: string): void { + this.clearSessionRefresh(); + + const expiresMs = new Date(expiresAt).getTime(); + const nowMs = Date.now(); + // Re-check 60 seconds before expiry, minimum 30s from now + const refreshIn = Number.isFinite(expiresMs) + ? Math.max(expiresMs - nowMs - 60_000, 30_000) + : WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000; + + this.sessionCheckTimer = setTimeout(() => { + this.checkSession(); + }, refreshIn); + } + + private clearSessionRefresh(): void { + if (this.sessionCheckTimer) { + clearTimeout(this.sessionCheckTimer); + this.sessionCheckTimer = undefined; + } + } + + private getStoredWebSessionID(): string | null { + if (typeof document === 'undefined') { + return null; + } + + const cookie = document.cookie + .split('; ') + .find(row => row.startsWith(`${WEB_SESSION_COOKIE}=`)); + + if (!cookie) { + return null; + } + + try { + return decodeURIComponent(cookie.substring(WEB_SESSION_COOKIE.length + 1)); + } catch { + return null; + } + } + + private setStoredWebSessionID(webSessionID: string): void { + if (typeof document === 'undefined') { + return; + } + + const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `${WEB_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${WEB_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Lax${secure}`; + } + + private clearStoredWebSessionID(): void { + if (typeof document === 'undefined') { + return; + } + + document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`; + } +} diff --git a/packages/auth/src/telegram/models/session.model.ts b/packages/auth/src/telegram/models/session.model.ts new file mode 100644 index 0000000..a6c554b --- /dev/null +++ b/packages/auth/src/telegram/models/session.model.ts @@ -0,0 +1,16 @@ +export interface AuthSession { + sessionId: string; + userId: number | null; + username: string | null; + displayName: string; + active: boolean; + expires: string; +} + +export interface WebSessionStart { + webSessionID: string; + url: string; +} + +export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated'; +export type AdminAuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated'; diff --git a/packages/auth/src/telegram/telegram-session-api.service.ts b/packages/auth/src/telegram/telegram-session-api.service.ts new file mode 100644 index 0000000..457debc --- /dev/null +++ b/packages/auth/src/telegram/telegram-session-api.service.ts @@ -0,0 +1,157 @@ +import { Injectable, inject, Optional } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of, catchError, map } from 'rxjs'; +import { AuthSession, WebSessionStart } from './models/session.model'; +import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '../config'; +import { generateGuid } from '../util/guid.util'; + +const SESSION_MAX_AGE_SECONDS = 60 * 60; +const DEFAULT_TELEGRAM_BOT_USERNAME = 'DexarSupport_bot'; + +/** + * The one Telegram QR/session API (`{authApiUrl}/users/sessions`). Customer + * login (AuthService) and admin login (AdminAuthService) both call this same + * service against this same endpoint - there is no separate admin backend. + * This class only does the HTTP call + response normalization; it holds no + * session state and writes no cookies, so each caller manages its own + * storage/signals independently on top of it. + */ +@Injectable({ providedIn: 'root' }) +export class TelegramSessionApiService { + private readonly http = inject(HttpClient); + private readonly authApiUrl = inject(AUTH_API_URL); + @Optional() private readonly telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true }); + + createSession(): Observable { + const webSessionID = generateGuid(); + + return this.http.post>( + `${this.authApiUrl}/users/sessions`, + { webSessionID }, + { headers: { WebSessionID: webSessionID } } + ).pipe( + map(response => { + const responseWebSessionID = this.extractSessionId(response, webSessionID); + return { + webSessionID: responseWebSessionID, + url: this.getBotLoginUrl(responseWebSessionID), + }; + }) + ); + } + + checkSessionOnce(webSessionID: string | null): Observable { + if (!webSessionID) { + return of(null); + } + + return this.http.get>( + `${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}` + ).pipe( + map(response => this.normalizeWebSession(response, webSessionID)), + catchError(() => of(null)) + ); + } + + logout(webSessionID: string): Observable { + return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, { + headers: { WebSessionID: webSessionID } + }).pipe(catchError(() => of(null))); + } + + getBotLoginUrl(webSessionID: string): string { + return `https://t.me/${this.getBotUsername()}?start=${encodeURIComponent(webSessionID)}`; + } + + getBotAppLoginUrl(webSessionID: string): string { + return `tg://resolve?domain=${encodeURIComponent(this.getBotUsername())}&start=${encodeURIComponent(webSessionID)}`; + } + + private getBotUsername(): string { + return this.telegramBotUsername || DEFAULT_TELEGRAM_BOT_USERNAME; + } + + private normalizeWebSession(response: Record | null, fallbackSessionId: string): AuthSession | null { + if (!response) { + return null; + } + + const user = this.asRecord(this.readFirst(response, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? response; + const status = this.readFirst(response, [ + 'status', 'Status', 'active', 'Active', 'loggedIn', 'LoggedIn', + 'isLoggedIn', 'IsLoggedIn', 'authenticated', 'Authenticated' + ]); + const active = this.isActiveStatus(status); + const sessionId = this.extractSessionId(response, fallbackSessionId); + const username = this.readString(this.readFirst(user, ['username', 'Username'])) + ?? this.readString(this.readFirst(response, ['username', 'Username'])); + const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName', 'First_name'])); + const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName', 'Last_name'])); + const fullName = [firstName, lastName].filter(Boolean).join(' '); + const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name'])) + ?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name'])); + const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User'); + const telegramUserId = this.readNumber(this.readFirst(user, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID'])) + ?? this.readNumber(this.readFirst(response, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID', 'UserId'])) + ?? null; + const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires'])) + ?? new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000).toISOString(); + + return { sessionId, userId: telegramUserId, username, displayName, active, expires: expiresAt }; + } + + private extractSessionId(response: Record | null, fallbackSessionId: string): string { + if (!response) { + return fallbackSessionId; + } + return this.readString(this.readFirst(response, [ + 'webSessionID', 'WebSessionID', 'webSessionId', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID' + ])) ?? fallbackSessionId; + } + + private readFirst(source: Record, keys: string[]): unknown { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + return source[key]; + } + } + return undefined; + } + + private readString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + return value; + } + if (typeof value === 'number' || typeof value === 'bigint') { + return value.toString(); + } + return null; + } + + private readNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + } + + private asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; + } + + private isActiveStatus(status: unknown): boolean { + if (status === true || status === 1) { + return true; + } + if (typeof status !== 'string') { + return false; + } + return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase()); + } +} diff --git a/packages/auth/src/util/guid.util.ts b/packages/auth/src/util/guid.util.ts new file mode 100644 index 0000000..88ec139 --- /dev/null +++ b/packages/auth/src/util/guid.util.ts @@ -0,0 +1,21 @@ +/** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */ +export function generateGuid(): string { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + + const bytes = new Uint8Array(16); + if (globalThis.crypto?.getRandomValues) { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index++) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`; +}