Skip to main content
Version: Current

How the Identity Broker Works

This guide explains the architecture of PrimusSaaS.Identity.Broker so you can reason about what the package does on your behalf, debug issues confidently, and make the right configuration decisions.

If you just want to get something running, start with the Integration Guide and come back here when you need to understand why something works the way it does.


1. The BFF (Backend-for-Frontend) Model

Traditional SPAs (React, Angular, Vue) have a problem: the browser is not a safe place to store secrets. If your frontend receives a JWT and puts it in localStorage, any injected script on your page can read it.

The BFF pattern solves this by keeping the token on the server and giving the browser only a session cookie. The Broker implements this pattern end-to-end:

Browser                      Your ASP.NET API               Identity Store
│ │ │
│── POST /api/auth/login ──────►│ │
│ {email, password} │── ValidateCredentials() ─────►│
│ │◄── user object ───────────────│
│ │ │
│ │── CreateSession() ────────────┐
│ │ │ (your DB)
│◄── 200 Set-Cookie: ───────────│ │
│ Primus.Session=... │ │
│ (HttpOnly, Secure) │ │
│ │ │
│── GET /api/data ─────────────►│ │
│ Cookie: Primus.Session=... │── GetSession() ──────────────►│
│ │◄── session record ────────────│
│◄── 200 {data} ────────────────│ │

Key point: The browser never sees the raw session token value because the cookie is HttpOnly. JavaScript running in the browser — including third-party scripts — cannot read it.


2. What Happens During Login

Here is the exact sequence the Broker executes for a local email/password login:

POST /api/auth/login
{ "email": "alice@example.com", "password": "..." }

Step 1 — CSRF check

The Broker reads the X-Primus-CSRF request header and compares it to the XSRF-TOKEN cookie value. If they do not match, the request is rejected with 400 Bad Request immediately, before any credential check.

Step 2 — Rate limit check

The Broker checks how many login attempts have been made from this IP address within the configured window. If the limit is exceeded, it returns 429 Too Many Requests with a Retry-After header.

Rate limiting runs before credential validation so that an attacker cannot brute-force passwords even if they defeat the CSRF check.

Step 3 — Domain allowlist check (if enabled)

If DomainAllowlist.Enabled = true, the email domain is checked against AllowedDomains. If the domain is not in the list, the request is rejected with 403 Forbidden.

Step 4 — Credential validation

Your IPrimusAuthCredentialValidator.ValidateCredentialsAsync() implementation is called. This is where you compare the password against your stored hash. The Broker does not perform hashing itself — it delegates this entirely to your implementation.

If validation fails, the Broker returns 401 Unauthorized. Critically, the same 401 response is returned whether the user does not exist or the password is wrong. This prevents user enumeration.

Step 5 — MFA check (if enabled)

If Mfa.Enabled = true, the Broker calls IPrimusMfaStore.IsMfaEnabledAsync().

  • User enrolled in MFA: A short-lived pending token is generated and 202 Accepted is returned. The session cookie is not set yet.
  • User NOT enrolled, RequireAllUsers = true: 403 Forbidden with { mfaSetupRequired: true }. An MfaEnforcementBlocked audit event is logged.
  • User NOT enrolled, RequireAllUsers = false: MFA is optional. Login proceeds to step 6.

Step 6 — Session creation

PrimusAuthSessionService.CreateSession() is called. The Broker creates a signed JWT containing the session JTI and user claims, then sets it as an HttpOnly session cookie. 200 OK is returned.

Step 7 — Audit log

IPrimusAuthAuditSink.LogAsync("LoginSuccess", ...) is called. If any step fails, the corresponding failure event is logged instead.


3. Session Lifecycle

Token structure

The session is stored as an ASP.NET Core cookie authentication ticket, encrypted with the Data Protection API. It is not a JWT. The browser never sees the raw values.

The ticket carries a ClaimsPrincipal with the following claims:

Claim typeValue
ClaimTypes.NameIdentifierUser ID (PrimusAuthUser.Id)
ClaimTypes.NameUser email
ClaimTypes.EmailUser email
ClaimTypes.RoleOne entry per role (uses Roles list; falls back to Role field)
"primus:provider"Authentication provider (local, azure, auth0, etc.)
Any key in PrimusAuthUser.ClaimsForwarded as-is (e.g. auth0:org_id)

Revocation

Because the JWT carries a jti, the Broker can revoke a session by marking that JTI as revoked in IPrimusSessionRevocationStore. On every authenticated request, IsRevokedAsync(jti) is called. If revoked, the request is rejected with 401 even if the cookie signature is still valid.

This gives you instant session revocation — useful for logout, forced sign-out of compromised accounts, and "log out all other devices".

Refresh

POST /api/auth/refresh extends the current session:

  1. Validates the current session cookie — returns 401 if invalid or already expired.
  2. Re-signs in with the same ClaimsPrincipal and a fresh expiry (ExpiryInMinutes from config).
  3. Issues a new session cookie with the updated expiry.

Refresh does not change the claims or revoke the previous cookie. Use DELETE /sessions/{jti} if you need explicit session invalidation.


4. MFA Flow

TOTP Enrolment

