Skip to main content

Authentication & permissions

GreekManage supports five sign-in paths and a layered permission model.

Sign-in methods

JWT

Library: djangorestframework-simplejwt 5.5 Custom auth class: apps.common.authentication.CookieJWTAuthentication

backend/apps/common/authentication.py:7-21
backend/greekmanage/settings.py:248-254

Token lifetimes

TokenLifetimeStorage
Access30 minuteshttpOnly cookie (web) / Authorization: Bearer (mobile)
Refresh24 hourshttpOnly cookie (web) / keychain (mobile)

Rotation + blacklist

Refresh tokens rotate on use — every refresh issues a new refresh + blacklists the old. So a stolen refresh token has a hard ceiling on its useful life:

  • Used legitimately → rotated → previous token blacklisted
  • Used by attacker → previous token blacklisted → real user's next refresh fails → forced re-login

Custom CookieJWTAuthentication

Reads the JWT from the httpOnly cookie access_token first; falls back to Authorization: Bearer <token> for mobile clients that don't speak cookies.

# backend/apps/common/authentication.py
class CookieJWTAuthentication(JWTAuthentication):
def authenticate(self, request):
token = request.COOKIES.get("access_token") \
or self.get_header(request)
if not token:
return None
validated = self.get_validated_token(token)
return self.get_user(validated), validated
HttpOnly: true
Secure: true (in production)
SameSite: Lax
Path: /

Set by set_auth_cookies() in apps/common/authentication.py:27-48.

SAML 2.0

Library: python3-saml 1.16

For enterprise customers with an existing IdP (Okta, Azure AD, OneLogin, Google Workspace SAML, etc.).

backend/apps/authentication/saml.py
backend/apps/authentication/models.py:36-55 (SAMLConfiguration)

Per-tenant config

Each org configures its own SAMLConfiguration with:

  • IdP entity ID + SSO URL + SLO URL
  • IdP x509 cert (encrypted via EncryptedTextField)
  • Attribute mapping (which SAML attributes map to user fields)
  • Auto-provision new users? (yes/no)

Endpoints (per IdP slug)

URLPurpose
/api/auth/sso/saml/<slug>/metadata/SP metadata XML for IdP-side configuration
/api/auth/sso/saml/<slug>/login/Initiates SSO (redirect to IdP)
/api/auth/sso/saml/<slug>/acs/Assertion Consumer Service (POST from IdP)
/api/auth/sso/saml/<slug>/sls/Single Logout Service

Bindings

  • HTTP-POST for assertions (signed XML payload)
  • HTTP-Redirect for logout

Attribute mapping

Default mapping looks up:

  • urn:oid:0.9.2342.19200300.100.1.3email
  • urn:oid:2.5.4.42first_name
  • urn:oid:2.5.4.4last_name

Customers can override per-config.

OAuth 2.0 / OIDC

Library: requests-oauthlib 2.0 Providers: Google Workspace, Microsoft 365, Okta, LinkedIn (LinkedIn used for profile sync, not auth)

backend/apps/authentication/oauth_providers.py
backend/apps/authentication/models.py:57-77 (OAuthConfiguration)

Per-tenant config

Each org configures OAuthConfiguration with:

  • Provider (Google / Microsoft / Okta)
  • Client ID + client secret (secret encrypted)
  • Scopes (defaults: openid email profile)
  • Allowed email domains (e.g., chapter-name.org)

Defaults for Google + Microsoft are pre-baked (authorization / token / userinfo URLs); Okta requires custom URLs.

Discovery

When a user enters their email on the login page, the frontend hits /api/auth/sso/discover?email=.... The backend looks up which orgs have an OAuthConfiguration matching that email's domain and returns the appropriate provider button.

Flow

Passkeys (WebAuthn)

Library: webauthn 2.x (Python) + @simplewebauthn/browser (frontend) Models: accounts.WebAuthnCredential, accounts.PasskeyEnrollmentDismissal

GreekManage uses discoverable WebAuthn credentials (resident keys). The user is identified by the credential itself, so the sign-in flow doesn't need an email up front — the browser/OS picks the right passkey from the user's vault and sends its userHandle.

backend/apps/accounts/webauthn_views.py (registration + auth flows, credential CRUD)
backend/apps/accounts/models.py:157+ (WebAuthnCredential, PasskeyEnrollmentDismissal)
frontend/src/lib/passkey-auth.ts (SimpleWebAuthn browser wrapper)
frontend/src/components/settings/passkey-card.tsx (manage UI)
frontend/src/components/auth/passkey-enrollment-prompt.tsx (post-login enrollment)

Relying party config

