ServicesAuth

Enterprise SSO

Lets one organization bring its own identity provider, so its people sign in at Okta or Entra instead of with a password here. Three things have to line up: a provider registered against the organization, at least one email domain proved to belong to it, and a policy saying whether that route is optional or mandatory.

There are two layers of provider, and they are easy to confuse. Platform providers under /api/v1/auth/providers are the deployment-wide social logins (Google, GitHub) and are admin-only - a normal user listing them gets 403 Admin access required. Organization providers under /api/v1/auth/org/{id}/providers belong to one customer and are managed by that organization's own admins. This page is about the second kind.

Register a provider

snug auth idp create --org <org_id> --name 'Acme Okta' \
  --client-id 0oaEXAMPLE --client-secret '<client secret>' \
  --authorization-endpoint https://acme.example.com/oauth2/v1/authorize \
  --token-endpoint https://acme.example.com/oauth2/v1/token \
  --userinfo-endpoint https://acme.example.com/oauth2/v1/userinfo \
  --scope openid --scope email

snug auth idp list --org <org_id>
snug auth idp get --org <org_id> --provider <provider_id>
snug auth idp update --org <org_id> --provider <provider_id> --name 'Acme SSO'
snug auth idp delete --org <org_id> --provider <provider_id>
{
  "provider_id": "idp_b18a604266984630a22787b9c7ce7f78",
  "org_id": "dqxPXsVANGJNbqchQTgS",
  "name": "Docs4 Okta",
  "enabled": false,
  "teardown_in_progress": false,
  "client_id": "0oaDOCS4EXAMPLE",
  "authorization_endpoint": "https://docs4.example.com/oauth2/v1/authorize",
  "token_endpoint": "https://docs4.example.com/oauth2/v1/token",
  "userinfo_endpoint": "https://docs4.example.com/oauth2/v1/userinfo",
  "scopes": ["openid", "email"],
  "redirect_uri": "http://localhost:4000/auth/oauth/callback/idp_b18a604266984630a22787b9c7ce7f78",
  "created_at": "2026-08-28T23:02:28.456304Z"
}

Three things to notice. The provider arrives disabled, so registering it cannot break sign-in for anyone. The client secret is encrypted on the way in and is never echoed back by any endpoint, so store it wherever you got it from. And redirect_uri is derived from the generated provider_id - copy that exact value into the provider's application configuration, because it is the address the callback arrives on.

update is a patch: omitted fields keep their current values. delete removes the provider along with its domain claims and its user links.

Claim and verify a domain

A provider only serves the email domains it has proved it controls.

snug auth idp add-domain --org <org_id> --provider <provider_id> --domain acme.example.com
snug auth idp domains --org <org_id> --provider <provider_id>
snug auth idp verify-domain --org <org_id> --provider <provider_id> --domain-id <domain_id>
snug auth idp remove-domain --org <org_id> --provider <provider_id> --domain-id <domain_id>

Adding a domain hands back the DNS record to publish:

{
  "domain_id": "PxFfrcgWSXxyWMVzqUBY",
  "provider_id": "idp_b18a604266984630a22787b9c7ce7f78",
  "domain": "docs4.example.com",
  "verified": false,
  "verification_record_name": "_snugnut-verify.docs4.example.com",
  "verification_record_value": "snugnut-verify=7bc0215e...",
  "created_at": "2026-08-28T23:02:35.232441Z"
}

Publish that as a TXT record, then call verify-domain. It re-checks DNS and returns the domain's current state rather than an error, so a run against a domain whose record is missing or not yet propagated simply comes back with "verified": false and the same record to publish. It is safe to call repeatedly, and the returned verified field is the only thing worth reading. Until a domain verifies, it steers nobody: discovery for an address in it answers {"sso_required": false}.

The federated sign-in flow

snug auth sso-discover --email person@acme.example.com
snug auth sso-exchange --code <code>

Discovery is the first step a login form takes: given an email address, it answers whether that address must go through a provider and, when it must, which one. For an address in no verified domain the answer is {"sso_required": false} and the form falls back to a password field.

After the user returns from the provider, the redirect carries a single-use code that is traded for a session at POST /auth/sso/exchange. Codes are consumed on first use; a stale or invented one is 400 invalid_request: sso code is invalid or has already been used. A provider that starts the flow on its own side lands on /auth/sso/idp-initiated/{provider}, and a federated login that named no application destination finishes on the /auth/sso/complete page.

The exchange half needs a real provider round-trip, so it is documented here from the contract and from its error path rather than from a completed login; the request and response bodies are in the API reference.

One deployment-level prerequisite is easy to miss: AUTH_SERVICE_ALLOWED_REDIRECT_URLS must list the origins you intend to send users back to. When that list is empty a federated login is refused outright rather than downgraded, because unlike an email link it delivers a live session rather than a single-use token.

Enforcement, and how not to lock yourself out

Requiring SSO is a platform admin operation, not an organization one.

snug auth admin org-sso --org <org_id> --enforced true \
  --exempt <user_id> --exempt <other_user_id>
{
  "org_id": "dqxPXsVANGJNbqchQTgS",
  "name": "Docs4 Auth Org",
  "slug": "docs4-auth-org",
  "member_count": 2,
  "sso_enforced": true,
  "sso_exempt_user_ids": []
}

With enforcement on, password sign-in for a member is refused:

{
  "status": 403,
  "msg": "organization dqxPXsVANGJNbqchQTgS requires single sign-on",
  "error": "sso_required"
}

This includes the owner. Enforcement is evaluated per member with no implicit exception for whoever created the organization, so turning it on before the provider actually works locks every member out of the password route at once. Verified: after enforcing, both the member and the owner were refused; adding the member to sso_exempt_user_ids restored their password sign-in immediately.

The exemption list is the break-glass path for exactly that situation, and for a provider outage. Two habits make it safe:

  • Verify a domain and complete one real federated sign-in before enforcing, not after.
  • Keep at least one exempt account, and remember the endpoint replaces the policy rather than amending it. --enforced takes an explicit true/false for this reason, and a call that passes --exempt without restating --enforced true would switch enforcement off for everyone.

Auditing drift

snug auth idp drift --org <org_id> --provider <provider_id>
{
  "unlinked_members": [
    { "user_id": "xhMLHYzgKQPSnTSsfcmz", "email": "docs4-auth@example.com", "external_id": null }
  ],
  "orphaned_links": [],
  "links_truncated": false,
  "recent_failures": []
}

unlinked_members are people in the organization with no identity at the provider - the ones who would be locked out the moment you enforce. orphaned_links is the mirror image: links pointing at users who are no longer members. recent_failures collects provisioning errors. Run this before flipping enforcement on, and treat a non-empty unlinked_members list as the work remaining.

Reference

  • Auth API - every provider, domain, and SSO endpoint
  • SCIM - directory-driven user provisioning, which pairs with SSO and needs a provider-bound API key
  • Organizations - the organizations these providers attach to

On this page