Skip to content

Commit 3640dea

Browse files
committed
feat(storage): inbound API-key credentials (migration 020 + repo)
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 <maa.ashik00@gmail.com>
1 parent 9e481e7 commit 3640dea

4 files changed

Lines changed: 173 additions & 2 deletions

File tree

crates/mcpmux-storage/src/database.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ const MIGRATIONS: &[Migration] = &[
128128
name: "space_base_dirs",
129129
sql: include_str!("migrations/019_space_base_dirs.sql"),
130130
},
131+
Migration {
132+
version: 20,
133+
name: "inbound_client_api_keys",
134+
sql: include_str!("migrations/020_inbound_client_api_keys.sql"),
135+
},
131136
];
132137

133138
/// SQLite database wrapper.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- Migration 020: Inbound client API keys
2+
--
3+
-- Long-lived, host-issued bearer credentials for manually-registered
4+
-- (preregistered) inbound clients, so headless/remote clients can authenticate
5+
-- WITHOUT the interactive OAuth consent flow (the mcpmux:// deep link only
6+
-- works on the host). Keys are shown once at creation and stored only as a
7+
-- SHA-256 hash — never in plaintext. Multiple keys per client allow rotation.
8+
9+
CREATE TABLE IF NOT EXISTS inbound_client_api_keys (
10+
key_id TEXT PRIMARY KEY,
11+
client_id TEXT NOT NULL,
12+
key_hash TEXT NOT NULL UNIQUE, -- SHA-256(presented key), hex
13+
key_prefix TEXT NOT NULL, -- first chars (e.g. "mcpk_ab12") for UI display
14+
label TEXT, -- optional user-facing name for the key
15+
revoked INTEGER NOT NULL DEFAULT 0,
16+
last_used_at TEXT,
17+
expires_at TEXT, -- optional ISO-8601; NULL = no expiry
18+
created_at TEXT NOT NULL,
19+
updated_at TEXT NOT NULL,
20+
FOREIGN KEY (client_id) REFERENCES inbound_clients(client_id) ON DELETE CASCADE
21+
);
22+
23+
CREATE INDEX IF NOT EXISTS idx_api_keys_client ON inbound_client_api_keys(client_id);
24+
-- Lookups on auth validate by hash and only care about live keys.
25+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash_live ON inbound_client_api_keys(key_hash) WHERE revoked = 0;

crates/mcpmux-storage/src/repositories/inbound_client_repository.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,27 @@ pub struct TokenRecord {
160160
pub parent_token_id: Option<String>,
161161
}
162162

