Compare commits
4 Commits
f6a58a9a3e
...
fix/auth-p
| Author | SHA1 | Date | |
|---|---|---|---|
| 061d303805 | |||
| ea6ac3af57 | |||
| 8a954916e1 | |||
| 3bc2a42488 |
7
.changeset/gate-ed25519-exports.md
Normal file
7
.changeset/gate-ed25519-exports.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@marketplaces/auth": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Remove `ed25519/*` exports from the public API (`index.ts`). The Ed25519 challenge/response admin auth flow has no backend yet (per ADR-0001) and was shipping fully in every consumer's bundle, unused. Source stays in `src/ed25519/` untouched — this only stops it being part of the published package's public surface; re-export it once that backend exists.
|
||||||
|
|
||||||
|
Breaking for any consumer importing `Ed25519AuthService`, `AuthFacade`, `AuthApiService`, `SessionService`, `JwtService`, `Ed25519KeypairService`, `PermissionService`, `Ed25519VerificationService`, `NoopEd25519VerificationService`, or related types from `@marketplaces/auth` — none confirmed to exist at the time of this change.
|
||||||
8
.changeset/legacy-cors-context.md
Normal file
8
.changeset/legacy-cors-context.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
"@marketplaces/auth": minor
|
||||||
|
"@marketplaces/payment": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Superseded 2026-08-23: this changeset originally proposed keeping legacy-CORS-compatible request parameters as the default transport for `marketplaceDomain`. That direction is reversed — both packages now send `X-Marketplace-Domain` as a header exclusively; the query/body-parameter transport mode (`contextTransport: 'parameter'`) has been removed entirely, not just de-defaulted.
|
||||||
|
|
||||||
|
Reason for the reversal: the `feat/auth-admin-credentials-login` branch dropped param-transport while adding credentials-login, and that direction was kept rather than restoring the legacy fallback. **Backend must allow `X-Marketplace-Domain` in CORS preflight for both the Auth API and Payment API before this ships** — tracked in `BACKEND-TODO.md`. If that turns out not to be feasible, this decision needs revisiting, not silently worked around.
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto
|
||||||
59
BACKEND-TODO.md
Normal file
59
BACKEND-TODO.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# vitanovaPackages — backend team TODO
|
||||||
|
|
||||||
|
Everything here needs a decision or confirmation from whoever owns the central Auth API / Payment API. Nothing in this list can be resolved from the frontend side alone. Hand this file to them directly.
|
||||||
|
|
||||||
|
## 1. Confirm `X-Marketplace-Domain` CORS support (blocking) — NOW LOAD-BEARING, ORIGIN ALLOWLIST CONFIRMED MISSING
|
||||||
|
As of 2026-08-23, `@marketplaces/auth` and `@marketplaces/payment` send the marketplace domain as a request header (`X-Marketplace-Domain`) exclusively. The previous query-parameter/body fallback (`contextTransport: 'parameter'`, kept for backends that hadn't allowed the header in CORS preflight) has been **removed**, not just de-defaulted.
|
||||||
|
|
||||||
|
**Backend must confirm**: both the Auth API and the Payment API allow `X-Marketplace-Domain` in CORS preflight (`Access-Control-Allow-Headers`), and reject requests with an unknown/disabled domain in that header. See `docs/BACKEND-CONTRACT.md` for the full contract.
|
||||||
|
|
||||||
|
If either service can't allow that header for some environment (legacy proxy, CDN stripping custom headers, etc.), say so before this ships — the frontend has no fallback for it anymore.
|
||||||
|
|
||||||
|
**2026-08-24 finding — this is now actively blocking the gorbushka admin login fix:**
|
||||||
|
|
||||||
|
`users.vitanova.network:456` (the auth host) rejects the CORS preflight
|
||||||
|
outright for gorbushka origins, before it even reaches app-level CORS
|
||||||
|
logic:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPTIONS /users/sessions Origin: https://admin.gorbushka.market
|
||||||
|
-> 403 Forbidden, no CORS headers at all
|
||||||
|
|
||||||
|
OPTIONS /users/sessions Origin: https://novo.market
|
||||||
|
-> 204 No Content
|
||||||
|
Access-Control-Allow-Origin: https://novo.market
|
||||||
|
Access-Control-Allow-Headers: *
|
||||||
|
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
|
||||||
|
Access-Control-Allow-Credentials: true
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a server-side **origin allowlist** — `novo.market` is on it,
|
||||||
|
`gorbushka.market`/`admin.gorbushka.market` is not. The frontend fix
|
||||||
|
(pointing `AUTH_API_URL` at this host instead of the wrong tenant
|
||||||
|
content origin) is ready but cannot work for gorbushka until this
|
||||||
|
lands — no frontend change can route around a 403 the browser gets
|
||||||
|
before the request is even sent.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
1. Add the gorbushka origins to the allowlist on `users.vitanova.network:456`.
|
||||||
|
At minimum: `https://admin.gorbushka.market` and `https://gorbushka.market`.
|
||||||
|
Confirm if any other gorbushka-family origins need it too (subdomains,
|
||||||
|
`www.`, etc. — whatever the real deployment topology needs).
|
||||||
|
2. Confirm the allowlist entry for these origins includes
|
||||||
|
`X-Marketplace-Domain` in `Access-Control-Allow-Headers` (novo.market's
|
||||||
|
grant above uses a wildcard `*`, which already covers it there — confirm
|
||||||
|
gorbushka's entry does too, don't assume the wildcard is uniform across
|
||||||
|
all allowlisted origins).
|
||||||
|
3. Same allowlist question applies to `qr.vitanova.network` (payment) —
|
||||||
|
not yet checked for gorbushka origins, worth confirming in the same pass.
|
||||||
|
|
||||||
|
Until this lands, no frontend change makes browser login work for gorbushka.
|
||||||
|
|
||||||
|
## 2. `@marketplaces/payment` version discipline
|
||||||
|
`@marketplaces/payment` changed the same way as auth (dropped param-transport) but was never version-bumped past 0.2.0, even though it's a behavior-breaking change for anyone integrating against it. Not a backend question exactly, but flagging since payment's release process seems to have skipped a changeset for this — worth a process check.
|
||||||
|
|
||||||
|
## 3. Rate-limiting on admin credentials login
|
||||||
|
`AdminAuthService.loginWithCredentials()` (new in `@marketplaces/auth` 0.3.0) expects `429` with a `Retry-After` header (seconds) for rate-limited attempts, and maps that to a `rate_limited` failure code with `retryAfterSeconds`. Confirm the Auth API actually returns `Retry-After` on 429 for `POST {credentialsPath}` — if it uses a different mechanism (custom header, response body field), the frontend's parsing needs to match.
|
||||||
|
|
||||||
|
## 4. Ed25519 admin auth — still just future work
|
||||||
|
No change requested here, just a status check: `packages/auth/src/ed25519/` (challenge/response admin auth) has never had a backend. As of 2026-08-23 it's also no longer exported from the package's public API (dead code in the bundle otherwise). If/when that backend gets built, this needs re-exporting and a real integration pass — flag when that's on the roadmap so the frontend side can be scheduled.
|
||||||
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.
|
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`.
|
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)
|
## Installing (no registry, no token)
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# @marketplaces/auth
|
# @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
|
## 0.2.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@marketplaces/auth",
|
"name": "@marketplaces/auth",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Standalone Angular authentication UI and client for marketplaces projects.",
|
"description": "Standalone Angular authentication UI and client for marketplaces projects.",
|
||||||
"module": "dist/fesm2022/marketplaces-auth.mjs",
|
"module": "dist/fesm2022/marketplaces-auth.mjs",
|
||||||
"typings": "dist/types/marketplaces-auth.d.ts",
|
"typings": "dist/types/marketplaces-auth.d.ts",
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
// docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md):
|
// docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md):
|
||||||
// - telegram/ — live Telegram QR/session auth (customer + admin)
|
// - telegram/ — live Telegram QR/session auth (customer + admin)
|
||||||
// - ed25519/ — future Ed25519 challenge/response admin auth (backend not shipped yet)
|
// - ed25519/ — future Ed25519 challenge/response admin auth (backend not shipped yet)
|
||||||
|
//
|
||||||
|
// ed25519/ is intentionally NOT exported below (2026-08-23) — its backend has
|
||||||
|
// never shipped, so it was dead weight in every consumer's bundle. Source
|
||||||
|
// stays in src/ed25519/ untouched; re-add the export block (removed here,
|
||||||
|
// see git history / .changeset/gate-ed25519-exports.md) once that backend
|
||||||
|
// is real. Breaking change for any consumer importing ed25519 symbols —
|
||||||
|
// none confirmed to exist at the time of this change.
|
||||||
|
//
|
||||||
// Provide AUTH_API_URL (and optionally TELEGRAM_BOT_USERNAME) from the consuming app's config.
|
// Provide AUTH_API_URL (and optionally TELEGRAM_BOT_USERNAME) from the consuming app's config.
|
||||||
|
|
||||||
export { AUTH_API_URL, TELEGRAM_BOT_USERNAME, MARKETPLACES_AUTH_CONFIG, provideMarketplacesAuth } from './config';
|
export { AUTH_API_URL, TELEGRAM_BOT_USERNAME, MARKETPLACES_AUTH_CONFIG, provideMarketplacesAuth } from './config';
|
||||||
@@ -20,30 +28,3 @@ export { AuthService } from './telegram/auth.service';
|
|||||||
export { AdminAuthService } from './telegram/admin-auth.service';
|
export { AdminAuthService } from './telegram/admin-auth.service';
|
||||||
export { adminAuthGuard } from './telegram/admin-auth.guard';
|
export { adminAuthGuard } from './telegram/admin-auth.guard';
|
||||||
export { adminAuthHeadersInterceptor } from './telegram/admin-auth-headers.interceptor';
|
export { adminAuthHeadersInterceptor } from './telegram/admin-auth-headers.interceptor';
|
||||||
|
|
||||||
// Ed25519 module (namespaced re-exports to avoid colliding with the telegram module's AuthService)
|
|
||||||
export { AuthService as Ed25519AuthService } from './ed25519/auth.service';
|
|
||||||
export { AuthFacade } from './ed25519/auth-facade.service';
|
|
||||||
export { AuthApiService } from './ed25519/auth-api.service';
|
|
||||||
export { SessionService } from './ed25519/session.service';
|
|
||||||
export { JwtService } from './ed25519/jwt.service';
|
|
||||||
export { Ed25519KeypairService } from './ed25519/ed25519-keypair.service';
|
|
||||||
export { PermissionService } from './ed25519/permission.service';
|
|
||||||
export {
|
|
||||||
Ed25519VerificationService,
|
|
||||||
Ed25519Challenge,
|
|
||||||
Ed25519SignedResponse,
|
|
||||||
Ed25519VerificationResult
|
|
||||||
} from './ed25519/ed25519-verification.model';
|
|
||||||
export { NoopEd25519VerificationService } from './ed25519/noop-ed25519-verification.service';
|
|
||||||
export {
|
|
||||||
AuthChallenge,
|
|
||||||
VerifySignatureRequest,
|
|
||||||
AuthTokenPair,
|
|
||||||
RefreshTokenRequest,
|
|
||||||
JwtClaims
|
|
||||||
} from './ed25519/models/auth-api.model';
|
|
||||||
export { AuthErrorCode, AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from './ed25519/models/auth-error.model';
|
|
||||||
export { AdminRole, Permission, ROLE_PERMISSIONS } from './ed25519/models/permission.model';
|
|
||||||
export type { LoginPhase } from './ed25519/auth.service';
|
|
||||||
export type { SessionStatus } from './ed25519/session.service';
|
|
||||||
|
|||||||
@@ -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 { Observable, tap } from 'rxjs';
|
||||||
import { AdminAuthStatus, AuthSession, WebSessionStart } from './models/session.model';
|
import { AdminAuthStatus, AuthSession, WebSessionStart } from './models/session.model';
|
||||||
import { TelegramSessionApiService } from './telegram-session-api.service';
|
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
|
* 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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminAuthService {
|
export class AdminAuthService {
|
||||||
private readonly api = inject(TelegramSessionApiService);
|
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 sessionSignal = signal<AuthSession | null>(null);
|
||||||
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
|
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
|
||||||
@@ -130,6 +137,21 @@ export class AdminAuthService {
|
|||||||
if (token && refreshToken) this.setAdminTokens(token, refreshToken);
|
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. */
|
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
|
||||||
getAdminToken(): string | null {
|
getAdminToken(): string | null {
|
||||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
|
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 url(path = ''): string { return `${this.config.apiUrl}${path.startsWith('/') ? path : `/${path}`}`; }
|
||||||
private failure(method: 'credentials' | 'yandex', cause: unknown): AuthFailure {
|
private failure(method: 'credentials' | 'yandex', cause: unknown): AuthFailure {
|
||||||
const response = cause instanceof HttpErrorResponse ? cause : null;
|
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 {
|
return {
|
||||||
method,
|
method,
|
||||||
code: response?.status === 401 ? 'invalid_credentials' : 'backend',
|
code,
|
||||||
message: response?.error?.message || response?.message || 'Authentication failed',
|
message: response?.error?.message || response?.message || 'Authentication failed',
|
||||||
|
status,
|
||||||
|
retryAfterSeconds: code === 'rate_limited' ? this.parseRetryAfter(response) : undefined,
|
||||||
cause,
|
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 ExternalAuthStart { attemptId: string; authorizationUrl: string; }
|
||||||
export interface AuthFailure {
|
export interface AuthFailure {
|
||||||
method: AuthMethod;
|
method: AuthMethod;
|
||||||
code: 'configuration' | 'invalid_credentials' | 'backend' | 'popup_blocked' | 'expired';
|
code: 'configuration' | 'invalid_credentials' | 'rate_limited' | 'backend' | 'popup_blocked' | 'expired';
|
||||||
message: string;
|
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;
|
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