Authentication & Permissions
SeedTrust has three separate authentication systems — one per service layer. They share the same user records in MySQL but operate independently. Understanding how each works is essential before touching any route, endpoint, or permission check.
The Three Auth Systems
| Service | Method | Session Type | Where Configured |
|---|---|---|---|
| Flask | Session cookie (Flask-Login) | Server-side session | seedtrust_flask/seedtrust/views/auth.py |
| FastAPI | JWT (Bearer token or cookie) | Stateless | seedtrustapi/src/seedtrust/modules/user/auth/ |
| Next.js | Next-Auth Credentials | Server-side session (wraps FastAPI JWT) | app/src/auth.ts |
Flask Authentication
Flask uses server-side session cookies via Flask-Login. When a user logs in, their user_id and user_type are stored in the session. The session is server-managed — invalidating it (logging out) destroys the session server-side.
Login Flow
POST /login → Validate email + password (bcrypt hash check) → Check TFA requirement → If TFA required → redirect to OTP verification → OTP verified → session created Else → session created immediatelyHow Routes Are Protected
Flask routes use the @authcheck() decorator:
@authcheck( user_types=["admin"], perm_check=Permission.Case.VIEW_CASE_DETAILS)def case_detail(case_id): ...Parameters:
user_types— list of allowed user type strings ("admin","surrogate","cm","owner","ip","ip_rep")perm_check— aPermissionenum value or list; user must have any one of them (OR logic)category_check— user must have any permission in this categorycase_id— if provided, also validates the user has access to that specific case
If the check fails, the user is redirected or receives a 403.
For API routes within Flask, @api_authcheck() is similar but not identical to @authcheck(). It never redirects — it always aborts with a status code. The authorization logic also differs: authcheck fails only when both the authorization check fails and g.admin is falsy (not authorized and not g.admin), while api_authcheck fails when either check fails (not authorized or not g.admin), requiring a valid g.admin context in addition to passing authorization. It also does not support the redirect_to or category_check parameters.
Code:
authcheck,api_authcheckinseedtrust_flask/seedtrust/utils/auth.py
Template-Level Permission Checks
Jinja2 templates use a custom extension to conditionally render UI elements:
{% perm_check Permission.Banking.CREATE_NACHA %} <button>Create Batch</button>{% endperm_check %}
{% perm_check not Permission.Case.EDIT_CASE_DETAILS %} <p>Read-only view</p>{% endperm_check %}Code:
PermissionExtensioninseedtrust_flask/seedtrust/permission_extension.py
Adding a New Flask Route with Auth
from seedtrust.utils.auth import authcheckfrom seedtrust.models.admin_permissions import Permission
@blueprint.route("/my-route")@authcheck(user_types=["admin"], perm_check=Permission.Disbursements.CREATE_DRS)def my_route(): # g.admin is available here for admin users # g.current_user is available for non-admin users ...FastAPI Authentication
FastAPI uses stateless JWT tokens. No session state is stored server-side — all user identity is encoded in the token itself.
Token Structure
POST /api/user/auth/loginBody: { email_address, user_type, pass_hash }
Response:{ "access_token": "<jwt>", "refresh_token": "<jwt>", "expires_at": "<iso-datetime>"}- Access token — short-lived (30 minutes by default), contains
email_addressanduser_type - Refresh token — longer-lived, used to get a new access token without re-login
- Tokens accepted as a
Bearerheader or anapi_tokencookie
How Routes Are Protected
FastAPI uses dependency injection:
from seedtrust.dependencies import CurrentUser, DbSession
@router.get("/cases")async def list_cases(current_user: CurrentUser, db: DbSession): # current_user is the authenticated user object # current_user.user_type tells you the role ...CurrentUser is a type alias for Annotated[User, Depends(get_current_user)]. The dependency:
- Extracts the JWT from the
Authorizationheader orapi_tokencookie - Decodes it using
settings.SECRET_KEY - Looks up the user in the database by
email_address+user_type - Returns the user object or raises HTTP 401
Token Refresh Flow
Access token expires →Next.js detects expiry (via NextAuth JWT callback) →POST /api/user/auth/refresh with refresh token →New access token returned →Session updated transparentlyThe Next.js auth.ts handles this automatically — the app never shows a login prompt unless the refresh token itself has expired.
Code:
seedtrustapi/src/seedtrust/modules/user/auth/route.py,app/src/auth.ts
Two-Factor Authentication (TOTP)
Both Flask and FastAPI support TOTP-based 2FA (authenticator app). After password validation:
- If TFA is enabled for the user, a partial session is created
- The user is prompted for their 6-digit TOTP code
POST /api/user/auth/verify-totpvalidates the code- On success, the full JWT is issued
TFA methods: email (code via SendGrid), sms (code via Twilio), app (TOTP authenticator).
Public API Access (Agencies)
Some FastAPI endpoints are accessible via API key rather than user JWT. Agencies use an X-API-Key header:
from seedtrust.dependencies import CurrentAgencyId
@router.post("/webhook")async def receive_webhook(agency: CurrentAgencyId, db: DbSession): # agency is the authenticated Agency object ...The API key is stored hashed on the Agency model.
Adding a New FastAPI Endpoint with Auth
from seedtrust.dependencies import CurrentUser, DbSession
@router.get("/my-endpoint")async def my_endpoint(current_user: CurrentUser, db: DbSession): if current_user.user_type != "admin": raise HTTPException(status_code=403, detail="Admins only") ...Note: FastAPI has no granular permission system and should not grow new product ownership. If a temporary endpoint requires a specific Admin permission, query
AdminRole/AdminPermissionmanually and document the intended Flask owner. New admin features should go in Flask, where the existing RBAC system is the source of truth.
Next.js Authentication
Next.js uses Next-Auth with a Credentials provider. It does not implement its own auth logic — it delegates entirely to the FastAPI login endpoint and stores the resulting JWT in a secure, server-side session cookie.
Login Flow
User submits login form (email + password + user_type) →Next-Auth Credentials provider →POST to FastAPI /api/user/auth/login →JWT returned →Stored in Next-Auth session (secure cookie) →User redirected to dashboardProtected Routes
Middleware (app/src/middleware.ts) intercepts all requests to (authenticated) routes and verifies the Next-Auth session. Unauthenticated requests are redirected to /login.
Accessing the Current User in Next.js
// Server componentimport { auth } from "@/auth"const session = await auth()const user = session?.user
// Client componentimport { useSession } from "next-auth/react"const { data: session } = useSession()Code:
app/src/auth.ts,app/src/middleware.ts
The Admin Permission System (Reference)
The full Admin permission system is documented in user-roles.md. Quick reference for developers:
Checking a Permission in Flask Code
if g.admin.has_permission(Permission.Payments.MAKE_PAYMENTS): ...
@authcheck(user_types=["admin"], perm_check=Permission.Payments.MAKE_PAYMENTS)def payment_route(): ...Adding a New Permission
- Add the permission to the appropriate
Permissioncategory inadmin_permissions.py:
class Permission: class Payments: MAKE_PAYMENTS = "make_payments" MY_NEW_PERMISSION = "my_new_permission" # add here-
Run
AdminPermission.sync_permissions()— this reads thePermissionclass, adds any new entries to theadmin_permissiontable, and removes stale ones. This runs automatically on app startup. -
Assign the permission to the appropriate
AdminRole(s) via the admin UI or a migration. -
Use it in a route:
@authcheck(user_types=["admin"], perm_check=Permission.Payments.MY_NEW_PERMISSION)Do not add inline
if g.admin.has_permission(...)checks in view functions. Always use the decorator — inline checks are inconsistent and easy to miss in code review.
Known Issues & Planned Work
Password change does not invalidate FastAPI tokens. If a user changes their password in Flask, their existing FastAPI JWT remains valid until it expires (up to 30 minutes). There is no token revocation mechanism currently.
FastAPI has no granular Admin permissions and should not become the permission owner. New admin features with permission requirements should go in Flask and use the existing RBAC system.
Flask session timeout is 60 minutes. Users left idle for 60 minutes are automatically logged out of the Flask admin UI.
ip_company vs ip_rep. The FastAPI UserTypes enum uses ip_company as the value for IP Representatives (a Flask compatibility alias). Use ip_rep when referring to this role in business contexts; use ip_company when writing FastAPI code that checks user_type.
Gotchas for Developers
The same email can have multiple user records. Always include user_type when querying for a user. User.query.filter_by(email=email) alone can return the wrong record.
pre_gsa affects approval logic, not auth. Case-level access control (which cases a user can see) is separate from the DR approval authority configuration. Do not conflate the two.
Flask session and FastAPI JWT do not share state. A user logged into Flask is not automatically authenticated in FastAPI. They are independent sessions. Testing an endpoint in one service does not validate behavior in the other.
Never skip the @authcheck decorator for “internal” routes. Flask has no network-level isolation — any route without auth is accessible to anyone who can reach the server.
Open Questions
1. Token revocation on password change There is currently no mechanism to invalidate a FastAPI JWT when a user’s password is changed in Flask. The token remains valid until it expires (up to 30 minutes). This should be resolved as auth ownership consolidates into Flask and the FastAPI/NextAuth split is reduced.