Sessions & Tokens¶
Every successful Craft Easy login returns a signed JWT. This page covers what that token contains, how it is signed, how sessions are tracked on the server, and how to refresh, list, or terminate them.
Everything on this page is implemented by craft_easy.core.auth.jwt.JWTService, craft_easy.core.auth.sessions.SessionService, and the UserSession / TokenBlacklist models.
Token types¶
Craft Easy has three token types, all carried in the same TokenPayload shape:
token_type |
Who gets it | Refreshable | Notes |
|---|---|---|---|
user |
Humans after a full login (OTP/OAuth2/WebAuthn + optional 2FA). | Yes | Default caller type. |
pre_auth |
Returned when requires_2fa=true. |
No | Only accepted by /auth/2fa/verify. |
service |
Issued by /auth/api-key and /auth/api-key/signed. |
No | Re-authenticate from the key instead. |
The signing key¶
Craft Easy uses ES512 (ECDSA on NIST P-521 with SHA-512) by default. Two settings carry the keypair:
class Settings(CraftEasySettings):
JWT_ALGORITHM: str = "ES512"
JWT_PRIVATE_KEY: SecretStr # PEM-encoded EC private key
JWT_PUBLIC_KEY: str # PEM-encoded EC public key
JWT_USER_EXPIRY_MINUTES: int = 30
JWT_REFRESH_MAX_LIFETIME_MINUTES: int = 43200 # 30 days
Generate a keypair locally:
openssl ecparam -genkey -name secp521r1 -noout -out jwt-private.pem
openssl ec -in jwt-private.pem -pubout -out jwt-public.pem
Store the private key in a secrets manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) and load it into JWT_PRIVATE_KEY. Never commit it. Rotating the key invalidates every outstanding token instantly — which is exactly what you want during an incident.
JWTService is instantiated from those settings by the get_jwt_service FastAPI dependency. You rarely have to construct it yourself.
Payload contents¶
TokenPayload (in craft_easy.core.auth.tokens) is serialised straight into the JWT body:
class TokenPayload(BaseModel):
token_id: str # uuid4 — used for blacklisting
token_type: TokenType
user_id: str
user_name: str | None
tenant_id: str | None
tenant_slug: str | None
scope: str = "tenant" # "system" | "partner" | "tenant"
partner_id: str | None
parent_tenant_id: str | None
org_scope_id: str | None
ip: str | None # original login IP
origin_app: str | None # "portal", "admin", "warden", ...
refresh_count: int = 0
is_system_user: bool = False
api_code: str | None # set on service tokens
role: str | None
requires_2fa: bool = False # only set on pre_auth tokens
exp: datetime
iat: datetime
extra: dict = {}
Everything except exp and iat is stable across refresh operations, so logging, tracing and auditing can carry the same IDs end to end.
Creating tokens¶
Normally the /auth/* routes do this for you. If you build a custom auth endpoint, use the JWTService factory methods:
service: JWTService = Depends(get_jwt_service)
token_str, payload = service.create_user_token(
user_id=str(user.id),
user_name=user.name,
ip=request.client.host,
origin_app="portal",
tenant_id=str(user.tenant_id),
tenant_slug=tenant.slug,
scope="tenant",
)
Other factories: create_pre_auth_token (for 2FA flows) and create_service_token (for API-key logins).
Refreshing¶
Under the hood this calls JWTService.refresh(token, ip=request.client.host), which:
- Decodes the token without expiry verification.
- Checks that the token is a
usertoken. - Verifies
now - iat <= refresh_max_lifetime(default 30 days). - Verifies the requesting IP matches
payload.ip(unless the token is a service token orpayload.ipis empty). - Increments
refresh_countand stamps a newexp. - Re-encodes and returns the new token.
Rejections:
| Exception | Cause |
|---|---|
TokenRefreshExpiredError |
More than JWT_REFRESH_MAX_LIFETIME_MINUTES since the original login — user must log in again. |
IPMismatchError |
The caller's IP no longer matches the one the token was issued to. |
TokenInvalidError |
Not a user token, or signature verification failed. |
Sessions¶
Every user token is also paired with a UserSession row. The session carries a token fingerprint (first 16 hex chars of sha256(token)) — not the token itself — plus device and IP metadata. This is what powers "log out other sessions" features in the admin UI.
from craft_easy.core.auth.sessions import SessionService
svc = SessionService()
session = await svc.create_session(
user_id=str(user.id),
token=token_str,
expires_at=payload.exp,
device_info=request.headers.get("user-agent"),
ip_address=request.client.host,
origin_app="portal",
)
sessions = await svc.list_user_sessions(str(user.id))
await svc.terminate_session(str(sessions[0].id), terminated_by="self")
To forcibly log a user out of all devices (for example during a security incident or password change):
await svc.terminate_all_sessions(
user_id=str(user.id),
except_token=current_token, # optional — keep the current device signed in
terminated_by="admin",
actor_user_id=str(admin.id),
)
Every terminate operation writes an AuditEntry into audit_log — see Audit Logging.
Token blacklisting¶
Terminating a session (or hitting DELETE /auth/logout) adds the token fingerprint to TokenBlacklist until its original expiry time. The blacklist is consulted on every authenticated request by get_current_user:
Because entries use only the short fingerprint, the blacklist stays tiny even under heavy churn. A scheduled job should run SessionService.cleanup_expired() to purge expired sessions and blacklist rows — the built-in session_cleanup job does this every 10 minutes by default.
Logout¶
The route:
- Looks up the session by token fingerprint.
- Marks it
is_active=falseand writesterminated_at. - Adds the fingerprint to the blacklist.
- Emits an audit entry.
After logout, the token is immediately rejected by any subsequent request.
Endpoints reference¶
| Method | Path | Purpose |
|---|---|---|
POST |
/auth/refresh |
Refresh the current user token. |
DELETE |
/auth/logout |
Terminate the current session and blacklist the token. |
GET |
/sessions |
List the current user's active sessions. |
DELETE |
/sessions/{id} |
Terminate a specific session. |
DELETE |
/sessions |
Terminate every session except (optionally) the current one. |