From 3bc2a424886e614e58e0bfff168c55528ccb1a43 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sun, 23 Aug 2026 21:33:52 +0400 Subject: [PATCH] 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. --- README.md | 23 ++++ packages/auth/CHANGELOG.md | 10 ++ packages/auth/package.json | 2 +- .../auth/src/telegram/admin-auth.service.ts | 24 +++- packages/auth/src/ui/auth.gateway.ts | 12 +- packages/auth/src/ui/auth.models.ts | 6 +- .../test/admin-credentials-login.test.mjs | 124 ++++++++++++++++++ 7 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 packages/auth/test/admin-credentials-login.test.mjs diff --git a/README.md b/README.md index f9af434..610c457 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,29 @@ bootstrapApplication(AppComponent, { Import `MarketplacesAuthComponent` / `MarketplacesPaymentComponent` into the consuming standalone component. See `docs/BACKEND-CONTRACT.md` for central API and CORS requirements. +### Admin credentials login + +`` already posts `{login, password, mode}` to `credentialsPath`. `AdminAuthService.loginWithCredentials()` is a convenience wrapper for apps building their own login screen instead of the built-in component — it calls the gateway and activates the resulting session in one step: + +```ts +export class MyAdminLoginComponent { + private readonly adminAuth = inject(AdminAuthService); + + submit(login: string, password: string) { + this.adminAuth.loginWithCredentials({ login, password }).subscribe({ + next: () => this.router.navigateByUrl('/admin'), + error: (failure: AuthFailure) => { + if (failure.code === 'invalid_credentials') this.error = 'Wrong login or password.'; + else if (failure.code === 'rate_limited') this.error = `Too many attempts, retry in ${failure.retryAfterSeconds ?? 60}s.`; + else this.error = failure.message; + }, + }); + } +} +``` + +The package has no notion of who the account is — it carries `{login, password}` to the backend exactly like any other admin login and stores whatever session/tokens come back. There is nothing to configure for a "superadmin" or any other privileged account: that decision (which login is special, what tenant to scope the resulting session to, audit logging) is entirely server-side. No password, username, or secret is ever hardcoded in this package. + Consumer documentation lives in the `marketplaces` repo: `docs/PACKAGES-USAGE.md`. Rationale: `docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md`. ## Installing (no registry, no token) diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index acf5482..347ea61 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -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 diff --git a/packages/auth/package.json b/packages/auth/package.json index e7b9d1c..344c3c1 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -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", diff --git a/packages/auth/src/telegram/admin-auth.service.ts b/packages/auth/src/telegram/admin-auth.service.ts index 4899dc5..1ed4c28 100644 --- a/packages/auth/src/telegram/admin-auth.service.ts +++ b/packages/auth/src/telegram/admin-auth.service.ts @@ -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(null); private readonly statusSignal = signal('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 { + // 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); diff --git a/packages/auth/src/ui/auth.gateway.ts b/packages/auth/src/ui/auth.gateway.ts index 862b274..bba8583 100644 --- a/packages/auth/src/ui/auth.gateway.ts +++ b/packages/auth/src/ui/auth.gateway.ts @@ -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; + } } diff --git a/packages/auth/src/ui/auth.models.ts b/packages/auth/src/ui/auth.models.ts index 7845286..dd8c325 100644 --- a/packages/auth/src/ui/auth.models.ts +++ b/packages/auth/src/ui/auth.models.ts @@ -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; } diff --git a/packages/auth/test/admin-credentials-login.test.mjs b/packages/auth/test/admin-credentials-login.test.mjs new file mode 100644 index 0000000..d4baab8 --- /dev/null +++ b/packages/auth/test/admin-credentials-login.test.mjs @@ -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); +});