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

1358 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, { optional: true });
}
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