Portal Pattern — Gateway + Angular SPA
1. Overview
Every user-facing frontend in EMS is a portal — a Spring Boot gateway module that serves a single-page Angular application and mediates all browser ⇄ admin-service traffic. The gateway holds the admin-service JWT in an HTTP session, resolves the current tenant server-side, and injects the admin-service API key on proxied calls. The Angular SPA is scoped to its gateway’s origin and never sees the backend JWT, the API key, or a direct admin-service URL.
This page is a specialisation of Microservice Pattern, which establishes the EMS-wide deployment baseline (Maven module, Jib, Helm, ArgoCD, GitHub Actions, OTel, probes, profiles, security context). Read the microservice pattern first; this page focuses on the portal-specific deltas — gateway + SPA topology, session-held JWT, tenant resolution, BFF endpoints, Hazelcast session replication. MCP Pattern is a sibling specialisation for LLM-facing adapters.
This is the pattern that registration-portal has implemented, that admin-portal will follow, and that any future portal (membership-portal, participant-portal, etc.) must follow. It is an explicit architectural choice that replaces several alternatives we have ruled out:
-
Angular-only with static hosting — loses server-side tenant enforcement; JWT must live in the browser; requires CORS on admin-service.
-
Direct admin-service static serving — conflates API concerns with SPA concerns; admin-service would gain UI-specific endpoints over time.
-
Per-portal bespoke gateway tech — Spring Cloud Gateway MVC gives us a shared base (Spring Boot, Jib, actuator, k8s profile) and the team already runs three of them.
2. Architecture Principles
2.1. Gateway Is a BFF, Not a Second Backend
The gateway does four things and nothing else:
-
Auth orchestration — OIDC login, token exchange with admin-service, session establishment and refresh.
-
Session — holds the admin-service JWT in an HTTP session; the browser holds only a
JSESSIONIDcookie. -
Proxy — forwards all
/services/admin-service/**traffic to admin-service withX-API-KEYinjected. -
Static SPA hosting — serves the Angular build with appropriate
Cache-Controlheaders.
Business logic does not live in the gateway. Every attempt to add "just one more" business endpoint must be rejected in favour of exposing that endpoint on admin-service and proxying to it.
2.2. admin-service Is the Single Source of Business Truth
Each portal is a lens onto admin-service. Multiple portals serve different user personas (public registrants, admin staff, members), but they all call the same admin-service API. There is no second backend, no per-portal database, no "gateway-local" business data.
The one exception is portal-scoped session state (the JSESSIONID-keyed server-side session containing the admin-service JWT, current tenant, and OIDC refresh state). This is operational plumbing for the portal’s own auth flow, not business data.
2.3. Browser Is Origin-Scoped to Its Gateway
The Angular SPA only knows about its own origin. It does not see:
-
admin-service’s hostname or port
-
the admin-service JWT
-
the admin-service API key
-
any other portal’s session
All admin-service calls go via /services/admin-service/… on the portal’s own origin; the gateway rewrites and forwards. This means the browser has no credentials that could be stolen via XSS and used to reach admin-service directly — an attacker would need to ride the JSESSIONID through the gateway, where request-level controls can apply.
2.4. Tenant Is Resolved Server-Side
Tenant context is resolved by the gateway on every request, in this precedence:
-
Session-held
currentTenantId— set by an explicitPOST /api/session/tenantswitch; highest priority once set. -
Domain —
tenant.domaincolumn lookup against the request’s hostname. -
X-TENANT-IDrequest header — fallback for headless clients and dev convenience.
The browser can request a tenant switch via the session endpoint; it cannot assert a tenant by sending its own header. Tenant enforcement is server-authoritative. See Multi-Tenancy for the tenant/organisation data model that backs this.
3. System Architecture
3.2. Components
3.2.1. Spring Boot Gateway Module
Maven module parented to za.co.idealogic:event. Packaged as a single Spring Boot jar and built into a container image via Jib. Deploys as a standard Kubernetes workload alongside admin-service.
Key technical choices:
| Concern | Choice |
|---|---|
Reverse proxy |
Spring Cloud Gateway MVC ( |
HTTP session |
Standard servlet session + HTTP-only |
OAuth2 / OIDC client |
Spring Security 6 |
Observability |
Spring Boot Actuator + existing EMS monitoring stack |
The gateway exposes a small surface of portal-local endpoints — session bootstrap, tenant switching, OIDC callbacks — plus the catch-all proxy under /services/admin-service/**.
3.2.2. Angular Single-Page Application
Sits under the gateway’s src/main/webapp/ (matching the JHipster gateway layout). Built with Angular CLI, packaged into the gateway’s final jar, and served by the gateway’s StaticResourcesWebConfiguration.
Key technical choices:
| Concern | Choice |
|---|---|
Framework |
Angular 16+ (TypeScript 5.1+, Node 18.18+) |
UI libraries |
PrimeNG + ng-bootstrap + FontAwesome |
Backend client |
Generated from admin-service’s OpenAPI spec at build time; wrapped by a dayjs-aware HTTP interceptor. See OpenAPI Contract Flow. |
Session bootstrap |
|
Shared primitives |
|
3.2.3. Session Store
Portal sessions are held in the gateway’s JVM heap. Two mechanisms keep this safe under horizontal scaling:
-
Sticky sessions at the ingress controller. The ingress routes a given
JSESSIONIDconsistently to the same gateway pod under normal conditions. This is the first line of defence against session loss. -
Hazelcast session replication. The gateway runs an embedded Hazelcast member (or client — see Hazelcast Configuration) with auto-discovery of peers; HTTP sessions replicate across the cluster so a pod restart, autoscale event, or failover does not log users out. This is EMS standard — admin-service already runs Hazelcast and the pattern is proven.
No external session store (Redis, Memcached) is used or needed — Hazelcast covers both caching and session replication in one stack.
The session carries:
-
Backend JWT (admin-service-signed) + expiry
-
OIDC
OAuth2AuthorizedClient(for refresh) -
currentTenantId -
Cached tenant list for the logged-in user
The JWT is never written to a cookie, never sent to the browser, and never logged.
5. What Belongs Where
| Concern | Portal Gateway | admin-service |
|---|---|---|
OIDC client config (trusted IdPs, client IDs/secrets) |
Yes — portal owns the trust boundary to the IdPs |
No — admin-service is IdP-agnostic |
JWT signing key |
No |
Yes — admin-service mints all admin-service JWTs |
Tenant context resolution |
Yes — resolves per-request, holds in session, enforces on proxy |
Trusts the JWT’s tenant claim |
API-key injection |
Yes — route filter adds |
Validates the API key on entry |
CORS |
No cross-origin — SPA is same-origin with its gateway |
Does not need to accept browser origins directly |
Static asset serving |
Yes — hashed assets immutable, index.html no-cache |
No |
Business endpoints |
Never |
Always — via REST controllers |
Session state |
Server-side HTTP session only |
Stateless (JWT-authenticated) |
6. Reference Implementation
registration-portal is the reference implementation of this pattern as of 2026-04. Key classes to read when implementing a new portal:
| File | Responsibility |
|---|---|
|
Per-request tenant resolution (domain / header / session) |
|
Session-held JWT, OIDC refresh, proxy header injection |
|
Per-path-class cache and CDN-cache headers |
|
Hashed-asset immutability, fingerprint resolution |
|
OIDC login, session management, filter chain order |
|
|
New portals should copy these classes rather than inherit from a shared library. The surface is small, variation between portals is expected (different auth sources, different session-endpoint sets), and sharing would couple portals to each other. Extract a shared library only if the same delta appears in three portals — not preemptively.
7. Portal-Specific Variations
This pattern is deliberately copied, not inherited (see Reference Implementation). Each portal extends it with variations specific to its user persona. These are not "deviations" — they are the expected shape of the difference between, for example, a staff admin portal and a public registration portal.
7.1. registration-portal — Public / Anonymous Flows
registration-portal serves public users who have not yet authenticated, so it adds:
-
Anonymous session exchange —
POST /api/auth/register-sessionissues an anonymous admin-service JWT in exchange for a browser-generated UUID + orgId. Used for the pre-login registration flow. -
Hash-based external login —
POST /api/auth/external-loginfor flows where a user arrives with a pre-signed userKey/userHash pair (from email links, partner sites). -
FormController family —
FormController,EventFormController,MembershipFormController,TestFormController. These live in admin-service underform/and are not@RestController— they are internal domain wrappers driving the registration process flow. Their HTTP surface isFormResource. The Form*Controller concept is specific to the registration use case and does not apply to admin-portal or other authenticated portals.
Other portals must not carry these forward unless they have the same public/anonymous requirement.
7.2. admin-portal — Authenticated-Only Staff
admin-portal serves staff users only, so it:
-
Supports OIDC only (no username/password, no anonymous, no hash-login)
-
Does not expose
/api/auth/register-sessionor/api/auth/external-login -
Does require a
currentTenantIdin the session for every proxied call; unauthenticated or tenant-less requests are 401/403
7.3. membership-ui and legacy admin-ui — Pre-Pattern
Both were generated by older JHipster versions, store JWT in browser storage, and have no token-exchange flow. They are in scope for progressive migration (membership-ui) or replacement (admin-ui → admin-portal) but not for rewrite-from-scratch in this pattern document; each has its own design-journal thread.
8. Further Reading
Portal architecture:
-
OIDC ⇄ admin-service JWT Token Exchange — the trust model and claims mapping
-
Session-Held JWT & JSESSIONID — refresh, logout, CSRF posture
-
Multi-Tenancy — tenant/organisation data model
-
SPA Cache & CDN Strategy — hashed vs unhashed, CDN-Cache-Control, index.html revalidation
-
API-Key Injection at the Gateway — key rotation procedure
-
OpenAPI Contract Flow — spec extraction in CI, in-portal client generation, dayjs transformer
-
Hazelcast Configuration — session replication, auto-discovery, cluster membership
-
Dev-Mode UI Awareness — no minify, dev banner, source maps in dev
Application bootstrap, build and deployment:
-
SpringApplication Bootstrap — profile scanning, startup banner, DB and profile reporting
-
Maven POM Conventions — Maven profiles determine what gets built
-
Jib Docker Image Build — container image build from Maven
-
Docker Compose for Developers — MySQL, gateway proxying for frontend-only devs
-
Helm Chart Structure — per-service chart conventions
-
ArgoCD Deployment Patterns — GitOps conventions
-
SMTP Configuration — outbound email from portals and admin-service
-
OpenTelemetry Configuration — traces and metrics from portals and admin-service
-
JHipster — What We Keep, What We Drop — inventory for post-JHipster portals
9. Change History
| Date | Change |
|---|---|
2026-04-24 |
Initial draft. Captures pattern established by registration-portal; reference for admin-portal greenfield (see design journal |
2026-04-27 |
Reframed as a specialisation of |