SSO internals
Setup steps are in Single sign-on.
Architecture
Two independent pieces work together:
- 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. identityapp — everything specific to this project: theIdpConnection/ExternalGroup/ExternalIdentitymodels, directory sync logic, and the signal that links a Google login to anExternalIdentityrow.
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)
| Model | Purpose |
|---|---|
IdpConnection | Wraps an allauth SocialApp with this project's config: whether it's active, service-account credentials for directory sync (Domain-Wide Delegation), sync timestamps. |
ExternalIdentity | Links a Django User to their external ID at an IdP (Google's sub claim). Created by identity/signals.py on first login. |
ExternalGroup | A 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. |
AgentPolicy | pi-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. |
DirectorySyncJob | One 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 aservices.py/get_directory_adapter()layer meant to support other providers later, but only Google is wired up.identity/signals.py— listens for allauth'ssocial_account_added/social_account_updatedsignals and creates/updates the matchingExternalIdentity.identity/tasks.py— Celery taskrun_directory_sync(job_id). Triggered manually (nodjango-celery-beatscheduling wired up yet — see the note intasks.py).
Login flow, end to end
- Frontend calls
GET /api/auth/sso-providersto find out which IdPs are active (backed byIdpConnection.objects.filter(is_active=True)). - If Google is listed, the frontend shows a "Sign in with Google" button. Clicking it does a
real form POST (not
fetch) toPOST /_allauth/browser/v1/auth/provider/redirect, withprovider=google,process=login, andcallback_urlset to an absolute URL on the frontend's own origin (this is what lets it redirect back tolocalhost:5173in dev instead of the backend's own origin — seeinitiateGoogleLogin()inlib/api.js). - Django (allauth) redirects the browser to Google's consent screen.
- Google redirects back to
GET /accounts/google/login/callback/on the backend. - allauth exchanges the code for tokens, creates/logs in the Django
User(matched or created by email — seeSOCIALACCOUNT_EMAIL_AUTHENTICATION/SOCIALACCOUNT_AUTO_SIGNUPin settings), establishes a session, and firessocial_account_added/updated. identity/signals.pycatches that signal and upserts anExternalIdentityrow linking the user to theIdpConnectionforprovider=google.- allauth redirects the browser to the
callback_urlfrom step 2 — landing back on the frontend, now with a valid session cookie. - The frontend calls
GET /api/auth/meto 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.
- A superuser calls
POST /api/identity/directory-sync(the Settings Sync now button) or an operator runsmanage.py sync_directory. Both callDirectorySyncJob.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-syncreturns the latest job. Identity and SSO are a tenant concern, so only superusers can call the API (adrs/0009.md section 2). run_directory_syncruns the Celery task. The management command runs the same task inline.- The task calls
identity.services.sync_directory(idp_connection), which:GoogleDirectorySyncAdapter.sync_groups()— lists all Workspace groups via the Admin SDK, upsertsExternalGrouprows, and caches each group's member emails.GoogleDirectorySyncAdapter.sync_users()— lists all Workspace users, bulk-upsertsUserrows (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 onExternalGroup.membersto match the directory.
- After
sync_directory()succeeds, the task callssync_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. - The job's
status/result/errorfields 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.