[Auth and Keys]

API key model, tiers, require_api_key / track_usage decorators, JSON/SQLite/Redis stores, and admin create_key.

Auth and Keys

Abstract

Pro routes authenticate with a raw API key presented as Authorization: Bearer …, Authorization: ApiKey …, or ?api_key=. Keys are minted as pyn_ + url-safe secret, stored with a short key_id and SHA-256 hash metadata, and tracked against monthly-ish call limits by tier. Free /run does not require a key.

Conceptual model

Interface surface

Tiers (_TIER_LIMITS)

Tiercalls_limit
free0 (treated as unlimited in is_rate_limited — limit 0 means “no cap”)
hobby5 000
pro50 000
team200 000
enterpriseinf

calls_remaining() returns inf when limit is 0.

Endpoints

POST /auth/create_key

Body schema CREATE_KEY_SCHEMA: optional tier (default "hobby").

Response:

{
  "status": "success",
  "api_key": "pyn_…",
  "key_id": "12hexchars",
  "tier": "hobby",
  "message": "Store this API key securely. It will not be shown again."
}

Decorated with @require_admin_token:

  • Requires non-empty ADMIN_TOKEN env (unset → 403, minting disabled)
  • Client must send matching X-Admin-Token (or Authorization: Bearer … / AdminToken …)
  • Comparison uses hmac.compare_digest

GET /auth/usage

@require_api_key — returns key_id, tier, and usage block (calls_used, calls_limit, calls_remaining, timestamps).

POST /auth/validate

Body: { "api_key": "…" } — validates without incrementing usage. Returns tier info, active, rate_limited.

Decorators

DecoratorBehavior
require_api_keyResolve key → 401 / 429 or set g.api_key
track_usageWraps require_api_key; increments calls when response status < 400 (tuple responses) or always for bare Response
require_admin_tokenFail-closed admin gate (ADMIN_TOKEN + X-Admin-Token)

Client presentation

curl -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d @body.json \
  http://127.0.0.1:5002/preview/chart

Internals

APIKey dataclass

Fields: key_id, key_hash, tier, calls_used, calls_limit, created_at, last_used.

Helpers: is_active() (always True today), is_rate_limited(), get_tier_info(), increment_calls().

Stores

ModuleUse
middleware/auth.pyAPIKeyStore + get_key_store()Facade; selects backend via STORE_BACKEND
middleware/key_store_sqlite.pyHash-only rows, WAL, multi-worker friendly
middleware/key_store_redis.pyHash-only Redis; multi-replica

get_key_store() backends (STORE_BACKEND):

ValuePersistence
json (default)File at API_KEY_STORE; raw keys as object keys (dev only)
sqliteAPI_KEY_STORE_SQLITE (or .db beside JSON path); hash-only
redisREDIS_URL required; hash-only

Prod compose defaults to sqlite on /data/api_keys.db so gunicorn workers share state.

Key minting:

raw_key = "pyn_" + secrets.token_urlsafe(32)
key_id  = sha256(raw_key).hexdigest()[:12]
key_hash = sha256(raw_key).hexdigest()

Schemas

CREATE_KEY_SCHEMA, VALIDATE_KEY_SCHEMA in middleware/schemas.py — strict types, reject unknown fields.

Invariants and edge cases

  1. /run is unauthenticated — rate-limit at reverse proxy if exposed publicly.
  2. Limit 0 means unlimited, not “zero calls” — free tier limit value is 0 with that semantics.
  3. Usage increments only after handler returns — failed auth never bills; handler 4xx under track_usage skips increment when returned as (response, status).
  4. Revocation exists on the store API (revoke_key); no public HTTP revoke route in app.py.
  5. Admin minting is fail-closed — unset ADMIN_TOKEN or wrong X-Admin-Token → 403.

Worked example

# create (requires ADMIN_TOKEN env + matching header)
export ADMIN_TOKEN=change-me
curl -s -X POST http://127.0.0.1:5002/auth/create_key \
  -H "X-Admin-Token: $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"tier":"hobby"}'

# validate
curl -s -X POST http://127.0.0.1:5002/auth/validate \
  -H 'Content-Type: application/json' \
  -d '{"api_key":"pyn_…"}'

Failure modes

CodeMeaning
UNAUTHORIZEDMissing/invalid key
RATE_LIMITEDcalls_used >= calls_limit (finite limits)
INVALID_TIERcreate_key with unknown tier string
INVALID_KEYvalidate endpoint miss

See also