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 Acceptedis returned. The session cookie is not set yet. - User NOT enrolled,
RequireAllUsers = true:403 Forbiddenwith{ mfaSetupRequired: true }. AnMfaEnforcementBlockedaudit 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 type | Value |
|---|---|
ClaimTypes.NameIdentifier | User ID (PrimusAuthUser.Id) |
ClaimTypes.Name | User email |
ClaimTypes.Email | User email |
ClaimTypes.Role | One entry per role (uses Roles list; falls back to Role field) |
"primus:provider" | Authentication provider (local, azure, auth0, etc.) |
Any key in PrimusAuthUser.Claims | Forwarded 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:
- Validates the current session cookie — returns
401if invalid or already expired. - Re-signs in with the same
ClaimsPrincipaland a fresh expiry (ExpiryInMinutesfrom config). - 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.
- Detect a
403response withmfaSetupRequired: truein the body. - Redirect the user to a dedicated "Set up MFA" page.
- Guide the user through
/mfa/enroll→/mfa/enroll/confirm. - 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:
GET /api/auth/providerssets aXSRF-TOKENcookie (SameSite=Strict; HttpOnly=false).- The frontend JavaScript reads this cookie value.
- Every mutating request must include
X-Primus-CSRF: <value>. - The Broker compares the header to the cookie. If they differ, the request is rejected with
400.
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.
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).
| Event | Trigger |
|---|---|
LoginSuccess | Successful local credential validation |
LoginFailed | Wrong email or password |
LoginMfaPending | MFA challenge issued |
MfaVerified | TOTP or backup code accepted |
MfaFailed | TOTP or backup code rejected |
MfaEnforcementBlocked | RequireAllUsers=true blocked unenrolled user |
MfaEnrolled | User completed MFA enrolment |
MfaDisabled | User disabled MFA |
LogoutSuccess | Session revoked by user |
SessionRefreshed | JTI rotated via /refresh |
SessionRevoked | Session revoked (admin or "log out everywhere") |
ImpersonateStart | Admin initiated impersonation |
ImpersonateEnd | Impersonation ended |
OidcLoginSuccess | OIDC callback completed |
OidcLoginFailed | OIDC callback failed |
RateLimitExceeded | IP exceeded login attempt limit |
DomainBlocked | Email domain not in allowlist |
OrgSwitched | User switched active organisation context |
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
- Integration Guide — complete setup walkthrough from install to first authenticated request
- Endpoint Reference — every
/api/auth/*endpoint with request and response shapes - Advanced Configuration — custom stores, HA clustering, and data protection
- Database Setup — persist users with EF Core for production deployments