Frontend                         Broker                          MFA Store
│ │ │
│── POST /mfa/enroll ──────────►│ │
│ (authenticated) │── generate TOTP secret ───────┐
│ │ generate enroll token │
│◄── 200 { │ │
│ secret, otpauthUri, │ │
│ enrollToken, issuer │ │
│ } ─────────────────────────│ │
│ │ │
│ [user scans QR in app] │ │
│ │ │
│── POST /mfa/enroll/confirm ──►│ │
│ {enrollToken, code} │── verify code against secret ──┬
│ │── EnableMfaAsync() ─────────►│
│◄── 200 {backupCodes} ─────────│ │

Login with MFA enrolled

POST /login → 202 { mfaPendingToken, mfaRequired: true }

POST /mfa/verify { mfaPendingToken, code }

200 → session cookie set

The pendingToken is single-use and expires after Mfa.PendingTokenExpiryMinutes (default: 5).

Global MFA enforcement (RequireAllUsers)

When Mfa.RequireAllUsers = true, every user must have MFA enrolled before their first session is created. This applies to both local login and OIDC SSO.

Recommended frontend handling for mfaSetupRequired
  1. Detect a 403 response with mfaSetupRequired: true in the body.
  2. Redirect the user to a dedicated "Set up MFA" page.
  3. Guide the user through /mfa/enroll/mfa/enroll/confirm.
  4. After successful enrolment, redirect back to login.

5. OIDC SSO Flow

Browser                         Broker                      Azure AD
│ │ │
│── GET /api/auth/azure ───────►│ │
│ │── Challenge(AzureAD) ─────►│
│◄── 302 redirect ──────────────│ │
│ │ │
│ [user authenticates with Microsoft] │
│ │ │
│── GET /signin-oidc?code=... ─►│◄── token exchange ─────────│
│ │ │
│ │── FindByEmailAsync() / AutoProvisionUserAsync()
│ │── MFA enforcement check │
│ │── CreateSession() │
│◄── 302 redirect + cookie ─────│ │
│ (to returnUrl) │ │

The Broker uses IPrimusAuthUserStore.FindByEmailAsync() to look up the user by their external email, then calls AutoProvisionUserAsync() if no matching user exists (JIT provisioning). The returnUrl is validated against configured allowed origins — an open redirect is not possible.


6. CSRF Protection

The Broker uses the double-submit cookie pattern:

  1. GET /api/auth/providers sets a XSRF-TOKEN cookie (SameSite=Strict; HttpOnly=false).
  2. The frontend JavaScript reads this cookie value.
  3. Every mutating request must include X-Primus-CSRF: <value>.
  4. The Broker compares the header to the cookie. If they differ, the request is rejected with 400.
Call /providers on app initialisation

Call GET /api/auth/providers when your app initialises — not just when the login page loads. This ensures the CSRF cookie is set before the user submits any form.


7. Rate Limiting

The Broker uses an in-memory sliding-window rate limiter, keyed by remote IP address, applied only to POST /login.

"PrimusAuth": {
"BotProtection": {
"Enabled": true,
"MaxAttemptsPerMinute": 5,
"MaxAttemptsPerHour": 30,
"ChallengeAfterFailures": 3
}
}

When exceeded: 429 Too Many Requests + Retry-After header + RateLimitExceeded audit event.

Not distributed by default

The default in-memory limiter resets when the process restarts and is not shared across multiple instances. For multi-replica deployments, implement a distributed rate limiter backed by Redis.


8. Audit Logging

Every significant authentication event is reported to IPrimusAuthAuditSink. The Broker calls LogAsync(action, actor, target, details).

EventTrigger
LoginSuccessSuccessful local credential validation
LoginFailedWrong email or password
LoginMfaPendingMFA challenge issued
MfaVerifiedTOTP or backup code accepted
MfaFailedTOTP or backup code rejected
MfaEnforcementBlockedRequireAllUsers=true blocked unenrolled user
MfaEnrolledUser completed MFA enrolment
MfaDisabledUser disabled MFA
LogoutSuccessSession revoked by user
SessionRefreshedJTI rotated via /refresh
SessionRevokedSession revoked (admin or "log out everywhere")
ImpersonateStartAdmin initiated impersonation
ImpersonateEndImpersonation ended
OidcLoginSuccessOIDC callback completed
OidcLoginFailedOIDC callback failed
RateLimitExceededIP exceeded login attempt limit
DomainBlockedEmail domain not in allowlist
OrgSwitchedUser switched active organisation context
Never log sensitive data

Never include raw passwords, full session tokens, JWT payloads, or any PII beyond email address in audit log detail strings.


9. How the Broker Fits into a Larger System

┌──────────────────────────────────────────────────────┐
│ React / Angular SPA │
│ - Calls /api/auth/login, /me, etc. │
│ - Carries session cookie (HttpOnly) │
└───────────────────┬──────────────────────────────────┘
│ session cookie

┌──────────────────────────────────────────────────────┐
│ ASP.NET API (your app) │
│ PrimusSaaS.Identity.Broker ← issues sessions │
│ - Validates session cookie for SPA requests │
│ - Issues short-lived JWTs for downstream calls │
└───────────────────┬──────────────────────────────────┘
│ Bearer JWT

┌──────────────────────────────────────────────────────┐
│ Downstream Microservice │
│ PrimusSaaS.Identity.Validator ← validates JWTs only │
│ - Does not issue sessions │
│ - Validates token signature, issuer, audience │
└──────────────────────────────────────────────────────┘

The Broker handles the user-facing login complexity; the Validator handles lightweight JWT verification in downstream services. The Broker's M2M feature (IPrimusM2MTokenService.GetTokenAsync()) enables short-lived inter-service JWTs for this pattern.


Next Steps