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:

  1. Auth orchestration — OIDC login, token exchange with admin-service, session establishment and refresh.

  2. Session — holds the admin-service JWT in an HTTP session; the browser holds only a JSESSIONID cookie.

  3. Proxy — forwards all /services/admin-service/** traffic to admin-service with X-API-KEY injected.

  4. Static SPA hosting — serves the Angular build with appropriate Cache-Control headers.

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:

  1. Session-held currentTenantId — set by an explicit POST /api/session/tenant switch; highest priority once set.

  2. Domaintenant.domain column lookup against the request’s hostname.

  3. X-TENANT-ID request 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.1. High-Level Topology

portal-topology

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 (spring.cloud.gateway.mvc.routes) — enough for path-based routing with header filters; avoids the reactive stack for a servlet-based portal

HTTP session

Standard servlet session + HTTP-only JSESSIONID cookie; Secure in production

OAuth2 / OIDC client

Spring Security 6 oauth2Login() + AuthorizedClientManager for refresh

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

APP_INITIALIZERGET /api/session/current to resolve user, current tenant, and available tenants before any route activates

Shared primitives

@ems/shared-ui npm package — SA ID validator, date pipes, filter/sort directives, auth directive, generic interceptors

3.2.3. Session Store

Portal sessions are held in the gateway’s JVM heap. Two mechanisms keep this safe under horizontal scaling:

  1. Sticky sessions at the ingress controller. The ingress routes a given JSESSIONID consistently to the same gateway pod under normal conditions. This is the first line of defence against session loss.

  2. 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.

4. Request Flow

4.1. Initial Page Load

portal-initial-load

4.2. Proxied API Call

portal-proxied-call

4.3. Tenant Switch

portal-tenant-switch

This design gives admin-service the opportunity to re-validate the user’s access to the requested tenant at mint time, rather than trusting a stale claim on an already-issued JWT. A user stripped of tenant access between login and switch will get a 403 at mint.

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 X-API-KEY on every proxied call

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

registration-portal/src/main/java/…​/filter/TenantResolutionFilter.java

Per-request tenant resolution (domain / header / session)

registration-portal/src/main/java/…​/filter/AdminServiceJwtRelayFilter.java

Session-held JWT, OIDC refresh, proxy header injection

registration-portal/src/main/java/…​/filter/CacheControlFilter.java

Per-path-class cache and CDN-cache headers

registration-portal/src/main/java/…​/config/StaticResourcesWebConfiguration.java

Hashed-asset immutability, fingerprint resolution

registration-portal/src/main/java/…​/config/SecurityConfig.java

OIDC login, session management, filter chain order

registration-portal/src/main/resources/config/application.yml

spring.cloud.gateway.mvc.routes + AddRequestHeader=X-API-KEY filter

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 exchangePOST /api/auth/register-session issues an anonymous admin-service JWT in exchange for a browser-generated UUID + orgId. Used for the pre-login registration flow.

  • Hash-based external loginPOST /api/auth/external-login for flows where a user arrives with a pre-signed userKey/userHash pair (from email links, partner sites).

  • FormController familyFormController, EventFormController, MembershipFormController, TestFormController. These live in admin-service under form/ and are not @RestController — they are internal domain wrappers driving the registration process flow. Their HTTP surface is FormResource. 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-session or /api/auth/external-login

  • Does require a currentTenantId in 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:

Application bootstrap, build and deployment:

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/admin-portal-greenfield.adoc).

2026-04-27

Reframed as a specialisation of microservice-pattern.adoc. Common bootstrap concerns (Maven shape, Jib, Helm, ArgoCD, GitHub Actions, OTel, probes) moved out to the parent doc; this page focuses on portal-specific deltas.