SettingValueSource
RP_IDapp.greekmanage.com (prod) / localhost (dev)WEBAUTHN_RP_ID env
RP_NAMEGreekManageWEBAUTHN_RP_NAME env
EXPECTED_ORIGINhttps://app.greekmanage.com (prod)WEBAUTHN_ORIGIN env
AttestationnoneWe don't enforce specific authenticator brands
User verificationpreferredAsks for biometric / PIN where available

Native apps (associated domains)

iOS and Android share passkeys with the web origin via:

  • iOS: apple-app-site-association JSON served from /.well-known/apple-app-site-association declaring the app's bundle ID under webcredentials. Configured in frontend/ios/App/App/App.entitlements.
  • Android: assetlinks.json served from /.well-known/assetlinks.json declaring the app's package + SHA-256 cert fingerprint. Configured in the manifest's intent-filter for android:autoVerify="true".

A passkey enrolled on the web is therefore usable in the native apps and vice versa — the OS treats app.greekmanage.com as the same identity surface across all three platforms.

Registration flow

Authentication flow (discoverable)

Counter regression check

Each authenticator returns a monotonically increasing signature counter. If the next authentication's counter is less than the stored value, GreekManage rejects the assertion — the credential may have been cloned. The user must re-enroll.

Throttles

  • passkey_begin: 10/min per IP+user (anon and authenticated)
  • passkey_complete: 5/min per IP+user

Tighter than auth (login) because each WebAuthn round-trip is more expensive on the verifier side.

Enrollment prompts

PasskeyEnrollmentDismissal records when a user dismissed the post-login "Set up a passkey" card. Subsequent sign-ins re-show the card after a 30-day cooldown (REMIND_AFTER_DAYS) unless the user has at least one credential.

Email verification

Users can have multiple emails (UserEmail table). Each carries:

  • email (the address)
  • is_verified (bool)
  • verification_token (random token, generated on add)
  • verification_sent_at

SSO flows match incoming verified emails against UserEmail first, then fall back to User.email. This lets users keep their account when they change primary email.

backend/apps/accounts/models.py:76-112 (UserEmail)

Permission classes

All in backend/apps/common/permissions.py (291 lines).

Hierarchy

Class reference

ClassWhat it checksExample endpoint
IsNationalAdminUser is platform admin OR active org adminPOST /api/organizations/<id>/admins/
IsRegionalAdminPlatform admin OR active regional admin (region-scoped)GET /api/regions/<id>/chapters/
IsNationalOrRegionalAdminEither of the aboveGET /api/compliance/region-overview/
IsChapterMemberPlatform admin OR has active membership in any chapterGET /api/chapters/<id>/feed/
IsChapterOfficerPlatform admin OR officer/president in active standingPOST /api/chapters/<id>/bulletins/
IsNationalAdminOrChapterOfficerEither national admin or chapter officer (mixed scope)PATCH /api/memberships/<id>/

App-specific permissions

Some apps have their own:

  • apps/alumni/permissions.pyIsAlumniMember, IsAuthenticatedMember
  • apps/foundation/permissions.pyIsFoundationAdmin, IsFoundationEditor
  • apps/forums/permissions.pyIsForumMemberOrAdmin

Object-level checks

TierPermission provides _get_object_org_id(), _get_object_chapter_id(), _get_object_region_id() helpers. Subclasses use them in has_object_permission() to check that an object belongs to the user's scope.

Example:

class IsChapterOfficer(TierPermission):
def has_object_permission(self, request, view, obj):
if self._is_platform_admin(request.user):
return True
chapter_id = self._get_object_chapter_id(obj)
return chapter_id in self._user_officer_chapter_ids(request.user)

Default permission

REST_FRAMEWORK settings:

"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
)

So every endpoint defaults to "must be signed in." Public endpoints (login, register, public donate, PNM apply) explicitly use permission_classes = [AllowAny].

Throttling

Throttle scopeAnon rateUser rate
Default200/day prod, 10000/day dev2000/day prod, 100000/day dev
auth10/minute prod, 10000/minute dev
sso20/minute prod
account_request3/hour prod
password_reset5/hour prod
pnm_submit_ip5/hour
pnm_submit_email1/minute

Production rates are deliberately tight to prevent enumeration / brute-force.

Mobile auth

Mobile clients (Capacitor) don't use cookies. They send Authorization: Bearer <access_token> and store both tokens in the device keychain (iOS: kSecAttrAccessibleAfterFirstUnlock; Android: EncryptedSharedPreferences).

Biometric unlock uses @aparajita/capacitor-biometric-auth to gate access to the stored tokens.