feat(auth): add AdminAuthService.loginWithCredentials + rate_limited AuthFailure code

Convenience wrapper around MarketplacesAuthGateway.loginWithCredentials('admin', ...)
that activates the returned session in one call, for apps building a custom admin
login screen. AuthFailure gains a rate_limited code (HTTP 429, parsed Retry-After)
and an optional status field so 401/403/429 surface distinctly without new plumbing.

No credentials, no client-side comparison, no superadmin concept anywhere in this
package - same code path as any other admin credential login. Inert without a
backend implementing POST {credentialsPath} { login, password, mode: 'admin' }.

Additive, backward-compatible. QR/Yandex flows unmodified.
This commit is contained in:
2026-08-23 21:33:52 +04:00
parent f6a58a9a3e
commit 3bc2a42488
7 changed files with 197 additions and 4 deletions

View File

@@ -1,5 +1,15 @@
# @marketplaces/auth
## 0.3.0
### Minor Changes
- Add `AdminAuthService.loginWithCredentials(credentials)`, a convenience wrapper around the existing `MarketplacesAuthGateway.loginWithCredentials('admin', credentials)` call that activates the returned session on success. Lets a consuming app build a fully custom admin login screen with one call instead of wiring the gateway token directly.
`AuthFailure` gained a `rate_limited` code (mapped from HTTP 429, with a parsed `retryAfterSeconds` from the `Retry-After` header) plus an optional `status` field, so a custom login UI can distinguish invalid credentials, rate-limiting/lockout, and other backend failures without new plumbing. Existing `invalid_credentials`/`backend` mapping is unchanged.
This ships **no credentials, no username/password comparison, and no notion of a privileged account** anywhere in this package — it is the exact same code path as any other admin credential login. It is inert until a real backend implements `POST {credentialsPath}` with `{ login, password, mode: 'admin' }` and returns a verified, audit-logged session for whatever account the backend chooses to treat specially. Additive and backward-compatible with the existing `qr`/`credentials`/`yandex` flows, which are unmodified.
## 0.2.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@marketplaces/auth",
"version": "0.2.0",
"version": "0.3.0",
"description": "Standalone Angular authentication UI and client for marketplaces projects.",
"module": "dist/fesm2022/marketplaces-auth.mjs",
"typings": "dist/types/marketplaces-auth.d.ts",

View File

@@ -1,7 +1,9 @@
import { Injectable, signal, computed, inject, isDevMode } from '@angular/core';
import { Injectable, Injector, signal, computed, inject, isDevMode } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { AdminAuthStatus, AuthSession, WebSessionStart } from './models/session.model';
import { TelegramSessionApiService } from './telegram-session-api.service';
import type { AuthResult, CredentialLogin } from '../ui/auth.models';
import { MARKETPLACES_AUTH_GATEWAY } from '../ui/auth.gateway';
/**
* Admin login uses the exact same Telegram QR/session API as the customer
@@ -25,6 +27,11 @@ const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
@Injectable({ providedIn: 'root' })
export class AdminAuthService {
private readonly api = inject(TelegramSessionApiService);
// Resolved lazily (not injected eagerly) because MARKETPLACES_AUTH_GATEWAY's
// default factory constructs HttpMarketplacesAuthGateway, which itself
// injects AdminAuthService - an eager inject() here would be a circular
// dependency. By call time both singletons already exist.
private readonly injector = inject(Injector);
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
@@ -130,6 +137,21 @@ export class AdminAuthService {
if (token && refreshToken) this.setAdminTokens(token, refreshToken);
}
/**
* Log an admin in with a login/password pair and activate the resulting
* session in one call. The package has no notion of who this account is -
* it carries {login, password} to the backend exactly like any other admin
* credential login and accepts whatever session comes back. A consuming
* app can build a fully custom login screen around this single call.
*/
loginWithCredentials(credentials: CredentialLogin): Observable<AuthResult> {
// Gateway.loginWithCredentials already calls acceptSession() on success
// for mode 'admin' (see HttpMarketplacesAuthGateway.accept()) - this
// wrapper only exists so a consuming app doesn't need to wire the
// gateway token itself.
return this.injector.get(MARKETPLACES_AUTH_GATEWAY).loginWithCredentials('admin', credentials);
}
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
getAdminToken(): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);

View File

@@ -69,11 +69,21 @@ export class HttpMarketplacesAuthGateway implements MarketplacesAuthGateway {
private url(path = ''): string { return `${this.config.apiUrl}${path.startsWith('/') ? path : `/${path}`}`; }
private failure(method: 'credentials' | 'yandex', cause: unknown): AuthFailure {
const response = cause instanceof HttpErrorResponse ? cause : null;
const status = response?.status;
const code: AuthFailure['code'] =
status === 401 ? 'invalid_credentials' : status === 429 ? 'rate_limited' : 'backend';
return {
method,
code: response?.status === 401 ? 'invalid_credentials' : 'backend',
code,
message: response?.error?.message || response?.message || 'Authentication failed',
status,
retryAfterSeconds: code === 'rate_limited' ? this.parseRetryAfter(response) : undefined,
cause,
};
}
private parseRetryAfter(response: HttpErrorResponse | null): number | undefined {
const header = response?.headers?.get('Retry-After');
const seconds = header ? Number(header) : NaN;
return Number.isFinite(seconds) ? seconds : undefined;
}
}

View File

