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
| Token | Lifetime | Storage |
|---|---|---|
| Access | 30 minutes | httpOnly cookie (web) / Authorization: Bearer (mobile) |
| Refresh | 24 hours | httpOnly 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
Cookie attributes
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)
| URL | Purpose |
|---|---|
/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.3→emailurn:oid:2.5.4.42→first_nameurn:oid:2.5.4.4→last_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:
| Hint | Target |
|---|---|
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:
- the app mints a 43-character verifier and sends only its SHA-256 challenge with the initiation request;
- the challenge is stored alongside the flow, then copied onto the
sso_code:cache entry; 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.
Deep links
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 link | Route | Added |
|---|---|---|
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:
| Platform | redirect_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
| Setting | Value | Source |
|---|---|---|
RP_ID | app.greekmanage.com (prod) / localhost (dev) | WEBAUTHN_RP_ID env |
RP_NAME | GreekManage | WEBAUTHN_RP_NAME env |
EXPECTED_ORIGIN | https://app.greekmanage.com (prod) | WEBAUTHN_ORIGIN env |
| Attestation | none | We don't enforce specific authenticator brands |
| User verification | preferred | Asks for biometric / PIN where available |
Native apps (associated domains)
iOS and Android share passkeys with the web origin via:
- iOS:
apple-app-site-associationJSON served from/.well-known/apple-app-site-associationdeclaring the app's bundle ID underwebcredentials. Configured infrontend/ios/App/App/App.entitlements. - Android:
assetlinks.jsonserved from/.well-known/assetlinks.jsondeclaring the app's package + SHA-256 cert fingerprint. Configured in the manifest'sintent-filterforandroid: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/minpasskey_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
| Class | What it checks | Example endpoint |
|---|---|---|
IsNationalAdmin | User is platform admin OR active org admin | POST /api/organizations/<id>/admins/ |
IsRegionalAdmin | Platform admin OR active regional admin (region-scoped) | GET /api/regions/<id>/chapters/ |
IsNationalOrRegionalAdmin | Either of the above | GET /api/compliance/region-overview/ |
IsChapterMember | Platform admin OR has active membership in any chapter | GET /api/chapters/<id>/feed/ |
IsChapterOfficer | Platform admin OR officer/president in active standing | POST /api/chapters/<id>/bulletins/ |
IsNationalAdminOrChapterOfficer | Either national admin or chapter officer (mixed scope) | PATCH /api/memberships/<id>/ |
App-specific permissions
Some apps have their own:
apps/alumni/permissions.py→IsAlumniMember,IsAuthenticatedMemberapps/foundation/permissions.py→IsFoundationAdmin,IsFoundationEditorapps/forums/permissions.py→IsForumMemberOrAdmin
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 scope | Anon rate | User rate |
|---|---|---|
| Default | 2000/day prod, 10000/day dev | 2000/day prod, 100000/day dev |
auth (login + OAuth callback) | 60/minute prod, AUTH_RATE | – |
token_refresh | 120/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_request | 30/hour prod, ACCOUNT_REQUEST_RATE | – |
account_request_email | 2/hour per email | – |
password_reset | 20/hour prod, PASSWORD_RESET_RATE | – |
password_reset_email | 3/hour per email | – |
pnm_submit_ip | 5/hour | – |
pnm_submit_email | 1/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:
| Claim | Meaning |
|---|---|
imp_by | The acting admin's user id — who to restore on exit. Not on its own sufficient to perform the restore; see below. |
imp_org | The organization the session is pinned to. |
imp_exp | Absolute unix deadline (30 minutes). |
imp_sid | Random 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:
- At mint — target must hold an active
Membershipin the administered org. - Per request — actor's
OrganizationAdminrow and target's membership are both re-verified inCookieJWTAuthentication, so a revoked role or a moved member ends a live session. - Pinned context —
X-Organization-Idmay only equalimp_org. This is the line that setsapp.current_org_id, so the pin is what makes RLS hold the boundary. - Nothing offered —
MeOrganizationsViewis filtered toimp_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.
Related
- Permission tiers — full role hierarchy
- Multi-tenancy & RLS — DB-level isolation
- Audit & encryption — what's logged
- Viewing the app as a member — the admin-facing guide