Merge branch 'feat/auth-admin-credentials-login' into main
Some checks failed
Release / release-branches (auth) (push) Has been cancelled
Release / release-branches (payment) (push) Has been cancelled
Release / version-pr (push) Has been cancelled

This commit is contained in:
2026-08-24 00:03:05 +04:00
7 changed files with 197 additions and 4 deletions

View File

@@ -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
`<mp-auth credentials mode="admin">` 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)

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);
});