Admin authentication¶
Three login flavors share the same JWT-minting entry point. Pick one in cert.config.json#admin.auth_mode:
| Mode | Flow |
|---|---|
password |
Email + password, single step. |
otp_email |
Email first β 6-digit code via email β submit code. Two steps. |
magic_link |
Email first β one-click URL via email β land on /admin?token=β¦. Two steps. |
Every successful login returns a JWT that encodes sub (user id), email, role, and exp (8h default). The token is HS256-signed with JWT_SECRET.
JWT_SECRET¶
Required. No ephemeral fallback β a missing secret raises TokenError('JWT_SECRET is not set') immediately. Use 32+ random characters; rotate on compromise.
Creating the first admin¶
The CLI doesn't ship a one-shot admin-create command yet (on the roadmap). Use the Python API:
from luonvuitoi_cert.auth import Role, create_admin_user
create_admin_user(
"data/my-portal.db", # same DB the handler reads
email="you@example.org",
role=Role.SUPER_ADMIN,
password="a-real-password-please",
)
OTP / magic-link users don't need a password β pass password=None.
Roles¶
| Role | Capabilities |
|---|---|
super-admin |
Everything + can create/delete other admins + flip public-surface feature gates. |
admin |
CRUD students, manage shipments, view activity log. |
viewer |
Read-only. Cannot update students or shipments. |
Handlers check role with an allowlist β token.role in (ADMIN, SUPER_ADMIN) β so future roles default to read-only until they're explicitly added to the allowlist. Super-admin-only surfaces (user management, feature gates) use a strict equality check instead of the allowlist.
Timing-safe lookup¶
verify_admin_password() runs the PBKDF2 hash even for unknown emails so a wall-clock observer can't distinguish a nonexistent account from a wrong password. The same pattern holds in the OTP/magic-link step-1 paths: unknown emails get a decoy KV write + hash, identical response shape, and no email is sent.
OTP (otp_email)¶
- Code: 6 digits,
secrets.SystemRandom. - Storage:
SHA-256(email + "|" + code)in KV keyed byotp:<email>with a 5-minute TTL. Plaintext never hits disk. - Verify: atomic
kv.consume()β a concurrent submit can't race into double-use. - Provider:
features.otp_email.providerβresendis the only adapter shipped. SetRESEND_API_KEYandCERT_EMAIL_FROMin.env.
Bootstrap the provider in custom transport code:
from luonvuitoi_cert.auth import ResendProvider
with ResendProvider(api_key=os.environ["RESEND_API_KEY"], from_address="no-reply@example.org") as mailer:
β¦
Magic link (magic_link)¶
- Token: 32-byte url-safe random.
- Storage:
SHA-256(token)β email, 15-minute TTL. - Verify: atomic consume β clicking the link twice only works once.
- Caller supplies a
link_builder(token) -> strcallback that assembles the click URL (Phase 11 dev server usesPUBLIC_BASE_URL + "/admin?token=").
Signing out / revoking sessions¶
JWTs are stateless β rotating JWT_SECRET invalidates every active session.
For the "one admin compromised, need to log them out without nuking everyone"
case, the portal ships a KV-backed revocation list.
Client flow¶
Response is always 200:
{"revoked": true, "jti": "<jti>"}β token accepted,jtiadded to the denylist with TTL = remaining-life of the token{"revoked": false, "error": "admin session expired"}β token was already invalid, nothing to do (the client is signing out anyway)
Server-side enforcement¶
Any handler that threads kv= through to verify_admin_token will reject a
revoked session with TokenError("admin session revoked"). In this repo, the
Flask shim passes kv to:
/api/admin/search(admin-mode search)/api/shipment/upsert/api/admin/logout(idempotent β the revoke call itself doesn't block on prior denylist hits)
Handlers that don't pass kv keep the pre-M7 behavior (token valid until exp).
This is deliberate: your custom transport code can opt into revocation per
endpoint as you thread the KV instance through.
Denylist storage¶
Entries land in the configured KV backend under jwt_denylist:<jti> with
TTL = exp - now. The denylist self-expires β no cron job needed. With the
default 8-hour session TTL, the worst-case denylist size is bounded by the
number of log-out events in any 8-hour window.
Programmatic revocation¶
from luonvuitoi_cert.auth import revoke_admin_token
jti = revoke_admin_token(kv, token=caller_jwt, env={"JWT_SECRET": "..."})
# Subsequent verify_admin_token(jwt, kv=kv) calls raise TokenError('revoked').
Activity log¶
When you pass an ActivityLog to perform_login, it records:
admin.login.successβ user_id, email, role, IP.admin.login.failureβ email (if provided), reason (bad-password,bad-otp,bad-magic-link,missing-email, β¦), IP.
Entries live in the admin_activity SQLite table and, if GSHEET_WEBHOOK_URL is set, are forwarded asynchronously on a daemon thread so the webhook being slow or down doesn't block the login response.
Transport-layer contract¶
Whoever wires perform_login into HTTP (the dev server does it at /api/admin/login) must:
- Call
validate_request_size(body, max_bytes=32 * 1024)before parsing JSON. - Catch
LoginErrorand translate to HTTP 401. Don't catchExceptionβ internal bugs should bubble to the platform's 500 handler, not leak as the 401 body. - Emit
Content-Security-Policy: script-src 'self' 'nonce-β¦'on/admin(or any page that renders the JWT-handling JS) so a reflected XSS sink can't exfiltrate the token fromsessionStorage.