ngx-libs-demo is a full-stack application built to test my two Angular libraries together against a real backend. ngx-auth-client owns the session — sign-in, token refresh, route guards, role checks and the bearer token. ngx-api-client owns every other API call — versioned URLs, retries with backoff, problem+json errors, success toasts and loading state.
cp .env.example .env # then set JWT_SIGNING_KEY: openssl rand -base64 48
docker compose up --buildOpen http://localhost:8080 and sign in as alice (role user) or admin (roles user and admin).
Why build a demo
Both libraries have unit tests and a CI matrix that checks every supported Angular version against the packaged tarball. None of that answers the question a user actually has: does this work with my backend?
Libraries fail at their boundaries — where their assumptions meet a server, a cookie policy, or another library's interceptor. The only way to test those boundaries is to build something real on top of them, with a backend that has its own opinions. So I picked one with plenty: ASP.NET Core, whose defaults for validation errors, claims and cookies are not the ones my libraries were written against.
What's in it
| Folder | Stack |
|---|---|
client/ | Angular 22 — zoneless, signals, Signal Forms — with Vitest and ESLint |
server/ | ASP.NET Core 10 minimal API, ASP.NET Core Identity, EF Core, PostgreSQL 18, xUnit v3 + Testcontainers |
| root | docker-compose.yml (PostgreSQL + API + nginx), CI, documentation |
The app itself is deliberately ordinary: sign up and sign in, personal notes, a profile page, an admin page that lists users and revokes their sessions, and a Playground page that makes the API misbehave on purpose.
Architecture
Browser
│ / /api/*
▼ ▼
nginx ── one origin
│ │
Angular build ASP.NET Core API ──► PostgreSQL 18nginx serves the Angular build and proxies /api to the API, so the app and the API share one origin. That single decision removes a whole category of problems: the refresh cookie can be SameSite=Strict, there is no CORS configuration, and nobody has to think about withCredentials. In development, ng serve plays nginx's role through a proxy config.
The server tells the client how to authenticate
Before Angular even starts, main.ts asks the API which auth provider is in use:
{ "auth": { "provider": "local", "local": { "endpoints": { "signIn": "/api/auth/login", "refresh": "…" } } } }provideAuth() needs its adapter at bootstrap, so the client loads this runtime configuration first and then picks the adapter — withHttpAuth() for the local provider today, withKeycloak() once Keycloak is wired in. Switching identity provider becomes a server setting, and one client build serves every environment. If the configuration can't be loaded, the app shows a plain error instead of half-working.
Nothing else in the client imports an adapter. Pages ask AuthService about capabilities — canSignIn(), canRegister(), canLoadProfile() — rather than checking which provider is active, and feature endpoints on the server read sub and roles from a validated token without knowing who issued it. Notes are keyed by sub rather than a foreign key to Identity's user table, so the data stays valid when the issuer changes.
How the two libraries share a request
The integration between the libraries comes down to one array — the order of the HTTP interceptors:
withInterceptors([
apiErrorInterceptor, // outermost: one ApiError per operation, reported once
apiSuccessInterceptor, // success toast for mutating calls
retryInterceptor, // retries the raw HttpErrorResponse → must be inside apiErrorInterceptor
authTokenInterceptor, // innermost (ngx-auth-client): each retried attempt gets a fresh token
]);A GET /api/v1/notes that hits a 503 flows like this: the error interceptor passes it to retry, retry sends attempt one through the auth interceptor with a bearer token, the API answers 503, retry waits and sends attempt two — and the auth interceptor refreshes the token first if it is close to expiry. The page receives its notes, or exactly one ApiError if every attempt failed.
A few more connection points:
- URLs line up without configuration.
provideApi({ baseUrl: '' })produces relative URLs like/api/v1/notes, which is exactly what the auth interceptor's default allowlist matches. - Auth calls stay out of the chain. The auth adapter calls
/api/auth/*directly, bypassing every interceptor, so sign-in and refresh are never retried, versioned or toasted. - A 401 means the session is gone. The app's error handler receives every
ApiError; a401can only mean the session couldn't be refreshed, so it sends the user to sign in. - No empty catch blocks. An error the handler has already shown still rejects the caller's promise, and a global handler ignores those — so pages simply
awaittheir calls and only catch when they display the error themselves, like a form showing field errors.
A real authentication backend
The local provider is a complete authentication server, not a stub that returns a fixed token:
- Access token — a short-lived JWT (5 minutes by default) carrying
sub,rolesand standard claims, held only in the client's memory.rolesis always an array and re-read on every refresh, so a role change reaches the client within one token lifetime. - Refresh token — 32 random bytes in an
HttpOnly,Secure,SameSite=Strictcookie, scoped to/api/authso it never travels with ordinary API calls. Only its SHA-256 hash is stored. - Rotation with reuse detection — every refresh issues a new token. Presenting an already-rotated one is treated as theft and revokes the whole session — except within a 10-second grace window from the same client, so two tabs reloading at once don't sign each other out.
- Abuse limits — sign-in and sign-up are rate limited per IP and answer
429withRetry-After; repeated wrong passwords lock the account for a few minutes; and sign-in failures return one message whatever the cause.
The admin page can revoke all of a user's refresh tokens, which makes "remote sign-out" something you can watch happen: the next background refresh in that user's tab fails, and the session ends.
A backend contract both libraries can rely on
Every error under /api is application/problem+json in the shape ngx-api-client reads, with a machine-readable code derived from the status (VALIDATION_ERROR, RATE_LIMITED, ENTITY_NOT_FOUND…) and a traceId. Feature endpoints are versioned in the URL, and paged endpoints return the shape getPage() expects. The server's integration tests check every point of that contract, against a real PostgreSQL started by Testcontainers.
The Playground
Most bugs in retry and error handling only appear when the server misbehaves, and real servers rarely misbehave on demand. The Playground page does:
| Endpoint | Behaviour |
|---|---|
/playground/flaky | Fails a chosen number of times with a chosen status, then succeeds — 429 adds Retry-After |
/playground/status/{code} | Any status as problem+json, including field errors on 400 and a custom code on 409 |
/playground/slow | Responds after a chosen delay |
/api/v1/… vs /api/v2/… | The same resource with a different shape per version |
One click triggers each scenario, and a written test plan covers the rest: session restore, background refresh, guards and roles, remote sign-out, retries, error normalisation, versioning and the open-redirect check on the login page.
What it found
The demo's most useful output is a document: library feedback, recording what went wrong while integrating the libraries.
- ngx-api-client 0.3.0 — the
provideApi()JSDoc example ordered the interceptors so that retries never run, contradicting the README; andApiError.errorswas copied from the response without checking its shape, so ASP.NET Core's validation dictionary arrived where an array was promised. The demo works around both — a test pins the interceptor order, and the server flattens validation errors — while the fixes belong in the library. - ngx-auth-client 0.2.0 — no issues: the defaults matched an ASP.NET Core backend once the server followed the documented contract. One documentation note: the bearer allowlist only matches relative URLs, so an absolute
baseUrlin ngx-api-client would silently send requests without a token. A cross-reference between the two READMEs would prevent that.
None of these would have shown up in either library's own tests, because each one lives at a boundary — between a library and a server, or between the two libraries.
Code conventions
- Standalone components,
OnPush, signals for state andcomputed()for anything derived. - Each feature that calls the API has its own
*-api.tsservice built onApiService. - Forms use Signal Forms, and server field errors from
ApiError.errorsare routed to the matching fields. - On the server,
Program.csis composition only, each feature has its own folder, and only theAuth/folder knows how tokens are issued or validated.
What's next
Keycloak is designed but not yet wired: the settings, client configuration and implementation plan are in place, and selecting it today stops the app at startup with a clear error rather than running half-configured. Once it lands, the same client build will run against either provider — the strongest possible test of ngx-auth-client's provider-agnostic port.
ngx-libs-demo
A full-stack application for testing two Angular libraries together against a real backend:
| Library | Role in the app |
|---|---|
@ismailza/ngx-auth-client |
The session: sign-in, token refresh, authGuard, role checks, the bearer token. The adapter (withHttpAuth() today) follows the server's configuration. |
@ismailza/ngx-api-client |
Every other API call: versioned URLs, retry with backoff, problem+json errors as ApiError, success toasts, loading state. |
| Folder | Stack |
|---|---|
server/ |
ASP.NET Core 10 minimal API, ASP.NET Core Identity, EF Core, Postgres 18, xUnit v3 + Testcontainers |
client/ |
Angular 22 (zoneless, signals, Signal Forms), Vitest, ESLint |
| root | docker-compose.yml (Postgres + API + nginx), CI, docs |
Quick start
cp .env.example .env # then set JWT_SIGNING_KEY: openssl rand -base64 48
docker compose up --buildOpen http://localhost:8080 and sign in as alice (role user) or admin
(roles user, admin). Both use the password Passw0rd!.
To work on the code with hot reload, see docs/development.md.
What to try
docs/test-scenarios.md is a checklist covering both libraries: session restore, background refresh, guards and roles, remote sign-out, retries, error normalisation, versioning and the open-redirect check. The Playground page triggers most of the API scenarios with one click.
Documentation
| Document | Contents |
|---|---|
| Architecture | How a request flows, the interceptor chain, project layout, design choices |
| Auth providers | How the server picks the provider and the client follows; the Keycloak plan |
| Backend contract | Endpoints, tokens and cookie, error format, paging, versioning |
| Development | Running locally, tests, lint, migrations, configuration reference |
| Test scenarios | Manual test plan for both libraries |
| Library feedback | Issues found in the libraries while building this demo |