Files
vitanovaPackages/dist/fesm2022/marketplaces-auth.mjs

1358 lines
67 KiB
JavaScript
Raw Normal View History

import * as i0 from '@angular/core';
import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, computed, isDevMode, input, booleanAttribute, output, DestroyRef, Component } from '@angular/core';
import { HttpHeaders, HttpClient, HttpErrorResponse } from '@angular/common/http';
import { form, required, FormField } from '@angular/forms/signals';
import * as QRCode from 'qrcode';
import { map, of, catchError, tap, throwError, timer, switchMap, Observable, finalize } from 'rxjs';
import { Router } from '@angular/router';
/** Base URL for the auth backend, e.g. `https://api.example.com`. Provide from the consuming app's environment config. */
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. */
const TELEGRAM_BOT_USERNAME = new InjectionToken('@marketplaces/auth TELEGRAM_BOT_USERNAME');
const MARKETPLACES_AUTH_CONFIG = new InjectionToken('@marketplaces/auth config');
function provideMarketplacesAuth(config) {
const normalized = {
...config,
apiUrl: config.apiUrl.replace(/\/$/, ''),
credentialsPath: config.credentialsPath ?? '/auth/credentials/login',
yandexStartPath: config.yandexStartPath ?? '/auth/yandex/sessions',
yandexSessionPath: config.yandexSessionPath ?? '/auth/yandex/sessions',
pollIntervalMs: config.pollIntervalMs ?? 1500,
};
return makeEnvironmentProviders([
{ provide: MARKETPLACES_AUTH_CONFIG, useValue: normalized },
{ provide: AUTH_API_URL, useValue: normalized.apiUrl },
...(normalized.telegramBotUsername
? [{ provide: TELEGRAM_BOT_USERNAME, useValue: normalized.telegramBotUsername }]
: []),
]);
}
const MARKETPLACE_DOMAIN_HEADER = 'X-Marketplace-Domain';
function normalizeMarketplaceDomain(domain) {
return domain.trim().toLowerCase().replace(/\.$/, '');
}
class AuthMarketplaceContext {
constructor() {
this.config = inject(MARKETPLACES_AUTH_CONFIG);
}
domain() {
const configured = this.config.marketplaceDomain;
const domain = typeof configured === 'function'
? configured()
: configured ?? (typeof location === 'undefined' ? '' : location.hostname);
return normalizeMarketplaceDomain(domain);
}
headers(extra) {
const domain = this.domain();
if (!domain)
throw new Error('Marketplace domain cannot be resolved');
return new HttpHeaders({ [MARKETPLACE_DOMAIN_HEADER]: domain, ...extra });
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthMarketplaceContext, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthMarketplaceContext, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthMarketplaceContext, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/** RFC4122 v4-ish GUID, using crypto when available. Shared by customer and admin session creation. */
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('')}`;
}
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.
*/
class TelegramSessionApiService {
constructor() {
this.http = inject(HttpClient);
this.authApiUrl = inject(AUTH_API_URL);
this.telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true });
this.marketplaceContext = inject(AuthMarketplaceContext);
}
createSession() {
const webSessionID = generateGuid();
return this.http.post(`${this.authApiUrl}/users/sessions`, { webSessionID }, { headers: this.marketplaceContext.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)}`, { headers: this.marketplaceContext.headers() }).pipe(map(response => this.normalizeWebSession(response, webSessionID)), catchError(() => of(null)));
}
logout(webSessionID) {
return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
headers: this.marketplaceContext.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());
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: TelegramSessionApiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: TelegramSessionApiService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: TelegramSessionApiService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* 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;
class AdminAuthService {
constructor() {
this.api = inject(TelegramSessionApiService);
this.sessionSignal = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "sessionSignal" }] : /* istanbul ignore next */ []));
this.statusSignal = signal('unknown', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "statusSignal" }] : /* istanbul ignore next */ []));
this.showLoginSignal = signal(false, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "showLoginSignal" }] : /* istanbul ignore next */ []));
this.session = this.sessionSignal.asReadonly();
this.status = this.statusSignal.asReadonly();
this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "isAuthenticated" }] : /* istanbul ignore next */ []));
this.showLoginDialog = this.showLoginSignal.asReadonly();
this.displayName = computed(() => this.sessionSignal()?.displayName ?? null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "displayName" }] : /* istanbul ignore next */ []));
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'));
}
/** Accept a session/tokens returned by credentials or an external provider. */
acceptSession(session, token, refreshToken) {
this.activateSession(session);
if (token && refreshToken)
this.setAdminTokens(token, refreshToken);
}
/** 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`;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AdminAuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AdminAuthService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AdminAuthService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [] });
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$1 = class AuthService {
constructor() {
this.api = inject(TelegramSessionApiService);
this.sessionSignal = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "sessionSignal" }] : /* istanbul ignore next */ []));
this.statusSignal = signal('unknown', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "statusSignal" }] : /* istanbul ignore next */ []));
this.showLoginSignal = signal(false, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "showLoginSignal" }] : /* istanbul ignore next */ []));
/** 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', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "isAuthenticated" }] : /* istanbul ignore next */ []));
/** Whether to show login dialog */
this.showLoginDialog = this.showLoginSignal.asReadonly();
/** Display name of authenticated user */
this.displayName = computed(() => this.sessionSignal()?.displayName ?? null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "displayName" }] : /* istanbul ignore next */ []));
// 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');
});
}
/** Accept a session returned by credentials or an external provider. */
acceptSession(session) {
this.activateSession(session);
}
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`;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, providedIn: 'root' }); }
};
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService$1, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}], ctorParameters: () => [] });
const MARKETPLACES_AUTH_GATEWAY = new InjectionToken('@marketplaces/auth gateway', { providedIn: 'root', factory: () => inject(HttpMarketplacesAuthGateway) });
class HttpMarketplacesAuthGateway {
constructor() {
this.http = inject(HttpClient);
this.config = inject(MARKETPLACES_AUTH_CONFIG);
this.context = inject(AuthMarketplaceContext);
this.customerAuth = inject(AuthService$1);
this.adminAuth = inject(AdminAuthService);
}
startQr(mode) {
return mode === 'admin' ? this.adminAuth.createWebSession() : this.customerAuth.createWebSession();
}
checkQr(mode, attemptId) {
return mode === 'admin' ? this.adminAuth.checkSessionOnce(attemptId) : this.customerAuth.checkSessionOnce(attemptId);
}
loginWithCredentials(mode, credentials) {
return this.http.post(this.url(this.config.credentialsPath), { ...credentials, mode }, {
headers: this.context.headers(),
}).pipe(map(result => this.accept(mode, { ...result, method: 'credentials', mode })), catchError(error => throwError(() => this.failure('credentials', error))));
}
startYandex(mode, returnUrl) {
return this.http.post(this.url(this.config.yandexStartPath), {
provider: 'yandex', mode, returnUrl,
}, { headers: this.context.headers() }).pipe(catchError(error => throwError(() => this.failure('yandex', error))));
}
checkYandex(mode, attemptId) {
return this.http.get(`${this.url(this.config.yandexSessionPath)}/${encodeURIComponent(attemptId)}`, { headers: this.context.headers() }).pipe(map(result => result ? this.accept(mode, { ...result, method: 'yandex', mode }) : null), catchError((error) => error.status === 404 || error.status === 202
? of(null)
: throwError(() => this.failure('yandex', error))));
}
accept(mode, result) {
if (mode === 'admin')
this.adminAuth.acceptSession(result.session, result.accessToken, result.refreshToken);
else
this.customerAuth.acceptSession(result.session);
return result;
}
url(path = '') { return `${this.config.apiUrl}${path.startsWith('/') ? path : `/${path}`}`; }
failure(method, cause) {
const response = cause instanceof HttpErrorResponse ? cause : null;
return {
method,
code: response?.status === 401 ? 'invalid_credentials' : 'backend',
message: response?.error?.message || response?.message || 'Authentication failed',
cause,
};
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HttpMarketplacesAuthGateway, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HttpMarketplacesAuthGateway, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HttpMarketplacesAuthGateway, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
class MarketplacesAuthComponent {
constructor() {
this.qr = input(false, { ...(ngDevMode ? { debugName: "qr" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
this.credentials = input(false, { ...(ngDevMode ? { debugName: "credentials" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
this.yandex = input(false, { ...(ngDevMode ? { debugName: "yandex" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
this.mode = input('customer', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
this.title = input('Вход', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
this.authenticated = output();
this.authError = output();
this.cancelled = output();
this.method = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "method" }] : /* istanbul ignore next */ []));
this.busy = signal(false, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
this.error = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
this.qrImage = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "qrImage" }] : /* istanbul ignore next */ []));
this.externalUrl = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "externalUrl" }] : /* istanbul ignore next */ []));
this.credentialsModel = signal({ login: '', password: '' }, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "credentialsModel" }] : /* istanbul ignore next */ []));
this.credentialsForm = form(this.credentialsModel, path => {
required(path.login, { message: 'Введите логин' });
required(path.password, { message: 'Введите пароль' });
});
this.gateway = inject(MARKETPLACES_AUTH_GATEWAY);
this.config = inject(MARKETPLACES_AUTH_CONFIG);
inject(DestroyRef).onDestroy(() => this.poll?.unsubscribe());
queueMicrotask(() => this.select(this.qr() ? 'qr' : this.credentials() ? 'credentials' : this.yandex() ? 'yandex' : null));
}
select(method) { this.poll?.unsubscribe(); this.busy.set(false); this.error.set(null); this.method.set(method); }
startQr() {
this.begin();
this.gateway.startQr(this.mode()).subscribe({
next: attempt => void this.prepareQr(attempt.url, attempt.webSessionID).catch(cause => this.fail('qr', cause)),
error: cause => this.fail('qr', cause),
});
}
loginWithCredentials(event) {
event.preventDefault();
if (this.credentialsForm().invalid())
return;
this.begin();
this.gateway.loginWithCredentials(this.mode(), this.credentialsModel()).subscribe({
next: result => this.finish(result), error: cause => this.fail('credentials', cause),
});
}
startYandex() {
this.begin();
const returnUrl = typeof location === 'undefined' ? '' : location.href;
this.gateway.startYandex(this.mode(), returnUrl).subscribe({
next: attempt => {
const popup = typeof window === 'undefined' ? null : window.open(attempt.authorizationUrl, 'mp-yandex-auth', 'popup,width=520,height=720');
if (!popup) {
this.fail('yandex', { method: 'yandex', code: 'popup_blocked', message: 'Браузер заблокировал окно Яндекса' });
return;
}
this.pollForYandex(attempt.attemptId);
},
error: cause => this.fail('yandex', cause),
});
}
pollForQr(attemptId) {
this.poll?.unsubscribe();
this.poll = timer(0, this.config.pollIntervalMs ?? 1500).pipe(switchMap(() => this.gateway.checkQr(this.mode(), attemptId)))
.subscribe({ next: session => { if (session?.active)
this.finish({ method: 'qr', mode: this.mode(), session }); }, error: cause => this.fail('qr', cause) });
}
async prepareQr(url, attemptId) {
this.externalUrl.set(url);
this.qrImage.set(await QRCode.toDataURL(url, { width: 320, margin: 1 }));
this.pollForQr(attemptId);
}
pollForYandex(attemptId) {
this.poll?.unsubscribe();
this.poll = timer(0, this.config.pollIntervalMs ?? 1500).pipe(switchMap(() => this.gateway.checkYandex(this.mode(), attemptId)))
.subscribe({ next: result => { if (result)
this.finish(result); }, error: cause => this.fail('yandex', cause) });
}
begin() { this.poll?.unsubscribe(); this.error.set(null); this.busy.set(true); }
finish(result) { this.poll?.unsubscribe(); this.busy.set(false); this.authenticated.emit(result); }
fail(method, cause) {
this.poll?.unsubscribe();
this.busy.set(false);
const failure = this.isFailure(cause) ? cause : { method, code: 'backend', message: 'Не удалось выполнить вход', cause };
this.error.set(failure);
this.authError.emit(failure);
}
isFailure(value) { return !!value && typeof value === 'object' && 'code' in value && 'message' in value; }
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: MarketplacesAuthComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: MarketplacesAuthComponent, isStandalone: true, selector: "mp-auth, marketplaces-auth", inputs: { qr: { classPropertyName: "qr", publicName: "qr", isSignal: true, isRequired: false, transformFunction: null }, credentials: { classPropertyName: "credentials", publicName: "credentials", isSignal: true, isRequired: false, transformFunction: null }, yandex: { classPropertyName: "yandex", publicName: "yandex", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { authenticated: "authenticated", authError: "authError", cancelled: "cancelled" }, ngImport: i0, template: `
<section class="mp-auth" aria-labelledby="mp-auth-title">
<h2 id="mp-auth-title">{{ title() }}</h2>
<div class="methods" role="tablist" aria-label="Способ входа">
@if (qr()) { <button type="button" [class.active]="method() === 'qr'" (click)="select('qr')">QR</button> }
@if (credentials()) { <button type="button" [class.active]="method() === 'credentials'" (click)="select('credentials')">Логин</button> }
@if (yandex()) { <button type="button" [class.active]="method() === 'yandex'" (click)="select('yandex')">Яндекс</button> }
</div>
@if (method() === 'credentials') {
<form (submit)="loginWithCredentials($event)">
<label>Логин<input autocomplete="username" [formField]="credentialsForm.login" /></label>
<label>Пароль<input type="password" autocomplete="current-password" [formField]="credentialsForm.password" /></label>
<button type="submit" [disabled]="busy() || credentialsForm().invalid()">Войти</button>
</form>
}
@if (method() === 'qr') {
@if (qrImage()) { <a [href]="externalUrl()!" target="_blank" rel="noopener"><img [src]="qrImage()!" alt="QR-код для входа" /></a> }
<button type="button" [disabled]="busy()" (click)="startQr()">{{ qrImage() ? 'Обновить QR' : 'Получить QR' }}</button>
}
@if (method() === 'yandex') { <button type="button" [disabled]="busy()" (click)="startYandex()">Войти через Яндекс</button> }
@if (busy()) { <p role="status">Ожидаем подтверждение</p> }
@if (error()) { <p class="error" role="alert">{{ error()!.message }}</p> }
</section>
`, isInline: true, styles: [":host{display:block}.mp-auth{font:inherit;color:inherit;display:grid;gap:1rem;max-width:25rem}h2,p{margin:0}.methods{display:flex;gap:.5rem;flex-wrap:wrap}.methods button{background:transparent;color:inherit}button,input{font:inherit;border:1px solid #c7c7c7;border-radius:.65rem;padding:.7rem .9rem}button{cursor:pointer}.active,button[type=submit]{background:#111;color:#fff;border-color:#111}button:disabled{opacity:.55;cursor:wait}form{display:grid;gap:.8rem}label{display:grid;gap:.35rem}img{display:block;width:min(15rem,100%);height:auto;border-radius:.75rem}.error{color:#b42318}\n"], dependencies: [{ kind: "directive", type: FormField, selector: "[formField]", inputs: ["formField"], exportAs: ["formField"] }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: MarketplacesAuthComponent, decorators: [{
type: Component,
args: [{ selector: 'mp-auth, marketplaces-auth', standalone: true, imports: [FormField], template: `
<section class="mp-auth" aria-labelledby="mp-auth-title">
<h2 id="mp-auth-title">{{ title() }}</h2>
<div class="methods" role="tablist" aria-label="Способ входа">
@if (qr()) { <button type="button" [class.active]="method() === 'qr'" (click)="select('qr')">QR</button> }
@if (credentials()) { <button type="button" [class.active]="method() === 'credentials'" (click)="select('credentials')">Логин</button> }
@if (yandex()) { <button type="button" [class.active]="method() === 'yandex'" (click)="select('yandex')">Яндекс</button> }
</div>
@if (method() === 'credentials') {
<form (submit)="loginWithCredentials($event)">
<label>Логин<input autocomplete="username" [formField]="credentialsForm.login" /></label>
<label>Пароль<input type="password" autocomplete="current-password" [formField]="credentialsForm.password" /></label>
<button type="submit" [disabled]="busy() || credentialsForm().invalid()">Войти</button>
</form>
}
@if (method() === 'qr') {
@if (qrImage()) { <a [href]="externalUrl()!" target="_blank" rel="noopener"><img [src]="qrImage()!" alt="QR-код для входа" /></a> }
<button type="button" [disabled]="busy()" (click)="startQr()">{{ qrImage() ? 'Обновить QR' : 'Получить QR' }}</button>
}
@if (method() === 'yandex') { <button type="button" [disabled]="busy()" (click)="startYandex()">Войти через Яндекс</button> }
@if (busy()) { <p role="status">Ожидаем подтверждение</p> }
@if (error()) { <p class="error" role="alert">{{ error()!.message }}</p> }
</section>
`, styles: [":host{display:block}.mp-auth{font:inherit;color:inherit;display:grid;gap:1rem;max-width:25rem}h2,p{margin:0}.methods{display:flex;gap:.5rem;flex-wrap:wrap}.methods button{background:transparent;color:inherit}button,input{font:inherit;border:1px solid #c7c7c7;border-radius:.65rem;padding:.7rem .9rem}button{cursor:pointer}.active,button[type=submit]{background:#111;color:#fff;border-color:#111}button:disabled{opacity:.55;cursor:wait}form{display:grid;gap:.8rem}label{display:grid;gap:.35rem}img{display:block;width:min(15rem,100%);height:auto;border-radius:.75rem}.error{color:#b42318}\n"] }]
}], ctorParameters: () => [], propDecorators: { qr: [{ type: i0.Input, args: [{ isSignal: true, alias: "qr", required: false }] }], credentials: [{ type: i0.Input, args: [{ isSignal: true, alias: "credentials", required: false }] }], yandex: [{ type: i0.Input, args: [{ isSignal: true, alias: "yandex", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], authenticated: [{ type: i0.Output, args: ["authenticated"] }], authError: [{ type: i0.Output, args: ["authError"] }], cancelled: [{ type: i0.Output, args: ["cancelled"] }] } });
/** Guards `/admin/**`-style routes. Never shares state with the customer auth guard/service. */
const adminAuthGuard = () => {
const adminAuth = inject(AdminAuthService);
if (adminAuth.isAuthenticated()) {
return true;
}
adminAuth.requestLogin();
return false;
};
/** 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.
*/
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 }));
};
/** 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',
};
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. */
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';
}
}
/**
* 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.
*/
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 });
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthApiService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthApiService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthApiService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* 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';
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);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: Ed25519KeypairService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: Ed25519KeypairService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: Ed25519KeypairService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* 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").
*/
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)));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: JwtService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: JwtService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: JwtService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
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.
*/
class SessionService {
constructor() {
this.jwt = new JwtService();
this.tokenSignal = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "tokenSignal" }] : /* istanbul ignore next */ []));
this.refreshTokenSignal = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "refreshTokenSignal" }] : /* istanbul ignore next */ []));
this.claimsSignal = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "claimsSignal" }] : /* istanbul ignore next */ []));
this.statusSignal = signal('unknown', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "statusSignal" }] : /* istanbul ignore next */ []));
this.token = this.tokenSignal.asReadonly();
this.claims = this.claimsSignal.asReadonly();
this.status = this.statusSignal.asReadonly();
this.isAuthenticated = computed(() => this.statusSignal() === 'authenticated', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "isAuthenticated" }] : /* istanbul ignore next */ []));
this.role = computed(() => this.claimsSignal()?.role ?? null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "role" }] : /* istanbul ignore next */ []));
}
/** 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);
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SessionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SessionService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: SessionService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* 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`.
*/
class AuthService {
constructor() {
this.api = inject(AuthApiService);
this.keypair = inject(Ed25519KeypairService);
this.session = inject(SessionService);
this.loginPhaseSignal = signal('idle', /* @ts-ignore */
...(ngDevMode ? [{ debugName: "loginPhaseSignal" }] : /* istanbul ignore next */ []));
this.lastErrorSignal = signal(null, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "lastErrorSignal" }] : /* istanbul ignore next */ []));
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;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [] });
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']
};
/**
* 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.
*/
class PermissionService {
constructor() {
this.session = inject(SessionService);
this.permissions = computed(() => {
const role = this.session.role();
return role ? ROLE_PERMISSIONS[role] : [];
}, /* @ts-ignore */
...(ngDevMode ? [{ debugName: "permissions" }] : /* istanbul ignore next */ []));
}
has(permission) {
return this.permissions().includes(permission);
}
hasAny(permissions) {
return permissions.some(permission => this.has(permission));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: PermissionService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* 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.
*/
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);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthFacade, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthFacade, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: AuthFacade, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
class Ed25519VerificationService {
}
/**
* 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.
*/
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.'));
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: NoopEd25519VerificationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: NoopEd25519VerificationService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: NoopEd25519VerificationService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
// @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.
/**
* Generated bundle index. Do not edit.
*/
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 };
//# sourceMappingURL=marketplaces-auth.mjs.map