Skip to content

feat(auth): dedicated MCP tool for one-time key minting, rate-limited via soft delete - #105

Merged
mruwnik merged 3 commits into
masterfrom
claude/u2-e19-ccbc50cc584f7592819d43e41128288f
Jul 7, 2026
Merged

feat(auth): dedicated MCP tool for one-time key minting, rate-limited via soft delete#105
mruwnik merged 3 commits into
masterfrom
claude/u2-e19-ccbc50cc584f7592819d43e41128288f

Conversation

@mruwnik

@mruwnik mruwnik commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Split one-time API key minting out of meta_get_user into a dedicated create_one_time_key MCP tool (SCOPE_WRITE), with a per-user rolling-window mint cap (ONE_TIME_KEY_RATE_LIMIT, default 10/hour) enforced by counting api_keys rows under a per-user pg_advisory_xact_lock.
  • Consuming a one-time key is now a soft delete: atomic UPDATE ... SET revoked WHERE NOT revoked RETURNING id (same CWE-367 race fencing as the old DELETE), so consumed keys stay countable inside the mint window. A daily maintenance task (cleanup_used_one_time_keys) purges tombstones once they age out.
  • Fixed a race in oauth_provider.lookup_principal: it ignored handle_api_key_use's return value, letting an MCP race-loser authenticate with an already-consumed key.

Hardening from the review loop (3 rounds, all comments resolved)

  • Minted one-time keys get a TTL (ONE_TIME_KEY_TTL_SECONDS, default 1h); unused-but-expired keys are purged once outside the mint window.
  • Rate-window comparisons run on the DB clock in-query (created_at > now() - interval), immune to non-UTC Postgres TimeZone or app/DB clock skew.
  • REST POST /users/{id}/api-keys rejects key_type=one_time (422 → use the MCP tool) and permanent-delete refuses one-time keys, so REST can't route around the TTL or mint limit.
  • Key listings hide consumed one-time tombstones; revoked regular keys stay visible per the existing revoke contract.

Deferred (acknowledged in review): migrating APIKey.created_at/expires_at to DateTime(timezone=True) — the remaining naive-datetime seam is in pre-existing is_valid() and only matters for non-UTC DB deployments.

Testing

  • 2767 passed across the full API tree (tests/memory/api + user models + rate limit) against real Postgres
  • New tests: rolling-window semantics (used keys still count, window slides, per-user buckets), TTL at mint, tombstone purge fates, REST rejection paths, listing filter, race-loser fencing

🤖 Generated with Claude Code

https://claude.ai/code/session_01EjRsK6NrPi9j8moiYHkxhX

… via soft delete

Split one-time API key minting out of meta_get_user into a dedicated
create_one_time_key MCP tool (SCOPE_WRITE), and cap minting per user with
a rolling window (ONE_TIME_KEY_RATE_LIMIT, default 10/hour) counted from
api_keys rows under a per-user pg advisory lock.

To make the count durable, consuming a one-time key is now a soft delete:
an atomic UPDATE ... SET revoked WHERE NOT revoked RETURNING id replaces
the hard DELETE, preserving the CWE-367 race fencing while keeping
consumed keys countable inside the window. A new maintenance task
(cleanup_used_one_time_keys, daily) purges tombstones once they age out.

Hardening that fell out of review:
- lookup_principal now honors handle_api_key_use's return value, so an
  MCP race-loser can no longer authenticate with an already-consumed key
- minted one-time keys get a TTL (ONE_TIME_KEY_TTL_SECONDS, default 1h);
  unused-but-expired keys are purged once outside the mint window
- rate-window comparisons run on the DB clock in-query, immune to a
  non-UTC Postgres TimeZone or app/DB clock skew
- REST POST /users/{id}/api-keys rejects key_type=one_time and
  permanent-delete refuses one-time keys, so the REST surface can't
  route around the TTL or the mint limit
- key listings hide consumed one-time tombstones (revoked regular keys
  stay visible per the existing revoke contract)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjRsK6NrPi9j8moiYHkxhX
Comment thread src/memory/api/MCP/servers/meta.py Outdated
Claude and others added 2 commits July 7, 2026 12:54
MCP callers only see tool descriptions, not code or settings — bake the
deployment's configured TTL and rate limit into create_one_time_key's
description instead of naming settings the caller can't resolve, and
return mints_remaining / rate_limit / expires_at in the response so
clients can see their quota.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjRsK6NrPi9j8moiYHkxhX
…uffix

Scope expansion: frontend CI has been red since 26b845a, which made the
client POST every MCP call to exactly /mcp — the tool name only travels
in the JSON-RPC body — while ~24 test files still routed their fetch
mocks by /mcp/<tool> URL substrings (195 failures inherited by this
branch).

mockFetchRoutes now synthesizes the old-style match target for MCP
requests (URL + body tool name), so both existing route-key styles keep
their substring semantics without touching every test; a new
mcpToolFromRequest helper covers hand-rolled mocks, and the remaining
URL-based finders/assertions in hook and component tests now match by
body tool. 1411 passed / 0 failed, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjRsK6NrPi9j8moiYHkxhX
@mruwnik
mruwnik merged commit 44b43ca into master Jul 7, 2026
2 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