Authentication & Security¶
Mirabelle uses JWT-based stateless authentication with support for local passwords, Microsoft OAuth, and Google OAuth. All tokens are HS256-signed JWTs. Token storage on the client uses localStorage.
Token Flow¶
sequenceDiagram
participant Browser
participant Vite
participant API
participant MasterDB
participant TenantDB
Browser->>Vite: POST /api/auth/login (email, password, account_id)
Vite->>API: proxy to http://localhost:8002/api/auth/login
API->>TenantDB: SELECT user WHERE email=?
TenantDB->>API: user row
API->>API: bcrypt.verify(password, hashed_password)
API->>MasterDB: get JWT secret from master.parameters
API->>API: jwt.encode(payload, secret, HS256)
API->>Browser: { access_token, expires_in, user }
Browser->>Browser: localStorage.setItem('forecastai_token', token)
Browser->>Vite: GET /api/forecasts (Authorization: Bearer <token>)
Vite->>API: proxy with Authorization header
API->>API: JWTAuthMiddleware.dispatch()
API->>API: jwt.decode(token, secret, HS256)
API->>TenantDB: SELECT 1 FROM revoked_tokens WHERE jti=?
API->>Browser: 200 OK with forecast data
JWT Structure¶
Every token issued by Mirabelle contains the following claims:
{
"jti": "550e8400-e29b-41d4-a716-446655440000",
"sub": "user-uuid",
"email": "alice@example.com",
"role": "admin",
"display_name": "Alice",
"auth_provider": "local",
"allowed_segments": [1, 2, 3],
"can_run_process": true,
"can_create_override": true,
"allowed_segments_edit": [1],
"is_superadmin": false,
"account_id": "tenant-account-uuid",
"account_name": "The Bicycle Company",
"exp": 1744000000
}
| Claim | Description |
|---|---|
jti |
JWT ID — unique identifier used for token revocation |
sub |
User UUID (from scenario.users.id) |
email |
User email |
role |
admin, user, or superadmin |
display_name |
Display name shown in the UI |
auth_provider |
local, microsoft, or google |
allowed_segments |
Segment IDs the user can view (empty = all for admin) |
can_run_process |
Permission to trigger pipeline runs |
can_create_override |
Permission to apply forecast adjustments |
allowed_segments_edit |
Segment IDs the user can edit |
is_superadmin |
True for SuperAdmin accounts |
account_id |
Tenant account UUID — used to route to the correct DB |
account_name |
Tenant display name — shown in the UI toolbar |
exp |
Expiry timestamp (Unix epoch seconds) |
Token Expiry¶
Default expiry is 480 minutes (8 hours). This is configurable in master.parameters:
UPDATE master.parameters
SET parameters_set = jsonb_set(parameters_set, '{token_expiry_minutes}', '480')
WHERE parameter_type = 'auth';
Algorithm and Secret¶
- Algorithm: HS256 (HMAC-SHA256)
- Secret storage:
master.parameters(type=auth, name=Default) under keyjwt_secret - Secret loading: Called in
_get_jwt_secret()inmain.pywhich first triesdb.master_db.get_master_jwt_secret()then falls back to the tenant DBauthparameter set
Change the JWT secret before production
The default secret is CHANGE-ME-IN-PRODUCTION. Any token signed with this secret can be decoded by anyone. Generate a cryptographically random secret of at least 32 bytes:
master.parameters:
Authentication Providers¶
Local (Username + Password)¶
Users with auth_provider = 'local' use bcrypt-hashed passwords stored in scenario.users.hashed_password.
Login endpoint: POST /api/auth/login
// Request
{
"email": "user@example.com",
"password": "their-password",
"account_id": "tenant-account-uuid"
}
// Response
{
"access_token": "<jwt>",
"token_type": "bearer",
"expires_in": 28800,
"user": { "id": "...", "email": "...", "role": "admin", ... }
}
Password hashing uses passlib.context.CryptContext(schemes=["bcrypt"], deprecated="auto"). The bcrypt work factor follows passlib's default (12 rounds).
Microsoft OAuth¶
Flow: Client obtains a Microsoft access token from the Microsoft Identity Platform (MSAL) and sends it to:
POST /api/auth/microsoft
The API uses httpx to call https://graph.microsoft.com/v1.0/me to validate the token and retrieve the user's email. It then looks up or creates the user in scenario.users with auth_provider = 'microsoft' and issues a Mirabelle JWT.
Google OAuth¶
Flow: Client uses Google Identity Services to obtain a Google ID token (a JWT) and sends it to:
POST /api/auth/google
The API decodes the Google ID token (verifying the signature against Google's public keys via httpx) to extract the user's email and display name. It then looks up or creates the user in scenario.users with auth_provider = 'google' and issues a Mirabelle JWT.
Token Storage (Frontend)¶
The React frontend stores the JWT in localStorage under two keys (for historical compatibility):
- forecastai_token — the current active token
- mirabelle_token — alias used by some components
All API calls use the shared Axios instance in files/frontend/src/utils/api.js, which automatically injects the Authorization: Bearer <token> header from localStorage. A 401 response triggers an automatic redirect to /login.
localStorage security
localStorage is accessible to all JavaScript on the page, making it vulnerable to XSS attacks. Mirabelle does not currently use HttpOnly cookies. Ensure your deployment has a strict Content Security Policy and avoids loading third-party JavaScript from untrusted sources.
Server-Sent Events (SSE) Authentication¶
Browsers cannot set custom headers on EventSource connections. For live log streaming (/api/process/stream/<run_id>), the token is passed as a query parameter:
The middleware handles this fallback:
auth_header = request.headers.get("Authorization", "")
token = None
if auth_header.startswith("Bearer "):
token = auth_header[7:]
else:
token = request.query_params.get("token") # SSE fallback
Token in URL
Query-parameter tokens appear in server access logs and browser history. Mirabelle limits this to SSE endpoints where no alternative exists. Ensure access logs are appropriately secured in production.
Logout and Token Revocation¶
POST /api/auth/logout (authenticated) inserts the token's jti into scenario.revoked_tokens and returns 200. The frontend clears localStorage.
# Conceptual logout handler
jti = request.state.jti
with conn.cursor() as cur:
cur.execute(
f"INSERT INTO {schema}.revoked_tokens (jti) VALUES (%s) ON CONFLICT DO NOTHING",
(jti,)
)
conn.commit()
The revocation check runs on every subsequent request in JWTAuthMiddleware before the request reaches any handler.
Password Change¶
POST /api/auth/change-password (authenticated, local accounts only):
The handler verifies current_password against the stored bcrypt hash, then hashes and stores new_password. Returns 400 if the current password is incorrect or if the account uses OAuth.
SuperAdmin password divergence
The change-password endpoint only updates the tenant DB (scenario.sys_users). SuperAdmins also have a row in master.superadmins with its own hashed_password. When a superAdmin changes their password via the UI, the master DB hash is not updated, causing the superadmin login to silently fall through to the tenant user path on next login (resulting in role: 'admin' instead of role: 'superadmin').
Fix: After a superAdmin changes their password, also update master.superadmins:
UPDATE master.superadmins
SET hashed_password = (SELECT hashed_password FROM scenario.sys_users WHERE email = 'user@example.com')
WHERE email = 'user@example.com';
The API logs a warning when this divergence is detected during login (since 2026-06-14). See User Management → SuperAdmin Password Divergence for full details.
User Management Endpoints¶
All user management requires admin or superadmin role.
| Method | Path | Description |
|---|---|---|
GET |
/api/auth/users |
List all users in the current tenant |
POST |
/api/auth/users |
Create a new user |
PUT |
/api/auth/users/{id} |
Update user (role, segments, permissions) |
DELETE |
/api/auth/users/{id} |
Deactivate (soft delete) a user |
GET |
/api/auth/me |
Get current user profile |
POST |
/api/auth/logout |
Revoke current token |
POST |
/api/auth/change-password |
Change password (local accounts) |
Dependency Injection in Endpoints¶
Every protected endpoint uses the get_current_user dependency from files/api/auth.py:
from api.auth import get_current_user
@app.get("/api/forecasts")
async def get_forecasts(
pipeline_id: int,
user: dict = Depends(get_current_user),
):
# user is already validated — 401 if missing/expired token
# user = {"id": ..., "email": ..., "role": ..., "allowed_segments": [...], ...}
if not _user_has_segment_access(user, requested_segment_id):
raise HTTPException(status_code=403, detail="Access denied")
...
get_current_user reads request.state.user which was set by JWTAuthMiddleware. It raises HTTP 401 if the user is not authenticated.
For admin-only endpoints:
@app.post("/api/admin/something")
async def admin_action(user: dict = Depends(get_current_user)):
if user.get("role") not in ("admin", "superadmin"):
raise HTTPException(status_code=403, detail="Admin access required")
...
Public Paths¶
The following paths bypass JWT validation entirely:
Exact paths (from PUBLIC_PATHS):
- / — API root
- /docs — Swagger UI
- /openapi.json — OpenAPI schema
- /redoc — ReDoc documentation
Path prefixes (from PUBLIC_PREFIXES):
- /api/auth/login — login endpoint
- /api/auth/microsoft — Microsoft OAuth
- /api/auth/google — Google OAuth
- /api/auth/probe — connectivity probe (used by the frontend to check API availability)
All other paths require a valid, non-expired, non-revoked Bearer token.
Security Considerations¶
| Concern | Current State | Recommendation |
|---|---|---|
| JWT secret | Configurable, defaults to weak value | Change before any network exposure |
| Token storage | localStorage (XSS-vulnerable) |
Consider HttpOnly cookies for higher-security deployments |
| Token lifetime | 480 min (8 hours) | Reduce to 60 min + implement refresh tokens for production |
| CORS | Localhost origins only | Update to production domain before exposing on network |
| HTTPS | Not enforced (HTTP) | Put Nginx with TLS termination in front for any non-localhost use |
| bcrypt rounds | 12 (passlib default) | Acceptable; increase to 14 for very high-security environments |
| Token in URL | SSE only | Ensure access logs are secured |
| Revocation table | Grows indefinitely | Add a periodic cleanup job to remove expired JTIs |