Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt

Use this file to discover all available pages before exploring further.

Per-org SSO lets each organization plug in their own identity provider (Okta, Auth0, Azure AD, Google Workspace, Keycloak, OneLogin, Ping, JumpCloud, anything OIDC-compliant). Members sign in by going to their org’s start URL; the framework handles discovery, PKCE, nonce, state, and auto-join. Both protocols ship in the binary:
  • OIDC — discovery doc + PKCE (S256) + nonce + state, single-use.
  • SAML 2.0 — SP-Initiated AuthnRequest (HTTP-Redirect), Response via HTTP-POST to the ACS, XML signature verified with the configured IdP cert.

Trust model

Org owners explicitly choose their IdP. The framework doesn’t second-guess that choice — it validates the IdP’s discovery endpoints use HTTPS, but doesn’t dictate which providers are acceptable. Domain claims are first-come-first-served per Pylon deployment. The framework refuses to let any org claim well-known freemail domains (gmail.com, outlook.com, icloud.com, etc.) so an org owner can’t intercept domain-detection sign-ins for every Gmail user. Operators on multi-tenant Pylon deployments should set PYLON_SSO_ALLOWED_DOMAINS as a positive allowlist — any domain not in the list is rejected on PUT /orgs/:id/sso or /saml.

Endpoints

All under /api/auth/.
EndpointMethodAuthPurpose
/orgs/:id/ssoGETany memberRead redacted OIDC config
/orgs/:id/ssoPUTOwnerConfigure OIDC SSO
/orgs/:id/ssoDELETEOwnerRemove OIDC SSO
/orgs/:id/sso/startGETnoneBegin OIDC sign-in (302 to IdP)
/orgs/:id/sso/callbackGETnoneIdP redirects here with code+state
/orgs/:id/samlGETany memberRead SAML config
/orgs/:id/samlPUTOwnerConfigure SAML
/orgs/:id/samlDELETEOwnerRemove SAML
/orgs/:id/saml/startGETnoneBegin SAML sign-in (302 to IdP)
/orgs/:id/saml/acsPOSTnoneSAML ACS — IdP POSTs the SAMLResponse here
/sso/discoverGETnoneResolve ?email=user@acme.com → org’s start URL

Configuring OIDC SSO

The org’s Owner posts the IdP’s issuer URL plus client credentials. Pylon fetches <issuer>/.well-known/openid-configuration automatically and caches the four endpoints (authorization, token, userinfo, jwks).
curl -X PUT https://your-app/api/auth/orgs/org_acme/sso \
  -H 'Authorization: Bearer pylon_token' \
  -H 'Content-Type: application/json' \
  -d '{
    "issuer_url": "https://acme.okta.com",
    "client_id": "0oa1abcd2efgh3ijk4",
    "client_secret": "OktaSecret...",
    "default_role": "member",
    "email_domains": ["acme.com", "acme.io"]
  }'
Required: issuer_url, client_id, client_secret. Optional: default_role (member | admin, default memberowner is refused so an IdP misconfiguration can’t silently hand over org control), email_domains (claimed domains for the /sso/discover flow; lowercased on store). Pylon stores the client_secret encrypted at rest when PYLON_SECRET is set, falling back to a plain: envelope in dev mode (with a loud boot-time warning in prod). See Sessions for the at-rest encryption story. Response on success:
{ "configured": true }
Errors:
StatusCodeReason
400MISSING_FIELDSissuer_url / client_id / client_secret missing
400BAD_DEFAULT_ROLEdefault_role: "owner" is refused
400DOMAIN_BLOCKLISTEDClaimed a freemail domain
400DOMAIN_NOT_ALLOWEDNot in PYLON_SSO_ALLOWED_DOMAINS (when set)
400DISCOVERY_FAILED<issuer>/.well-known/openid-configuration unreachable or invalid
409DOMAIN_ALREADY_CLAIMEDAnother org already claimed one of the email domains
500SSO_SECRET_SEAL_FAILEDPYLON_SECRET is set but the ChaCha20-Poly1305 seal failed

OIDC sign-in flow

  1. Client redirects user to GET /api/auth/orgs/org_acme/sso/start?callback=<success_url>&error_callback=<error_url>.
  2. Pylon validates both URLs against manifest.auth.trustedOrigins (or PYLON_TRUSTED_ORIGINS), mints a single-use state + PKCE verifier + nonce, persists them, and 302s to the IdP’s authorization endpoint with scope=openid email profile, response_type=code, code_challenge_method=S256.
  3. IdP authenticates the user and 302s back to /api/auth/orgs/org_acme/sso/callback?code=...&state=....
  4. Pylon consumes the state (single-use), exchanges code for tokens against the IdP’s token endpoint with the PKCE verifier, validates the id_token’s nonce claim per OIDC §3.1.2.1, fetches userinfo for email + name.
  5. Pylon either matches the user’s email to an existing User row OR creates one. emailVerified is stamped to now — the IdP just vouched for it.
  6. If the user isn’t already a member of the org, they’re added with the configured default_role. Idempotent — re-signing-in via SSO doesn’t downgrade an admin.
  7. Pylon mints a session, writes the auth cookie, audits a SignIn event with method=org_sso, and 302s to the caller’s callback URL.
