navree
Documentation

Single sign-on

SAML 2.0 and OpenID Connect sign-in per organization or workspace, verified email domains that route sign-in and can refuse passwords, and just-in-time provisioning with group to role mapping.

On this page

Introduction

Featuresso Companies want their people to sign in to your app with the company's identity provider: Okta, Microsoft Entra ID, Google Workspace, AD FS, Ping, Keycloak or anything else that speaks SAML 2.0 or OpenID Connect. navree-sso gives each organization or workspace its own connection, and a sign-in through it ends with the same Navree user and the same access and refresh tokens as a password login, so every route, permission and audit entry behaves as before.

It covers the whole flow:

  • Connections per tenant. A connection belongs to the app, an organization or a workspace, and talks SAML 2.0 (the identity provider's metadata, by URL or pasted) or OpenID Connect (an issuer, a client id and a secret).
  • Verified domains. A DNS TXT record proves acme.com belongs to Acme's connection. From then on POST /api/sso/discover {email} sends @acme.com addresses to it, and the connection can refuse their passwords.
  • Just-in-time provisioning. The first sign-in creates the user, makes them a member of the connection's organization or workspace and gives them the role their identity provider groups map to.
  • Signatures checked in Rust. SAML responses are verified by Navree itself: XML read into a strict tree, exclusive canonicalization, RSA-SHA256 or ECDSA on ring. SHA-1 is refused, signature wrapping can't move what a signature covers, and every assertion works once.
  • A fake identity provider for your tests, whose responses pass the same checks.

Installation

The sso feature is part of the facade's default full feature. With your own feature list, add it:

Cargo.toml

Add SsoModule after AuthModule, and after WorkspaceModule when connections belong to organizations or workspaces:

src/app.rs

CacheModule is optional: without it, sign-in states, used assertion ids and one-time codes live in the process, which is enough for a single instance. With several instances, give the cache a shared store, so a state or an assertion used on one instance is refused on the others. The management routes check a permission, so the app needs AccessModule for them; without it, set sso.management_routes = false (the boot fails otherwise, naming the routes).

The module contributes the migration set sso (after auth), which creates three tables:

TableOne row per
sso_connectionsconnection: its owner, slug, name, protocol, settings (the client secret encrypted with the app key), provisioning rules and switches
sso_domainsemail domain of a connection, with its verification token and when it was verified
sso_identitiesperson at an identity provider (the SAML name id or the OpenID Connect sub), linked to a user, with the attributes of their last sign-in

Deleting a connection deletes its domains and identities; deleting a user deletes their identities.

Configuration

config/app.toml
KeyDefaultWhat it does
sso.base_urlapp.urlThe API's public URL. The assertion consumer service, metadata and callback URLs you give identity providers start with it.
sso.complete_urlnoneThe front end's page that finishes a sign-in (see Finishing a sign-in). Without it, the assertion consumer service and the callback answer with the login JSON.
sso.enforce_for_verified_domainsfalseRefuse passwords for every verified domain of an enabled connection. A connection's own enforce overrides it.
sso.routestrueMount the routes.
sso.management_routestrueMount the management routes too (they need AccessModule).
sso.routes_prefix/ssoThe routes' prefix under http.api_prefix.
sso.state_ttl_secs600How long a sign-in may take from the login route to the identity provider's answer.
sso.code_ttl_secs60How long the one-time code of a finished sign-in works.
sso.domains.txt_prefixnavree-sso-verificationThe TXT record's value is {txt_prefix}={token}.
sso.domains.doh_urlhttps://cloudflare-dns.com/dns-queryThe DNS-over-HTTPS resolver TXT records are read from (JSON API).
sso.saml.allow_idp_initiatedfalseAccept responses that answer no request, sent from the identity provider's dashboard.
sso.saml.clock_skew_secs120How far an identity provider's clock may be off.
sso.saml.metadata_ttl_secs3600How long metadata fetched from a metadata_url is used.
sso.saml.max_response_bytes1048576The largest SAMLResponse accepted.
sso.oidc.scopesopenid email profileThe scopes asked for when a connection names none.
sso.oidc.clock_skew_secs60How far a provider's clock may be off.

Environment variables follow the usual pattern: APP_SSO__BASE_URL, APP_SSO__SAML__ALLOW_IDP_INITIATED=true, and APP_SSO__OIDC__SCOPES="openid,email,groups" (a comma-separated list). A base_url or complete_url that isn't an absolute URL, a prefix without a leading /, a zero time-to-live or scopes without openid fail the boot, naming the key. SsoModule::for_root(SsoOptions { .. }) takes the same options in code.

Connections

A connection is one identity provider for one owner:

OwnerJSONA signed-in person becomes
SsoOwner::App{"type": "app"}a user (single-tenant apps, your own staff)
SsoOwner::Organization(id){"type": "organization", "id": "..."}a member of the organization, reaching all its workspaces
SsoOwner::Workspace(id){"type": "workspace", "id": "..."}a member of the workspace

Each connection has a slug, the URL segment of its routes (acme in /api/sso/acme/login). It is derived from the name when you leave it out (Acme Okta becomes acme-okta, then acme-okta-2), and it is lower-case letters, digits and dashes. Changing a slug changes the URLs the identity provider knows, so set it once.

SAML connections

Give the identity provider's metadata as a URL or as the document itself:

JSON
SettingDefaultWhat it does
metadata_urlFetched over https and kept sso.saml.metadata_ttl_secs
metadata_xmlThe metadata document (give exactly one of the two)
name_id_formatthe identity provider'sThe NameIDPolicy format to ask for. Prefer persistent or emailAddress: a transient name id changes at every sign-in.
force_authnfalseAsk the identity provider to authenticate the person again, even with a session
require_signed_assertionfalseRefuse a signed response around an unsigned assertion

Navree reads the metadata's entity id, the HTTP-Redirect sign-on URL and every signing certificate (a rotation can list two). Pasted metadata that doesn't parse, has no signing certificate or has an RSA key under 2048 bits is refused when you save the connection; metadata from a URL is read at the first sign-in, and the same problems refuse that sign-in. The metadata's own signature is not checked: trust comes from where the document comes from, an https URL or XML an administrator pasted.

Give the identity provider Navree's side, which every connection carries in service_provider:

Identity provider fieldValue
Entity id, audience, identifier{base_url}/api/sso/saml/{slug}/metadata
Assertion consumer service, reply URL, ACS URL (HTTP-POST){base_url}/api/sso/saml/{slug}/acs
Metadata URL (to import){base_url}/api/sso/saml/{slug}/metadata
  • Okta: create a SAML 2.0 app with those two URLs, name id format EmailAddress or Persistent, and attribute statements email, firstName, lastName plus a group attribute statement groups. Copy the app's metadata URL into the connection.
  • Microsoft Entra ID: create an enterprise application with SAML sign-on, paste the identifier and reply URL, and add a group claim (it sends group object ids). Copy the "App Federation Metadata Url".
  • Google Workspace: add a custom SAML app, download its metadata (paste it as metadata_xml), set the ACS URL and entity id, and map first_name, last_name and group membership to groups.

OpenID Connect connections

JSON

Navree reads the issuer's discovery document ({issuer}/.well-known/openid-configuration), which must name the same issuer. Register {base_url}/api/sso/oidc/{slug}/callback as the client's redirect URI; it is in service_provider.redirect_uri. The client secret is encrypted with the app key before it is stored and never comes back from the API (has_client_secret says whether there is one); an update without client_secret keeps the stored one. A public client with no secret works too, with PKCE.

Managing connections

The management routes need the permission sso.manage (MANAGE_PERMISSION), which roles grant like any other:

RouteDoes
GET /api/sso/connections?owner_type=&owner_id=List connections, of one owner when given
POST /api/sso/connectionsCreate one (201): owner, name, slug?, settings, provisioning?, enforce?, enabled?
GET /api/sso/connections/{id}Show one (by id or slug)
PATCH /api/sso/connections/{id}Change name, slug, settings (same protocol), provisioning, enabled, enforce, or follow_default_enforcement: true
DELETE /api/sso/connections/{id}Delete it with its domains and identities (204)
GET /api/sso/connections/{id}/domainsIts domains
POST /api/sso/connections/{id}/domainsAdd a domain (201)
POST /api/sso/domains/{id}/verifyLook its TXT record up
DELETE /api/sso/domains/{id}Remove a domain (204)

A taken or reserved slug, settings without metadata or with metadata that doesn't parse, a metadata URL or issuer that isn't https, rules that grant the owner role, and an owner that doesn't exist are 400s with field errors (errors.slug, errors["settings.metadata_xml"], errors.owner, ...). Every change is written through Repo, so the audit log records it (client secrets only as ciphertext).

To let workspace administrators manage their own connection, mount your own routes on the Sso service (see Using the Sso service).

Verified domains

A domain routes sign-in only once its owner proves it is theirs. Adding one answers the TXT record to create:

HTTP
JSON

Once the record is published, POST /api/sso/domains/{id}/verify reads the domain's TXT records through DNS over HTTPS and marks it verified. A missing record is a 400 (domain_not_verified; DNS changes take a while to appear, so try again later), and a domain already verified for another connection is a 409 (domain_taken). Operators and seeders can skip DNS with Sso::mark_domain_verified.

Domains are lower-cased and checked (acme.com, mail.acme.co.uk; no IP addresses, no single labels, ASCII only: write internationalized domains in their xn-- form). Verification emits SsoDomainVerified { connection_id, domain }.

Discovery

A sign-in page asks for the email first, then shows the password field or sends the person to their identity provider:

HTTP
JSON

An email whose domain no enabled connection has verified answers {"sso": false, "password_allowed": true} and nulls.

Refusing passwords

With sso.enforce_for_verified_domains = true, or enforce: true on a connection, people of its verified domains can't use a password: POST /api/auth/login and POST /api/auth/register answer 403 with code sso_required before any password is checked.

JSON

The refusal is audited as auth.login_refused. It comes from a navree-auth PasswordSignInPolicy the module registers; your own password flows can ask the same question with AuthService::check_password_sign_in(email). A connection with enforce: false keeps passwords working for its domains whatever the app's default; follow_default_enforcement: true goes back to the default.

How sign-in works

  1. The sign-in page sends the browser to GET /api/sso/{connection}/login?return_to=/deals. Navree creates a state, keeps it in the cache for sso.state_ttl_secs, and redirects (302) to the identity provider: a SAML AuthnRequest with the HTTP-Redirect binding, or an OpenID Connect authorization request with PKCE (S256) and a nonce. With Accept: application/json the route answers {"url": "...", "state": "..."} instead, for a front end that redirects itself.
  2. The person signs in at their identity provider.
  3. The identity provider posts a SAMLResponse and the RelayState to POST /api/sso/saml/{connection}/acs, or redirects the browser to GET /api/sso/oidc/{connection}/callback?code=...&state=....
  4. Navree takes the state (it works once), checks the answer, finds, links or creates the user, provisions their membership, and finishes the sign-in.

return_to must be a relative path (/deals, never https://... or //...); it comes back when the sign-in finishes.

Finishing a sign-in

The identity provider talks to the API directly, but tokens belong in your front end. With sso.complete_url set, the assertion consumer service and the callback redirect (303) there with a one-time code:

Text

The front end (or its backend-for-frontend) exchanges the code within sso.code_ttl_secs, once:

HTTP

state is the one the login route answered in its JSON form. Keep it in the browser (session storage, or the BFF's cookie) when the sign-in starts and send it with the exchange: Navree then refuses a code from any other sign-in, so nobody can sign a victim into the attacker's account by posting the attacker's response through the victim's browser. A sign-in started with the redirect form has no state the front end knows; the exchange works without one.

JSON

When the sign-in fails, the redirect carries the error instead, so the page can show it in the person's language: ?error=unverified_domain&message=.... Without complete_url, the assertion consumer service and the callback answer with the JSON above, or with the error.

A single sign-on skips Navree's own two-factor challenge: the identity provider does its own.

What can go wrong

AnswercodeWhen
404sso_connection_not_foundNo connection has this slug or id
403sso_connection_disabledThe connection is turned off (enabled: false)
400invalid_stateThe state is unknown, expired, used, or for another connection
400invalid_saml_responseThe SAML response failed a check (the reason is logged under navree::security, never sent)
403sso_refusedThe identity provider answered with an error status (the person isn't assigned to the app, say)
400assertion_replayedThe assertion was used already
400sso_provider_refusedThe OpenID Connect provider refused the sign-in or the code (access_denied, invalid_grant)
400invalid_id_tokenThe ID token's signature, issuer, audience, expiry or nonce is wrong
400missing_emailA new user would be created, but the identity provider sent no email
403unverified_domainA new user's email isn't in a verified domain of the connection
409email_takenThe email belongs to a user and can't be linked (see below)
403sso_provisioning_disabledNo linked user, and just-in-time provisioning is off
403sso_not_assignedThe person's groups map to no role and the connection has no default role
400invalid_sso_codeThe one-time code is unknown, expired or used
400invalid_return_toreturn_to isn't a relative path
502sso_provider_unavailableMetadata, discovery, the key set or the token endpoint couldn't be reached

A sign-in that fails half way writes nothing: the user, the identity and the membership are written in one transaction of their own.

Just-in-time provisioning

Navree finds the user of a sign-in in this order:

  1. A linked identity. An sso_identities row for this connection and subject (name id or sub) signs in its user, and is updated with the latest email and attributes.
  2. The same email, in a verified domain. When the email's domain is a verified domain of this connection and the user with that email has verified it (email_verified_at), the user gets the identity linked. An email outside the connection's verified domains never links: one company's identity provider must not take over another company's accounts. An account whose address was never verified doesn't link either, so whoever registered [email protected] without owning the mailbox doesn't get the CEO's sign-ins; and an OpenID Connect token that says email_verified: false never links.
  3. A new user, when provisioning.jit is on (the default) and the email is in a verified domain. The user gets the profile's name and email, no password, and a verified email. With allow_unverified_emails: true, users outside the verified domains are created too, with an unverified email.
  4. Otherwise the answer is 409 email_taken (the email belongs to someone) or 403 (unverified_domain, sso_provisioning_disabled).

Then the person's membership of the connection's owner follows their groups. The first rule whose group they have wins; without one, default_role applies:

JSON
  • A workspace connection adds the person to the workspace with the role (Members::add); an organization connection adds them to the organization with that organization role, reaching all its workspaces.
  • With sync_roles (the default), the role follows the groups at every sign-in. The owner's role never changes, and no rule may grant owner.
  • default_role: null refuses people whose groups match no rule (403 sso_not_assigned), which keeps everyone outside the listed groups out.
  • Groups are compared ignoring ASCII case. Entra ID sends group object ids, so write those in group.

A sign-in that created the user or changed a membership emits SsoUserProvisioned { connection_id, user_id, owner, role, created } after the commit. Sign-ins are audited as sso.login (with metadata.connection), new users as auth.register.

Attribute mapping

The profile is read from the SAML attributes or the ID token claims, the first name present winning. The defaults cover Okta, Entra ID, Google Workspace, AD FS and the standard claims:

FieldDefault names
emailemail, mail, Email, emailAddress, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress, urn:oid:0.9.2342.19200300.100.1.3
namename, displayName, http://schemas.microsoft.com/identity/claims/displayname, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name, urn:oid:2.16.840.1.113730.3.1.241
first_namegiven_name, firstName, first_name, givenName, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname, urn:oid:2.5.4.42
last_namefamily_name, lastName, last_name, surname, sn, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname, urn:oid:2.5.4.4
groupsgroups, memberOf, http://schemas.microsoft.com/ws/2008/06/identity/claims/groups, Group

Override any list in provisioning.attributes ("attributes": {"groups": ["department"]}). A SAML name id that is an email address stands in for a missing email attribute; the name is the display name, or the first and last names joined.

SAML in detail

Navree verifies SAML in Rust, with no XML security library: quick-xml reads the message into a strict tree, the crate canonicalizes it and ring checks the signature through navree_crypto::jose. A response must pass, in order:

  1. XML. UTF-8 XML 1.0. A document type declaration (and so any entity expansion or external entity), an entity other than the five predefined ones, an unbound prefix, a duplicate attribute, more than 64 levels or 100,000 nodes is refused.
  2. Status. Success; otherwise 403 sso_refused, with the identity provider's status and message in the log.
  3. One assertion. Exactly one Assertion, a direct child of the Response. An EncryptedAssertion is refused: Navree reads assertions protected by TLS, so turn assertion encryption off at the identity provider.
  4. Signatures. The response, the assertion or both are signed by a certificate of the metadata (require_signed_assertion insists on the assertion). Every signature present must verify, ids must be unique in the document, and signatures may appear nowhere else.
  5. Destination and issuers. Destination (when present) is the assertion consumer service; the response's and the assertion's Issuer are the metadata's entity id.
  6. The request. InResponseTo names the request the login route sent. A response without one is accepted only with sso.saml.allow_idp_initiated; its RelayState, when it is a relative path, becomes return_to.
  7. The subject. A bearer SubjectConfirmationData whose Recipient is the assertion consumer service and whose NotOnOrAfter hasn't passed.
  8. Conditions. NotBefore and NotOnOrAfter (with sso.saml.clock_skew_secs either way), and an AudienceRestriction naming the service provider's entity id.
  9. The session. An AuthnStatement whose SessionNotOnOrAfter hasn't passed.
  10. Once. The assertion id is remembered until the assertion expires; using it again is 400 assertion_replayed.

The signature rules:

  • Algorithms. Signatures RSA-SHA256, RSA-SHA384, RSA-SHA512, ECDSA-SHA256 and ECDSA-SHA384; digests SHA-256, SHA-384 and SHA-512. SHA-1 and MD5 signatures and digests are refused, and RSA keys need 2048 bits or more.
  • Canonicalization. Exclusive XML Canonicalization 1.0 (with its InclusiveNamespaces prefix list) or Canonical XML 1.0, with or without comments.
  • The reference. Exactly one Reference, whose URI is # and the id of the element the signature sits in, with the transforms enveloped-signature and a canonicalization. XPath, XSLT and other transforms are refused.
  • Keys. Only the metadata's certificates. A certificate inside the message's KeyInfo is ignored.
  • What is read. Only the element a signature covers. An attacker who moves the signed assertion somewhere else and puts their own in its place (signature wrapping) gets a refusal, because the document then has two assertions, or a duplicate id, or a signature in the wrong place. Text is read whole, so a comment inside a name id can't shorten it.

The crate's tests check the canonical forms against signatures made by AD FS, SimpleSAMLphp and the JDK's XML Signature implementation, with Okta-, Entra ID- and Google Workspace-shaped responses among them.

GET /api/sso/saml/{connection}/metadata serves the service provider metadata (application/samlmetadata+xml): the entity id, the HTTP-POST assertion consumer service, the email and persistent name id formats, and WantAssertionsSigned. Requests are not signed (AuthnRequestsSigned="false"), and the HTTP-Redirect binding is used for them, so an identity provider needs an HTTP-Redirect sign-on URL in its metadata.

OpenID Connect in detail

  • Discovery is read when a connection is first used and kept for an hour; the document must name the configured issuer.
  • The authorization request uses the code flow with PKCE (S256), a nonce and the state; the verifier and the nonce stay on the server, in the state.
  • The code exchange sends the client's credentials with HTTP Basic, or in the form when the provider supports only client_secret_post.
  • The ID token is verified against the provider's JSON Web Key Set (RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384 or EdDSA; never none or an HMAC token), then its issuer, audience (and azp with several audiences), expiry, iat, nbf, nonce and sub. Key sets are kept for an hour and fetched again when a new key id appears, at most every 30 seconds.
  • Userinfo. When the ID token carries no email claim, the userinfo endpoint fills the missing claims in (for the same sub).

Using the Sso service

The routes are built on the Sso service. Use it from seeders, commands, an onboarding flow or your own routes:

src/modules/onboarding/sso.rs

A workspace administrator's own page lists the workspace's connections. Behind InWorkspace::guard(), the sso.manage permission comes from the member's workspace role:

src/modules/settings/sso_controller.rs
MethodDoes
create_connection(&uow, NewConnection), update_connection(&uow, key, ConnectionUpdate), delete_connection(&uow, key)Manage connections (key is an id or a slug)
connection(key), connections(Option<SsoOwner>)Read them
add_domain(&uow, key, domain), verify_domain(&uow, id), mark_domain_verified(&uow, id), remove_domain(&uow, id), domains(key)Manage domains
discover(email)Where an email signs in
login(key, return_to)The identity provider URL to send the browser to (SsoRedirect { url })
complete_saml(key, saml_response, relay_state, &meta), complete_oidc(key, &OidcCallback, &meta)Check an answer and sign the person in (SsoSignIn { user, connection_id, return_to, created })
sign_in_profile(key, SsoProfile, &meta)Sign in a profile your own code verified, with the same linking and provisioning
issue_code(&sign_in), exchange(code, &meta)The one-time code of a finished sign-in, and its tokens
saml_metadata(key)The service provider metadata XML

The SAML building blocks are public in navree::sso::saml: IdpMetadata::parse, ServiceProvider::metadata_xml, AuthnRequest::new and validate_response with a ResponseCheck, which returns the verified SamlAssertion.

Testing

SsoFake is a SAML identity provider for tests: give a connection its metadata_xml(), call the login route, and answer the request it redirected to. Its responses are signed like a real identity provider's (RSA-SHA256 over the exclusive canonical form) and pass every check:

tests/sso.rs

A FakeResponse can also be unsigned (sign_assertion(false)), signed on the response (sign_response(true)), a failure (failure(status, message)), for another audience, reuse an assertion id (assertion_id(..)) or expire sooner (valid_for(..)), to test your app's handling of each refusal. SsoFake::unsolicited(&sp, at) answers no request, for IdP-initiated sign-in. Its key and certificate are published with Navree, so never trust SsoFake::CERTIFICATE_PEM outside tests.

For domain verification, replace the resolver with a StaticTxtResolver:

Rust

OpenID Connect connections talk to a provider over HTTP; point a connection's issuer at a mock server (wiremock) that serves discovery, a key set and a token endpoint, as the crate's own tests do. http://127.0.0.1 and http://localhost issuers and metadata URLs are accepted for that.

Reference

Routes

RouteAccessAnswer
POST /api/sso/discoverpublicSsoDiscovery
GET /api/sso/{connection}/loginpublic302 to the identity provider, or {url}
POST /api/sso/saml/{connection}/acspublic303 to complete_url, or the login JSON
GET /api/sso/saml/{connection}/metadatapublicservice provider metadata XML
GET /api/sso/oidc/{connection}/callbackpublic303 to complete_url, or the login JSON
POST /api/sso/exchangepublicthe login JSON, plus return_to
/api/sso/connections..., /api/sso/domains...sso.managesee Managing connections

Events

EventNameWhen
SsoUserProvisionedsso.user_provisionedA sign-in created a user, or created or changed a membership
SsoDomainVerifiedsso.domain_verifiedA domain was verified

Limits

  • Encrypted assertions and encrypted name ids are refused; requests are not signed; single logout isn't offered. Sign-out ends the Navree session (POST /api/auth/logout), not the identity provider's.
  • Only the HTTP-Redirect binding is used for requests, and HTTP-POST for responses.
  • Directory sync (SCIM) is not part of the module: users are created and their roles updated at sign-in.
  • States, used assertion ids and one-time codes are kept in the cache: give it a shared store with several instances.
  • Only the JSON form of the login route binds a sign-in to the browser (through state at the exchange); IdP-initiated sign-ins can't be bound at all, which is why they are off by default.
  • Enforcement refuses passwords only: a social sign-in provider (navree-social) or a passwordless magic link or code that you also offer still signs the same person in. Leave those off for apps whose customers enforce single sign-on.