SAML SSO Architecture
This document describes the technical architecture of SAML federated authentication in MinuteMenu. It covers how the components connect, how the login flow works, and how the system manages IdP configurations and user migration.
For end-user setup guides, see:
System Components
graph TB
Browser["Browser<br/>(KK or SSO SPA)"]
subgraph "MinuteMenu Services"
KK["KidKare API<br/>KK/Projects/KidKare.Service"]
SSO["SingleSignOn Service<br/>SingleSignOn-Service/SingleSignOn.Api"]
Redis[(Redis<br/>Sessions + Exchange Tokens)]
end
subgraph "External"
IdP["Identity Provider<br/>(Microsoft Entra ID)"]
end
subgraph "Databases"
CXADMIN[(CXADMIN<br/>SQL Server<br/>IdP configs, policies,<br/>CX users)]
SSO_DB[(SSO MySQL<br/>User accounts,<br/>roles, credentials)]
end
Browser -->|"1. Enter email"| KK
KK -->|"2. Discover domain"| SSO
SSO -->|"3. Lookup config"| CXADMIN
Browser -->|"4. Redirect"| IdP
IdP -->|"5. SAML Response"| SSO
SSO -->|"6. Exchange token"| Redis
Browser -->|"7. Login with token"| KK
KK -->|"8. Validate token"| Redis
KK -->|"9. Create session"| Redis
SSO --> SSO_DB
Where things live:
| Component | Repo | Key Files |
|---|---|---|
| Identifier-first login UI | KK | Projects/KidKare.Web/app/states/login/ |
| Domain discovery + SAML BLL | KK | Projects/KidKare.Bll/Authentication/TenantDiscoveryService.cs |
| IdP config management | KK | Projects/KidKare.Bll/Authentication/FederatedIdpConfigBll.cs |
| SAML assertion handling | KK | Projects/KidKare.Bll/Authentication/SamlService.cs (ITfoxtec library) |
| Federated auth endpoints | SSO | SingleSignOn.Api/Controllers/FederatedAuthController.cs |
| Login with exchange token | SSO | SingleSignOn.Api/Services/LoginService.cs |
| IdP config data model | KK | Projects/KidKare.Data/Models/Cx/FederatedIdpConfig.cs |
| Policy A.13 constant | KK | Projects/KidKare.Bll/Constants/Policy.cs |
SAML Login Flow
The login flow has four phases: discover, redirect to IdP, callback, and session creation.
graph TD
A["User enters email<br/>on login page"] --> B["GET /auth/federated/discover<br/>?email=user@company.com"]
B --> C{"Domain is<br/>SSO-enabled?"}
C -->|No| D["Show password field<br/>(normal login)"]
C -->|Yes| E{"User account<br/>checks pass?"}
E -->|Error| F["Show error message<br/>(disabled, locked, inactive)"]
E -->|OK| G["Redirect to<br/>GET /auth/federated/initiate"]
G --> H["302 → IdP SSO URL<br/>with SAML AuthnRequest"]
H --> I["User authenticates<br/>at Azure AD"]
I --> J["IdP POSTs SAMLResponse<br/>to /auth/federated/callback"]
J --> K["Validate SAML assertion<br/>(signature, timestamps, audience)"]
K --> L["Create exchange token<br/>in Redis (5-min TTL)"]
L --> M["302 → KK SPA<br/>/#/login/federated:{token}"]
M --> N["KK calls POST /auth<br/>with exchange token"]
N --> O["Validate + delete<br/>exchange token from Redis"]
O --> P["Create session in Redis<br/>Return session cookie"]
style A fill:#e1f5fe
style P fill:#c8e6c9
style D fill:#fff3e0
style F fill:#ffcdd2
Phase 1: Domain Discovery (Home Realm Discovery)
When the user enters their email and clicks Continue, the frontend calls the discover endpoint.
GET /auth/federated/discover?email=user@company.com
What happens inside:
TenantDiscoveryService.DiscoverByEmail(email)
│
├─ Parse domain from email ("company.com")
├─ Query CXADMIN for enabled IdP configs matching this domain
├─ Check Policy A.13 = Y for the sponsor
│ └─ Policy OFF → return null (show password field)
├─ If multiple configs match → tie-break by CX user's client_id
│
└─ SSO FederatedAuthController.Discover()
├─ Look up SSO user by email
├─ Check: has FEDERATED_USER role?
│ └─ No role → isFederated: false
├─ Check: account disabled? → error: SAML_USER_DISABLED
├─ Check: account locked? → error: SAML_USER_LOCKED
├─ Check: CX staff active? → error: SAML_STAFF_INACTIVE
└─ All OK → isFederated: true, tenantId: "xxx"
Response:
{ "isFederated": true, "tenantId": "azure-company", "displayName": "Company Inc." }
or with error:
{ "isFederated": false, "error": "SAML_USER_DISABLED" }
Phase 2: Redirect to Identity Provider
When discovery returns isFederated: true, the browser redirects to the initiate endpoint.
GET /auth/federated/initiate?tenantId=azure-company&email=user@company.com
What happens inside:
FederatedAuthController.Initiate()
│
├─ Load IdP config by tenantId
├─ Build SAML AuthnRequest (via SamlService / ITfoxtec)
├─ Encode RelayState = returnUrl + "|" + email
└─ 302 redirect → IdP SSO URL with SAMLRequest query param
The user now sees their corporate login page (Azure AD / Entra ID). After authenticating (and MFA if configured), Azure generates a SAML Response.
Phase 3: SAML Callback
Azure POSTs the SAML Response back to the Assertion Consumer Service (ACS) endpoint.
POST /auth/federated/callback
Body: SAMLResponse={base64}&RelayState={encoded}
What happens inside:
FederatedAuthController.Callback()
│
├─ Decode SAMLResponse (base64 → XML)
├─ ValidateWithAnyConfig(samlResponse)
│ ├─ Try each enabled IdP config
│ ├─ Verify XML signature using IdP's X.509 certificate
│ ├─ Check NotOnOrAfter timestamp (reject expired assertions)
│ ├─ Check Audience restriction matches our Entity ID
│ └─ Extract email from NameID element
│
├─ Verify email matches expected email from RelayState
├─ Verify user exists in SSO database
│
├─ Generate exchange token (GUID)
│ └─ Store in Redis: key="saml_exchange:{token}", value=email, TTL=5 min
│
├─ Build BasicAuthToken = base64(email + ":SAML:" + token)
└─ 302 redirect → /#/login/federated:{basicAuthToken}?returnUrl=...
Phase 4: Session Creation
The KK SPA receives the redirect, parses the token from the URL, and calls the standard login endpoint.
POST /auth
Body: { basicAuthToken: "Basic {base64(email:SAML:{exchangeToken})}" }
What happens inside:
LoginService.Post(LoginRequest)
│
├─ Parse credentials → detect "SAML:" prefix in password
├─ Extract exchange token from password
├─ Redis lookup: "saml_exchange:{token}"
│ ├─ Not found → 401 "Token expired or already used"
│ └─ Found → get email, delete key (one-time use)
│
├─ Verify email matches request
├─ Add FEDERATED_USER role to user
├─ Set Meta["AuthProvider"] = "SAML"
│
├─ Fix BasicAuthHeader (critical step):
│ ├─ KK: look up CX password from CXADMIN → override with real credential
│ └─ SSO: create/reuse SelfMade token → override BasicAuthHeader
│
├─ Create session in Redis (same as normal login)
└─ Return LoginResponse with session cookie
Why BasicAuthHeader needs fixing: After SAML login, the BasicAuthHeader contains email:SAML:{exchangeToken}. But the exchange token is deleted from Redis — it's not a real credential. Downstream CX API calls that use BasicAuthHeader would fail. So the system replaces it with a valid credential immediately.
Policy A.13 Gate
SAML is controlled per sponsor by Policy A.13 (USE_SAML_SSO_FOR_LOGIN). This policy is checked at multiple points.
graph LR
A13{"Policy A.13 = Y?"}
A13 -->|Yes| ON["SAML Active"]
A13 -->|No| OFF["SAML Inactive"]
ON --> D1["Discover returns isFederated: true"]
ON --> D2["Sponsor sees SSO Config menu"]
ON --> D3["Password UI hidden for SSO users"]
ON --> D4["IsFederatedSsoUser = true in session"]
OFF --> D5["Discover returns null → password field"]
OFF --> D6["SSO Config menu hidden"]
OFF --> D7["Normal password login"]
OFF --> D8["IsFederatedSsoUser = false"]
Check points in code:
| Location | What it checks |
|---|---|
TenantDiscoveryService.DiscoverByEmail() |
Config + policy → return null if policy OFF |
TenantDiscoveryService.GetByTenantIdentifier() |
Same gate for direct config lookup |
TenantDiscoveryService.GetAllEnabled() |
Filter out configs where policy is OFF |
SSO LoginService (user details) |
Config enabled AND policy Y → set IsFederatedSsoUser |
KK LoginTypeBase (user details) |
FEDERATED_USER role AND setting CxUseSamlSsoForLogin |
| Frontend left nav | isCxSponsor() && hasAdminBit() && setting == true |
FederatedIdpConfigController |
Require sponsor admin AND policy for sponsor access |
IdP Configuration Lifecycle
IdP configurations are stored in CXADMIN database. They follow a draft → active lifecycle.
graph TD
CREATE["SystemConfig creates config"] --> DRAFT["INACTIVE<br/>(IsEnabled = false, always)"]
DRAFT --> PATH_A["Create CX Account<br/>with SAML option"]
DRAFT --> PATH_B["Sponsor Enable SSO flow"]
PATH_A --> ACTIVE["ACTIVE<br/>(IsEnabled = true)"]
PATH_B --> ACTIVE
ACTIVE --> DISABLE["Sponsor or SystemConfig<br/>disables SSO"]
DISABLE --> INACTIVE["INACTIVE"]
INACTIVE --> NOTIFY["Find SAML_INVT users<br/>(no KK password)<br/>Email sponsor admin"]
PATH_A -.- NOTE_A["Auto: create SSO user,<br/>send invite, set policy A.13=Y,<br/>assign sponsor, enable config"]
PATH_B -.- NOTE_B["Steps: test connection,<br/>validate users, migrate valid,<br/>disable invalid, enable config,<br/>send invites, log migration"]
style DRAFT fill:#fff3e0
style ACTIVE fill:#c8e6c9
style INACTIVE fill:#ffcdd2
IdP config data model (CXADMIN):
| Field | Purpose |
|---|---|
| TenantIdentifier | Unique ID for this config |
| DisplayName | Human-readable name (e.g., "Contoso Azure AD") |
| IdpSsoUrl | SAML SSO endpoint at the IdP |
| IdpEntityId | SAML issuer identifier |
| X509Certificate | Public cert for signature validation |
| AllowedEmailDomains | Comma-separated list of email domains |
| DefaultSponsorId | Associated CX sponsor |
| IsEnabled | Active/inactive flag |
| MigrationLog | JSON audit trail of user migrations |
User Migration Flow
When a sponsor enables SSO for existing users, the system migrates usernames to corporate emails.
EnableWithMigration(configId)
│
├─ Load IdP config + allowed domains
├─ Load all CX users for this sponsor
├─ Skip deleted staff (status code 290)
│
├─ For each CX user:
│ ├─ Look up SSO user by username
│ │ └─ Not found → InvalidUser (NO_SSO_USER)
│ │
│ └─ Check email in priority order:
│ ├─ 1. SSO Email → domain allowed? → ValidUser
│ ├─ 2. SSO PrimaryEmail → domain allowed? → ValidUser
│ ├─ 3. CX Staff Email → domain allowed? → ValidUser
│ └─ None matched → InvalidUser (EMAIL_DOMAIN_NOT_ALLOWED)
│
├─ Migrate valid users:
│ ├─ Change username to corporate email (SSO DB + CX DB)
│ ├─ Add FEDERATED_USER role
│ └─ Send federated invite email
│
├─ Disable invalid users:
│ └─ Disable via SSO (no valid email = can't log in via SSO)
│
├─ Enable the IdP config
└─ Append migration log (JSON) to config record
Migration log format:
[{
"date": "2026-03-19T10:00:00Z",
"migratedBy": "admin@company.com",
"migrated": [
{ "UserId": 1, "oldUsername": "john", "newUsername": "john@company.com" }
],
"disabled": [
{ "UserId": 2, "username": "jane", "Reason": "NO_EMAIL" }
]
}]
User Roles and Flags
SAML introduces two roles and one session flag.
| Role / Flag | Where set | Purpose |
|---|---|---|
FEDERATED_USER |
SSO user roles | Marks user as SAML-authenticated. Added during migration or SAML login. |
SAML_INVT |
SSO user roles | Marks user as created via SAML invite (never had a KK password). Used to identify affected users when SSO is disabled. |
IsFederatedSsoUser |
Login response | Frontend flag. True when user has FEDERATED_USER role AND policy A.13 is ON. Controls password UI visibility. |
Impact of IsFederatedSsoUser = true:
- Frontend hides password fields and "Change Password" button
- Backend rejects password change requests
- Sponsor sees "SSO Configuration" menu item
Security Design
SAML Assertion Validation
The callback validates every SAML response before trusting it:
- Signature — XML signature verified against IdP's X.509 certificate
- Timestamps —
NotOnOrAfterchecked to reject expired assertions - Audience — Must match our Entity ID (
https://api.kidkare.com/ssoservice) - Issuer — Must match a known, enabled IdP config
- Email match — NameID email must match the email from RelayState
Exchange Token Security
The exchange token bridges the SAML callback (server-side) to the SPA login (client-side):
- One-time use: deleted from Redis after first read
- Short-lived: 5-minute TTL
- Random GUID: not guessable
- Email-bound: token is tied to a specific email address
No Password for SSO Users
SAML-invited users (SAML_INVT role) never have a KidKare password. If SSO is disabled:
- System identifies all
SAML_INVTusers for the sponsor - Emails sponsor admin with the list of affected users
- Those users must use "Forgot Password" to set a KidKare password
SP (Service Provider) Configuration
These are the MinuteMenu SAML endpoints that must be configured in the IdP (Azure AD).
| SAML Field | Value |
|---|---|
| Entity ID (Identifier) | https://api.kidkare.com/ssoservice |
| ACS URL (Reply URL) | https://api.kidkare.com/ssoservice/auth/federated/callback |
| SP Metadata URL | https://api.kidkare.com/ssoservice/auth/federated/metadata |
| NameID format | Email (user.mail, not user.userprincipalname) |
| Binding | HTTP-POST (for SAML Response) |
Cross-Product Behavior
Parachute
Parachute portal detects federated users and disables password UI. The IsFederatedSsoUser flag from the login response controls this.
Centers-CX
CX users are the primary audience for SAML. The IdP config is stored in CXADMIN database. CX user/staff status is checked during discovery to block inactive users before they reach the IdP.
HX
HX users, Parents, and Independent Center users are not supported for SAML SSO. Only CX Sponsor and CX Center users can use federated login.