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

Files touched

--- 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",
     )

Review this PR

Python practice