Merge branch 'feat/auth-admin-credentials-login' into main
This commit is contained in:
23
README.md
23
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
|
||||
|
||||
`<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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
124
packages/auth/test/admin-credentials-login.test.mjs
Normal file
124
packages/auth/test/admin-credentials-login.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user