Skip to main content

SSO internals

Setup steps are in Single sign-on.

Architecture​

Two independent pieces work together:

  1. Login (django-allauth) — handles the actual OAuth handshake with Google and creates a Django session. This is generic, third-party code; we don't maintain it.
  2. identity app — everything specific to this project: the IdpConnection/ExternalGroup/ ExternalIdentity models, directory sync logic, and the signal that links a Google login to an ExternalIdentity row.

Session/user-facing auth endpoints (/api/auth/login, /me, /logout, /csrf, /sso-providers) live in chat/auth_api.py, not in identity — identity only owns the IdP/sync-specific pieces.

chat/google_oauth_api.py is unrelated: it's a separate feature that happens to also talk to Google OAuth, with its own client and its own model (GoogleCredential). Don't confuse the two.

Models (identity/models.py)​

ModelPurpose
IdpConnectionWraps an allauth SocialApp with this project's config: whether it's active, service-account credentials for directory sync (Domain-Wide Delegation), sync timestamps.
ExternalIdentityLinks a Django User to their external ID at an IdP (Google's sub claim). Created by identity/signals.py on first login.
ExternalGroupA Google Workspace group, synced in by services_google.py. Members are a M2M to User. The sync is the only writer. The group grants nothing until an org manager links a Team to it.
AgentPolicypi-permission-system rules for an Org, applied to every member or to chosen Teams (adrs/0009.md section 5). This replaced Role, DefaultRoleAssignment, and ExternalGroup.roles.
DirectorySyncJobOne row per sync run — status, trigger (manual/scheduled), result/error. create_if_not_running() prevents overlapping syncs per connection.

AvailableIdpGroup was removed. Google sync already stores every group as an ExternalGroup, and the team picker lists those. chat.Team.external_group is the link from a group to the permissions the product already had: Application ownership, model grants, and agent policies.

Key files​

  • identity/services_google.py — talks to the Google Admin SDK (Directory API) to sync groups, users, and memberships. This is the only IdP-specific adapter; there's a services.py / get_directory_adapter() layer meant to support other providers later, but only Google is wired up.
  • identity/signals.py — listens for allauth's social_account_added/social_account_updated signals and creates/updates the matching ExternalIdentity.
  • identity/tasks.py — Celery task run_directory_sync(job_id). Triggered manually (no django-celery-beat scheduling wired up yet — see the note in tasks.py).

Login flow, end to end​

  1. Frontend calls GET /api/auth/sso-providers to find out which IdPs are active (backed by IdpConnection.objects.filter(is_active=True)).
  2. If Google is listed, the frontend shows a "Sign in with Google" button. Clicking it does a real form POST (not fetch) to POST /_allauth/browser/v1/auth/provider/redirect, with provider=google, process=login, and callback_url set to an absolute URL on the frontend's own origin (this is what lets it redirect back to localhost:5173 in dev instead of the backend's own origin — see initiateGoogleLogin() in lib/api.js).
  3. Django (allauth) redirects the browser to Google's consent screen.
  4. Google redirects back to GET /accounts/google/login/callback/ on the backend.
  5. allauth exchanges the code for tokens, creates/logs in the Django User (matched or created by email — see SOCIALACCOUNT_EMAIL_AUTHENTICATION/SOCIALACCOUNT_AUTO_SIGNUP in settings), establishes a session, and fires social_account_added/updated.
  6. identity/signals.py catches that signal and upserts an ExternalIdentity row linking the user to the IdpConnection for provider=google.
  7. allauth redirects the browser to the callback_url from step 2 — landing back on the frontend, now with a valid session cookie.
  8. The frontend calls GET /api/auth/me to pick up the now-authenticated user.

Username/password login (POST /api/auth/login) is a separate, parallel path in chat/auth_api.py and doesn't go through any of the above.

Directory sync flow​

Directory sync is not part of login — it's a separate, admin-triggered process that keeps ExternalGroup/ExternalIdentity/User rows in sync with what's actually in Google Workspace, independent of who has or hasn't logged in recently.

  1. A superuser calls POST /api/identity/directory-sync (the Settings Sync now button) or an operator runs manage.py sync_directory. Both call DirectorySyncJob.create_if_not_running(idp_connection, trigger="manual"). This returns (new_job, None) if nothing else is running, or (None, existing_job) if a sync is already in progress for that connection. GET /api/identity/directory-sync returns the latest job. Identity and SSO are a tenant concern, so only superusers can call the API (adrs/0009.md section 2).
  2. run_directory_sync runs the Celery task. The management command runs the same task inline.
  3. The task calls identity.services.sync_directory(idp_connection), which:
    • GoogleDirectorySyncAdapter.sync_groups() — lists all Workspace groups via the Admin SDK, upserts ExternalGroup rows, and caches each group's member emails.
    • GoogleDirectorySyncAdapter.sync_users() — lists all Workspace users, bulk-upserts User rows (creates users who don't exist yet, deactivates users no longer in the directory).
    • update_all_memberships() — using the cached member-email data, adds and removes users on ExternalGroup.members to match the directory.
  4. After sync_directory() succeeds, the task calls sync_linked_teams(). That function is not part of the directory sync itself. It makes each linked Team's membership equal the group's active members, and rewrites the sandbox policy for users whose teams changed.
  5. The job's status/result/error fields are updated on completion or failure.

scheduled_directory_sync stays unscheduled. Turning on a schedule later is a Celery beat entry, not a change to this flow. A person removed from a Google group keeps their Team membership until the next sync.

This requires IdpConnection.has_group_sync to be true, which for Google means a service-account JSON (with Domain-Wide Delegation) and a delegated_admin_email are both set on the IdpConnection.