Skip to content

Identity & Auth

Both verticals authenticate against one Keycloak realm per network. Signals and the Aggregator share it, so a single identity works across the stack and integrating DPGs authenticate the same way regardless of which service they call.

The realm name follows the network or domain being deployed and is set with KEYCLOAK_REALM — bluedots, yellowdots, purpledots and so on. It is a deployment input, not a fixed constant, so treat any realm name in these docs as an example. A separate network means a separate realm.

Sharing one realm is what makes the audience checks below load-bearing: a token minted for the Aggregator portal carries the same issuer and the same signature as one minted for the Signals UI.

ClientUsed byKind
signals-uiSignals web UIPublic — OIDC authorization code
signals-apiSignals API’s own Admin REST callsConfidential — not accepted as a caller on either path
aggregator-portalAggregator portalPublic — OIDC authorization code
aggregator-bffAggregator BFF → Aggregator APIConfidential — client credentials
aggregator-apiAggregator APIConfidential
aggregator-dpgAggregator → SignalsConfidential — client credentials
voice-dpgVoice DPG → SignalsConfidential — client credentials

Realm roles are signals_participant and signals_admin. Tokens also carry claim mappers for aggregator_id, aggregator_type, phone_number, signalstack_org_id and signals_acting_orgs. The full inventory is in the Keycloak realm reference.

Every request to the Signals API presents a bearer token, and the token itself decides which path it takes.

People sign in through the browser with the OIDC authorization-code flow. Keycloak hosts the login screen — a custom OTP authenticator sends a code by email or SMS, so neither app owns a login form. On first sign-in the subject is mirrored into the local user table, keyed on the token’s sub.

Integrating DPGs (the Aggregator app, a voice DPG) use the OAuth2 client-credentials grant. The resulting service-account token resolves to that DPG’s organisation by convention: the Keycloak client id equals the organisation’s slug. Register a client whose id matches the slug and the mapping follows.

authorization: Bearer <token>
x-acting-org-id: <the organisation it is acting as>

Because the realm is shared, a valid signature and issuer are not enough. Three independent checks apply:

SettingChecksDefault
KEYCLOAK_ACCEPTED_CLIENT_IDSClients allowed on the human pathsignals-ui
KEYCLOAK_SERVICE_CLIENT_IDSClients allowed on the service path(empty)
KEYCLOAK_REQUIRED_REALM_ROLESA realm role the token must carrysignals_participant,signals_admin

The two allowlists are deliberately separate rather than merged. Merging them would let a token from the public signals-ui client be honoured as a service account, and let an integrating DPG’s token be provisioned as a human user. Both directions are rejected. signals-api appears in neither list — it is the API’s own Admin REST client, not a caller.

The realm-role check is defence in depth. A client allowlist rests on a claim the client itself controls; a realm role does not. Emptying KEYCLOAK_REQUIRED_REALM_ROLES leaves the allowlist as the only cross-DPG gate — do it only knowingly. Service tokens are exempt, since their service accounts hold realm-management roles rather than participant roles.

Admin endpoints (/api/v1/admin/*) additionally require an x-acting-org-id header, validated against the organisation’s type (network_service | aggregator | voice). Only a network_service org may upsert aggregators, for example.

ACTING_ORG_SOURCE decides whether that header authorises itself:

ValueBehaviour
headerThe header is taken at face value
claim_preferredThe asserted org must fall inside the token’s signals_acting_orgs grant, falling back to the header when the token carries no grant
claim_requiredA token with no grant is refused outright

The middleware populates request.user and request.acting_org; routes read those rather than re-parsing headers. AUTH_MIDDLEWARE_ENABLED (default true) is a kill switch for seed and migration scripts that must not hit the auth path.

On the Aggregator side one rule outranks the rest: aggregator_id is never trusted from the client. Every handler asserts it from the authenticated session, so one aggregator can never read or write another’s data.

Two settings are easy to confuse and account for most setup failures:

  • KEYCLOAK_BASE_URL is browser-facing and must equal the iss claim byte-for-byte.
  • KEYCLOAK_INTERNAL_BASE_URL is what the API process dials for JWKS and Admin REST — a service name, not the public hostname, in containerised setups.

When moving off localhost, update each public client’s Valid Redirect URIs and Web Origins. See the Keycloak setup guide and Deployment.