Skip to content

Architecture Overview: CommandCenter Platform with BFF Gateway#

Current Architecture Context diagram

flowchart TB
    user(user)

    user--personal data-->bff

    subgraph external [Internet]
        direction LR
        entraId[Entra ID]
        bff((YARP Gateway\nFront Door BFF))
        react((React\nWeb UI))

        bff-->react
        react-.api calls.->bff
    end

    subgraph dmz [Internal Network]
        direction LR
        ldb[|borders:tb|legacy db]
        servers((Servers API))

        bff--api routes-->api
        db<-->api
        api-.->legacy-api
        api-.->entraId
        api-->mediation

        subgraph cc-api [CommandCenter API]
            direction LR
            db[|borders:tb|database]
            api((CommandCenter\nAPI))
        end

        subgraph legacy-api [Legacy CC]
            direction LR
            legacy((Legacy API))
            legacy-service((CC Service))

            bff--legacy api routes-->legacy
            legacy<-->ldb
            legacy-service<-->ldb
        end
        subgraph mediation [Mediation Layer]
            direction LR
            servers-.->ldb
        end
        subgraph benefit [BenefitManager]
            direction LR
            db-customer[|borders:tb|customer db]
            bm-service((BenefitManager\nService))
            bm-app((BenefitManager\nWeb))
            api -- "`versioned req/res`" --> bm-app
            bm-service <--> db-customer
            bm-app <--> db-customer
        end
    end

%% Element type definitions

    classDef boundary fill:none
    dmz:::boundary
    external:::boundary
    classDef apis stroke-dasharray: 5 5
    legacy-api:::apis
    mediation:::apis
    cc-api:::apis
    benefit:::apis
Hold "Alt" / "Option" to enable pan & zoom

This architecture describes a layered enterprise web platform built around the Backend-for-Frontend (BFF) pattern, separating an internet-facing edge from an internal network that hosts both a modern API and a legacy system in mid-migration. The picture that emerges is a classic strangler-fig setup: a new CommandCenter API gradually taking over responsibilities from a Legacy CC system, with a mediation layer and a domain-specific BenefitManager module living alongside them.

The Internet-Facing Edge#

The user's entry point is a YARP Gateway acting as a Front Door BFF. All personal data flows from the user into this gateway, which serves two roles at once: it delivers the React Web UI to the browser, and it receives the API calls that the React app makes back to it. This is the security-relevant design decision of the whole architecture — the browser never talks to any backend service directly. The BFF is the single choke point where authentication, token handling, request routing, and header sanitization can be enforced, which is exactly what YARP (Yet Another Reverse Proxy, the .NET reverse-proxy library) is built for.

Entra ID sits in the internet zone as the identity provider. Notably, the dashed line shows the CommandCenter API calling out to Entra ID — suggesting the internal API validates tokens or queries directory/graph data itself, rather than identity being handled exclusively at the edge.

The Internal Network#

Everything behind the gateway lives in an internal network zone (labeled as a DMZ-style boundary), which the BFF reaches through two distinct routing rules:

CommandCenter API is the modern core. The BFF forwards standard API routes here, and the API owns its own database with bidirectional read/write access. From this hub, three outbound relationships fan out: a dashed (loosely coupled or transitional) dependency on the Legacy API, the dashed call to Entra ID, and a solid dependency on the Mediation Layer.

Legacy CC is the system being phased out. Importantly, the BFF routes legacy API routes directly to the Legacy API — meaning the old system is still serving live traffic for some endpoints, not just being called internally. Both the Legacy API and a background CC Service share read/write access to the legacy db. That shared-database pattern between an API and a service is typical of older architectures and is usually one of the harder things to untangle during migration.

Mediation Layer contains a Servers API with a dashed connection into the legacy database. Its role appears to be an anti-corruption or adapter layer: it gives the modern CommandCenter API a controlled, indirect path to legacy data without coupling the new API directly to the old schema. The dashed lines reinforce that this is intended as a temporary or loosely bound bridge.

BenefitManager is a self-contained business domain with three parts: a BenefitManager Web front-end, a BenefitManager Service, and a dedicated customer db that both components read and write. The CommandCenter API communicates with BenefitManager Web through explicitly versioned request/response contracts — a deliberate choice that decouples the two systems' release cycles and signals that BenefitManager is treated as an independent product with its own lifecycle rather than an internal module.

Key Design Characteristics#

A few properties are worth calling out:

  • Single ingress, zero direct backend exposure. All user traffic funnels through the YARP BFF; internal services are unreachable from the internet. Personal data crosses the boundary exactly once, at a point where it can be inspected and protected.
  • Strangler-fig migration in progress. Solid arrows mark the target-state paths (BFF → CommandCenter API → its own database), while dashed arrows mark transitional dependencies (API → Legacy, Mediation → legacy db). The diagram's dashed subgraph borders on Legacy CC, Mediation, and the API groups visually echo this "boundary in flux" status.
  • Database-per-context, with one legacy exception. CommandCenter, BenefitManager, and Legacy each own their data store. The only shared database is the legacy one — shared between Legacy API, CC Service, and (indirectly) the Servers API — which is precisely where the migration risk concentrates.
  • Contract versioning at the seam. The versioned req/res link between CommandCenter and BenefitManager is the formal integration contract that lets the two evolve independently.

In short: a YARP-fronted React application backed by a modern CommandCenter API, coexisting with a legacy system through direct legacy routing, a mediation/adapter layer, and a versioned contract to an autonomous BenefitManager domain — an architecture clearly designed to migrate away from the legacy core incrementally while keeping a single hardened entry point for all user traffic.

