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

Credential changes evict sessions. A completed self-service password reset, an admin reset, or a temporary-password set blacklists every outstanding refresh token for that user — so an attacker's session can't outlive a recovery. A self-service password change evicts every session except the one performing the change (so the user isn't logged out of the tab they're using). Helper: apps/accounts/token_utils.blacklist_user_tokens(user, exclude_jti=...).

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

RelayState carries an opaque SSO flow id (see Native SSO) — it is echoed back to the ACS by the user's browser, so it is treated as a lookup key into server-side state and never as a destination.

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

Native SSO

backend/apps/authentication/sso_redirect.py (platform allowlist + SSORedirect)
backend/apps/authentication/pkce.py (S256 challenge/verifier)
frontend/src/lib/native-sso.ts (verifier, in-app browser launch)
frontend/src/hooks/use-app-url-open.ts (deep-link handler)

Both SSO flows above end by redirecting to {FRONTEND_URL}/auth/sso/callback?code=…, where the frontend exchanges a one-time code for tokens. That does not work in the Capacitor apps: a WebView hands an external-origin navigation to the system browser, which then completes the IdP round trip, consumes the code and keeps the session. The app never learns anything happened, so an SSO-only member cannot sign in on native at all (#769).

Native clients therefore pass platform=native when initiating, and the callback redirects to greekmanage://auth/sso/callback?code=… instead.

The redirect target is an allowlist, not a parameter

Every SSO callback is AllowAny. Reflecting a caller-supplied redirect target would make them open redirects leaking a code that exchanges for a full session.

The client therefore names a platform, never a URL:

HintTarget
native{MOBILE_APP_SCHEME}://auth/sso/callback
web, absent, or anything unrecognised{FRONTEND_URL}/auth/sso/callback

The hint reaches the callback through server-side state, keyed by an opaque id: SAML puts that id in RelayState, OAuth and platform Google store it in the existing state cache entry. A forged or expired id resolves to web rather than erroring.

SSORedirect narrows Django's redirect-scheme guard to http, https and the single scheme this deployment owns. MOBILE_APP_SCHEME is validated as a real URI scheme and refused if it names a scriptable one (javascript, data, …).

PKCE is mandatory on native

A private-use URI scheme can be claimed by any app on the device — Android shows a chooser, iOS leaves the winner undefined — so the callback and the code in it must be assumed interceptable. Per RFC 8252 §8.1, the code alone is not sufficient:

  1. the app mints a 43-character verifier and sends only its SHA-256 challenge with the initiation request;
  2. the challenge is stored alongside the flow, then copied onto the sso_code: cache entry;
  3. POST /api/auth/sso/exchange/ requires the original verifier whenever the entry carries a challenge.

The code is deleted before the verifier is checked, so a wrong verifier cannot be retried against a live code. Web flows send no challenge and are unaffected.

useAppUrlOpen matches an incoming URL against a fixed table and ignores anything that is not on it. Each entry pairs a filter recognising exactly one callback with the fixed in-app route it maps to; only the query string carries over.

Deep linkRouteAdded
greekmanage://auth/sso/callback/auth/sso/callback#769
greekmanage://linkedin/callback/linkedin/callback#777, reached via the HTTPS bounce (#817)

The table is the security boundary, not a convenience. Anything on the device can hand the app a URL, so forwarding arbitrary paths to router.navigate would let a third-party app drive in-app navigation. Adding a deep link means adding a filter and a route here and a matching <data android:scheme="greekmanage" android:host="…" /> to AndroidManifest.xml — Android's intent filter matches on the host segment, so a host it does not name never reaches the app at all. iOS registers the scheme whole via CFBundleURLSchemes and has no per-host knob, which is why this table is where both platforms are actually filtered.

There are no Universal Links or App Links; applinks: is not declared in the iOS entitlements.

Native LinkedIn account linking

backend/apps/authentication/linkedin_redirect.py (redirect_uri allowlist)
backend/apps/authentication/pkce.py (shared with native SSO)
frontend/src/lib/native-linkedin.ts (verifier, in-app browser launch)
frontend/src/lib/pkce.ts (shared S256 helpers)

LinkedIn linking had the same two structural failures as SSO and never worked in the native apps (#777): the redirect_uri was built from window.location.origin — a localhost-shaped WebView address LinkedIn rejects and which cannot be registered — and the authorize URL went to window.location.href, which the WebView hands to the system browser.

LinkedIn will not register a private-use scheme

Unlike every SAML/OIDC provider behind the SSO flow, LinkedIn rejects a custom scheme outright:

Redirect URLs must be HTTP or HTTPS

So greekmanage://linkedin/callback cannot be the OAuth redirect_uri; #777 shipped that shape and it could never have worked (#817). Native therefore takes one extra hop — an HTTPS endpoint we own, which bounces to the scheme:

app → LinkedIn authorize (redirect_uri = {BACKEND_URL}/api/linkedin/native-callback/)
→ LinkedIn 302s the in-app tab to that endpoint
→ endpoint 302s to greekmanage://linkedin/callback?code=…&state=…
→ useAppUrlOpen → POST /api/linkedin/callback/

That is exactly what the SSO callbacks above have always done. The scheme is still the last hop; it is just not the value LinkedIn is asked to store.

redirect_uri is checked against a server-side allowlist with exactly two members, both of which must also be registered on the LinkedIn developer app:

Platformredirect_uri
web{FRONTEND_URL}/linkedin/callback
native{BACKEND_URL}/api/linkedin/native-callback/

Clients name a platform, not a URL — the same rule stated above for SSO. The native URI is a backend address the app would otherwise have to guess and keep in step, and OAuth compares redirect_uri literally across both legs.

LinkedInNativeCallbackView is AllowAny because the in-app browser tab has its own cookie jar and carries no session. Its redirect target is built from MOBILE_APP_SCHEME server-side, never from request input, and only code/state/error/error_description are forwarded. Driving a bogus code into the app achieves nothing — the exchange still has to clear the state lookup, the owner check and PKCE.

Unlike the SSO callbacks this endpoint is IsAuthenticated and the value is only forwarded to LinkedIn, which enforces its own allowlist — so this was never an open redirect. What it fixes is that a mismatch used to surface as an opaque LinkedIn error page instead of a 400 naming the problem, and that the token exchange trusted a caller-supplied value for the one parameter OAuth compares literally between its two legs. The URI is now stored with the flow at initiation and read back at callback time.

Native flows are PKCE-bound on the same reasoning as SSO, using the same pkce.py and the same burn-before-verify ordering: the challenge is stored with the linkedin_oauth_state: entry, the state is deleted before the verifier is checked, and POST /api/linkedin/callback/ requires the verifier whenever the entry carries a challenge. Web flows send no challenge and are unchanged.

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: 60/min
  • passkey_complete: 30/min

Both were raised in v0.71.13. passkey_begin deliberately matches auth rather than sitting below it: these are sign-in paths, and a passkey user hitting a tighter limit than a password user is an arbitrary difference. passkey_complete is half because completion follows a begin 1:1.

A per-IP bucket is a shared network — a chapter house NAT, campus wifi — not one person, which is what the v0.71.13 keying correction made true in practice. See backend/apps/common/client_ip.py.

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, account request, public donate, PNM apply) explicitly use permission_classes = [AllowAny]. There is no self-registration endpoint: the only public signup path is the org-scoped account request flow (/request-account), which an admin approves before a user + membership is created.

Throttling

Throttle scopeAnon rateUser rate
Default2000/day prod, 10000/day dev2000/day prod, 100000/day dev
auth (login + OAuth callback)60/minute prod, AUTH_RATE
token_refresh120/minute prod, TOKEN_REFRESH_RATE
sso (SSO initiate + code exchange)20/minute prod
sso_discovery (SSO/OAuth discovery)120/minute prod, SSO_DISCOVERY_RATE
account_request30/hour prod, ACCOUNT_REQUEST_RATE
account_request_email2/hour per email
password_reset20/hour prod, PASSWORD_RESET_RATE
password_reset_email3/hour per email
pnm_submit_ip5/hour
pnm_submit_email1/minute

A per-IP bucket is a shared network, not a person. Until v0.71.13 the cache key included the whole X-Forwarded-For chain, which behind CloudFront → ALB ends in a rotating edge IP — so one caller scattered across many buckets and the configured rate was not the effective rate. Setting NUM_PROXIES keys on the caller instead, which made every limit above real for the first time, and therefore tight enough to lock out a chapter house signing in together. Hence the raised numbers. See backend/apps/common/client_ip.py.

token_refresh is separate from auth for the same reason sso_discovery is separate from sso (below): it is machine-driven. In an 11-day production window every throttled request was a token refresh and none was a login — an automatic retry loop was spending the budget interactive sign-in depends on.

account_request and password_reset are capped twice, per IP and per target email, with both throttles on the view so both must pass. The IP bucket is loose because it is a shared campus NAT during recruitment; the email bucket is tight because "how many resets can be aimed at one person" is a per-victim property no IP limit bounds. Note this is not a combined ip+email key, which would let a caller vary the email to mint unlimited buckets.

Discovery is separate from initiation on purpose. The login page re-queries both discovery endpoints on every debounced change to the typed email domain, so ordinary typing costs several requests per sign-in attempt. While the two shared one bucket, typing could exhaust the budget the sign-in itself needed — and a throttled discovery call renders as the org's SSO button simply not being on the page. Discovery's protection comes from requiring a domain param (a caller only ever learns about the org whose domain they already supplied), not from a tight rate, so its bucket is generous. See #708.

Anon throttles behind CloudFront must key on the client IP. DRF's BaseThrottle.get_ident() falls back to the entire X-Forwarded-For chain when NUM_PROXIES is unset, and in production that chain is <viewer-ip>, <cloudfront-edge-ip> — the ALB appends the edge it was contacted by. Edge IPs rotate, so whole-chain keying scatters one caller across many buckets: the configured rate stops being the effective rate, and behaviour varies per device. Subclass apps.common.throttles.ClientIPAnonRateThrottle (or write a get_cache_key using its client_ip helper, as the PNM / ICS / public-directory throttles do) rather than AnonRateThrottle directly.

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.

Impersonation ("view as member")

Added in v0.72.0 (#853). An org admin may hold a read-only session as one of their own members. The session is a real JWT minted for the target (apps/accounts/impersonation.py) carrying three claims:

ClaimMeaning
imp_byThe acting admin's user id — who to restore on exit. Not on its own sufficient to perform the restore; see below.
imp_orgThe organization the session is pinned to.
imp_expAbsolute unix deadline (30 minutes).
imp_sidRandom per-session id, shared with the restore token.

Ending a session needs a second, actor-bound credential

Both exit paths — /api/auth/impersonate/stop/ and the expiry recovery in token refresh — require a restore token alongside the session bearer. It is minted for the actor, carries imp_restore plus the session's imp_sid, and is delivered in its own httpOnly cookie scoped to /api/auth/ (native clients get it in the body and hold it in secure storage).

:::danger Why imp_by alone is not enough Treating the claim as sufficient proof — the obvious design, and the one this feature shipped with first — makes the member-scoped, read-only session token exchangeable for unrestricted admin tokens in a single request. The realistic leak path for a bearer (a proxy log, an error report, a captured Authorization header) is quite different from stealing a whole cookie jar, so a credential that never travels in that header is a real barrier. A token the product advertises as read-only must not be one call away from admin. :::

Because everything downstream derives authority from request.user, the permission classes, MeSerializer and OrganizationContextMiddleware._suborg_scope() all resolve against the member — so RLS scopes the session at the database, not merely at the API layer.

:::danger imp_exp is not redundant with the token's own exp SimpleJWT copies custom claims through both RefreshToken.access_token and rotation. Without an independent deadline a session would survive every refresh and live for the full 24-hour REFRESH_TOKEN_LIFETIME. :::

Same-org enforcement, in four places

_get_user_org_ids() spans all three role tiers for whoever request.user is — during a session, the member. A member belonging to two orgs would otherwise satisfy _header_matches_org for the org the admin does not administer, via a supported path with a valid token. So:

  1. At mint — target must hold an active Membership in the administered org.
  2. Per request — actor's OrganizationAdmin row and target's membership are both re-verified in CookieJWTAuthentication, so a revoked role or a moved member ends a live session.
  3. Pinned contextX-Organization-Id may only equal imp_org. This is the line that sets app.current_org_id, so the pin is what makes RLS hold the boundary.
  4. Nothing offeredMeOrganizationsView is filtered to imp_org, hiding the switcher.

Read-only, on two transports

ImpersonationGuardMiddleware refuses unsafe HTTP methods and closes the self-service account surface and the messaging module outright. Separately, greekmanage/asgi.py routes WebSockets through the Channels JWTAuthMiddleware, which the Django MIDDLEWARE stack never touches — MessagingConsumer._handle_send_message creates and broadcasts real DMs. Impersonated tokens are refused there too. Both points are required; either one alone leaves "read-only" false.

Expiry recovery

At the deadline authentication fails, and /api/auth/token/refresh/ answers with the actor's tokens plus X-Impersonation-Ended: 1 rather than a 401 — otherwise the refresh interceptor in api-client.ts would exhaust its retry and sign the admin out of an account they never left. The refresh path is exempt from impersonation rejection for this reason: DRF authenticates every view before dispatch, including AllowAny ones.

Forbidden targets

Platform admins, org admins and regional admins — in any org, since an ordinary member of your org may administer a different one. Also members with must_change_password (they would strand the session on /force-password-change), disabled accounts, and self.

Since v0.72.2 the same rules are available as a queryset filter, impersonation.exclude_non_impersonable(queryset, actor), reached through GET /api/organizations/<org_pk>/member-search/?q=<term>&impersonable=1. It exists so the profile-menu picker can decline to offer a name the start endpoint would only refuse later — none of the four reasons above are derivable client-side from the unfiltered payload.

:::warning Keep the two in step exclude_non_impersonable and assert_can_impersonate encode the same rules for different callers. A rule added to the endpoint but not the filter offers admins a name that dead-ends on submit; a rule added to the filter but not the endpoint hides someone who was in fact allowed. ImpersonableMemberSearchTests.test_every_offered_member_can_actually_be_impersonated asserts the agreement directly rather than by inspection. :::

The flag is opt-in. OrgMemberSearchView deliberately includes admins and backs eight other pickers; a default would change all of them.

:::danger The flag needs its own permission check OrgMemberSearchView admits any org member, which is correct for the unfiltered search and not sufficient here. The filtered and unfiltered result sets differ by exactly the four refusal reasons, so any caller able to run both learns, for a known email, whether that person is disabled, has a pending password change, or holds an elevated role in an org the caller cannot see at all. impersonable=1 therefore returns 403 (impersonable_search_forbidden) unless the caller is an active org admin of that org — the refusal is scoped to the flag, so the eight non-admin pickers are untouched. ::: Chapter officers are permitted: an org admin already outranks them within their own org.