163+
/// A stored API-key record. Never exposes the secret — only its display prefix.
164+
#[derive(Debug, Clone, Serialize, Deserialize)]
165+
pub struct InboundApiKey {
166+
pub key_id: String,
167+
pub client_id: String,
168+
pub key_prefix: String,
169+
pub label: Option<String>,
170+
pub revoked: bool,
171+
pub last_used_at: Option<String>,
172+
pub expires_at: Option<String>,
173+
pub created_at: String,
174+
pub updated_at: String,
175+
}
176+
177+
/// Identity resolved from a presented API key.
178+
#[derive(Debug, Clone)]
179+
pub struct ApiKeyAuth {
180+
pub key_id: String,
181+
pub client_id: String,
182+
}
183+
163184
/// OAuth Repository with database persistence
164185
pub struct InboundClientRepository {
165186
db: Arc<Mutex<Database>>,
@@ -566,6 +587,126 @@ impl InboundClientRepository {
566587
hex::encode(hasher.finalize())
567588
}
568589

590+
// =========================================================================
591+
// Inbound client API keys (long-lived, host-issued bearer credentials)
592+
// =========================================================================
593+
594+
/// SHA-256 hex of an API key — the only form ever persisted. Same algorithm
595+
/// as `hash_token`; named separately for intent.
596+
pub fn hash_api_key(key: &str) -> String {
597+
Self::hash_token(key)
598+
}
599+
600+
/// Persist a freshly-generated API key for a client. The caller generates
601+
/// the random `plaintext` (shown to the user once) and a unique `key_id`;
602+
/// only the SHA-256 hash + a display prefix are stored.
603+
pub async fn create_api_key(
604+
&self,
605+
key_id: &str,
606+
client_id: &str,
607+
plaintext: &str,
608+
key_prefix: &str,
609+
label: Option<&str>,
610+
expires_at: Option<&str>,
611+
) -> Result<()> {
612+
let now = chrono::Utc::now().to_rfc3339();
613+
let hash = Self::hash_api_key(plaintext);
614+
let db = self.db.lock().await;
615+
let conn = db.connection();
616+
conn.execute(
617+
"INSERT INTO inbound_client_api_keys
618+
(key_id, client_id, key_hash, key_prefix, label, revoked, expires_at, created_at, updated_at)
619+
VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, ?7)",
620+
params![key_id, client_id, hash, key_prefix, label, expires_at, now],
621+
)?;
622+
info!(
623+
"[ApiKey] Created key {} for client {}",
624+
key_prefix, client_id
625+
);
626+
Ok(())
627+
}
628+
629+
/// Validate a presented API key: look up a live (non-revoked, unexpired) key
630+
/// by hash, touch `last_used_at`, and return the owning client.
631+
pub async fn validate_api_key(&self, presented: &str) -> Result<Option<ApiKeyAuth>> {
632+
let hash = Self::hash_api_key(presented);
633+
let now = chrono::Utc::now().to_rfc3339();
634+
let db = self.db.lock().await;
635+
let conn = db.connection();
636+
637+
let result = conn.query_row(
638+
"SELECT key_id, client_id, expires_at
639+
FROM inbound_client_api_keys
640+
WHERE key_hash = ?1 AND revoked = 0",
641+
params![hash],
642+
|r| {
643+
Ok((
644+
r.get::<_, String>(0)?,
645+
r.get::<_, String>(1)?,
646+
r.get::<_, Option<String>>(2)?,
647+
))
648+
},
649+
);
650+
let (key_id, client_id, expires_at) = match result {
651+
Ok(t) => t,
652+
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
653+
Err(e) => return Err(e.into()),
654+
};
655+
656+
// ISO-8601 strings compare lexicographically; reject expired keys.
657+
if let Some(exp) = expires_at.as_deref() {
658+
if exp <= now.as_str() {
659+
return Ok(None);
660+
}
661+
}
662+
663+
conn.execute(
664+
"UPDATE inbound_client_api_keys SET last_used_at = ?1 WHERE key_id = ?2",
665+
params![now, key_id],
666+
)?;
667+
Ok(Some(ApiKeyAuth { key_id, client_id }))
668+
}
669+
670+
/// List a client's API keys (no secrets — prefix + metadata only).
671+
pub async fn list_api_keys(&self, client_id: &str) -> Result<Vec<InboundApiKey>> {
672+
let db = self.db.lock().await;
673+
let conn = db.connection();
674+
let mut stmt = conn.prepare(
675+
"SELECT key_id, client_id, key_prefix, label, revoked, last_used_at, expires_at, created_at, updated_at
676+
FROM inbound_client_api_keys WHERE client_id = ?1 ORDER BY created_at DESC",
677+
)?;
678+
let rows = stmt.query_map(params![client_id], |r| {
679+
Ok(InboundApiKey {
680+
key_id: r.get(0)?,
681+
client_id: r.get(1)?,
682+
key_prefix: r.get(2)?,
683+
label: r.get(3)?,
684+
revoked: r.get::<_, i32>(4)? != 0,
685+
last_used_at: r.get(5)?,
686+
expires_at: r.get(6)?,
687+
created_at: r.get(7)?,
688+
updated_at: r.get(8)?,
689+
})
690+
})?;
691+
let mut keys = Vec::new();
692+
for k in rows {
693+
keys.push(k?);
694+
}
695+
Ok(keys)
696+
}
697+
698+
/// Revoke a single API key (irreversible — it can never authenticate again).
699+
pub async fn revoke_api_key(&self, key_id: &str) -> Result<()> {
700+
let now = chrono::Utc::now().to_rfc3339();
701+
let db = self.db.lock().await;
702+
let conn = db.connection();
703+
conn.execute(
704+
"UPDATE inbound_client_api_keys SET revoked = 1, updated_at = ?1 WHERE key_id = ?2",
705+
params![now, key_id],
706+
)?;
707+
Ok(())
708+
}
709+
569710
/// Save a token record
570711
pub async fn save_token(&self, record: &TokenRecord) -> Result<()> {
571712
let db = self.db.lock().await;

crates/mcpmux-storage/src/repositories/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ pub use app_settings_repository::SqliteAppSettingsRepository;
1717
pub use credential_repository::SqliteCredentialRepository;
1818
pub use feature_set_repository::SqliteFeatureSetRepository;
1919
pub use inbound_client_repository::{
20-
AuthorizationCode, InboundClient, InboundClientRepository, RegistrationType, TokenRecord,
21-
TokenType,
20+
ApiKeyAuth, AuthorizationCode, InboundApiKey, InboundClient, InboundClientRepository,
21+
RegistrationType, TokenRecord, TokenType,
2222
};
2323
pub use inbound_mcp_client_repository::SqliteInboundMcpClientRepository;
2424
pub use installed_server_repository::SqliteInstalledServerRepository;

0 commit comments

Comments
 (0)