SSO (OpenID Connect / social login)
Sign in with an external identity provider — Google, Microsoft / Azure AD, GitHub, GitLab, Discord, or any OpenID Connect provider (Okta, Auth0, Keycloak, …) — instead of a local password.
You can configure multiple providers, each managed from the admin UI as a row (no config file, no rebuild). A provider's endpoints are auto-discovered from its OIDC issuer URL at login; social providers use built-in presets.
SSO signs in the user linked to the IdP identity: one
rustango_sso_links row per (provider, sub). The email the IdP sends is
never enough on its own. A first-time user is linked by verified email
only when the provider has allow_email_link on (default off), and
never when the account is a superuser or staff (tenant: holds any
permission; bare admin: every account). Those accounts are linked by an
admin, who adds the SsoLink row in the admin; the refusal log line names
the subject. (The member flow, below, auto-provisions by default; see
below to turn it off.)
Source: the admin-independent core
rustango::sso(SsoProvider,build_provider,verified_email,ResolvedSso,SsoError), the link tablerustango::sso::link(SsoLink,ProviderKey,sign_in), the bare-admin wiringrustango::admin::sso, per-tenant/console SSOrustango::tenancy::sso(SharedSsoProvider), and member SSOrustango::tenancy::member_auth.
Features & who uses them
Since 0.49 the SSO core is its own feature, independent of the
auto-admin, so an end-user (member) login can build without pulling in
crate::admin:
| Feature | Pulls in | Gives you |
|---|---|---|
sso | oauth2, casts | The admin-independent core: rustango::sso — the OIDC / social OAuth handshake, the DB-backed SsoProvider model (secret encrypted at rest via casts), and the member flow (tenancy::member_auth, with tenancy). |
admin-sso | admin, sso | The above plus the bare-admin login wiring (rustango::admin::sso) — SSO buttons on the admin login page that mint the admin session. |
[dependencies]
# Admin login with SSO:
rustango = { version = "0.60", features = ["admin-sso"] }
# Member (end-user) SSO without the auto-admin:
rustango = { version = "0.60", features = ["tenancy", "sso"] }
admin::sso_provider and the historical admin::sso::* core paths are
now re-export shims over sso::provider / sso::*, so existing
crate::admin::sso::{build_provider, ResolvedSso, …} and
crate::admin::sso_provider::SsoProvider imports keep resolving
unchanged. Since 0.58 rustango_sso_providers has an allow_email_link
column and there is a rustango_sso_links table; makemigrations
emits both.
The email opt-in email linking matches is the email column. On the tenant
User model it is gated on the sso feature (moved off admin-sso
in 0.49, so member-SSO-only builds still get the column); the bare
AdminUser.email remains behind admin-sso. Enabling or disabling the
feature emits an AddColumn / DropColumn migration for that column.
How it works
- The login page shows one "Sign in with <provider>" button per enabled provider.
- Clicking one (
GET <login>/sso/<slug>) redirects to the IdP with a signed, short-lived flow cookie (PKCE + CSRFstate). - The IdP sends the user back to
<login>/sso/<slug>/callback. - rustango verifies the flow, exchanges the code and reads
/userinfo. - It looks up the link for this provider and the IdP
sub. With no link, andallow_email_linkon, a verified email that matches a non-privileged user creates the link. - If the linked user is active, it mints the same signed-cookie session a password login produces — so every existing gate (superuser / permissions, live password-change invalidation) still applies.
- Otherwise the user is bounced back to the login page with a generic error (details go to the server log, never the browser).
The link table is a normal migrated model: in the tenant's storage for tenant logins, and in the admin database for the bare admin. A link is matched exactly (issuer and subject keyed by a SHA-256; the email ignores ASCII case only), whatever the database collation.
The client secret is encrypted at rest — the client_secret column
is an EncryptedString cast, decrypted in-memory only
at login time.
Providers are rows, managed in the admin
Each provider is a SsoProvider row. It shows up as an ordinary
admin model — add/edit/enable from the admin UI, no redeploy. Fields:
| Field | Meaning |
|---|---|
slug | Stable route key + button id (<login>/sso/<slug>). Unique. |
label | Button text, e.g. "Sign in with Google". |
kind | A preset — google / microsoft / github / gitlab / discord — or oidc for a generic OpenID Connect provider. |
issuer_url | OIDC discovery base URL (for kind = "oidc"); rustango fetches {issuer}/.well-known/openid-configuration. Unused for presets. |
client_id | The OAuth client id from the IdP. |
client_secret | The OAuth client secret, encrypted at rest (never plaintext in the DB). |
enabled | Whether the button shows on the login page. |
sort_order | Button ordering (ascending). |
scopes | Optional space-separated scope override (default openid email profile). |
allow_email_link | Link a first-time user by verified email (default off). Never links a superuser or staff account; ignored by the bare admin. |
Only a superuser can add, change or delete SsoProvider and SsoLink
rows in the admin; other staff can list them with the usual permissions.
To add a provider: enter the client_id + client_secret, pick a
kind (or oidc + an issuer_url), and save. The endpoints are
discovered at login — no per-provider endpoint wiring.
Where each surface manages providers
- Single-tenant / standalone admin (
crate::admin):SsoProviderrows are a plain global table, managed from the bare admin. RequiresBuilder::with_session_auth(SSO mints the same session). - Tenant admin (multi-tenancy): each tenant manages its own
SsoProviderrows from its admin — granular, self-service, isolated per tenant. - Operator console (multi-tenancy): an operator defines a
SharedSsoProvideronce and it's offered to every tenant (a company-wide Google, say). Managed from the console's Shared SSO panel, where Allow email linking togglesallow_email_linkin place (the id and its links stay). The flag applies to every tenant.
On a tenant's login page the two sets merge, and on a slug clash the tenant's own provider wins over the shared one — so a tenant can override a shared provider for itself.
The callback URL is derived per request from the host + slug
(https://<host><login>/sso/<slug>/callback), so register that with the
IdP. A user is linked by opt-in email linking (non-privileged tenant
users), or by a superuser adding an SsoLink row: provider_source
(tenant, shared or admin), provider_id (the provider row id),
issuer (kind, or kind|issuer_url without a trailing slash),
subject and user_id. The admin computes
key_sha256. The refusal log line (sso refused) carries provider_id,
issuer and subject. Adding a row needs the admin's session auth
(Builder::with_session_auth, or the tenant admin's with_session);
without it nobody can add links.
Member (end-user) SSO
The surfaces above sign people in to an admin. tenancy::member_auth
is the member-facing analogue: it logs an end-user into a tenant's own
user pool (rustango_users) and mints a member session, so a gym
member / SaaS customer can "Sign in with Google" without touching the
admin. It reuses the exact same rustango::sso core and the tenant's own
SsoProvider rows — only the session it mints differs, which is why it
lives behind the sso feature (not admin-sso) and needs no auto-admin.
Mount member_sso_router into a tenancy::server::Builder stack (it
reads the resolved Arc<TenantContext> the builder injects):
use rustango::tenancy::member_auth::{member_sso_router, MemberAuthConfig};
let members = member_sso_router(MemberAuthConfig {
login_base: "/auth".into(), // buttons link to /auth/sso/<slug>
landing_url: "/".into(), // post-login destination (honors a same-origin ?next)
auto_provision: true, // create a user from a verified email on first sign-in
session_ttl: 7 * 24 * 60 * 60, // 7 days
..Default::default()
});
With several backends compiled in, member_sso_router is for the default
(Postgres) tenant type; use member_sso_router_for::<sqlx::Sqlite> (or MySql).
It mounts two per-slug routes off login_base:
GET {login_base}/sso/{slug}— begin the handshake, redirect to the IdP.GET {login_base}/sso/{slug}/callback— complete it, find-or-provision the member, mint the session cookie.
Differences from the admin flow:
- Auto-provisioning. With
auto_provision = true(the default), a verified IdP email with no matchingrustango_usersrow creates one — username from the email local-part (deduped on a clash), a real but unusable random password hash (SSO users can't password-login) — and links it. An email that matches an existing account follows theallow_email_linkrule above. Set it tofalseto refuse unknown emails. A native sign-in callsfind_or_provision_memberdirectly; itsMemberSignInresult tellsNotLinked(an account has the email but may not be linked by it) apart fromNoAccount. - Its own session cookie. The member cookie
(
rustango_member_session) is domain-separated from the tenant / admin session cookies: the signed message carries a per-domain tag and an audience claim, so a member cookie can never validate as a tenant/admin cookie (or vice-versa) even though both are signed withRUSTANGO_SESSION_SECRET. It is slug-bound (a cookie minted foracmenever authenticates onglobex) and invalidated by a password rotation (parity with the admin session).
Read the current member in a handler with the CurrentMember
extractor — the member analogue of SessionUser. It's infallible
(None for anonymous / expired / rotated-out / cross-tenant sessions),
so it composes with public routes:
use rustango::tenancy::member_auth::CurrentMember;
async fn dashboard(CurrentMember(member): CurrentMember) -> impl axum::response::IntoResponse {
match member {
Some(user) => format!("Hi, {}", user.username),
None => "Please sign in".to_owned(),
}
}
v1 scope. Member SSO resolves providers from the tenant's own
SsoProviderrows only — the registry-wideSharedSsoProvidermerge and a customprovisionhook are follow-ups.
Secret storage
client_secret is stored encrypted at rest with XChaCha20-Poly1305
(AEAD), the key derived from the RUSTANGO_SECRET_KEY environment
variable. It's decrypted in memory only at login, to authenticate to the
IdP's token endpoint. So a leaked DB dump never exposes the secret, and
each tenant keeps its own secret with no per-provider env var.
Set
RUSTANGO_SECRET_KEYin the deployment (any length; it's SHA-256'd to a 32-byte key). Without it, saving or using a provider fails fast — the same posture as a missing database URL.
Providers (presets)
Built-in presets: google, microsoft (Azure AD), github, gitlab,
discord. For anything else, use kind = "oidc" with an issuer_url —
rustango runs OpenID Connect discovery to find the endpoints, once per issuer
per hour. (Sign in
with Apple isn't a preset; it needs id_token/JWKS verification.)
Security notes
- Link first — the
(provider, sub)link decides; the email is used only by opt-in email linking, and only when verified. - No privileged email links — superusers and staff are linked by a superuser only. A link made by email keeps working after the user is promoted; delete it if that is not wanted.
- No auto-provisioning for the admins — an unknown email can't get in.
- Secrets encrypted at rest (
RUSTANGO_SECRET_KEY), decrypted only in memory at login; edit forms mask the stored secret. - The flow cookie is short-lived (10 min),
HttpOnly,SameSite=Lax, andSecureper configuration — not per request scheme. Precedence issecurity.secure_cookies(defaulttrueon themanagepath, so it fails closed), falling back to "secure on the prod tier" fromRUSTANGO_ENVwhen nothing is set. Nothing inspects whether the request actually arrived over HTTPS. The handshake carries PKCE + a signedstate. - SSO sessions are the ordinary admin session — rotating or deactivating the linked user invalidates them through the existing live gate.
- Trust model is
/userinfoover TLS (the id_token isn't independently verified); front the admin with HTTPS.