Single Sign-On (SAML)
The SIS can act as a SAML service provider against one or more identity providers. Users pick a provider on the login page; accounts are created on first successful login.
Administration → SSO Identity Providers (/admin/sso-providers), gated on ADMIN.SSO_IDP (migration 093).
Server configuration
| Variable | Meaning | Default |
|---|---|---|
SAML_SP_BASE_URL | The public base for this instance's SP URLs | http://localhost:5173 |
SAML_SP_CERT_PATH | SP certificate | ./saml/sp.crt |
SAML_SP_KEY_PATH | SP private key | ./saml/sp.key |
SAML_METADATA_MAX_AGE | How long fetched IdP metadata is trusted | 24h |
SAML_SP_BASE_URL must keep the path prefix
The ACS URL is built as <base>/api/v1/auth/saml/{slug}/acs, and the identity provider posts to it directly — so it must be reachable exactly as written.
Each instance therefore keeps its prefix:
| Instance | SAML_SP_BASE_URL |
|---|---|
/sdasis | https://sdasis.puc.edu/sdasis |
/demo | https://sdasis.puc.edu/demo |
/records | https://sdasis.puc.edu/records |
This is the opposite of PUBLIC_API_BASE_PATH's neighbours in the same file — do not "simplify" it to the bare hostname. The backend warns at startup if the value is not a public HTTPS URL, because the SP metadata would then be unusable by an IdP.
Each instance also has its own saml volume holding its keypair, so the three instances are separate service providers rather than one shared identity.
Adding an identity provider
Providers live in core.sso_identity_providers:
| Field | Notes |
|---|---|
display_name | Shown on the login page |
slug | Appears in the URLs below. Stable; changing it changes the ACS URL |
metadata_url | Fetched and refreshed |
metadata_xml | Pasted metadata, as an alternative to a URL |
metadata_fetched_at | When the URL was last read |
is_active, sort_order |
Two are configured here: puc (active) and puc-forge (inactive).
Per-provider URLs
| URL | Who calls it |
|---|---|
GET /auth/saml/{slug}/metadata | The IdP, to configure its side |
GET /auth/saml/{slug}/login | The browser, to begin a login |
POST /auth/saml/{slug}/acs | The IdP, posting the assertion |
The metadata endpoint is public — SP metadata has to be fetchable by the identity provider itself. So is GET /auth/sso/providers, which the login page reads before any session exists; it returns only display name and slug.
POST .../refresh-metadata re-fetches a provider's metadata on demand rather than waiting for SAML_METADATA_MAX_AGE to expire.
Steps
- Set
SAML_SP_BASE_URLfor the instance, and make sure itssamlvolume holds a keypair. - Create the provider with its metadata URL.
- Give the identity provider your SP metadata URL:
https://<host><prefix>/api/v1/auth/saml/<slug>/metadata. - Confirm the IdP's ACS destination matches
https://<host><prefix>/api/v1/auth/saml/<slug>/acs. - Activate the provider; it appears on the login page.
- Test with a real account.
Relay state is held in Valkey for the duration of the round trip, so the login flow depends on Valkey being available.
What happens on login
- The assertion is validated against the provider's metadata.
- The user is matched, or JIT-provisioned — an account is created in
core.auth_userson first successful login. DeriveRolesruns on every login, so role membership implied by the person's record (student, teacher, advisor) is recomputed rather than being a one-time assignment.
Derived roles are recomputed, not accumulated
Because derivation runs per login, changing someone's underlying record changes their roles at their next sign-in without an administrator touching anything. A duplicated derivation rule once caused 87,000 rows of nightly churn, so if derived-role counts move unexpectedly, look for two rules describing the same condition rather than for a data problem.
The production tier's write gate
The /records instance runs with WRITE_MODE=restricted.
A restricted instance refuses writes before the permission check
WRITE_MODE=restricted rejects a write earlier in the request than the permission guard, so a SAML-provisioned administrator with every grant can still be refused. That looked like a permissions bug and was not.
The fix is an allowlist of the paths that must remain writable — SSO administration among them — rather than a broader grant.
Demo mode
With DEMO_MODE=true, SSO is deliberately disabled: GET /auth/sso/providers returns the disabled response, and no SAML flow is registered. The demo tier publishes its own local logins instead.
Related
- Authentication (developer) — sessions, cookies, password hashing
- Roles — including derived roles
- Deployment — the three instances and their prefixes
- Environment — the full variable list