@@ -13,7 +13,11 @@ export interface AuthResult {
export interface ExternalAuthStart { attemptId: string; authorizationUrl: string; }
export interface AuthFailure {
method: AuthMethod;
code: 'configuration' | 'invalid_credentials' | 'backend' | 'popup_blocked' | 'expired';
code: 'configuration' | 'invalid_credentials' | 'rate_limited' | 'backend' | 'popup_blocked' | 'expired';
message: string;
/** HTTP status of the failed request, when the failure came from an HTTP response. */
status?: number;
/** Parsed `Retry-After` header (seconds), present when code is 'rate_limited'. */
retryAfterSeconds?: number;
cause?: unknown;
}

View File

@@ -0,0 +1,124 @@
import '@angular/compiler';
import assert from 'node:assert/strict';
import test from 'node:test';
import { Injector } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import {
AdminAuthService,
AuthService,
AuthMarketplaceContext,
TelegramSessionApiService,
HttpMarketplacesAuthGateway,
MARKETPLACES_AUTH_GATEWAY,
MARKETPLACES_AUTH_CONFIG,
AUTH_API_URL,
} from '../dist/fesm2022/marketplaces-auth.mjs';
const API_URL = 'https://api.example.com';
const CREDENTIALS_URL = `${API_URL}/auth/credentials/login`;
/** Wires up the real gateway/context/service classes with a scripted HttpClient - no TestBed, no NgModule compiler needed. */
function setup(postResponse) {
const calls = [];
const fakeHttp = { post: (url, body, opts) => { calls.push({ url, body, opts }); return postResponse; } };
const injector = Injector.create({
providers: [
{ provide: HttpClient, useValue: fakeHttp },
{ provide: MARKETPLACES_AUTH_CONFIG, useValue: {
apiUrl: API_URL,
marketplaceDomain: 'admin.tenant-a.example.com',
credentialsPath: '/auth/credentials/login',
} },
{ provide: AUTH_API_URL, useValue: API_URL },
AuthMarketplaceContext,
TelegramSessionApiService,
AuthService,
AdminAuthService,
HttpMarketplacesAuthGateway,
{ provide: MARKETPLACES_AUTH_GATEWAY, useExisting: HttpMarketplacesAuthGateway },
],
});
return { admin: injector.get(AdminAuthService), calls };
}
test('loginWithCredentials: success activates the returned session and stores tokens', () => {
const backendResponse = {
session: {
sessionId: 's1', userId: 1, username: 'root', displayName: 'Root',
active: true, expires: new Date(Date.now() + 60_000).toISOString(),
},
accessToken: 'jwt-access',
refreshToken: 'jwt-refresh',
};
const { admin, calls } = setup(of(backendResponse));
let result;
admin.loginWithCredentials({ login: 'root', password: 'secret' }).subscribe(r => (result = r));
assert.equal(calls.length, 1);
assert.equal(calls[0].url, CREDENTIALS_URL);
assert.deepEqual(calls[0].body, { login: 'root', password: 'secret', mode: 'admin' });
assert.equal(calls[0].opts.headers.get('X-Marketplace-Domain'), 'admin.tenant-a.example.com');
assert.equal(admin.isAuthenticated(), true);
assert.equal(admin.session()?.sessionId, 's1');
// getAdminToken()/setAdminTokens() are guarded no-ops without a `localStorage`
// (e.g. this Node test runner), same as production SSR - token storage
// itself isn't under test here, just that acceptSession() was invoked.
assert.equal(result.accessToken, 'jwt-access');
assert.equal(result.mode, 'admin');
assert.equal(result.method, 'credentials');
});
test('loginWithCredentials: 401 surfaces invalid_credentials and does not mutate session state', () => {
const error = new HttpErrorResponse({ status: 401, statusText: 'Unauthorized', error: { message: 'Invalid login or password' } });
const { admin } = setup(throwError(() => error));
let failure;
admin.loginWithCredentials({ login: 'root', password: 'wrong' }).subscribe({
next: () => assert.fail('should not succeed'),
error: err => (failure = err),
});
assert.equal(failure.code, 'invalid_credentials');
assert.equal(failure.status, 401);
assert.equal(admin.isAuthenticated(), false);
assert.equal(admin.session(), null);
assert.equal(admin.getAdminToken(), null);
});
test('loginWithCredentials: 429 surfaces rate_limited with parsed Retry-After', () => {
const error = new HttpErrorResponse({
status: 429, statusText: 'Too Many Requests', error: { message: 'Too many attempts' },
headers: new HttpHeaders({ 'Retry-After': '30' }),
});
const { admin } = setup(throwError(() => error));
let failure;
admin.loginWithCredentials({ login: 'root', password: 'secret' }).subscribe({
next: () => assert.fail('should not succeed'),
error: err => (failure = err),
});
assert.equal(failure.code, 'rate_limited');
assert.equal(failure.retryAfterSeconds, 30);
assert.equal(admin.isAuthenticated(), false);
});
test('loginWithCredentials: 403 surfaces as a generic backend failure', () => {
const error = new HttpErrorResponse({ status: 403, statusText: 'Forbidden', error: { message: 'Tenant disabled' } });
const { admin } = setup(throwError(() => error));
let failure;
admin.loginWithCredentials({ login: 'root', password: 'secret' }).subscribe({
next: () => assert.fail('should not succeed'),
error: err => (failure = err),
});
assert.equal(failure.code, 'backend');
assert.equal(failure.status, 403);
assert.equal(admin.isAuthenticated(), false);
});