Skip to content

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

client.post("/auth/refresh")

Under the hood this calls JWTService.refresh(token, ip=request.client.host), which:

  1. Decodes the token without expiry verification.
  2. Checks that the token is a user token.
  3. Verifies now - iat <= refresh_max_lifetime (default 30 days).
  4. Verifies the requesting IP matches payload.ip (unless the token is a service token or payload.ip is empty).
  5. Increments refresh_count and stamps a new exp.
  6. 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:

if await session_svc.is_token_blacklisted(token):
    return None  # treated as unauthenticated → 401

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

client.delete("/auth/logout")

The route:

  1. Looks up the session by token fingerprint.
  2. Marks it is_active=false and writes terminated_at.
  3. Adds the fingerprint to the blacklist.
  4. 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.