Sign in with your organization's SSO
Python · Django · intermediate · modification
Adds SSO sign-in for customer organizations. `sso_start` puts a random `state` and `nonce` in the session and sends the browser to the identity service; `sso_callback` takes its form post, checks the state (once, within 10 minutes), verifies the id_token (signature, issuer, audience, expiry, the `org` claim and the nonce), finds or creates the employee and logs them in. A first sign-in creates the Django user and the employee in one transaction, with a random `sso_` username since nobody signs in by username. Each check that fails sends the browser back to the login page with its own `?error=` code. Ran the whole flow against the staging identity service.
Django 5.1 multi-tenant HR SaaS on PostgreSQL 16, PyJWT 2.9. The platform's identity service at `auth.workhub.app` federates each customer organization's own IdP (Okta, Entra ID, Google Workspace): the customer's IdP authenticates the person and asserts their email (each customer's admins create the accounts in their own IdP and set their emails; the identity service passes the asserted email through as the `email` claim without checking its domain), and the identity service signs an `id_token` whose `org` claim names that organization. The identity service is on the same site as the app (`app.workhub.app`), so the default `SameSite=Lax` session cookie is sent with its form post. `SSO_JWT_SECRET`, `SSO_ISSUER`, `SSO_AUDIENCE` (also the `client_id`), `SSO_AUTHORIZE_URL` and `SSO_CALLBACK_URL` (the absolute callback URL registered with the identity service) are already in `settings.py`. `accounts/models.py` is unchanged and its migration is applied; `login` and `dashboard` are existing named routes in `core.views`; the sign-in page's per-organization SSO buttons, which link to the start URL, ship in a separate frontend PR. Sessions are database-backed and Django's `ModelBackend` is the only authentication backend. The identity service serializes sign-ins per person, so two first sign-ins for the same email do not run at the same time.
Requirements
- `GET /accounts/sso/start/<org slug>/` starts a sign-in for one organization (404 for an unknown slug). It stores a fresh random `state` and `nonce` in the user's session together with the organization, then redirects to the identity service's authorize URL (`SSO_AUTHORIZE_URL`) with `response_mode=form_post`, the `state`, the `nonce` and the organization slug. Starting a new sign-in replaces any pending one in that session.
- `POST /accounts/sso/callback/` receives the identity service's form post with `id_token` and `state`. A missing field redirects to `/login/?error=missing_params`. The `state` must match the pending sign-in of this session, can be used once, and must arrive within 10 minutes of the start; otherwise `?error=invalid_state`. If the organization was deleted in the meantime, `?error=unknown_org`.
- The `id_token` is an HS256 JWT signed with `SSO_JWT_SECRET`. It must verify against `SSO_ISSUER` and `SSO_AUDIENCE`, carry `exp` and `iat`, name the pending organization in its `org` claim and carry the pending `nonce`; otherwise `?error=invalid_token`. A token without `email` or `sub` gives `?error=incomplete_claims`.
- Within an organization an employee is identified by email (the `UniqueConstraint` on `(organization, email)` in `accounts/models.py`). The same email can belong to different employees in different organizations, and an email is vouched for only by the organization whose IdP signed the person in, so the callback resolves the employee within the pending organization and, on first sign-in, creates the Django user and the employee together. `sub` is stored as `sso_subject` for audit only. A deactivated user gets `?error=inactive`; anyone else is signed in and sent to the dashboard.
- Claims from the identity service: `email` is a lower-case address of at most 254 characters, `sub` a string of at most 255 characters, and `name` is optional, may be null, and is at most 255 characters. A missing, null or empty `name` falls back to the email for `display_name`.
Files touched
- accounts/models.py
- accounts/sso.py
- accounts/urls.py
- config/urls.py
--- accounts/models.py
from django.conf import settings
from django.db import models
class Organization(models.Model):
"""A customer organization (tenant)."""
slug = models.SlugField(max_length=64, unique=True)
name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.slug
class Employee(models.Model):
"""A person's account inside one organization."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="employee",
)
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="employees",
)