release: @marketplaces/auth 0.1.0 (5ffc1b1)
This commit is contained in:
2
dist/.npmignore
vendored
Normal file
2
dist/.npmignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Nested package.json's are only needed for development.
|
||||||
|
**/package.json
|
||||||
5
dist/config.d.ts
vendored
5
dist/config.d.ts
vendored
@@ -1,5 +0,0 @@
|
|||||||
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<string>;
|
|
||||||
/** 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<string>;
|
|
||||||
5
dist/config.js
vendored
5
dist/config.js
vendored
@@ -1,5 +0,0 @@
|
|||||||
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');
|
|
||||||
17
dist/ed25519/auth-api.service.d.ts
vendored
17
dist/ed25519/auth-api.service.d.ts
vendored
@@ -1,17 +0,0 @@
|
|||||||
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<AuthChallenge>;
|
|
||||||
verifySignature(request: VerifySignatureRequest): Observable<AuthTokenPair>;
|
|
||||||
refresh(request: RefreshTokenRequest): Observable<AuthTokenPair>;
|
|
||||||
logout(refreshToken: string): Observable<void>;
|
|
||||||
}
|
|
||||||
38
dist/ed25519/auth-api.service.js
vendored
38
dist/ed25519/auth-api.service.js
vendored
@@ -1,38 +0,0 @@
|
|||||||
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 };
|
|
||||||
22
dist/ed25519/auth-facade.service.d.ts
vendored
22
dist/ed25519/auth-facade.service.d.ts
vendored
@@ -1,22 +0,0 @@
|
|||||||
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<boolean>;
|
|
||||||
readonly status: import("@angular/core").Signal<import("./session.service").SessionStatus>;
|
|
||||||
readonly role: import("@angular/core").Signal<import("..").AdminRole | null>;
|
|
||||||
readonly loginPhase: import("@angular/core").Signal<import("./auth.service").LoginPhase>;
|
|
||||||
readonly lastError: import("@angular/core").Signal<import("..").AuthError | null>;
|
|
||||||
restoreSession(): void;
|
|
||||||
login(onSuccessRedirectTo?: string): void;
|
|
||||||
logout(redirectTo?: string): void;
|
|
||||||
can(permission: Permission): boolean;
|
|
||||||
}
|
|
||||||
60
dist/ed25519/auth-facade.service.js
vendored
60
dist/ed25519/auth-facade.service.js
vendored
@@ -1,60 +0,0 @@
|
|||||||
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 };
|
|
||||||
35
dist/ed25519/auth.service.d.ts
vendored
35
dist/ed25519/auth.service.d.ts
vendored
@@ -1,35 +0,0 @@
|
|||||||
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<LoginPhase>;
|
|
||||||
readonly lastError: import("@angular/core").Signal<AuthError | null>;
|
|
||||||
constructor();
|
|
||||||
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
|
|
||||||
restoreSession(): void;
|
|
||||||
login(): Observable<AuthTokenPair>;
|
|
||||||
refresh(): Observable<AuthTokenPair>;
|
|
||||||
logout(): Observable<void>;
|
|
||||||
private signChallenge;
|
|
||||||
private handleAuthError;
|
|
||||||
private toAuthErrorShape;
|
|
||||||
private toAuthError;
|
|
||||||
}
|
|
||||||
107
dist/ed25519/auth.service.js
vendored
107
dist/ed25519/auth.service.js
vendored
@@ -1,107 +0,0 @@
|
|||||||
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 };
|
|
||||||
15
dist/ed25519/ed25519-keypair.service.d.ts
vendored
15
dist/ed25519/ed25519-keypair.service.d.ts
vendored
@@ -1,15 +0,0 @@
|
|||||||
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<string>;
|
|
||||||
/** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */
|
|
||||||
clear(): Promise<void>;
|
|
||||||
private generateAndPersist;
|
|
||||||
private loadFromStore;
|
|
||||||
private openDatabase;
|
|
||||||
private toBase64;
|
|
||||||
}
|
|
||||||
114
dist/ed25519/ed25519-keypair.service.js
vendored
114
dist/ed25519/ed25519-keypair.service.js
vendored
@@ -1,114 +0,0 @@
|
|||||||
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 };
|
|
||||||
27
dist/ed25519/ed25519-verification.model.d.ts
vendored
27
dist/ed25519/ed25519-verification.model.d.ts
vendored
@@ -1,27 +0,0 @@
|
|||||||
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<Ed25519Challenge>;
|
|
||||||
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
|
|
||||||
}
|
|
||||||
2
dist/ed25519/ed25519-verification.model.js
vendored
2
dist/ed25519/ed25519-verification.model.js
vendored
@@ -1,2 +0,0 @@
|
|||||||
export class Ed25519VerificationService {
|
|
||||||
}
|
|
||||||
14
dist/ed25519/jwt.service.d.ts
vendored
14
dist/ed25519/jwt.service.d.ts
vendored
@@ -1,14 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
48
dist/ed25519/jwt.service.js
vendored
48
dist/ed25519/jwt.service.js
vendored
@@ -1,48 +0,0 @@
|
|||||||
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 };
|
|
||||||
36
dist/ed25519/models/auth-api.model.d.ts
vendored
36
dist/ed25519/models/auth-api.model.d.ts
vendored
@@ -1,36 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
1
dist/ed25519/models/auth-api.model.js
vendored
1
dist/ed25519/models/auth-api.model.js
vendored
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
15
dist/ed25519/models/auth-error.model.d.ts
vendored
15
dist/ed25519/models/auth-error.model.d.ts
vendored
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
24
dist/ed25519/models/auth-error.model.js
vendored
24
dist/ed25519/models/auth-error.model.js
vendored
@@ -1,24 +0,0 @@
|
|||||||
/** 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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
dist/ed25519/models/permission.model.d.ts
vendored
10
dist/ed25519/models/permission.model.d.ts
vendored
@@ -1,10 +0,0 @@
|
|||||||
/** 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<Record<AdminRole, readonly Permission[]>>;
|
|
||||||
7
dist/ed25519/models/permission.model.js
vendored
7
dist/ed25519/models/permission.model.js
vendored
@@ -1,7 +0,0 @@
|
|||||||
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']
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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<Ed25519Challenge>;
|
|
||||||
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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 };
|
|
||||||
12
dist/ed25519/permission.service.d.ts
vendored
12
dist/ed25519/permission.service.d.ts
vendored
@@ -1,12 +0,0 @@
|
|||||||
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<readonly Permission[]>;
|
|
||||||
has(permission: Permission): boolean;
|
|
||||||
hasAny(permissions: readonly Permission[]): boolean;
|
|
||||||
}
|
|
||||||
33
dist/ed25519/permission.service.js
vendored
33
dist/ed25519/permission.service.js
vendored
@@ -1,33 +0,0 @@
|
|||||||
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 };
|
|
||||||
35
dist/ed25519/session.service.d.ts
vendored
35
dist/ed25519/session.service.d.ts
vendored
@@ -1,35 +0,0 @@
|
|||||||
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<string | null>;
|
|
||||||
readonly claims: import("@angular/core").Signal<JwtClaims | null>;
|
|
||||||
readonly status: import("@angular/core").Signal<SessionStatus>;
|
|
||||||
readonly isAuthenticated: import("@angular/core").Signal<boolean>;
|
|
||||||
readonly role: import("@angular/core").Signal<import("..").AdminRole | null>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
120
dist/ed25519/session.service.js
vendored
120
dist/ed25519/session.service.js
vendored
@@ -1,120 +0,0 @@
|
|||||||
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 };
|
|
||||||
1357
dist/fesm2022/marketplaces-auth.mjs
vendored
Normal file
1357
dist/fesm2022/marketplaces-auth.mjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
dist/fesm2022/marketplaces-auth.mjs.map
vendored
Normal file
1
dist/fesm2022/marketplaces-auth.mjs.map
vendored
Normal file
File diff suppressed because one or more lines are too long
21
dist/index.d.ts
vendored
21
dist/index.d.ts
vendored
@@ -1,21 +0,0 @@
|
|||||||
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';
|
|
||||||
24
dist/index.js
vendored
24
dist/index.js
vendored
@@ -1,24 +0,0 @@
|
|||||||
// @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';
|
|
||||||
36
dist/package.json
vendored
Normal file
36
dist/package.json
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@marketplaces/auth",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Standalone Angular authentication UI and client for marketplaces projects.",
|
||||||
|
"module": "fesm2022/marketplaces-auth.mjs",
|
||||||
|
"typings": "types/marketplaces-auth.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/core": ">=22.0.0",
|
||||||
|
"@angular/common": ">=22.0.0",
|
||||||
|
"@angular/forms": ">=22.0.0",
|
||||||
|
"@angular/router": ">=22.0.0",
|
||||||
|
"rxjs": ">=7.8.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "restricted"
|
||||||
|
},
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"exports": {
|
||||||
|
"./package.json": {
|
||||||
|
"default": "./package.json"
|
||||||
|
},
|
||||||
|
".": {
|
||||||
|
"types": "./types/marketplaces-auth.d.ts",
|
||||||
|
"default": "./fesm2022/marketplaces-auth.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sideEffects": false,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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;
|
|
||||||
26
dist/telegram/admin-auth-headers.interceptor.js
vendored
26
dist/telegram/admin-auth-headers.interceptor.js
vendored
@@ -1,26 +0,0 @@
|
|||||||
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 }));
|
|
||||||
};
|
|
||||||
3
dist/telegram/admin-auth.guard.d.ts
vendored
3
dist/telegram/admin-auth.guard.d.ts
vendored
@@ -1,3 +0,0 @@
|
|||||||
import { CanActivateFn } from '@angular/router';
|
|
||||||
/** Guards `/admin/**`-style routes. Never shares state with the customer auth guard/service. */
|
|
||||||
export declare const adminAuthGuard: CanActivateFn;
|
|
||||||
11
dist/telegram/admin-auth.guard.js
vendored
11
dist/telegram/admin-auth.guard.js
vendored
@@ -1,11 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
44
dist/telegram/admin-auth.service.d.ts
vendored
44
dist/telegram/admin-auth.service.d.ts
vendored
@@ -1,44 +0,0 @@
|
|||||||
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<AuthSession | null>;
|
|
||||||
readonly status: import("@angular/core").Signal<AdminAuthStatus>;
|
|
||||||
readonly isAuthenticated: import("@angular/core").Signal<boolean>;
|
|
||||||
readonly showLoginDialog: import("@angular/core").Signal<boolean>;
|
|
||||||
readonly displayName: import("@angular/core").Signal<string | null>;
|
|
||||||
private sessionCheckTimer?;
|
|
||||||
constructor();
|
|
||||||
checkSession(): void;
|
|
||||||
/** Check session without mutating internal state beyond activating on success (used for polling). */
|
|
||||||
checkSessionOnce(webSessionID?: string | null): Observable<AuthSession | null>;
|
|
||||||
/** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */
|
|
||||||
createWebSession(): Observable<WebSessionStart>;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
188
dist/telegram/admin-auth.service.js
vendored
188
dist/telegram/admin-auth.service.js
vendored
@@ -1,188 +0,0 @@
|
|||||||
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 };
|
|
||||||
49
dist/telegram/auth.service.d.ts
vendored
49
dist/telegram/auth.service.d.ts
vendored
@@ -1,49 +0,0 @@
|
|||||||
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<AuthSession | null>;
|
|
||||||
/** Current auth status */
|
|
||||||
readonly status: import("@angular/core").Signal<AuthStatus>;
|
|
||||||
/** Whether user is fully authenticated */
|
|
||||||
readonly isAuthenticated: import("@angular/core").Signal<boolean>;
|
|
||||||
/** Whether to show login dialog */
|
|
||||||
readonly showLoginDialog: import("@angular/core").Signal<boolean>;
|
|
||||||
/** Display name of authenticated user */
|
|
||||||
readonly displayName: import("@angular/core").Signal<string | null>;
|
|
||||||
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<AuthSession | null>;
|
|
||||||
/**
|
|
||||||
* 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<WebSessionStart>;
|
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
161
dist/telegram/auth.service.js
vendored
161
dist/telegram/auth.service.js
vendored
@@ -1,161 +0,0 @@
|
|||||||
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 };
|
|
||||||
14
dist/telegram/models/session.model.d.ts
vendored
14
dist/telegram/models/session.model.d.ts
vendored
@@ -1,14 +0,0 @@
|
|||||||
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';
|
|
||||||
1
dist/telegram/models/session.model.js
vendored
1
dist/telegram/models/session.model.js
vendored
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
28
dist/telegram/telegram-session-api.service.d.ts
vendored
28
dist/telegram/telegram-session-api.service.d.ts
vendored
@@ -1,28 +0,0 @@
|
|||||||
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<WebSessionStart>;
|
|
||||||
checkSessionOnce(webSessionID: string | null): Observable<AuthSession | null>;
|
|
||||||
logout(webSessionID: string): Observable<unknown>;
|
|
||||||
getBotLoginUrl(webSessionID: string): string;
|
|
||||||
getBotAppLoginUrl(webSessionID: string): string;
|
|
||||||
private getBotUsername;
|
|
||||||
private normalizeWebSession;
|
|
||||||
private extractSessionId;
|
|
||||||
private readFirst;
|
|
||||||
private readString;
|
|
||||||
private readNumber;
|
|
||||||
private asRecord;
|
|
||||||
private isActiveStatus;
|
|
||||||
}
|
|
||||||
137
dist/telegram/telegram-session-api.service.js
vendored
137
dist/telegram/telegram-session-api.service.js
vendored
@@ -1,137 +0,0 @@
|
|||||||
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 };
|
|
||||||
545
dist/types/marketplaces-auth.d.ts
vendored
Normal file
545
dist/types/marketplaces-auth.d.ts
vendored
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import * as _angular_core from '@angular/core';
|
||||||
|
import { InjectionToken, EnvironmentProviders } from '@angular/core';
|
||||||
|
import { HttpHeaders, HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import * as _angular_forms_signals from '@angular/forms/signals';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { CanActivateFn } from '@angular/router';
|
||||||
|
import * as _marketplaces_auth from '@marketplaces/auth';
|
||||||
|
|
||||||
|
interface MarketplacesAuthConfig {
|
||||||
|
/** Central auth service URL. It is not the tenant API URL. */
|
||||||
|
apiUrl: string;
|
||||||
|
/** Override only for SSR/custom-domain integrations. Browser default is location.hostname. */
|
||||||
|
marketplaceDomain?: string | (() => string);
|
||||||
|
telegramBotUsername?: string;
|
||||||
|
credentialsPath?: string;
|
||||||
|
yandexStartPath?: string;
|
||||||
|
yandexSessionPath?: string;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
}
|
||||||
|
/** Base URL for the auth backend, e.g. `https://api.example.com`. Provide from the consuming app's environment config. */
|
||||||
|
declare const AUTH_API_URL: InjectionToken<string>;
|
||||||
|
/** Telegram bot username used to build QR/deep-link login URLs. Optional — falls back to a default if not provided. */
|
||||||
|
declare const TELEGRAM_BOT_USERNAME: InjectionToken<string>;
|
||||||
|
declare const MARKETPLACES_AUTH_CONFIG: InjectionToken<MarketplacesAuthConfig>;
|
||||||
|
declare function provideMarketplacesAuth(config: MarketplacesAuthConfig): EnvironmentProviders;
|
||||||
|
|
||||||
|
declare const MARKETPLACE_DOMAIN_HEADER = "X-Marketplace-Domain";
|
||||||
|
declare function normalizeMarketplaceDomain(domain: string): string;
|
||||||
|
declare class AuthMarketplaceContext {
|
||||||
|
private readonly config;
|
||||||
|
domain(): string;
|
||||||
|
headers(extra?: Record<string, string>): HttpHeaders;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AuthMarketplaceContext, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<AuthMarketplaceContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthSession {
|
||||||
|
sessionId: string;
|
||||||
|
userId: number | null;
|
||||||
|
username: string | null;
|
||||||
|
displayName: string;
|
||||||
|
active: boolean;
|
||||||
|
expires: string;
|
||||||
|
}
|
||||||
|
interface WebSessionStart {
|
||||||
|
webSessionID: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||||
|
type AdminAuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||||
|
|
||||||
|
type AuthMode = 'customer' | 'admin';
|
||||||
|
type AuthMethod = 'qr' | 'credentials' | 'yandex';
|
||||||
|
interface CredentialLogin {
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
interface AuthResult {
|
||||||
|
method: AuthMethod;
|
||||||
|
mode: AuthMode;
|
||||||
|
session: AuthSession;
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
}
|
||||||
|
interface ExternalAuthStart {
|
||||||
|
attemptId: string;
|
||||||
|
authorizationUrl: string;
|
||||||
|
}
|
||||||
|
interface AuthFailure {
|
||||||
|
method: AuthMethod;
|
||||||
|
code: 'configuration' | 'invalid_credentials' | 'backend' | 'popup_blocked' | 'expired';
|
||||||
|
message: string;
|
||||||
|
cause?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare class MarketplacesAuthComponent {
|
||||||
|
readonly qr: _angular_core.InputSignalWithTransform<boolean, unknown>;
|
||||||
|
readonly credentials: _angular_core.InputSignalWithTransform<boolean, unknown>;
|
||||||
|
readonly yandex: _angular_core.InputSignalWithTransform<boolean, unknown>;
|
||||||
|
readonly mode: _angular_core.InputSignal<AuthMode>;
|
||||||
|
readonly title: _angular_core.InputSignal<string>;
|
||||||
|
readonly authenticated: _angular_core.OutputEmitterRef<AuthResult>;
|
||||||
|
readonly authError: _angular_core.OutputEmitterRef<AuthFailure>;
|
||||||
|
readonly cancelled: _angular_core.OutputEmitterRef<void>;
|
||||||
|
readonly method: _angular_core.WritableSignal<AuthMethod | null>;
|
||||||
|
readonly busy: _angular_core.WritableSignal<boolean>;
|
||||||
|
readonly error: _angular_core.WritableSignal<AuthFailure | null>;
|
||||||
|
readonly qrImage: _angular_core.WritableSignal<string | null>;
|
||||||
|
readonly externalUrl: _angular_core.WritableSignal<string | null>;
|
||||||
|
private readonly credentialsModel;
|
||||||
|
readonly credentialsForm: _angular_forms_signals.FieldTree<{
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}, string | number, "writable">;
|
||||||
|
private readonly gateway;
|
||||||
|
private readonly config;
|
||||||
|
private poll?;
|
||||||
|
constructor();
|
||||||
|
select(method: AuthMethod | null): void;
|
||||||
|
startQr(): void;
|
||||||
|
loginWithCredentials(event: Event): void;
|
||||||
|
startYandex(): void;
|
||||||
|
private pollForQr;
|
||||||
|
private prepareQr;
|
||||||
|
private pollForYandex;
|
||||||
|
private begin;
|
||||||
|
private finish;
|
||||||
|
private fail;
|
||||||
|
private isFailure;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<MarketplacesAuthComponent, never>;
|
||||||
|
static ɵcmp: _angular_core.ɵɵComponentDeclaration<MarketplacesAuthComponent, "mp-auth, marketplaces-auth", never, { "qr": { "alias": "qr"; "required": false; "isSignal": true; }; "credentials": { "alias": "credentials"; "required": false; "isSignal": true; }; "yandex": { "alias": "yandex"; "required": false; "isSignal": true; }; "mode": { "alias": "mode"; "required": false; "isSignal": true; }; "title": { "alias": "title"; "required": false; "isSignal": true; }; }, { "authenticated": "authenticated"; "authError": "authError"; "cancelled": "cancelled"; }, never, never, true, never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketplacesAuthGateway {
|
||||||
|
startQr(mode: AuthMode): Observable<WebSessionStart>;
|
||||||
|
checkQr(mode: AuthMode, attemptId: string): Observable<AuthSession | null>;
|
||||||
|
loginWithCredentials(mode: AuthMode, credentials: CredentialLogin): Observable<AuthResult>;
|
||||||
|
startYandex(mode: AuthMode, returnUrl: string): Observable<ExternalAuthStart>;
|
||||||
|
checkYandex(mode: AuthMode, attemptId: string): Observable<AuthResult | null>;
|
||||||
|
}
|
||||||
|
declare const MARKETPLACES_AUTH_GATEWAY: InjectionToken<MarketplacesAuthGateway>;
|
||||||
|
declare class HttpMarketplacesAuthGateway implements MarketplacesAuthGateway {
|
||||||
|
private readonly http;
|
||||||
|
private readonly config;
|
||||||
|
private readonly context;
|
||||||
|
private readonly customerAuth;
|
||||||
|
private readonly adminAuth;
|
||||||
|
startQr(mode: AuthMode): Observable<WebSessionStart>;
|
||||||
|
checkQr(mode: AuthMode, attemptId: string): Observable<AuthSession | null>;
|
||||||
|
loginWithCredentials(mode: AuthMode, credentials: CredentialLogin): Observable<AuthResult>;
|
||||||
|
startYandex(mode: AuthMode, returnUrl: string): Observable<ExternalAuthStart>;
|
||||||
|
checkYandex(mode: AuthMode, attemptId: string): Observable<AuthResult | null>;
|
||||||
|
private accept;
|
||||||
|
private url;
|
||||||
|
private failure;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<HttpMarketplacesAuthGateway, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<HttpMarketplacesAuthGateway>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
declare class TelegramSessionApiService {
|
||||||
|
private readonly http;
|
||||||
|
private readonly authApiUrl;
|
||||||
|
private readonly telegramBotUsername;
|
||||||
|
private readonly marketplaceContext;
|
||||||
|
createSession(): Observable<WebSessionStart>;
|
||||||
|
checkSessionOnce(webSessionID: string | null): Observable<AuthSession | null>;
|
||||||
|
logout(webSessionID: string): Observable<unknown>;
|
||||||
|
getBotLoginUrl(webSessionID: string): string;
|
||||||
|
getBotAppLoginUrl(webSessionID: string): string;
|
||||||
|
private getBotUsername;
|
||||||
|
private normalizeWebSession;
|
||||||
|
private extractSessionId;
|
||||||
|
private readFirst;
|
||||||
|
private readString;
|
||||||
|
private readNumber;
|
||||||
|
private asRecord;
|
||||||
|
private isActiveStatus;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<TelegramSessionApiService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<TelegramSessionApiService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer-facing Telegram QR/session auth. Distinct storage/state from AdminAuthService by design. */
|
||||||
|
declare class AuthService$1 {
|
||||||
|
private readonly api;
|
||||||
|
private sessionSignal;
|
||||||
|
private statusSignal;
|
||||||
|
private showLoginSignal;
|
||||||
|
/** Current auth session */
|
||||||
|
readonly session: _angular_core.Signal<AuthSession | null>;
|
||||||
|
/** Current auth status */
|
||||||
|
readonly status: _angular_core.Signal<AuthStatus>;
|
||||||
|
/** Whether user is fully authenticated */
|
||||||
|
readonly isAuthenticated: _angular_core.Signal<boolean>;
|
||||||
|
/** Whether to show login dialog */
|
||||||
|
readonly showLoginDialog: _angular_core.Signal<boolean>;
|
||||||
|
/** Display name of authenticated user */
|
||||||
|
readonly displayName: _angular_core.Signal<string | null>;
|
||||||
|
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<AuthSession | null>;
|
||||||
|
/**
|
||||||
|
* 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<WebSessionStart>;
|
||||||
|
/** 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;
|
||||||
|
/** Accept a session returned by credentials or an external provider. */
|
||||||
|
acceptSession(session: AuthSession): void;
|
||||||
|
private activateSession;
|
||||||
|
private clearAuthState;
|
||||||
|
/** Schedule a session re-check before it expires */
|
||||||
|
private scheduleSessionRefresh;
|
||||||
|
private clearSessionRefresh;
|
||||||
|
private getStoredWebSessionID;
|
||||||
|
private setStoredWebSessionID;
|
||||||
|
private clearStoredWebSessionID;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AuthService$1, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<AuthService$1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare class AdminAuthService {
|
||||||
|
private readonly api;
|
||||||
|
private readonly sessionSignal;
|
||||||
|
private readonly statusSignal;
|
||||||
|
private readonly showLoginSignal;
|
||||||
|
readonly session: _angular_core.Signal<AuthSession | null>;
|
||||||
|
readonly status: _angular_core.Signal<AdminAuthStatus>;
|
||||||
|
readonly isAuthenticated: _angular_core.Signal<boolean>;
|
||||||
|
readonly showLoginDialog: _angular_core.Signal<boolean>;
|
||||||
|
readonly displayName: _angular_core.Signal<string | null>;
|
||||||
|
private sessionCheckTimer?;
|
||||||
|
constructor();
|
||||||
|
checkSession(): void;
|
||||||
|
/** Check session without mutating internal state beyond activating on success (used for polling). */
|
||||||
|
checkSessionOnce(webSessionID?: string | null): Observable<AuthSession | null>;
|
||||||
|
/** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */
|
||||||
|
createWebSession(): Observable<WebSessionStart>;
|
||||||
|
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;
|
||||||
|
/** Accept a session/tokens returned by credentials or an external provider. */
|
||||||
|
acceptSession(session: AuthSession, token?: string, refreshToken?: string): 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;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AdminAuthService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<AdminAuthService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guards `/admin/**`-style routes. Never shares state with the customer auth guard/service. */
|
||||||
|
declare const adminAuthGuard: CanActivateFn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
declare const adminAuthHeadersInterceptor: HttpInterceptorFn;
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
type Permission = 'backoffice.read' | 'backoffice.write' | 'builder.read' | 'builder.write' | 'users.manage' | 'settings.manage';
|
||||||
|
declare const ROLE_PERMISSIONS: Readonly<Record<AdminRole, readonly Permission[]>>;
|
||||||
|
|
||||||
|
/** Wire contracts for the Ed25519 challenge/response admin auth flow. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
interface VerifySignatureRequest {
|
||||||
|
publicKey: string;
|
||||||
|
signature: string;
|
||||||
|
nonce: string;
|
||||||
|
}
|
||||||
|
interface AuthTokenPair {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
type AuthErrorCode = 'session-expired' | 'invalid-signature' | 'unauthorized' | 'forbidden' | 'backend-unavailable';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
declare function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined;
|
||||||
|
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
|
||||||
|
declare function authErrorCodeFromStatus(status: number): AuthErrorCode;
|
||||||
|
|
||||||
|
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`.
|
||||||
|
*/
|
||||||
|
declare class AuthService {
|
||||||
|
private readonly api;
|
||||||
|
private readonly keypair;
|
||||||
|
private readonly session;
|
||||||
|
private readonly loginPhaseSignal;
|
||||||
|
private readonly lastErrorSignal;
|
||||||
|
readonly loginPhase: _angular_core.Signal<LoginPhase>;
|
||||||
|
readonly lastError: _angular_core.Signal<AuthError | null>;
|
||||||
|
constructor();
|
||||||
|
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
|
||||||
|
restoreSession(): void;
|
||||||
|
login(): Observable<AuthTokenPair>;
|
||||||
|
refresh(): Observable<AuthTokenPair>;
|
||||||
|
logout(): Observable<void>;
|
||||||
|
private signChallenge;
|
||||||
|
private handleAuthError;
|
||||||
|
private toAuthErrorShape;
|
||||||
|
private toAuthError;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AuthService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<AuthService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
declare class AuthFacade {
|
||||||
|
private readonly auth;
|
||||||
|
private readonly session;
|
||||||
|
private readonly permissions;
|
||||||
|
private readonly router;
|
||||||
|
readonly isAuthenticated: _angular_core.Signal<boolean>;
|
||||||
|
readonly status: _angular_core.Signal<_marketplaces_auth.SessionStatus>;
|
||||||
|
readonly role: _angular_core.Signal<_marketplaces_auth.AdminRole | null>;
|
||||||
|
readonly loginPhase: _angular_core.Signal<_marketplaces_auth.LoginPhase>;
|
||||||
|
readonly lastError: _angular_core.Signal<_marketplaces_auth.AuthError | null>;
|
||||||
|
restoreSession(): void;
|
||||||
|
login(onSuccessRedirectTo?: string): void;
|
||||||
|
logout(redirectTo?: string): void;
|
||||||
|
can(permission: Permission): boolean;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AuthFacade, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<AuthFacade>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
declare class AuthApiService {
|
||||||
|
private readonly http;
|
||||||
|
private readonly baseUrl;
|
||||||
|
requestChallenge(): Observable<AuthChallenge>;
|
||||||
|
verifySignature(request: VerifySignatureRequest): Observable<AuthTokenPair>;
|
||||||
|
refresh(request: RefreshTokenRequest): Observable<AuthTokenPair>;
|
||||||
|
logout(refreshToken: string): Observable<void>;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AuthApiService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<AuthApiService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
declare class SessionService {
|
||||||
|
private readonly jwt;
|
||||||
|
private readonly tokenSignal;
|
||||||
|
private readonly refreshTokenSignal;
|
||||||
|
private readonly claimsSignal;
|
||||||
|
private readonly statusSignal;
|
||||||
|
readonly token: _angular_core.Signal<string | null>;
|
||||||
|
readonly claims: _angular_core.Signal<JwtClaims | null>;
|
||||||
|
readonly status: _angular_core.Signal<SessionStatus>;
|
||||||
|
readonly isAuthenticated: _angular_core.Signal<boolean>;
|
||||||
|
readonly role: _angular_core.Signal<_marketplaces_auth.AdminRole | null>;
|
||||||
|
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;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SessionService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<SessionService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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").
|
||||||
|
*/
|
||||||
|
declare class JwtService {
|
||||||
|
decode(token: string): JwtClaims | null;
|
||||||
|
isExpired(claims: JwtClaims, skewSeconds?: number): boolean;
|
||||||
|
private isJwtClaims;
|
||||||
|
private base64UrlDecode;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<JwtService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<JwtService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>;
|
||||||
|
/** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */
|
||||||
|
clear(): Promise<void>;
|
||||||
|
private generateAndPersist;
|
||||||
|
private loadFromStore;
|
||||||
|
private openDatabase;
|
||||||
|
private toBase64;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Ed25519KeypairService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<Ed25519KeypairService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
declare class PermissionService {
|
||||||
|
private readonly session;
|
||||||
|
readonly permissions: _angular_core.Signal<readonly Permission[]>;
|
||||||
|
has(permission: Permission): boolean;
|
||||||
|
hasAny(permissions: readonly Permission[]): boolean;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<PermissionService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<PermissionService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
interface Ed25519Challenge {
|
||||||
|
nonce: string;
|
||||||
|
timestamp: string;
|
||||||
|
/** Opaque challenge payload the client must sign with its private key. */
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
interface Ed25519SignedResponse {
|
||||||
|
challenge: Ed25519Challenge;
|
||||||
|
publicKey: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
interface Ed25519VerificationResult {
|
||||||
|
valid: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
declare abstract class Ed25519VerificationService {
|
||||||
|
abstract requestChallenge(): Observable<Ed25519Challenge>;
|
||||||
|
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
declare class NoopEd25519VerificationService implements Ed25519VerificationService {
|
||||||
|
requestChallenge(): Observable<Ed25519Challenge>;
|
||||||
|
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
|
||||||
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<NoopEd25519VerificationService, never>;
|
||||||
|
static ɵprov: _angular_core.ɵɵInjectableDeclaration<NoopEd25519VerificationService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AUTH_API_URL, AdminAuthService, AuthApiService, AuthFacade, AuthMarketplaceContext, AuthService$1 as AuthService, AuthService as Ed25519AuthService, Ed25519KeypairService, Ed25519VerificationService, HttpMarketplacesAuthGateway, JwtService, MARKETPLACES_AUTH_CONFIG, MARKETPLACES_AUTH_GATEWAY, MARKETPLACE_DOMAIN_HEADER, MarketplacesAuthComponent, NoopEd25519VerificationService, PermissionService, ROLE_PERMISSIONS, SessionService, TELEGRAM_BOT_USERNAME, TelegramSessionApiService, adminAuthGuard, adminAuthHeadersInterceptor, authErrorCodeFromBackendCode, authErrorCodeFromStatus, normalizeMarketplaceDomain, provideMarketplacesAuth };
|
||||||
|
export type { AdminAuthStatus, AdminRole, AuthChallenge, AuthError, AuthErrorCode, AuthFailure, AuthMethod, AuthMode, AuthResult, AuthSession, AuthStatus, AuthTokenPair, CredentialLogin, Ed25519Challenge, Ed25519SignedResponse, Ed25519VerificationResult, ExternalAuthStart, JwtClaims, LoginPhase, MarketplacesAuthConfig, MarketplacesAuthGateway, Permission, RefreshTokenRequest, SessionStatus, VerifySignatureRequest, WebSessionStart };
|
||||||
2
dist/util/guid.util.d.ts
vendored
2
dist/util/guid.util.d.ts
vendored
@@ -1,2 +0,0 @@
|
|||||||
/** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */
|
|
||||||
export declare function generateGuid(): string;
|
|
||||||
19
dist/util/guid.util.js
vendored
19
dist/util/guid.util.js
vendored
@@ -1,19 +0,0 @@
|
|||||||
/** 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('')}`;
|
|
||||||
}
|
|
||||||
18
package.json
18
package.json
@@ -1,15 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "@marketplaces/auth",
|
"name": "@marketplaces/auth",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Shared customer + admin auth client (Telegram QR/session, Ed25519 admin verification, guards, interceptors) for marketplaces projects.",
|
"description": "Standalone Angular authentication UI and client for marketplaces projects.",
|
||||||
"main": "dist/index.js",
|
"module": "dist/fesm2022/marketplaces-auth.mjs",
|
||||||
"types": "dist/index.d.ts",
|
"typings": "dist/types/marketplaces-auth.d.ts",
|
||||||
"files": ["dist"],
|
"files": ["dist"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "ng-packagr -p ng-package.json -c tsconfig.json",
|
||||||
|
"test": "node --test test/*.test.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@angular/core": ">=22.0.0",
|
"@angular/core": ">=22.0.0",
|
||||||
"@angular/common": ">=22.0.0",
|
"@angular/common": ">=22.0.0",
|
||||||
|
"@angular/forms": ">=22.0.0",
|
||||||
"@angular/router": ">=22.0.0",
|
"@angular/router": ">=22.0.0",
|
||||||
"rxjs": ">=7.8.0"
|
"rxjs": ">=7.8.0"
|
||||||
},
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "restricted"
|
||||||
|
},
|
||||||
"license": "UNLICENSED"
|
"license": "UNLICENSED"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user