Last updated: 2026-07-29
This guide covers configuring single sign-on for the MindRouter dashboard. It is grounded in the actual implementation:
- Settings:
backend/app/settings.py(theazure_ad_*,google_sso_*,oidc_sso_*,saml_*fields and the*_enabledproperties) - Provider registry + routes:
backend/app/dashboard/sso/registry.py - OIDC driver (Google + generic):
backend/app/dashboard/sso/oidc.py - SAML 2.0 SP driver:
backend/app/dashboard/sso/saml.py - Shared JIT provisioning:
backend/app/dashboard/sso/base.py - Legacy Azure AD driver (own routes, shared linking rule as of 2.9.0):
backend/app/dashboard/azure_auth.py
- Providers are enabled purely by environment variables. There is no admin-UI toggle. A provider is "on" when its required variables are set (see the per-provider enablement rules below); it is "off" when they are unset. No code changes or feature flags involved.
- Any subset can be enabled simultaneously. Azure AD, Google, generic OIDC, and SAML are independent. The login page renders one button per enabled provider, in this fixed order: Azure AD, SAML, generic OIDC, Google.
- Local username/password accounts are always available. SSO never disables the local login form; SSO buttons appear alongside it.
- Config is process-level.
get_settings()is@lru_cached — each worker reads the environment once at startup. OIDC discovery documents and SAML IdP metadata are also cached in-process (1 hour TTL, per worker). After changing any SSO env var, restart the app (docker compose up -drecreates the container); do not expect a live reload.
Enablement rules (from the settings.py properties):
| Provider | Enabled when |
|---|---|
| Azure AD | AZURE_AD_CLIENT_ID and AZURE_AD_TENANT_ID set (secret is still required for the token exchange to succeed) |
GOOGLE_SSO_CLIENT_ID and GOOGLE_SSO_CLIENT_SECRET set |
|
| Generic OIDC | OIDC_SSO_ISSUER and OIDC_SSO_CLIENT_ID and OIDC_SSO_CLIENT_SECRET set |
| SAML | SAML_SP_ENTITY_ID and (SAML_IDP_METADATA_URL or all three of SAML_IDP_ENTITY_ID / SAML_IDP_SSO_URL / SAML_IDP_X509_CERT) set |
The original MindRouter SSO provider. It keeps its own routes in
azure_auth.py, its own azure_oid identity column, and its jobTitle group
mapping, and appears in the new registry only as a login-button descriptor.
Changed in 2.9.0: its email-linking fallback now enforces the same
unclaimed-account-only rule as the shared driver (see JIT provisioning and
account linking). The one difference an operator can notice: an Azure login
whose email matches an account carrying a different azure_oid is now
refused (logged as sso_email_link_refused) instead of silently rebinding that
account to the new object id. This is rare — the Entra object id is stable.
Genuinely unclaimed accounts (no azure_oid, no sso_provider — e.g. local
password accounts) still link exactly as before.
Routes: GET /login/azure (start), GET /login/azure/authorized (callback).
IdP-side setup (Azure portal → App registrations):
- Register a web application in your tenant.
- Add a Web redirect URI:
https://<your-mindrouter-host>/login/azure/authorized. - Create a client secret.
- Grant delegated Microsoft Graph permission
User.Read(the driver requests scopesopenid profile email User.Readand reads the profile fromhttps://graph.microsoft.com/v1.0/me).
MindRouter-side env vars:
AZURE_AD_CLIENT_ID=<application (client) id>
AZURE_AD_CLIENT_SECRET=<client secret value>
AZURE_AD_TENANT_ID=<directory (tenant) id>
AZURE_AD_REDIRECT_URI=https://<your-mindrouter-host>/login/azure/authorizedUnlike the newer providers, the Azure redirect URI is not derived from the
request — AZURE_AD_REDIRECT_URI must be set to the full absolute URL and must
match the app registration exactly.
Group mapping via job title (Azure-only behavior, in
_map_job_title_to_group()): for a brand-new user, the Graph jobTitle is
matched case-insensitively — contains "student" → group students, contains
"faculty" or "professor" → faculty, contains "staff" → staff; anything else
(or no title) → AZURE_AD_DEFAULT_GROUP (default other). The group name also
maps to the user's role (students→STUDENT, faculty→FACULTY, staff→STAFF,
admin→ADMIN). Graph department and officeLocation populate the user's
department/college fields.
OIDC authorization-code flow against https://accounts.google.com (hard-coded
issuer), handled by the shared driver in sso/oidc.py.
Routes: GET /login/google, GET /login/google/authorized.
IdP-side setup (Google Cloud console → APIs & Services → Credentials):
- Create an OAuth client ID of type Web application.
- Add authorized redirect URI:
https://<your-mindrouter-host>/login/google/authorized. - Configure the OAuth consent screen for your organization.
MindRouter-side env vars:
GOOGLE_SSO_CLIENT_ID=<client id>.apps.googleusercontent.com
GOOGLE_SSO_CLIENT_SECRET=<client secret>
# Optional — defaults to <APP_BASE_URL>/login/google/authorized:
GOOGLE_SSO_REDIRECT_URI=https://<your-mindrouter-host>/login/google/authorized
# Optional — restrict sign-in to one Google Workspace domain:
GOOGLE_SSO_HOSTED_DOMAIN=example.edu
GOOGLE_SSO_DEFAULT_GROUP=otherGOOGLE_SSO_HOSTED_DOMAIN does two things: it passes hd=<domain> on the
authorization request (Google pre-filters the account picker) and the
callback rejects any profile whose hd claim does not match — so it is
enforced server-side, not just cosmetically. Sign-in is rejected when the IdP
sends email_verified and it is not true (string forms like "false" count as
unverified); an IdP that omits the claim entirely is trusted.
The login button is always labeled "Sign in with Google".
Any spec-compliant OIDC IdP works. Endpoints are taken from the issuer's
discovery document at <issuer>/.well-known/openid-configuration (fetched at
first login, cached in-process for 1 hour) — you never configure token/authorize
URLs by hand. Identity comes from the IdP's userinfo endpoint, so the IdP must
publish one (all mainstream IdPs do).
Routes: GET /login/oidc, GET /login/oidc/authorized.
IdP-side setup:
- Register a confidential Web client (authorization-code grant).
- Redirect/callback URI:
https://<your-mindrouter-host>/login/oidc/authorized. - Ensure the client can request scopes
openid profile email(or adjustOIDC_SSO_SCOPES).
MindRouter-side env vars:
OIDC_SSO_ISSUER=https://idp.example.edu/realms/campus # issuer base URL, no trailing slash needed
OIDC_SSO_CLIENT_ID=<client id>
OIDC_SSO_CLIENT_SECRET=<client secret>
# Optional — defaults to <APP_BASE_URL>/login/oidc/authorized:
OIDC_SSO_REDIRECT_URI=https://<your-mindrouter-host>/login/oidc/authorized
OIDC_SSO_DISPLAY_NAME=Okta # login button reads "Sign in with <this>"
OIDC_SSO_SCOPES="openid profile email"
OIDC_SSO_DEFAULT_GROUP=otherClaims used: sub (stable subject), email (required; rejected if
email_verified is present and not true — string forms like "false" count as unverified), name (display name),
preferred_username (username hint for the generated local username).
MindRouter's recommended way to accept InCommon federation logins (university Shibboleth accounts nationwide) is CILogon, which acts as an OIDC gateway in front of the whole federation. You configure MindRouter's generic OIDC provider against CILogon — no SAML metadata exchange, no per-campus registration:
- Register an OIDC client at cilogon.org (CILogon client registration).
Callback URL:
https://<your-mindrouter-host>/login/oidc/authorized. - Configure:
OIDC_SSO_ISSUER=https://cilogon.org
OIDC_SSO_CLIENT_ID=cilogon:/client_id/<...>
OIDC_SSO_CLIENT_SECRET=<secret>
OIDC_SSO_DISPLAY_NAME=InCommon
OIDC_SSO_DEFAULT_GROUP=otherUsers pick their home institution on the CILogon page, authenticate at their
campus IdP, and come back with standard OIDC claims (sub is a stable
http://cilogon.org/serverA/users/... identifier). If you need direct SAML to a
single campus IdP instead, use the native SAML provider below.
A single-IdP SAML SP built on python3-saml, for deployments that must speak SAML directly (campus Shibboleth IdP, ADFS) rather than going through CILogon.
Routes:
| Route | Purpose |
|---|---|
GET /login/saml |
SP-initiated AuthnRequest redirect to the IdP (HTTP-Redirect binding) |
POST /login/saml/acs |
Assertion Consumer Service (HTTP-POST binding) |
GET /saml/metadata |
SP metadata XML — give this URL (or its output) to the IdP admin |
SP characteristics (from build_saml_settings()): strict mode, assertions
must be signed (wantAssertionsSigned: true) while a message-level signature is
not required (wantMessagesSigned: false), requested NameID format is
urn:oasis:names:tc:SAML:2.0:nameid-format:persistent. The request adapter
derives scheme and host from APP_BASE_URL rather than from
X-Forwarded-Proto / X-Forwarded-Host — precisely so a client-supplied
forwarded host cannot relax the Destination/Recipient validation that
python3-saml performs against that value. (It falls back to the request scheme
and Host header only when APP_BASE_URL is blank, which is why you should
keep it set behind the nginx proxy.)
Decide first: do you need an SP key pair? Without one MindRouter cannot decrypt encrypted assertions or sign AuthnRequests, and its metadata carries no
<KeyDescriptor>. Stock Shibboleth encrypts assertions by default, so most campus IdPs need you to either supply a key pair (below) or turn encryption off for this SP. If encryption stays on and you have no key pair, the login fails at the IdP — no response is ever POSTed to MindRouter, so nothing appears in MindRouter's logs; check the IdP's.There is still no Single Logout (SLO) endpoint, and login is SP-initiated only.
At an InCommon member institution, CILogon is the easier path (see the CILogon section above): same campus IdP over OIDC, no metadata exchange and no key material to manage.
Generate a self-signed, long-lived pair. This is not your web server's TLS certificate and must not come from a public CA — in SAML the trust comes from the certificate registered in the IdP's or federation's metadata, so a short-lived CA cert just forces needless metadata re-exchanges.
openssl req -x509 -newkey rsa:3072 -nodes -days 3650 \
-keyout sp.key -out sp.crt \
-subj "/CN=<your-mindrouter-host>"Point the settings at the files (mount them into the container) or paste the
PEM inline — a single-line .env value may use \n escapes:
SAML_SP_X509_CERT=/etc/mindrouter/saml/sp.crt
SAML_SP_PRIVATE_KEY=/etc/mindrouter/saml/sp.key
# Both require the pair above:
# Publishes the ENCRYPTION KeyDescriptor and requires encrypted assertions.
# Set this if your IdP encrypts (Shibboleth does by default).
SAML_WANT_ASSERTIONS_ENCRYPTED=true
SAML_AUTHN_REQUESTS_SIGNED=true # sign our AuthnRequestsWith a cert configured, /saml/metadata publishes a signing
<KeyDescriptor> — what a federation registrar expects and what lets the IdP
verify our signatures. The encryption <KeyDescriptor>, which is what an
IdP reads in order to encrypt assertions to this SP, is published only when
SAML_WANT_ASSERTIONS_ENCRYPTED=true: python3-saml derives it from that flag
rather than from the presence of a certificate. So for an encrypting IdP, set
both the key pair and that flag, then re-export your metadata.
Keep sp.key readable only by the app; it belongs in the host .env or a
mounted file, never in the repo. Rotating the pair means re-publishing metadata
to the IdP.
If either option is enabled without a key pair, MindRouter logs
saml_sp_keypair_missing and ignores it rather than letting python3-saml
refuse the whole configuration and take SAML login down.
IdP-side setup:
- Register MindRouter as an SP using the metadata served at
https://<your-mindrouter-host>/saml/metadata. - Enable assertion signing. For assertion encryption, either configure the SP key pair above (then the metadata carries the key the IdP needs), or disable encryption for this SP.
- Release attributes:
mail,displayName,eduPersonPrincipalName(or whatever you map viaSAML_ATTR_*below). An email-format NameID also works as a fallback for the email (common with ADFS).
MindRouter-side env vars — metadata-URL style (typical Shibboleth):
SAML_SP_ENTITY_ID=https://<your-mindrouter-host>/saml/metadata
SAML_IDP_METADATA_URL=https://idp.example.edu/idp/shibboleth— or explicit style (no metadata URL available):
SAML_SP_ENTITY_ID=https://<your-mindrouter-host>/saml/metadata
SAML_IDP_ENTITY_ID=https://idp.example.edu/idp/shibboleth
SAML_IDP_SSO_URL=https://idp.example.edu/idp/profile/SAML2/Redirect/SSO
SAML_IDP_X509_CERT="MIIC...single-line base64, no PEM headers..."Optional:
# Defaults to <APP_BASE_URL>/login/saml/acs when unset:
SAML_SP_ACS_URL=https://<your-mindrouter-host>/login/saml/acs
SAML_DISPLAY_NAME=Example University
SAML_DEFAULT_GROUP=other
# Attribute mapping (defaults are eduPerson conventions):
SAML_ATTR_EMAIL=mail
SAML_ATTR_NAME=displayName
SAML_ATTR_USERNAME=eduPersonPrincipalNameSubject selection for account keying: persistent NameID if the IdP sends one,
else the SAML_ATTR_USERNAME attribute (ePPN), else the email.
Configure your IdP to release a persistent (or otherwise stable) NameID.
MindRouter asks for
urn:oasis:names:tc:SAML:2.0:nameid-format:persistent in its AuthnRequest but
does not verify the format it gets back, so an IdP configured for
transient NameIDs will hand MindRouter a new subject on every login. The
consequence is a one-login lockout: the first login provisions the account and
stamps sso_provider; the second login misses on subject, falls through to the
email match, sees sso_provider already set, and is refused permanently
(sso_email_link_refused). Clearing the stale identity requires editing the
users row directly in the database — there is no admin UI or API for it. If
your IdP cannot emit a persistent NameID, release a stable
eduPersonPrincipalName and suppress the NameID.
SAML requires HTTPS. The saml_request_id cookie that carries the
AuthnRequest ID is set SameSite=None; Secure, because the IdP returns the
assertion by a cross-site HTTP-POST to the ACS and browsers withhold
SameSite=Lax cookies on cross-site POSTs. A Secure cookie is not stored
over plain http, so SAML cannot be exercised against a plain-http dev URL —
the ACS will reject every response as unsolicited. Test SAML against a TLS
origin.
Dependency note: python3-saml and its xmlsec system libraries ship in the
Docker image (Dockerfile installs libxmlsec1-dev + libxmlsec1-openssl
and runs pip install -e .[saml]). Bare-metal installs need
apt install libxmlsec1-dev libxmlsec1-openssl then pip install .[saml].
Without it, SAML routes fail gracefully with a "SAML support is not installed"
error; the other providers are unaffected (the import is lazy). GET /saml/metadata returns 404 (SAML is not configured) when the SAML env
vars above are unset, and 501 (SAML support is not installed) when
python3-saml is absent — but do not rely on the status code to tell the two
apart. metadata_response() builds the SAML settings first, and in the common
metadata-URL setup that build needs the python3-saml metadata parser: with
SAML_IDP_METADATA_URL set and the library missing, settings construction
fails and you get 404, not 501. The 501 is only reliably reached in the
explicit entity-id / SSO-URL / cert configuration. Confirm the library
directly (python -c "import onelogin.saml2") rather than inferring it from
the response code.
All providers share the same semantics (find_or_create_sso_user() in
sso/base.py; the Azure driver implements the same logic with azure_oid):
-
Lookup by
(provider, subject)first — the stable IdP identifier (OIDCsub, SAML persistent NameID, Azure object ID) stored on the user row. -
Then lookup by email (lowercased), but only unclaimed accounts are adopted. If the matched account already carries any IdP identity —
azure_oidset, orsso_providerset to anything, including the same provider — the login is refused and ansso_email_link_refusedwarning is logged. Email is an IdP-supplied attribute, not proof of ownership — without this rule, any enabled IdP could assert an existing user's address (including an admin's) and inherit that account.Because the refusal keys on
sso_providerbeing set at all — not on it being a different provider — an IdP that rotates its subject locks the user out after one login: the second login misses on(provider, subject), matches by email, seessso_provideralready set, and is refused. So the IdP must emit a stable subject: a persistent NameID for SAML (MindRouter requestsnameid-format:persistentbut does not verify what comes back), a stablesubfor OIDC.Unclaimed accounts — notably local username/password accounts — are linked: the SSO identity is attached and the local password is kept, so the user can continue to log in either way. Display name is refreshed from the IdP on every login; department and college are refreshed for Azure only —
profile_from_claims()(OIDC/Google) andprofile_from_assertion()(SAML) never populate those fields, so they stay empty for those providers.Moving a user between providers, or clearing a stale identity after a subject rotation, means editing
azure_oid/sso_provider/sso_subjecton theusersrow directly in the database. There is no admin UI or API for it. -
Otherwise, a new user is created:
- Username = local part of the username hint (ePPN /
preferred_username) or email; on collision,_<first 8 chars of subject>is appended. - No password hash. The account is SSO-only: it has no local password and
cannot use the local login form. There is currently no admin UI or API
to add one —
/dashboard/change-passwordreturns early whenpassword_hashis NULL, and the admin user-update endpoint has no password field. A local credential means creating a separate local account (admin → Users → Create Local User) or setting the hash directly in the database. - Group = the provider's
*_DEFAULT_GROUPsetting (defaultother). The group must already exist — create it on the admin Groups page first. If the named group does not exist, provisioning is refused: the user is bounced to the login page with "Failed to provision user account" and the server logssso_default_group_missingnaming the group. Existing users are unaffected; fix the setting (or create the group) and the login succeeds on retry. - Quota is seeded from the group (
rpm_limitcopied from the group's defaults).
- Username = local part of the username hint (ePPN /
SSO cannot create your first admin. Every SSO account is provisioned into a non-admin group chosen at provision time —
*_DEFAULT_GROUP(defaultother), or for Azure thejobTitlemapping falling back toAZURE_AD_DEFAULT_GROUP— nothing promotes a first user, and creating the API key needed to drive the admin API requires a principal that does not exist yet. Bootstrap the localadminaccount first (DEPLOYMENT.md → Bootstrap the First Admin Account), then either pre-create a local account whose email matches your SSO email and put it in theadmingroup (your first SSO login links to it and keeps the group — do this before logging in via SSO), or log in via SSO once and promote that account from Admin → Users.Do not point a
*_DEFAULT_GROUPatadminas a shortcut: it makes every user from that provider an admin for as long as it is set, and the group is fixed at provision time, so accounts created in the meantime stay admin after you change it back. Keep the localadminaccount as your way back in — an SSO-provisioned account can never be given a local password.
Deactivated accounts (is_active = false) are refused at login regardless of
provider.
Button labels come from enabled_providers() in sso/registry.py and tie into
Admin → Branding → "Institution / organization name" (branding.org_name):
- Azure AD is treated as the primary/institutional provider: its button is labeled with the branding org name (e.g. "Sign in with University of Idaho"); falls back to "SSO" when no org name is set.
- SAML uses the org name when Azure is not enabled and
SAML_DISPLAY_NAMEis left at its defaultSSO; otherwise it showsSAML_DISPLAY_NAME. - Generic OIDC uses the org name when it is the first enabled provider and
OIDC_SSO_DISPLAY_NAMEis left at its defaultSSO; otherwise it showsOIDC_SSO_DISPLAY_NAME. - Google is always labeled "Google".
Practical rule: for your institutional IdP, set the org name in Admin →
Branding and leave *_DISPLAY_NAME alone; for secondary providers, set an
explicit OIDC_SSO_DISPLAY_NAME / SAML_DISPLAY_NAME.
| Variable | Default | Required? |
|---|---|---|
AZURE_AD_CLIENT_ID |
– | Required for Azure |
AZURE_AD_CLIENT_SECRET |
– | Required for Azure |
AZURE_AD_TENANT_ID |
– | Required for Azure |
AZURE_AD_REDIRECT_URI |
https://your-domain.example.com/login/azure/authorized (placeholder) |
Required for Azure (absolute URL) |
AZURE_AD_DEFAULT_GROUP |
other |
Optional |
GOOGLE_SSO_CLIENT_ID |
– | Required for Google |
GOOGLE_SSO_CLIENT_SECRET |
– | Required for Google |
GOOGLE_SSO_REDIRECT_URI |
<APP_BASE_URL>/login/google/authorized |
Optional |
GOOGLE_SSO_HOSTED_DOMAIN |
– | Optional (restricts to a Workspace domain) |
GOOGLE_SSO_DEFAULT_GROUP |
other |
Optional |
OIDC_SSO_ISSUER |
– | Required for OIDC |
OIDC_SSO_CLIENT_ID |
– | Required for OIDC |
OIDC_SSO_CLIENT_SECRET |
– | Required for OIDC |
OIDC_SSO_REDIRECT_URI |
<APP_BASE_URL>/login/oidc/authorized |
Optional |
OIDC_SSO_DISPLAY_NAME |
SSO |
Optional |
OIDC_SSO_SCOPES |
openid profile email |
Optional |
OIDC_SSO_DEFAULT_GROUP |
other |
Optional |
SAML_SP_ENTITY_ID |
– | Required for SAML |
SAML_SP_ACS_URL |
<APP_BASE_URL>/login/saml/acs |
Optional |
SAML_IDP_METADATA_URL |
– | Required for SAML unless the explicit trio below is set |
SAML_IDP_ENTITY_ID |
– | Required if no metadata URL |
SAML_IDP_SSO_URL |
– | Required if no metadata URL |
SAML_IDP_X509_CERT |
– | Required if no metadata URL |
SAML_DISPLAY_NAME |
SSO |
Optional |
SAML_DEFAULT_GROUP |
other |
Optional |
SAML_ATTR_EMAIL |
mail |
Optional |
SAML_ATTR_NAME |
displayName |
Optional |
SAML_ATTR_USERNAME |
eduPersonPrincipalName |
Optional |
Note on AZURE_AD_REDIRECT_URI: in a Docker Compose deployment you must set
it explicitly. Compose passes it as ${AZURE_AD_REDIRECT_URI:-}, so an unset
variable arrives in the container as an empty string — the placeholder
default in settings.py never applies, and the Azure flow will fail with a
redirect-URI mismatch.
The app reads settings from the container environment. How values get there depends on which Compose stack the deployment runs, and the two stacks in this repo do it differently — check which one you are on before editing anything:
| Stack | Put values in | How they reach the container |
|---|---|---|
docker-compose.yml (host-networked stack; what a bare docker compose command starts) |
/opt/mindrouter/.env on the host |
Compose interpolates ${VAR:-} into the service's environment: block. Every variable in the table above — including AZURE_AD_DEFAULT_GROUP — is already wired through, so setting it in the env file is enough. |
docker-compose.prod.yml (nginx/TLS stack; see ../deploy/DEPLOYMENT.md) |
.env.prod in the deployment directory |
The service declares env_file:, so the file is handed to the container wholesale. Any variable you add is picked up as-is. |
-
No compose edit is needed for the variables in the table above on either stack. If you add a variable that is not listed there and you run the
docker-compose.ymlstack, add a matching- NEW_VAR=${NEW_VAR:-}line to itsenvironment:block; theenv_file:stack needs no such edit. -
Secrets stay on the host.
.env/.env.prodare never committed — no client secrets, no certificates, no private keys in the repo. -
Restart with the same Compose file the deployment was started with. Settings are cached per process (see Overview), so a recreate is required:
# docker-compose.yml stack docker compose up -d # docker-compose.prod.yml stack docker compose -f docker-compose.prod.yml up -d
These are not interchangeable. Running the bare command on a host started with
-f docker-compose.prod.ymldoes not reload your SSO settings — it starts the other stack alongside the running one. -
APP_BASE_URLmust name this deployment's own public HTTPS origin before any provider will work; redirect URIs and the SAMLDestinationcheck are derived from it. On thedocker-compose.ymlstack it is passed through as${APP_BASE_URL:-}, so leaving it unset delivers an empty string to the app — the code then falls back to the request scheme andHostheader, which behind a TLS-terminating proxy yields anhttp://SAMLDestinationand a failed validation at the IdP. Set it explicitly. See the Security notes below.
Behavior enforced by the shared framework (see sso/base.py, sso/oidc.py,
sso/saml.py):
- Email linking only adopts unclaimed accounts. A login is refused (logged
as
sso_email_link_refused) when the email matches an account already bound to any identity provider. Prevents a second enabled IdP from asserting an existing user's address — including an admin's — and inheriting the account. Since 2.9.0 the Azure driver enforces this too. - Unverified emails are rejected for OIDC/Google when the IdP sends
email_verifiedand it is not true; IdPs that omit the claim entirely are trusted. The claim is normalized, so string forms ("false","0") do not pass. - CSRF state is a signed, timed token (10 min) round-tripped through an HttpOnly cookie, checked on every OIDC callback.
- SAML is SP-initiated only. The ACS requires a signed
saml_request_idcookie from/login/samland requires the response'sInResponseToto echo that AuthnRequest ID, so unsolicited IdP-initiated POSTs to the ACS are refused. (This is enforced in MindRouter, not by a library setting — python3-saml has no unsolicited-response option, and it skips its ownInResponseTocomparison when the response omits the attribute.)rejectDeprecatedAlgorithmblocks SHA-1 signatures, and assertions must be signed (strictmode). That cookie isSameSite=None; Secureso it survives the IdP's cross-site POST to the ACS, which makes HTTPS a hard requirement for SAML. Note: IdP-initiated login (e.g. launching MindRouter from a campus app portal tile) is therefore not supported — users must start at the MindRouter login page. - SAML IdP metadata must be served over HTTPS — it carries the signing
certificate, the only trust anchor for assertion validation. A plain-
httpSAML_IDP_METADATA_URLdisables the provider. For a stronger anchor, pin the certificate locally withSAML_IDP_ENTITY_ID/SAML_IDP_SSO_URL/SAML_IDP_X509_CERTinstead of fetching metadata. - Public URLs are derived from
APP_BASE_URLrather than from request headers — keepAPP_BASE_URLset. OIDC redirect URIs and the SAML Destination/Recipient check are built from the configured base URL, so a spoofedX-Forwarded-Hostcannot influence them. IfAPP_BASE_URLis blank the code falls back to the request's own scheme/Host headers — the OIDC path readsX-Forwarded-Proto(sso/oidc.py), while the SAML request adapter usesrequest.url.schemeplus theHostheader and never consultsX-Forwarded-Proto(sso/saml.py), so behind a TLS-terminating proxy a blankAPP_BASE_URLyields anhttpSAML Destination. Which is exactly why leaving it set matters. Point it at your public HTTPS origin. - What the IdP must sign (SAML): the assertion
(
wantAssertionsSigned: true). A message-level signature on the SAML<Response>is not required (wantMessagesSigned: false). - Encrypted assertions (SAML) require an SP key pair. Configure
SAML_SP_X509_CERT/SAML_SP_PRIVATE_KEY(see "SAML SP key pair" above) and setSAML_WANT_ASSERTIONS_ENCRYPTED=true; otherwise encryption must be disabled for this SP at the IdP. Stock Shibboleth encrypts by default, and a mismatch fails at the IdP before any response reaches MindRouter — so nothing appears in MindRouter's logs. The private key is a secret: host.envor a mounted file, never the repo. SECRET_KEYunderpins the SSO handshake. The signed OIDCstatecookie and the SAMLsaml_request_idcookie are both signed with it (state_serializer()insso/base.py). A weak or leakedSECRET_KEYtherefore weakens OIDC CSRF protection and SAML SP-initiated-only enforcement; rotating it invalidates any login already in flight.