commit 93f99cc7b19f88112337e7a6544c1c09d9904744 Author: sdarbinyan Date: Tue Aug 18 01:55:26 2026 +0400 release: @marketplaces/auth 0.1.0 (built from main) diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc94982 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# @marketplaces/auth — release branch + +**Generated branch. Do not edit by hand.** + +This branch contains only the built output of `packages/auth` from `main`, laid out so the repository root *is* the package. That makes it installable directly over git, with no npm registry, no auth token, and no tunnel: + +```json +"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth" +``` + +Source, tests, and history live on `main` under `packages/auth`. To change anything, edit there and re-run the release (see `main`'s README). diff --git a/dist/config.d.ts b/dist/config.d.ts new file mode 100644 index 0000000..ffe5b56 --- /dev/null +++ b/dist/config.d.ts @@ -0,0 +1,5 @@ +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 declare const AUTH_API_URL: InjectionToken; +/** Telegram bot username used to build QR/deep-link login URLs. Optional — falls back to a default if not provided. */ +export declare const TELEGRAM_BOT_USERNAME: InjectionToken; diff --git a/dist/config.js b/dist/config.js new file mode 100644 index 0000000..847d624 --- /dev/null +++ b/dist/config.js @@ -0,0 +1,5 @@ +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/dist/ed25519/auth-api.service.d.ts b/dist/ed25519/auth-api.service.d.ts new file mode 100644 index 0000000..e94c306 --- /dev/null +++ b/dist/ed25519/auth-api.service.d.ts @@ -0,0 +1,17 @@ +import { Observable } from 'rxjs'; +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. + */ +export declare class AuthApiService { + private readonly http; + private readonly baseUrl; + requestChallenge(): Observable; + verifySignature(request: VerifySignatureRequest): Observable; + refresh(request: RefreshTokenRequest): Observable; + logout(refreshToken: string): Observable; +} diff --git a/dist/ed25519/auth-api.service.js b/dist/ed25519/auth-api.service.js new file mode 100644 index 0000000..efebb56 --- /dev/null +++ b/dist/ed25519/auth-api.service.js @@ -0,0 +1,38 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { AUTH_API_URL } from '../config'; +/** + * 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. + */ +let AuthApiService = class AuthApiService { + constructor() { + this.http = inject(HttpClient); + this.baseUrl = `${inject(AUTH_API_URL)}/api/admin/auth`; + } + requestChallenge() { + return this.http.get(`${this.baseUrl}/challenge`); + } + verifySignature(request) { + return this.http.post(`${this.baseUrl}/verify`, request); + } + refresh(request) { + return this.http.post(`${this.baseUrl}/refresh`, request); + } + logout(refreshToken) { + return this.http.post(`${this.baseUrl}/logout`, { refreshToken }); + } +}; +AuthApiService = __decorate([ + Injectable({ providedIn: 'root' }) +], AuthApiService); +export { AuthApiService }; diff --git a/dist/ed25519/auth-facade.service.d.ts b/dist/ed25519/auth-facade.service.d.ts new file mode 100644 index 0000000..359f2bc --- /dev/null +++ b/dist/ed25519/auth-facade.service.d.ts @@ -0,0 +1,22 @@ +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. + */ +export declare class AuthFacade { + private readonly auth; + private readonly session; + private readonly permissions; + private readonly router; + readonly isAuthenticated: import("@angular/core").Signal; + readonly status: import("@angular/core").Signal; + readonly role: import("@angular/core").Signal; + readonly loginPhase: import("@angular/core").Signal; + readonly lastError: import("@angular/core").Signal; + restoreSession(): void; + login(onSuccessRedirectTo?: string): void; + logout(redirectTo?: string): void; + can(permission: Permission): boolean; +} diff --git a/dist/ed25519/auth-facade.service.js b/dist/ed25519/auth-facade.service.js new file mode 100644 index 0000000..ef045d2 --- /dev/null +++ b/dist/ed25519/auth-facade.service.js @@ -0,0 +1,60 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +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'; +/** + * 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. + */ +let AuthFacade = class AuthFacade { + constructor() { + this.auth = inject(AuthService); + this.session = inject(SessionService); + this.permissions = inject(PermissionService); + this.router = inject(Router); + this.isAuthenticated = this.session.isAuthenticated; + this.status = this.session.status; + this.role = this.session.role; + this.loginPhase = this.auth.loginPhase; + this.lastError = this.auth.lastError; + } + restoreSession() { + this.auth.restoreSession(); + } + login(onSuccessRedirectTo) { + 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') { + this.auth + .logout() + .pipe(finalize(() => this.router.navigateByUrl(redirectTo))) + .subscribe({ error: () => undefined }); + } + can(permission) { + return this.permissions.has(permission); + } +}; +AuthFacade = __decorate([ + Injectable({ providedIn: 'root' }) +], AuthFacade); +export { AuthFacade }; diff --git a/dist/ed25519/auth.service.d.ts b/dist/ed25519/auth.service.d.ts new file mode 100644 index 0000000..5049a31 --- /dev/null +++ b/dist/ed25519/auth.service.d.ts @@ -0,0 +1,35 @@ +import { Observable } from 'rxjs'; +import { AuthTokenPair } from './models/auth-api.model'; +import { AuthError } from './models/auth-error.model'; +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`. + */ +export declare class AuthService { + private readonly api; + private readonly keypair; + private readonly session; + private readonly loginPhaseSignal; + private readonly lastErrorSignal; + readonly loginPhase: import("@angular/core").Signal; + readonly lastError: import("@angular/core").Signal; + constructor(); + /** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */ + restoreSession(): void; + login(): Observable; + refresh(): Observable; + logout(): Observable; + private signChallenge; + private handleAuthError; + private toAuthErrorShape; + private toAuthError; +} diff --git a/dist/ed25519/auth.service.js b/dist/ed25519/auth.service.js new file mode 100644 index 0000000..a5b4023 --- /dev/null +++ b/dist/ed25519/auth.service.js @@ -0,0 +1,107 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +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 { authErrorCodeFromBackendCode, authErrorCodeFromStatus } from './models/auth-error.model'; +import { AuthApiService } from './auth-api.service'; +import { Ed25519KeypairService } from './ed25519-keypair.service'; +import { SessionService } from './session.service'; +/** + * 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`. + */ +let AuthService = class AuthService { + constructor() { + this.api = inject(AuthApiService); + this.keypair = inject(Ed25519KeypairService); + this.session = inject(SessionService); + this.loginPhaseSignal = signal('idle'); + this.lastErrorSignal = signal(null); + this.loginPhase = this.loginPhaseSignal.asReadonly(); + this.lastError = this.lastErrorSignal.asReadonly(); + this.session.onRefreshDue(() => this.refresh().subscribe()); + } + /** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */ + restoreSession() { + this.session.restore(); + } + login() { + 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() { + 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() { + 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))); + } + signChallenge(nonce) { + this.loginPhaseSignal.set('signing'); + return new Observable(subscriber => { + this.keypair + .getOrCreateKeyPair() + .then(({ publicKeyBase64 }) => this.keypair.sign(nonce).then(signature => { + subscriber.next({ publicKeyBase64, signature }); + subscriber.complete(); + })) + .catch(error => subscriber.error(error)); + }); + } + handleAuthError(error, fallbackCode, onError) { + onError?.(); + return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode))); + } + toAuthErrorShape(error, fallbackCode) { + if (error instanceof HttpErrorResponse) { + const bodyCode = error.error?.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.' }; + } + toAuthError(error) { + this.lastErrorSignal.set(error); + return error; + } +}; +AuthService = __decorate([ + Injectable({ providedIn: 'root' }) +], AuthService); +export { AuthService }; diff --git a/dist/ed25519/ed25519-keypair.service.d.ts b/dist/ed25519/ed25519-keypair.service.d.ts new file mode 100644 index 0000000..1673355 --- /dev/null +++ b/dist/ed25519/ed25519-keypair.service.d.ts @@ -0,0 +1,15 @@ +export declare class Ed25519KeypairService { + private cached; + isSupported(): boolean; + /** Returns the device's Ed25519 keypair, generating and persisting one on first use. */ + getOrCreateKeyPair(): Promise<{ + publicKeyBase64: string; + }>; + sign(message: string): Promise; + /** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */ + clear(): Promise; + private generateAndPersist; + private loadFromStore; + private openDatabase; + private toBase64; +} diff --git a/dist/ed25519/ed25519-keypair.service.js b/dist/ed25519/ed25519-keypair.service.js new file mode 100644 index 0000000..86ff988 --- /dev/null +++ b/dist/ed25519/ed25519-keypair.service.js @@ -0,0 +1,114 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +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'; +let Ed25519KeypairService = class Ed25519KeypairService { + constructor() { + this.cached = null; + } + isSupported() { + return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined'; + } + /** Returns the device's Ed25519 keypair, generating and persisting one on first use. */ + async getOrCreateKeyPair() { + 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) { + 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() { + 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); + }); + } + async generateAndPersist() { + const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])); + const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey); + const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw)); + const record = { + 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; + } + async loadFromStore() { + 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 ?? null); + request.onerror = () => reject(request.error); + }); + } + openDatabase() { + 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); + }); + } + toBase64(bytes) { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); + } +}; +Ed25519KeypairService = __decorate([ + Injectable({ providedIn: 'root' }) +], Ed25519KeypairService); +export { Ed25519KeypairService }; diff --git a/dist/ed25519/ed25519-verification.model.d.ts b/dist/ed25519/ed25519-verification.model.d.ts new file mode 100644 index 0000000..6c1f277 --- /dev/null +++ b/dist/ed25519/ed25519-verification.model.d.ts @@ -0,0 +1,27 @@ +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 declare abstract class Ed25519VerificationService { + abstract requestChallenge(): Observable; + abstract verify(response: Ed25519SignedResponse): Observable; +} diff --git a/dist/ed25519/ed25519-verification.model.js b/dist/ed25519/ed25519-verification.model.js new file mode 100644 index 0000000..ba228af --- /dev/null +++ b/dist/ed25519/ed25519-verification.model.js @@ -0,0 +1,2 @@ +export class Ed25519VerificationService { +} diff --git a/dist/ed25519/jwt.service.d.ts b/dist/ed25519/jwt.service.d.ts new file mode 100644 index 0000000..29a5707 --- /dev/null +++ b/dist/ed25519/jwt.service.d.ts @@ -0,0 +1,14 @@ +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"). + */ +export declare class JwtService { + decode(token: string): JwtClaims | null; + isExpired(claims: JwtClaims, skewSeconds?: number): boolean; + private isJwtClaims; + private base64UrlDecode; +} diff --git a/dist/ed25519/jwt.service.js b/dist/ed25519/jwt.service.js new file mode 100644 index 0000000..841eb61 --- /dev/null +++ b/dist/ed25519/jwt.service.js @@ -0,0 +1,48 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +import { Injectable } from '@angular/core'; +/** + * 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"). + */ +let JwtService = class JwtService { + decode(token) { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + try { + const payload = this.base64UrlDecode(parts[1]); + const claims = JSON.parse(payload); + return this.isJwtClaims(claims) ? claims : null; + } + catch { + return null; + } + } + isExpired(claims, skewSeconds = 0) { + return claims.exp * 1000 <= Date.now() + skewSeconds * 1000; + } + isJwtClaims(value) { + if (!value || typeof value !== 'object') { + return false; + } + const claims = value; + return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number'; + } + base64UrlDecode(input) { + const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '='); + return decodeURIComponent(escape(atob(base64))); + } +}; +JwtService = __decorate([ + Injectable({ providedIn: 'root' }) +], JwtService); +export { JwtService }; diff --git a/dist/ed25519/models/auth-api.model.d.ts b/dist/ed25519/models/auth-api.model.d.ts new file mode 100644 index 0000000..50623f9 --- /dev/null +++ b/dist/ed25519/models/auth-api.model.d.ts @@ -0,0 +1,36 @@ +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/dist/ed25519/models/auth-api.model.js b/dist/ed25519/models/auth-api.model.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/ed25519/models/auth-api.model.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/ed25519/models/auth-error.model.d.ts b/dist/ed25519/models/auth-error.model.d.ts new file mode 100644 index 0000000..b1c2cdc --- /dev/null +++ b/dist/ed25519/models/auth-error.model.d.ts @@ -0,0 +1,15 @@ +/** + * 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; +} +export declare function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined; +/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */ +export declare function authErrorCodeFromStatus(status: number): AuthErrorCode; diff --git a/dist/ed25519/models/auth-error.model.js b/dist/ed25519/models/auth-error.model.js new file mode 100644 index 0000000..3c0e348 --- /dev/null +++ b/dist/ed25519/models/auth-error.model.js @@ -0,0 +1,24 @@ +/** 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 = { + TOKEN_EXPIRED: 'session-expired', + INVALID_SIGNATURE: 'invalid-signature', + UNAUTHENTICATED: 'unauthorized', + FORBIDDEN: 'forbidden', + SERVICE_UNAVAILABLE: 'backend-unavailable', +}; +export function authErrorCodeFromBackendCode(code) { + 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) { + 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/dist/ed25519/models/permission.model.d.ts b/dist/ed25519/models/permission.model.d.ts new file mode 100644 index 0000000..c36fbb4 --- /dev/null +++ b/dist/ed25519/models/permission.model.d.ts @@ -0,0 +1,10 @@ +/** 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 declare const ROLE_PERMISSIONS: Readonly>; diff --git a/dist/ed25519/models/permission.model.js b/dist/ed25519/models/permission.model.js new file mode 100644 index 0000000..b111921 --- /dev/null +++ b/dist/ed25519/models/permission.model.js @@ -0,0 +1,7 @@ +export const 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'] +}; diff --git a/dist/ed25519/noop-ed25519-verification.service.d.ts b/dist/ed25519/noop-ed25519-verification.service.d.ts new file mode 100644 index 0000000..6a72f14 --- /dev/null +++ b/dist/ed25519/noop-ed25519-verification.service.d.ts @@ -0,0 +1,12 @@ +import { Observable } 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. + */ +export declare class NoopEd25519VerificationService implements Ed25519VerificationService { + requestChallenge(): Observable; + verify(_response: Ed25519SignedResponse): Observable; +} diff --git a/dist/ed25519/noop-ed25519-verification.service.js b/dist/ed25519/noop-ed25519-verification.service.js new file mode 100644 index 0000000..94a37b7 --- /dev/null +++ b/dist/ed25519/noop-ed25519-verification.service.js @@ -0,0 +1,26 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +import { Injectable } from '@angular/core'; +import { throwError } from 'rxjs'; +/** + * 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. + */ +let NoopEd25519VerificationService = class NoopEd25519VerificationService { + requestChallenge() { + return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.')); + } + verify(_response) { + return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.')); + } +}; +NoopEd25519VerificationService = __decorate([ + Injectable({ providedIn: 'root' }) +], NoopEd25519VerificationService); +export { NoopEd25519VerificationService }; diff --git a/dist/ed25519/permission.service.d.ts b/dist/ed25519/permission.service.d.ts new file mode 100644 index 0000000..dae0db0 --- /dev/null +++ b/dist/ed25519/permission.service.d.ts @@ -0,0 +1,12 @@ +import { Permission } from './models/permission.model'; +/** + * 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. + */ +export declare class PermissionService { + private readonly session; + readonly permissions: import("@angular/core").Signal; + has(permission: Permission): boolean; + hasAny(permissions: readonly Permission[]): boolean; +} diff --git a/dist/ed25519/permission.service.js b/dist/ed25519/permission.service.js new file mode 100644 index 0000000..0974177 --- /dev/null +++ b/dist/ed25519/permission.service.js @@ -0,0 +1,33 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +import { Injectable, computed, inject } from '@angular/core'; +import { 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. + */ +let PermissionService = class PermissionService { + constructor() { + this.session = inject(SessionService); + this.permissions = computed(() => { + const role = this.session.role(); + return role ? ROLE_PERMISSIONS[role] : []; + }); + } + has(permission) { + return this.permissions().includes(permission); + } + hasAny(permissions) { + return permissions.some(permission => this.has(permission)); + } +}; +PermissionService = __decorate([ + Injectable({ providedIn: 'root' }) +], PermissionService); +export { PermissionService }; diff --git a/dist/ed25519/session.service.d.ts b/dist/ed25519/session.service.d.ts new file mode 100644 index 0000000..5580573 --- /dev/null +++ b/dist/ed25519/session.service.d.ts @@ -0,0 +1,35 @@ +import { AuthTokenPair, JwtClaims } from './models/auth-api.model'; +export type SessionStatus = 'unknown' | 'restoring' | 'authenticated' | 'unauthenticated' | 'expired'; +/** + * 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. + */ +export declare class SessionService { + private readonly jwt; + private readonly tokenSignal; + private readonly refreshTokenSignal; + private readonly claimsSignal; + private readonly statusSignal; + readonly token: import("@angular/core").Signal; + readonly claims: import("@angular/core").Signal; + readonly status: import("@angular/core").Signal; + readonly isAuthenticated: import("@angular/core").Signal; + readonly role: import("@angular/core").Signal; + private refreshTimer?; + private refreshCallback?; + /** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */ + onRefreshDue(callback: () => void): void; + /** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */ + restore(): boolean; + activate(tokens: AuthTokenPair): void; + getRefreshToken(): string | null; + markExpired(): void; + clear(): void; + private scheduleRefresh; + private clearRefreshTimer; + private readStorage; + private writeStorage; + private removeStorage; +} diff --git a/dist/ed25519/session.service.js b/dist/ed25519/session.service.js new file mode 100644 index 0000000..8dc8723 --- /dev/null +++ b/dist/ed25519/session.service.js @@ -0,0 +1,120 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +import { Injectable, computed, signal } from '@angular/core'; +import { JwtService } from './jwt.service'; +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. + */ +let SessionService = class SessionService { + constructor() { + this.jwt = new JwtService(); + this.tokenSignal = signal(null); + this.refreshTokenSignal = signal(null); + this.claimsSignal = signal(null); + this.statusSignal = signal('unknown'); + this.token = this.tokenSignal.asReadonly(); + this.claims = this.claimsSignal.asReadonly(); + this.status = this.statusSignal.asReadonly(); + this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); + this.role = computed(() => this.claimsSignal()?.role ?? null); + } + /** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */ + onRefreshDue(callback) { + this.refreshCallback = callback; + } + /** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */ + restore() { + 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) { + 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() { + return this.refreshTokenSignal(); + } + markExpired() { + this.statusSignal.set('expired'); + this.clearRefreshTimer(); + } + clear() { + 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(); + } + scheduleRefresh(claims) { + 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); + } + clearRefreshTimer() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + } + } + readStorage(key) { + return typeof localStorage === 'undefined' ? null : localStorage.getItem(key); + } + writeStorage(key, value) { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(key, value); + } + } + removeStorage(key) { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(key); + } + } +}; +SessionService = __decorate([ + Injectable({ providedIn: 'root' }) +], SessionService); +export { SessionService }; diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..030fe7d --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,21 @@ +export { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from './config'; +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'; +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/dist/index.js b/dist/index.js new file mode 100644 index 0000000..58f08cc --- /dev/null +++ b/dist/index.js @@ -0,0 +1,24 @@ +// @marketplaces/auth — public API barrel. +// 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'; +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 } from './ed25519/ed25519-verification.model'; +export { NoopEd25519VerificationService } from './ed25519/noop-ed25519-verification.service'; +export { authErrorCodeFromBackendCode, authErrorCodeFromStatus } from './ed25519/models/auth-error.model'; +export { ROLE_PERMISSIONS } from './ed25519/models/permission.model'; diff --git a/dist/telegram/admin-auth-headers.interceptor.d.ts b/dist/telegram/admin-auth-headers.interceptor.d.ts new file mode 100644 index 0000000..11c87d5 --- /dev/null +++ b/dist/telegram/admin-auth-headers.interceptor.d.ts @@ -0,0 +1,7 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +/** + * 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 declare const adminAuthHeadersInterceptor: HttpInterceptorFn; diff --git a/dist/telegram/admin-auth-headers.interceptor.js b/dist/telegram/admin-auth-headers.interceptor.js new file mode 100644 index 0000000..a2ade05 --- /dev/null +++ b/dist/telegram/admin-auth-headers.interceptor.js @@ -0,0 +1,26 @@ +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 = (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/dist/telegram/admin-auth.guard.d.ts b/dist/telegram/admin-auth.guard.d.ts new file mode 100644 index 0000000..7a5f6fe --- /dev/null +++ b/dist/telegram/admin-auth.guard.d.ts @@ -0,0 +1,3 @@ +import { CanActivateFn } from '@angular/router'; +/** Guards `/admin/**`-style routes. Never shares state with the customer auth guard/service. */ +export declare const adminAuthGuard: CanActivateFn; diff --git a/dist/telegram/admin-auth.guard.js b/dist/telegram/admin-auth.guard.js new file mode 100644 index 0000000..5d347dd --- /dev/null +++ b/dist/telegram/admin-auth.guard.js @@ -0,0 +1,11 @@ +import { inject } from '@angular/core'; +import { AdminAuthService } from './admin-auth.service'; +/** Guards `/admin/**`-style routes. Never shares state with the customer auth guard/service. */ +export const adminAuthGuard = () => { + const adminAuth = inject(AdminAuthService); + if (adminAuth.isAuthenticated()) { + return true; + } + adminAuth.requestLogin(); + return false; +}; diff --git a/dist/telegram/admin-auth.service.d.ts b/dist/telegram/admin-auth.service.d.ts new file mode 100644 index 0000000..d9361e7 --- /dev/null +++ b/dist/telegram/admin-auth.service.d.ts @@ -0,0 +1,44 @@ +import { Observable } from 'rxjs'; +import { AdminAuthStatus, AuthSession, WebSessionStart } from './models/session.model'; +export declare class AdminAuthService { + private readonly api; + private readonly sessionSignal; + private readonly statusSignal; + private readonly showLoginSignal; + readonly session: import("@angular/core").Signal; + readonly status: import("@angular/core").Signal; + readonly isAuthenticated: import("@angular/core").Signal; + readonly showLoginDialog: import("@angular/core").Signal; + readonly displayName: import("@angular/core").Signal; + private sessionCheckTimer?; + constructor(); + checkSession(): void; + /** Check session without mutating internal state beyond activating on success (used for polling). */ + checkSessionOnce(webSessionID?: string | null): Observable; + /** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */ + createWebSession(): Observable; + getAdminAppLoginUrl(webSessionID: string): string; + onLoginComplete(): void; + requestLogin(): void; + /** + * 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; + hideLogin(): void; + logout(): void; + /** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */ + getAdminToken(): string | null; + setAdminTokens(token: string, refreshToken: string): void; + clearAdminTokens(): void; + private activateSession; + private clearAuthState; + private scheduleSessionRefresh; + private clearSessionRefresh; + private getStoredAdminSessionID; + private setStoredAdminSessionID; + private clearStoredAdminSessionID; +} diff --git a/dist/telegram/admin-auth.service.js b/dist/telegram/admin-auth.service.js new file mode 100644 index 0000000..6953a16 --- /dev/null +++ b/dist/telegram/admin-auth.service.js @@ -0,0 +1,188 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +import { Injectable, signal, computed, inject, isDevMode } from '@angular/core'; +import { tap } from 'rxjs'; +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; +let AdminAuthService = class AdminAuthService { + constructor() { + this.api = inject(TelegramSessionApiService); + this.sessionSignal = signal(null); + this.statusSignal = signal('unknown'); + this.showLoginSignal = signal(false); + this.session = this.sessionSignal.asReadonly(); + this.status = this.statusSignal.asReadonly(); + this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); + this.showLoginDialog = this.showLoginSignal.asReadonly(); + this.displayName = computed(() => this.sessionSignal()?.displayName ?? null); + this.checkSession(); + } + checkSession() { + 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()) { + 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() { + return this.api.createSession(); + } + getAdminAppLoginUrl(webSessionID) { + return this.api.getBotAppLoginUrl(webSessionID); + } + onLoginComplete() { + this.hideLogin(); + if (!this.isAuthenticated()) { + this.checkSession(); + } + } + requestLogin() { + 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() { + 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() { + this.showLoginSignal.set(false); + } + logout() { + 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() { + return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY); + } + setAdminTokens(token, refreshToken) { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token); + localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken); + } + clearAdminTokens() { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY); + localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY); + } + activateSession(session) { + this.sessionSignal.set(session); + this.statusSignal.set('authenticated'); + this.setStoredAdminSessionID(session.sessionId); + this.scheduleSessionRefresh(session.expires); + } + clearAuthState(status) { + this.sessionSignal.set(null); + this.statusSignal.set(status); + this.clearStoredAdminSessionID(); + this.clearAdminTokens(); + this.clearSessionRefresh(); + } + scheduleSessionRefresh(expiresAt) { + 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); + } + clearSessionRefresh() { + if (this.sessionCheckTimer) { + clearTimeout(this.sessionCheckTimer); + this.sessionCheckTimer = undefined; + } + } + getStoredAdminSessionID() { + 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; + } + } + setStoredAdminSessionID(webSessionID) { + 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}`; + } + clearStoredAdminSessionID() { + if (typeof document === 'undefined') { + return; + } + document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`; + } +}; +AdminAuthService = __decorate([ + Injectable({ providedIn: 'root' }) +], AdminAuthService); +export { AdminAuthService }; diff --git a/dist/telegram/auth.service.d.ts b/dist/telegram/auth.service.d.ts new file mode 100644 index 0000000..cfefaf6 --- /dev/null +++ b/dist/telegram/auth.service.d.ts @@ -0,0 +1,49 @@ +import { Observable } from 'rxjs'; +import { AuthSession, AuthStatus, WebSessionStart } from './models/session.model'; +/** Customer-facing Telegram QR/session auth. Distinct storage/state from AdminAuthService by design. */ +export declare class AuthService { + private readonly api; + private sessionSignal; + private statusSignal; + private showLoginSignal; + /** Current auth session */ + readonly session: import("@angular/core").Signal; + /** Current auth status */ + readonly status: import("@angular/core").Signal; + /** Whether user is fully authenticated */ + readonly isAuthenticated: import("@angular/core").Signal; + /** Whether to show login dialog */ + readonly showLoginDialog: import("@angular/core").Signal; + /** Display name of authenticated user */ + readonly displayName: import("@angular/core").Signal; + private sessionCheckTimer?; + constructor(); + /** Check the current webSessionID cookie against the auth backend. */ + checkSession(): void; + /** Check session without updating internal state beyond activating on success (used for polling). */ + checkSessionOnce(webSessionID?: string | null): Observable; + /** + * Called after user completes Telegram login. + */ + onTelegramLoginComplete(): void; + /** Generate the Telegram login URL for bot-based auth */ + getTelegramLoginUrl(webSessionID: string): string; + /** Generate a Telegram app deep link for mobile login without opening a browser tab. */ + getTelegramAppLoginUrl(webSessionID: string): string; + /** Create a backend web session and return the Telegram start link for it. */ + createWebSession(): Observable; + /** Show login dialog (called when user tries to pay without being logged in) */ + requestLogin(): void; + /** Hide login dialog */ + hideLogin(): void; + /** Logout — clears session on backend and locally */ + logout(): void; + private activateSession; + private clearAuthState; + /** Schedule a session re-check before it expires */ + private scheduleSessionRefresh; + private clearSessionRefresh; + private getStoredWebSessionID; + private setStoredWebSessionID; + private clearStoredWebSessionID; +} diff --git a/dist/telegram/auth.service.js b/dist/telegram/auth.service.js new file mode 100644 index 0000000..d518aa4 --- /dev/null +++ b/dist/telegram/auth.service.js @@ -0,0 +1,161 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +import { Injectable, signal, computed, inject } from '@angular/core'; +import { tap } from 'rxjs'; +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. */ +let AuthService = class AuthService { + constructor() { + this.api = inject(TelegramSessionApiService); + this.sessionSignal = signal(null); + this.statusSignal = signal('unknown'); + this.showLoginSignal = signal(false); + /** Current auth session */ + this.session = this.sessionSignal.asReadonly(); + /** Current auth status */ + this.status = this.statusSignal.asReadonly(); + /** Whether user is fully authenticated */ + this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); + /** Whether to show login dialog */ + this.showLoginDialog = this.showLoginSignal.asReadonly(); + /** Display name of authenticated user */ + this.displayName = computed(() => this.sessionSignal()?.displayName ?? null); + // On init, check existing session via cookie + this.checkSession(); + } + /** Check the current webSessionID cookie against the auth backend. */ + checkSession() { + 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()) { + return this.api.checkSessionOnce(webSessionID).pipe(tap(session => { + if (session?.active) { + this.activateSession(session); + } + })); + } + /** + * Called after user completes Telegram login. + */ + onTelegramLoginComplete() { + this.hideLogin(); + if (!this.isAuthenticated()) { + this.checkSession(); + } + } + /** Generate the Telegram login URL for bot-based auth */ + getTelegramLoginUrl(webSessionID) { + return this.api.getBotLoginUrl(webSessionID); + } + /** Generate a Telegram app deep link for mobile login without opening a browser tab. */ + getTelegramAppLoginUrl(webSessionID) { + return this.api.getBotAppLoginUrl(webSessionID); + } + /** Create a backend web session and return the Telegram start link for it. */ + createWebSession() { + return this.api.createSession(); + } + /** Show login dialog (called when user tries to pay without being logged in) */ + requestLogin() { + this.showLoginSignal.set(true); + } + /** Hide login dialog */ + hideLogin() { + this.showLoginSignal.set(false); + } + /** Logout — clears session on backend and locally */ + logout() { + const webSessionID = this.sessionSignal()?.sessionId || this.getStoredWebSessionID(); + if (!webSessionID) { + this.clearAuthState('unauthenticated'); + return; + } + this.api.logout(webSessionID).subscribe(() => { + this.clearAuthState('unauthenticated'); + }); + } + activateSession(session) { + this.sessionSignal.set(session); + this.statusSignal.set('authenticated'); + this.setStoredWebSessionID(session.sessionId); + this.scheduleSessionRefresh(session.expires); + } + clearAuthState(status) { + this.sessionSignal.set(null); + this.statusSignal.set(status); + this.clearStoredWebSessionID(); + this.clearSessionRefresh(); + } + /** Schedule a session re-check before it expires */ + scheduleSessionRefresh(expiresAt) { + 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); + } + clearSessionRefresh() { + if (this.sessionCheckTimer) { + clearTimeout(this.sessionCheckTimer); + this.sessionCheckTimer = undefined; + } + } + getStoredWebSessionID() { + 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; + } + } + setStoredWebSessionID(webSessionID) { + 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}`; + } + clearStoredWebSessionID() { + if (typeof document === 'undefined') { + return; + } + document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`; + } +}; +AuthService = __decorate([ + Injectable({ + providedIn: 'root' + }) +], AuthService); +export { AuthService }; diff --git a/dist/telegram/models/session.model.d.ts b/dist/telegram/models/session.model.d.ts new file mode 100644 index 0000000..fa97ba7 --- /dev/null +++ b/dist/telegram/models/session.model.d.ts @@ -0,0 +1,14 @@ +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/dist/telegram/models/session.model.js b/dist/telegram/models/session.model.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/telegram/models/session.model.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/telegram/telegram-session-api.service.d.ts b/dist/telegram/telegram-session-api.service.d.ts new file mode 100644 index 0000000..5ea8650 --- /dev/null +++ b/dist/telegram/telegram-session-api.service.d.ts @@ -0,0 +1,28 @@ +import { Observable } from 'rxjs'; +import { AuthSession, WebSessionStart } from './models/session.model'; +/** + * 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. + */ +export declare class TelegramSessionApiService { + private readonly http; + private readonly authApiUrl; + private readonly telegramBotUsername; + createSession(): Observable; + checkSessionOnce(webSessionID: string | null): Observable; + logout(webSessionID: string): Observable; + getBotLoginUrl(webSessionID: string): string; + getBotAppLoginUrl(webSessionID: string): string; + private getBotUsername; + private normalizeWebSession; + private extractSessionId; + private readFirst; + private readString; + private readNumber; + private asRecord; + private isActiveStatus; +} diff --git a/dist/telegram/telegram-session-api.service.js b/dist/telegram/telegram-session-api.service.js new file mode 100644 index 0000000..d3149ff --- /dev/null +++ b/dist/telegram/telegram-session-api.service.js @@ -0,0 +1,137 @@ +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { of, catchError, map } from 'rxjs'; +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. + */ +let TelegramSessionApiService = class TelegramSessionApiService { + constructor() { + this.http = inject(HttpClient); + this.authApiUrl = inject(AUTH_API_URL); + this.telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true }); + } + createSession() { + 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) { + 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) { + return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, { + headers: { WebSessionID: webSessionID } + }).pipe(catchError(() => of(null))); + } + getBotLoginUrl(webSessionID) { + return `https://t.me/${this.getBotUsername()}?start=${encodeURIComponent(webSessionID)}`; + } + getBotAppLoginUrl(webSessionID) { + return `tg://resolve?domain=${encodeURIComponent(this.getBotUsername())}&start=${encodeURIComponent(webSessionID)}`; + } + getBotUsername() { + return this.telegramBotUsername || DEFAULT_TELEGRAM_BOT_USERNAME; + } + normalizeWebSession(response, fallbackSessionId) { + 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 }; + } + extractSessionId(response, fallbackSessionId) { + if (!response) { + return fallbackSessionId; + } + return this.readString(this.readFirst(response, [ + 'webSessionID', 'WebSessionID', 'webSessionId', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID' + ])) ?? fallbackSessionId; + } + readFirst(source, keys) { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + return source[key]; + } + } + return undefined; + } + readString(value) { + if (typeof value === 'string' && value.trim()) { + return value; + } + if (typeof value === 'number' || typeof value === 'bigint') { + return value.toString(); + } + return null; + } + readNumber(value) { + 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; + } + asRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value + : null; + } + isActiveStatus(status) { + 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()); + } +}; +TelegramSessionApiService = __decorate([ + Injectable({ providedIn: 'root' }) +], TelegramSessionApiService); +export { TelegramSessionApiService }; diff --git a/dist/util/guid.util.d.ts b/dist/util/guid.util.d.ts new file mode 100644 index 0000000..810d3ac --- /dev/null +++ b/dist/util/guid.util.d.ts @@ -0,0 +1,2 @@ +/** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */ +export declare function generateGuid(): string; diff --git a/dist/util/guid.util.js b/dist/util/guid.util.js new file mode 100644 index 0000000..19c7d8c --- /dev/null +++ b/dist/util/guid.util.js @@ -0,0 +1,19 @@ +/** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */ +export function generateGuid() { + 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('')}`; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3b86686 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "@marketplaces/auth", + "version": "0.1.0", + "description": "Shared customer + admin auth client (Telegram QR/session, Ed25519 admin verification, guards, interceptors) for marketplaces projects.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "peerDependencies": { + "@angular/core": ">=22.0.0", + "@angular/common": ">=22.0.0", + "@angular/router": ">=22.0.0", + "rxjs": ">=7.8.0" + }, + "license": "UNLICENSED" +}