Authentication & Authorization Flow: OIDC Authorization Code + PKCE, and a gateway-signed user token#

Participants

  • The browser and the SPA
  • The gateway (BFF: YARP + ASP.NET Core)
  • The identity provider: Entra ID today, or Keycloak, One Identity, any OpenID Connect provider (docs/identity-provider.md)
  • The backends (WebApi, the main API) and OPA
sequenceDiagram
    participant Browser as Browser + SPA
    participant BFF as Gateway (BFF)
    participant IdP as Identity provider
    participant API as Backend (WebApi)
    participant OPA

    Note over Browser, BFF: Sign-in (the user is not signed in)
    Browser->>BFF: GET /.login?returnUrl=/…
    BFF->>Browser: 302 to the provider's authorize endpoint (client_id, redirect_uri, scope, state, nonce, code_challenge, response_mode=form_post)
    Browser->>IdP: authorize: the user signs in there
    IdP->>Browser: form POST back to the gateway's /signin-oidc (code, state)
    Browser->>BFF: POST /signin-oidc
    BFF->>IdP: token request: code, code_verifier, and a client secret or a client assertion (certificate, or a managed identity's token)
    IdP-->>BFF: ID token
    BFF->>BFF: validates it, maps the provider's roles to internal roles (role map), sets the session cookie
    BFF->>Browser: 302 back to the SPA, with an HttpOnly session cookie

    Note over Browser, API: An API call
    Browser->>BFF: GET /env/{env}/api/… (session cookie, X-CSRF: 1)
    BFF->>BFF: signs a 5-minute user token (ES256): who the user is, their internal roles
    BFF->>API: forwarded request, Authorization: Bearer <user token>
    API->>API: validates it against the gateway's key set (/.well-known/cc-internal/jwks)
    API->>OPA: may this user do this? (the action and resource, never a value)
    OPA-->>API: allow, or deny with reasons
    API-->>BFF: 200, or 401 / 403 / 503
    BFF-->>Browser: the response
Hold "Alt" / "Option" to enable pan & zoom

The gateway is the security choke point. It implements the token handler pattern: the browser never sees, stores or sends an OAuth token. The SPA's only credential is an opaque HttpOnly session cookie, and every proxied call must carry X-CSRF: 1.

Phase 1: signing in#

The SPA loads without a user and sends the browser to GET /.login (a local returnUrl only, so it cannot be an open redirect). From there the gateway drives the sign-in, not the browser.

Phase 2: authorization code flow with PKCE#

The gateway redirects to the provider's authorize endpoint with its client_id, redirect_uri, the scopes, state, nonce and a code_challenge, and asks for the answer as a form POST.

  • PKCE on a confidential client. Even with a client credential the gateway sends a code_challenge/code_verifier pair, as OAuth 2.1 requires. It binds the code to whoever started the sign-in.
  • state and nonce protect the callback against forged answers and replayed ID tokens.

The user signs in at the provider; their credentials never reach the gateway or the SPA.

Phase 3: redeeming the code, and the session#

At /signin-oidc the gateway posts the code and the code_verifier to the provider's token endpoint, proving who it is with one of three client credentials (docs/identity-provider.md):

  • a client secret;
  • a JWT signed with its certificate (private_key_jwt);
  • or, with Entra ID, a token from its managed identity (a federated credential, so no secret exists anywhere).

It validates the ID token (signature, issuer, audience, expiry, nonce), turns the provider's roles into internal roles with the role map (/config/access), and sets an HttpOnly, Secure, SameSite Strict session cookie. It keeps no OAuth token: there is nothing to refresh and nothing to leak.

Phase 4: API calls through the gateway#

The SPA calls relative paths such as /env/{env}/api/…; the browser attaches the cookie (same origin, so no CORS). The gateway checks X-CSRF, asks OPA whether the user may reach that environment, and picks that environment's backend (docs/environments.md).

Phase 5: the user token the gateway signs#

On clusters marked UserAssertion, the gateway removes whatever Authorization the browser sent and attaches a user token it signs itself: - ES256, iss=commandcenter-bff, aud=commandcenter-internal, valid five minutes; - claims: the user's id and name, their internal roles, the provider, the session, and the environment.

The backends trust the gateway, not the provider. That is why switching from Entra ID to Keycloak only changes the gateway. The signing key is rotated on /config/credentials (docs/authorization.md).

Phase 6: validation at the backend, and OPA#

Each backend validates the user token against the gateway's published key set, which it re-reads every five minutes: signature, issuer, audience and expiry. A protected endpoint then asks OPA whether this user may do this action on this resource. OPA fails closed: a timeout or error is a denial, and when it cannot be asked the answer is 503 (docs/authorization.md).

Why this design holds together#

Concern Where it's solved
Token exposure to the browser None: the token handler pattern, an HttpOnly cookie only
CSRF X-CSRF: 1 on every proxied request, Sec-Fetch-Site, SameSite cookie
Code interception PKCE, even on the confidential client
Which provider The gateway only (Authentication:Oidc); backends never see it
User identity at the backend The gateway-signed user token, five minutes, one audience
Who may do what OPA, deciding with the internal roles, failing closed

The net effect: the SPA is a pure UI with no security machinery. The gateway is the single OpenID Connect client and the only issuer the backends trust, and OPA decides every protected action. No bearer token ever crosses into the browser.