From 3640dea441d5dd665ff81b3f6b94621f4cccab7f Mon Sep 17 00:00:00 2001
From: Mohammod Al Amin Ashik
Date: Sun, 28 Jun 2026 18:30:31 +0800
Subject: [PATCH 1/6] feat(storage): inbound API-key credentials (migration 020
+ repo)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 1 of API-key inbound auth (so headless/remote clients authenticate
without the host-only OAuth consent deep link). Adds the
inbound_client_api_keys table — SHA-256 hashes only, never plaintext; multiple
keys per client for rotation — and InboundClientRepository methods
create/validate/list/revoke (validation rejects revoked/expired keys and
touches last_used_at), plus InboundApiKey + ApiKeyAuth types.
Signed-off-by: Mohammod Al Amin Ashik
---
crates/mcpmux-storage/src/database.rs | 5 +
.../020_inbound_client_api_keys.sql | 25 ++++
.../repositories/inbound_client_repository.rs | 141 ++++++++++++++++++
crates/mcpmux-storage/src/repositories/mod.rs | 4 +-
4 files changed, 173 insertions(+), 2 deletions(-)
create mode 100644 crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql
diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs
index 14a32c2a..93fc27d7 100644
--- a/crates/mcpmux-storage/src/database.rs
+++ b/crates/mcpmux-storage/src/database.rs
@@ -128,6 +128,11 @@ const MIGRATIONS: &[Migration] = &[
name: "space_base_dirs",
sql: include_str!("migrations/019_space_base_dirs.sql"),
},
+ Migration {
+ version: 20,
+ name: "inbound_client_api_keys",
+ sql: include_str!("migrations/020_inbound_client_api_keys.sql"),
+ },
];
/// SQLite database wrapper.
diff --git a/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql b/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql
new file mode 100644
index 00000000..4b686086
--- /dev/null
+++ b/crates/mcpmux-storage/src/migrations/020_inbound_client_api_keys.sql
@@ -0,0 +1,25 @@
+-- Migration 020: Inbound client API keys
+--
+-- Long-lived, host-issued bearer credentials for manually-registered
+-- (preregistered) inbound clients, so headless/remote clients can authenticate
+-- WITHOUT the interactive OAuth consent flow (the mcpmux:// deep link only
+-- works on the host). Keys are shown once at creation and stored only as a
+-- SHA-256 hash — never in plaintext. Multiple keys per client allow rotation.
+
+CREATE TABLE IF NOT EXISTS inbound_client_api_keys (
+ key_id TEXT PRIMARY KEY,
+ client_id TEXT NOT NULL,
+ key_hash TEXT NOT NULL UNIQUE, -- SHA-256(presented key), hex
+ key_prefix TEXT NOT NULL, -- first chars (e.g. "mcpk_ab12") for UI display
+ label TEXT, -- optional user-facing name for the key
+ revoked INTEGER NOT NULL DEFAULT 0,
+ last_used_at TEXT,
+ expires_at TEXT, -- optional ISO-8601; NULL = no expiry
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY (client_id) REFERENCES inbound_clients(client_id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_api_keys_client ON inbound_client_api_keys(client_id);
+-- Lookups on auth validate by hash and only care about live keys.
+CREATE INDEX IF NOT EXISTS idx_api_keys_hash_live ON inbound_client_api_keys(key_hash) WHERE revoked = 0;
diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs
index 44676fc6..f20b353a 100644
--- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs
+++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs
@@ -160,6 +160,27 @@ pub struct TokenRecord {
pub parent_token_id: Option,
}
+/// A stored API-key record. Never exposes the secret — only its display prefix.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InboundApiKey {
+ pub key_id: String,
+ pub client_id: String,
+ pub key_prefix: String,
+ pub label: Option,
+ pub revoked: bool,
+ pub last_used_at: Option,
+ pub expires_at: Option,
+ pub created_at: String,
+ pub updated_at: String,
+}
+
+/// Identity resolved from a presented API key.
+#[derive(Debug, Clone)]
+pub struct ApiKeyAuth {
+ pub key_id: String,
+ pub client_id: String,
+}
+
/// OAuth Repository with database persistence
pub struct InboundClientRepository {
db: Arc>,
@@ -566,6 +587,126 @@ impl InboundClientRepository {
hex::encode(hasher.finalize())
}
+ // =========================================================================
+ // Inbound client API keys (long-lived, host-issued bearer credentials)
+ // =========================================================================
+
+ /// SHA-256 hex of an API key — the only form ever persisted. Same algorithm
+ /// as `hash_token`; named separately for intent.
+ pub fn hash_api_key(key: &str) -> String {
+ Self::hash_token(key)
+ }
+
+ /// Persist a freshly-generated API key for a client. The caller generates
+ /// the random `plaintext` (shown to the user once) and a unique `key_id`;
+ /// only the SHA-256 hash + a display prefix are stored.
+ pub async fn create_api_key(
+ &self,
+ key_id: &str,
+ client_id: &str,
+ plaintext: &str,
+ key_prefix: &str,
+ label: Option<&str>,
+ expires_at: Option<&str>,
+ ) -> Result<()> {
+ let now = chrono::Utc::now().to_rfc3339();
+ let hash = Self::hash_api_key(plaintext);
+ let db = self.db.lock().await;
+ let conn = db.connection();
+ conn.execute(
+ "INSERT INTO inbound_client_api_keys
+ (key_id, client_id, key_hash, key_prefix, label, revoked, expires_at, created_at, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, ?7)",
+ params![key_id, client_id, hash, key_prefix, label, expires_at, now],
+ )?;
+ info!(
+ "[ApiKey] Created key {} for client {}",
+ key_prefix, client_id
+ );
+ Ok(())
+ }
+
+ /// Validate a presented API key: look up a live (non-revoked, unexpired) key
+ /// by hash, touch `last_used_at`, and return the owning client.
+ pub async fn validate_api_key(&self, presented: &str) -> Result