Skip to content

fix(api): /classify accepts API keys; harden keys page reveal UX#243

Merged
ramdhanyk merged 2 commits into
mainfrom
fix/classify-api-key-auth
May 26, 2026
Merged

fix(api): /classify accepts API keys; harden keys page reveal UX#243
ramdhanyk merged 2 commits into
mainfrom
fix/classify-api-key-auth

Conversation

@ramdhanyk
Copy link
Copy Markdown
Contributor

@ramdhanyk ramdhanyk commented May 25, 2026

Summary

  • Bug fix: /api/v1/classify now accepts developer API keys. The route was wired to get_current_user (JWT-only), so every wot_<hex> / aix_<hex> key was silently rejected with {"detail": "Invalid token"} from jwt.decode. Switched to Depends(require_scope("wot:classify")). Pro/Enterprise tier gate preserved, billing attribution prefers the key's org.
  • UX hardening for /developers/keys: a forced-acknowledgment modal on key creation, a sticky reveal banner that survives accidental refresh (via sessionStorage), a Copy button, and theme-aware colors so the key is readable in dark mode.

Two independent commits so they can be reviewed and (if desired) split:

  • 186dec3 fix(api): /classify accepts developer API keys via require_scope
  • d908add feat(frontend): harden API key reveal UX on /developers/keys

Why the bug existed

require_scope was defined in world_of_taxonomy/api/deps.py but never used in production routers. Every paid endpoint that needed API-key auth was instead using get_current_user, which only knows how to decode JWTs. An API key passed as Authorization: Bearer wot_<hex> would hit jwt.decode, raise jwt.InvalidTokenError, and return 401 - the same response regardless of whether the key was valid, expired, revoked, or just the wrong shape.

What changed in the handler

# before
user: dict = Depends(get_current_user),

# after
auth: dict = Depends(require_scope("wot:classify")),
# tier gate still applied via app_user.tier lookup
# billing now prefers auth["org_id"] (set by require_scope from the key row)

The handler still 403s a free-tier user even if they hold a wot:classify key - the scope-issuance path doesn't yet enforce tier, so the endpoint-level check is the gate that prevents free users from consuming paid features.

Frontend: why not just "mask + eye toggle to reveal later"

Storing the raw key so an eye toggle has something to reveal would require either plaintext or reversibly-encrypted storage server-side, which is a meaningful downgrade from today's bcrypt-only hash on api_key.key_hash. Stripe, AWS, GitHub, Anthropic all show the secret exactly once for this reason.

sessionStorage gives the practical benefit (survive refresh, copy any time) without ever sending the raw key back to the server. Tab-scoped, cleared on close, no DB regression.

Test plan

  • tests/test_api_classify.py - all 18 tests green against the real WoT Postgres (wot-postgres container, schema test_wot). Includes:
    • TestClassifyRouteWiring::test_classify_route_uses_require_scope_dependency - non-DB sentinel, cycled red -> green
    • TestClassifyAPIKeyAuth::test_api_key_with_classify_scope_authenticates - DB-backed, Pro user + wot:classify scope -> 200
    • TestClassifyAPIKeyAuth::test_api_key_without_classify_scope_is_403 - DB-backed, wot:read-only -> 403 scope_missing
  • Adjacent suites swept: tests/test_keys_issue_validate.py, tests/test_keys_logic.py, tests/test_auth.py, tests/test_api_developers.py - 53 pass, 1 pre-existing failure (test_keys_create_returns_raw_key_once_with_correct_prefix, CSRF-token-mismatch). The failure reproduces identically on origin/main with this PR's files reverted; not caused by this PR. Worth a separate fix.
  • next build passes on the frontend; tsc and eslint clean.
  • Manual: visit /developers/keys in both light and dark themes after deploy, generate a key, confirm the modal forces acknowledgment, the banner persists across refresh, the Copy button works, and the key text is legible in both themes.
  • Manual: smoke test the live endpoint after deploy:
    curl -s -X POST https://wot.aixcelerator.ai/api/v1/classify \
      -H "Authorization: Bearer wot_<full key>" \
      -H "Content-Type: application/json" \
      -d '{"text":"manufacturing","systems":["naics_2022"],"limit":3}'

Follow-up (not in this PR)

  • Unified require_principal(scope) dependency that accepts cookie / JWT / API key and returns a normalized Principal so the dashboard and programmatic clients can hit the same endpoint with the same auth model. Separate PR.
  • Fix test_keys_create_returns_raw_key_once_with_correct_prefix (pre-existing CSRF test failure on main). Separate PR.

🤖 Generated with Claude Code

ramdhanyk and others added 2 commits May 25, 2026 19:48
The /api/v1/classify route was wired to get_current_user (JWT-only),
which rejected every API key with `{"detail": "Invalid token"}`
because jwt.decode raises on wot_<hex> payloads. Any developer
with a Pro API key was silently blocked from the paid endpoint.

Switch the dependency to require_scope("wot:classify"). The handler
now fetches the key owner tier from app_user so the Pro/Enterprise
gate still applies, and billing attribution prefers the principal
org_id (set by require_scope from the key row) over the user row.

Tests added in tests/test_api_classify.py:
- TestClassifyRouteWiring: non-DB sentinel that introspects the
  route dependency tree, fails fast if get_current_user is ever
  reintroduced. Runs in any environment.
- TestClassifyAPIKeyAuth: DB-backed integration via ASGITransport.
  Mints a real api_key row, asserts 200 with wot:classify scope and
  403 scope_missing with wot:read-only.

The sentinel was cycled red -> green locally. The DB-backed tests
require a reachable Postgres matching the .env DATABASE_URL; they
were authored against the test_wot schema fixtures in conftest.py
but not executed in this environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three problems users hit on the keys page:

1. The one-time raw-key banner was easy to miss - it rendered above
   the fold, with no scroll-to, no forced acknowledgment, and reset to
   null on the next refresh.
2. The code block was hardcoded bg-white text-foreground, which made
   the key invisible in dark mode.
3. No copy button - users had to triple-click to select.

This change layers three improvements:

- Modal on creation (base-ui Dialog). disablePointerDismissal so an
  outside click does not silently discard the key; only the explicit
  "I have saved this key" button closes it.
- Sticky reveal banner persists after the modal closes for the rest
  of the browser tab session, backed by sessionStorage under the key
  wot:keys:revealed. Survives accidental refresh. Cleared on tab
  close and on explicit dismiss.
- Copy button in both the modal and the banner (lucide Copy/Check
  swap, 1.8s reset).

The raw key is never sent to the server after creation. sessionStorage
is tab-scoped, so the persistence layer adds no DB-side regression
from the bcrypt-only hashed storage we already have on api_key.

Also replaced hardcoded amber-50 / red-50 / bg-white with theme
tokens (bg-muted, border-border, bg-amber-500/10, border-destructive)
so the page renders correctly in both light and dark themes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ramdhanyk ramdhanyk merged commit 43566de into main May 26, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant