feat(auth): dedicated MCP tool for one-time key minting, rate-limited via soft delete - #105
Merged
Merged
Conversation
… 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
mruwnik
commented
Jul 7, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
meta_get_userinto a dedicatedcreate_one_time_keyMCP tool (SCOPE_WRITE), with a per-user rolling-window mint cap (ONE_TIME_KEY_RATE_LIMIT, default 10/hour) enforced by countingapi_keysrows under a per-userpg_advisory_xact_lock.UPDATE ... SET revoked WHERE NOT revoked RETURNING id(same CWE-367 race fencing as the oldDELETE), so consumed keys stay countable inside the mint window. A daily maintenance task (cleanup_used_one_time_keys) purges tombstones once they age out.oauth_provider.lookup_principal: it ignoredhandle_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)
ONE_TIME_KEY_TTL_SECONDS, default 1h); unused-but-expired keys are purged once outside the mint window.created_at > now() - interval), immune to non-UTC PostgresTimeZoneor app/DB clock skew.POST /users/{id}/api-keysrejectskey_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.Deferred (acknowledged in review): migrating
APIKey.created_at/expires_attoDateTime(timezone=True)— the remaining naive-datetime seam is in pre-existingis_valid()and only matters for non-UTC DB deployments.Testing
tests/memory/api+ user models + rate limit) against real Postgres🤖 Generated with Claude Code
https://claude.ai/code/session_01EjRsK6NrPi9j8moiYHkxhX