On error: Pylon 302s to the error_callback URL with ?sso_error=<code>&sso_error_message=<msg> query params so the client can render a friendly message. PYLON_PUBLIC_URL is required so Pylon can construct the redirect URI to register with the IdP. Without it, /sso/start returns 500 REDIRECT_URI_UNAVAILABLE.

Configuring SAML

curl -X PUT https://your-app/api/auth/orgs/org_acme/saml \
  -H 'Authorization: Bearer pylon_token' \
  -H 'Content-Type: application/json' \
  -d '{
    "idp_entity_id": "https://idp.okta.com/exk1abcd...",
    "idp_sso_url": "https://acme.okta.com/app/abc/sso/saml",
    "idp_x509_cert_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
    "default_role": "member",
    "email_domains": ["acme.com"],
    "email_attribute": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
    "name_attribute": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
  }'
Required: idp_entity_id, idp_sso_url (must be https://), idp_x509_cert_pem. Optional: default_role, email_domains, email_attribute (defaults to the standard emailaddress claim URI), name_attribute. Errors:
StatusCodeReason
400MISSING_FIELDSOne of the three required fields is empty
400INSECURE_SSO_URLidp_sso_url is not https://
400BAD_DEFAULT_ROLEdefault_role: "owner" refused
400DOMAIN_BLOCKLISTED / DOMAIN_NOT_ALLOWEDSame as OIDC

SAML sign-in flow

  1. Client redirects to GET /api/auth/orgs/org_acme/saml/start?callback=<success_url>&error_callback=<error_url>.
  2. Pylon builds a SAML AuthnRequest, deflates + base64-encodes it for the HTTP-Redirect binding, 302s to the IdP’s SSO URL with SAMLRequest=...&RelayState=<state>.
  3. IdP authenticates the user and HTTP-POSTs a SAMLResponse to POST /api/auth/orgs/org_acme/saml/acs.
  4. Pylon parses the XML, verifies the digital signature against the configured idp_x509_cert_pem using xmlsec1, validates assertions, extracts the email + display name from the configured attributes.
  5. Same user-lookup / auto-join / session-mint flow as OIDC.
xmlsec1 + libxml2 are required as system dependencies. The Pylon docker image includes them. For self-hosted from-source builds:
# macOS
brew install libxmlsec1 libxml2

# Debian / Ubuntu
apt-get install libxmlsec1-dev libxml2-dev

Email-domain discovery

A sign-in form can ask for the user’s email first and route to the right SSO automatically:
curl https://your-app/api/auth/sso/discover?email=alice@acme.com
OIDC match:
{
  "org_id": "org_acme",
  "kind": "oidc",
  "start_url": "/api/auth/orgs/org_acme/sso/start"
}
SAML match:
{
  "org_id": "org_acme",
  "kind": "saml",
  "start_url": "/api/auth/orgs/org_acme/saml/start"
}
No claim → 404 NO_SSO_FOR_DOMAIN. The response never reveals anything about the user — it’s purely a per-domain routing hint. OIDC is checked first; if both protocols claim the same domain, OIDC wins.

Security guarantees

  • State + PKCE + nonce on OIDC. State is single-use, scoped to the org, and re-used or wrong-org states return 403 INVALID_SSO_STATE.
  • PKCE S256 binds the token exchange to the same client that started the flow. A leaked authorization code can’t be redeemed without the matching code_verifier.
  • Nonce binding per OIDC §3.1.2.1 defeats id_token replay across distinct sign-in attempts.
  • Redirect-URL allowlist — both callback and error_callback are validated against manifest.auth.trustedOrigins (or PYLON_TRUSTED_ORIGINS) before the IdP gets to see them. Loopback is auto-trusted.
  • SAML signature verification with xmlsec1 — assertions without a valid signature from the configured cert are rejected.
  • https:// enforced on idp_sso_url.
  • Freemail domain blocklist — common consumer-mail domains (gmail, yahoo, outlook, icloud, hotmail, live, msn, aol, mail.com, protonmail / proton.me, gmx, yandex, qq, 163, 126, fastmail, mac.com, me.com, the full list lives in BLOCKLIST_FREEMAIL_DOMAINS in crates/auth/src/org_sso.rs) refuse to be claimed by any org.
  • Operator domain allowlist via PYLON_SSO_ALLOWED_DOMAINS=acme.com,acme.io,....
  • Default role can never be owner — IdP-mediated owner-promotion would let an IdP misconfiguration silently hand over org control.
  • client_secret encrypted at rest when PYLON_SECRET is set.
  • Auto-join is idempotent — re-signing-in via SSO doesn’t change an existing member’s role. To re-apply the IdP’s default role, remove the membership and let the next sign-in re-add it.

Configuration

PYLON_PUBLIC_URL=https://your-app.com           # required for SSO callbacks
PYLON_SECRET=<openssl rand -hex 32>             # encrypts client_secret at rest
PYLON_TRUSTED_ORIGINS=https://your-app.com,...  # OAuth callback allowlist (or declare in manifest.auth.trustedOrigins)
PYLON_SSO_ALLOWED_DOMAINS=acme.com,acme.io      # optional: positive domain allowlist

Where to go next

  • Organizations — the org model SSO auto-joins users to
  • OAuth — global OAuth (Google + GitHub for everyone) vs per-org SSO