Skip to content

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

ServiceMethodSession TypeWhere Configured
FlaskSession cookie (Flask-Login)Server-side sessionseedtrust_flask/seedtrust/views/auth.py
FastAPIJWT (Bearer token or cookie)Statelessseedtrustapi/src/seedtrust/modules/user/auth/
Next.jsNext-Auth CredentialsServer-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 immediately

How 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 — a Permission enum value or list; user must have any one of them (OR logic)
  • category_check — user must have any permission in this category
  • case_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_authcheck in seedtrust_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: PermissionExtension in seedtrust_flask/seedtrust/permission_extension.py

Adding a New Flask Route with Auth

from seedtrust.utils.auth import authcheck
from 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/login
Body: { 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_address and user_type
  • Refresh token — longer-lived, used to get a new access token without re-login
  • Tokens accepted as a Bearer header or an api_token cookie

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:

  1. Extracts the JWT from the Authorization header or api_token cookie
  2. Decodes it using settings.SECRET_KEY
  3. Looks up the user in the database by email_address + user_type
  4. 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 transparently

The 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:

  1. If TFA is enabled for the user, a partial session is created
  2. The user is prompted for their 6-digit TOTP code
  3. POST /api/user/auth/verify-totp validates the code
  4. 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/AdminPermission manually 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 dashboard

Protected 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 component
import { auth } from "@/auth"
const session = await auth()
const user = session?.user
// Client component
import { 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

  1. Add the permission to the appropriate Permission category in admin_permissions.py:
class Permission:
class Payments:
MAKE_PAYMENTS = "make_payments"
MY_NEW_PERMISSION = "my_new_permission" # add here
  1. Run AdminPermission.sync_permissions() — this reads the Permission class, adds any new entries to the admin_permission table, and removes stale ones. This runs automatically on app startup.

  2. Assign the permission to the appropriate AdminRole(s) via the admin UI or a migration.

  3. 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.