Skip to main content

zeph_core/bootstrap/
oauth.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! OAuth credential store backed by Zeph's age vault.
5
6use std::sync::Arc;
7
8use rmcp::transport::auth::{AuthError, CredentialStore, StoredCredentials};
9use tokio::sync::RwLock;
10
11use crate::vault::AgeVaultProvider;
12use crate::vault::VaultProvider as _;
13
14/// `CredentialStore` backed by Zeph's age vault.
15///
16/// Vault key naming: `ZEPH_MCP_OAUTH_{SERVER_ID}` (uppercased, hyphens → underscores).
17/// Value: JSON-serialized `StoredCredentials`.
18///
19/// Uses `Arc<RwLock<AgeVaultProvider>>` directly because saving requires `&mut self`
20/// (`set_secret_mut` + `save`), and the `VaultProvider` trait only exposes `&self`.
21pub struct VaultCredentialStore {
22    vault_key: String,
23    vault: Arc<RwLock<AgeVaultProvider>>,
24}
25
26impl VaultCredentialStore {
27    /// Derive vault key and create the store.
28    ///
29    /// Key format: `ZEPH_MCP_OAUTH_{server_id.to_uppercase().replace('-', "_")}`.
30    pub fn new(server_id: &str, vault: Arc<RwLock<AgeVaultProvider>>) -> Self {
31        let normalized = server_id.to_uppercase().replace('-', "_");
32        Self {
33            vault_key: format!("ZEPH_MCP_OAUTH_{normalized}"),
34            vault,
35        }
36    }
37
38    /// Return the vault key this store uses.
39    #[must_use]
40    pub fn vault_key(&self) -> &str {
41        &self.vault_key
42    }
43}
44
45#[async_trait::async_trait]
46impl CredentialStore for VaultCredentialStore {
47    async fn load(&self) -> Result<Option<StoredCredentials>, AuthError> {
48        let guard = self.vault.read().await;
49        let value = guard
50            .get_secret(&self.vault_key)
51            .await
52            .map_err(|e| AuthError::InternalError(format!("vault read: {e}")))?;
53        match value {
54            None => Ok(None),
55            Some(json) => {
56                let creds: StoredCredentials = serde_json::from_str(&json)
57                    .map_err(|e| AuthError::InternalError(format!("vault deserialize: {e}")))?;
58                Ok(Some(creds))
59            }
60        }
61    }
62
63    async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> {
64        let json = serde_json::to_string(&credentials)
65            .map_err(|e| AuthError::InternalError(format!("vault serialize: {e}")))?;
66        let vault = Arc::clone(&self.vault);
67        let key = self.vault_key.clone();
68        tokio::task::spawn_blocking(move || {
69            let mut guard = vault.blocking_write();
70            guard.set_secret_mut(key, json);
71            guard
72                .save()
73                .map_err(|e| AuthError::InternalError(format!("vault save: {e}")))
74        })
75        .await
76        .map_err(|e| AuthError::InternalError(format!("spawn_blocking: {e}")))?
77    }
78
79    async fn clear(&self) -> Result<(), AuthError> {
80        let vault = Arc::clone(&self.vault);
81        let key = self.vault_key.clone();
82        tokio::task::spawn_blocking(move || {
83            let mut guard = vault.blocking_write();
84            guard.remove_secret_mut(&key);
85            guard
86                .save()
87                .map_err(|e| AuthError::InternalError(format!("vault clear: {e}")))
88        })
89        .await
90        .map_err(|e| AuthError::InternalError(format!("spawn_blocking: {e}")))?
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    #[test]
97    fn vault_key_normalization_hyphen() {
98        let key = format!(
99            "ZEPH_MCP_OAUTH_{}",
100            "my-server".to_uppercase().replace('-', "_")
101        );
102        assert_eq!(key, "ZEPH_MCP_OAUTH_MY_SERVER");
103    }
104
105    #[test]
106    fn vault_key_collision_documented() {
107        // "my-app" and "my_app" normalize to the same key — config validation must reject this.
108        let a = "my-app".to_uppercase().replace('-', "_");
109        let b = "my_app".to_uppercase().replace('-', "_");
110        assert_eq!(
111            a, b,
112            "vault key collision exists for hyphens vs underscores"
113        );
114    }
115}