Rustango docs
← Authentication

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 table rustango::sso::link (SsoLink, ProviderKey, sign_in), the bare-admin wiring rustango::admin::sso, per-tenant/console SSO rustango::tenancy::sso (SharedSsoProvider), and member SSO rustango::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:

FeaturePulls inGives you
ssooauth2, castsThe 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-ssoadmin, ssoThe 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

  1. The login page shows one "Sign in with <provider>" button per enabled provider.
  2. Clicking one (GET <login>/sso/<slug>) redirects to the IdP with a signed, short-lived flow cookie (PKCE + CSRF state).
  3. The IdP sends the user back to <login>/sso/<slug>/callback.
  4. rustango verifies the flow, exchanges the code and reads /userinfo.
  5. It looks up the link for this provider and the IdP sub. With no link, and allow_email_link on, a verified email that matches a non-privileged user creates the link.
  6. 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.
  7. 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:

FieldMeaning
slugStable route key + button id (<login>/sso/<slug>). Unique.
labelButton text, e.g. "Sign in with Google".
kindA preset — google / microsoft / github / gitlab / discord — or oidc for a generic OpenID Connect provider.
issuer_urlOIDC discovery base URL (for kind = "oidc"); rustango fetches {issuer}/.well-known/openid-configuration. Unused for presets.
client_idThe OAuth client id from the IdP.
client_secretThe OAuth client secret, encrypted at rest (never plaintext in the DB).
enabledWhether the button shows on the login page.
sort_orderButton ordering (ascending).
scopesOptional space-separated scope override (default openid email profile).
allow_email_linkLink 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): SsoProvider rows are a plain global table, managed from the bare admin. Requires Builder::with_session_auth (SSO mints the same session).
  • Tenant admin (multi-tenancy): each tenant manages its own SsoProvider rows from its admin — granular, self-service, isolated per tenant.
  • Operator console (multi-tenancy): an operator defines a SharedSsoProvider once and it's offered to every tenant (a company-wide Google, say). Managed from the console's Shared SSO panel, where Allow email linking toggles allow_email_link in 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 matching rustango_users row 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 the allow_email_link rule above. Set it to false to refuse unknown emails. A native sign-in calls find_or_provision_member directly; its MemberSignIn result tells NotLinked (an account has the email but may not be linked by it) apart from NoAccount.
  • 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 with RUSTANGO_SESSION_SECRET. It is slug-bound (a cookie minted for acme never authenticates on globex) 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 SsoProvider rows only — the registry-wide SharedSsoProvider merge and a custom provision hook 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_KEY in 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, and Secure per configuration — not per request scheme. Precedence is security.secure_cookies (default true on the manage path, so it fails closed), falling back to "secure on the prod tier" from RUSTANGO_ENV when nothing is set. Nothing inspects whether the request actually arrived over HTTPS. The handshake carries PKCE + a signed state.
  • SSO sessions are the ordinary admin session — rotating or deactivating the linked user invalidates them through the existing live gate.
  • Trust model is /userinfo over TLS (the id_token isn't independently verified); front the admin with HTTPS.

See also