Skip to main content

meerkat_core/auth/
lease.rs

1//! Auth lease, credential-material kinds, authorizer trait, and refresh semantics.
2//!
3//! `meerkat-core` owns the trait shape; concrete lease implementations
4//! (`StaticLease`, `DynamicLease`) live in `meerkat-client/src/runtime/binding.rs`.
5//! `meerkat-core` declares none of the Phase 2 shim surface — the
6//! `ResolvedConnection.shim_credential` seam is entirely on the client side.
7
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use super::error::AuthError;
15use super::metadata::AuthMetadata;
16
17/// Why a refresh or re-resolution was triggered.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(rename_all = "snake_case")]
21pub enum AuthRefreshReason {
22    StartupValidation,
23    Preflight,
24    Unauthorized,
25    ExpiringSoon,
26    Manual,
27    ConnectionChanged,
28}
29
30/// The resolved credential material. `meerkat-client` resolvers produce
31/// this and wrap it in an [`AuthLease`]; `ProviderRuntime::build_client`
32/// reads it to construct the LLM wire-layer.
33#[derive(Clone)]
34pub enum ResolvedAuthKind {
35    /// A pre-resolved secret (api key, static bearer, OAuth access
36    /// token). Wrapped in `Arc<String>` to keep the clone cheap and
37    /// to avoid multiplying the credential across `Arc<dyn AuthLease>`
38    /// holders. Plan §6.11: replaces the legacy `StaticHeaders` with
39    /// `__secret__` synthetic-header convention (dogma §5 "typed
40    /// truth, never folklore").
41    InlineSecret(Arc<String>),
42    /// A vector of `(name, value)` header pairs — used by resolvers
43    /// that pre-project wire-correct headers directly (some provider
44    /// runtimes may do this post-§6.12 when `build_client` owns HTTP
45    /// assembly). Empty/placeholder headers are no longer produced by
46    /// any resolver in the repo.
47    StaticHeaders(Vec<(String, String)>),
48    /// A runtime authorizer invoked per request (AWS SigV4, Google
49    /// Auth, Azure AD, host-supplied ExternalAuthorizer-Dynamic).
50    DynamicAuthorizer(Arc<dyn HttpAuthorizer>),
51    /// No credential material (e.g., self-hosted without auth).
52    None,
53}
54
55impl std::fmt::Debug for ResolvedAuthKind {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Self::InlineSecret(_) => f.debug_tuple("InlineSecret").field(&"<redacted>").finish(),
59            Self::StaticHeaders(headers) => f
60                .debug_tuple("StaticHeaders")
61                .field(&headers.len())
62                .finish(),
63            Self::DynamicAuthorizer(auth) => f
64                .debug_tuple("DynamicAuthorizer")
65                .field(&auth.label())
66                .finish(),
67            Self::None => f.debug_struct("None").finish(),
68        }
69    }
70}
71
72/// Surface-safe projection of the resolved credential state. Returned by
73/// external resolver handles (WASM, desktop bridges) where shipping a full
74/// trait object is not practical. Serde-roundtrippable so WASM/RPC bridges
75/// can cross the process boundary.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78#[serde(tag = "kind", rename_all = "snake_case")]
79pub enum ResolvedAuthEnvelope {
80    /// A pre-resolved raw secret (api key, bearer token, OAuth access
81    /// token). Plan §6.11 + dogma §5 closure: replaces the legacy
82    /// `StaticHeaders` with a synthetic `"__secret__"` header-key
83    /// convention. External resolvers that produce simple secrets
84    /// should use this variant.
85    InlineSecret {
86        secret: String,
87        metadata: AuthMetadata,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
90        expires_at: Option<DateTime<Utc>>,
91    },
92    StaticHeaders {
93        headers: Vec<(String, String)>,
94        metadata: AuthMetadata,
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
97        expires_at: Option<DateTime<Utc>>,
98    },
99    DynamicAuthorizer {
100        metadata: AuthMetadata,
101        #[serde(default, skip_serializing_if = "Option::is_none")]
102        #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
103        expires_at: Option<DateTime<Utc>>,
104    },
105    None {
106        metadata: AuthMetadata,
107    },
108}
109
110/// Minimal request view passed to a dynamic authorizer.
111pub struct HttpAuthorizationRequest<'a> {
112    pub method: &'a str,
113    pub url: &'a str,
114    pub headers: &'a mut Vec<(String, String)>,
115}
116
117/// Dynamic authorizer trait. Used when auth artifacts need to be computed
118/// per request (ADC, refreshed OAuth bearer, etc.).
119#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
120#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
121pub trait HttpAuthorizer: Send + Sync {
122    async fn authorize(&self, req: &mut HttpAuthorizationRequest<'_>) -> Result<(), AuthError>;
123    fn label(&self) -> &str;
124
125    /// Non-secret freshness projection for authorizers that cache expiring
126    /// token material internally. Implementations that never observe an
127    /// expiring credential should keep the default `None`.
128    fn expires_at(&self) -> Option<DateTime<Utc>> {
129        None
130    }
131}
132
133/// Trait contract for a resolved credential lease. `meerkat-core` declares
134/// only generic lifecycle methods. No Phase 2 shim surface lives here.
135#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
136#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
137pub trait AuthLease: Send + Sync {
138    fn kind(&self) -> &ResolvedAuthKind;
139    fn metadata(&self) -> &AuthMetadata;
140    fn expires_at(&self) -> Option<DateTime<Utc>>;
141    fn source_label(&self) -> &str;
142    async fn refresh(&self, reason: AuthRefreshReason) -> Result<(), AuthError>;
143}
144
145/// Non-secret constraints on a resolved binding (per-realm or per-profile).
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
148pub struct AuthConstraints {
149    #[serde(default)]
150    pub require_workspace_id: bool,
151    #[serde(default)]
152    pub require_account_id: bool,
153    #[serde(default)]
154    pub allow_interactive_login: bool,
155    #[serde(default = "default_true")]
156    pub allow_refresh: bool,
157}
158
159impl Default for AuthConstraints {
160    fn default() -> Self {
161        Self {
162            require_workspace_id: false,
163            require_account_id: false,
164            allow_interactive_login: false,
165            allow_refresh: true,
166        }
167    }
168}
169
170fn default_true() -> bool {
171    true
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn refresh_reason_roundtrip() {
181        let r = AuthRefreshReason::ExpiringSoon;
182        let s = serde_json::to_string(&r).unwrap();
183        assert_eq!(s, "\"expiring_soon\"");
184    }
185
186    #[test]
187    fn resolved_auth_kind_debug_smoke() {
188        let k = ResolvedAuthKind::StaticHeaders(vec![("k".into(), "v".into())]);
189        assert!(format!("{k:?}").contains("StaticHeaders"));
190        let n = ResolvedAuthKind::None;
191        assert!(format!("{n:?}").contains("None"));
192    }
193
194    #[test]
195    fn auth_constraints_defaults() {
196        let c = AuthConstraints::default();
197        assert!(!c.require_workspace_id);
198        assert!(!c.require_account_id);
199        assert!(!c.allow_interactive_login);
200        assert!(c.allow_refresh, "allow_refresh defaults to true");
201    }
202
203    #[test]
204    fn resolved_auth_envelope_serde_roundtrip() {
205        let meta = AuthMetadata {
206            account_id: Some("acct_x".into()),
207            ..AuthMetadata::default()
208        };
209        // Headers variant.
210        let env = ResolvedAuthEnvelope::StaticHeaders {
211            headers: vec![("Authorization".into(), "Bearer xyz".into())],
212            metadata: meta,
213            expires_at: None,
214        };
215        let s = serde_json::to_string(&env).unwrap();
216        assert!(s.contains("\"kind\":\"static_headers\""));
217        let back: ResolvedAuthEnvelope = serde_json::from_str(&s).unwrap();
218        match back {
219            ResolvedAuthEnvelope::StaticHeaders {
220                headers, metadata, ..
221            } => {
222                assert_eq!(headers.len(), 1);
223                assert_eq!(metadata.account_id.as_deref(), Some("acct_x"));
224            }
225            other => panic!("unexpected variant: {other:?}"),
226        }
227
228        // Dynamic + None variants.
229        let dyn_env = ResolvedAuthEnvelope::DynamicAuthorizer {
230            metadata: AuthMetadata::default(),
231            expires_at: None,
232        };
233        let s = serde_json::to_string(&dyn_env).unwrap();
234        assert!(s.contains("dynamic_authorizer"));
235        let _back: ResolvedAuthEnvelope = serde_json::from_str(&s).unwrap();
236
237        let none_env = ResolvedAuthEnvelope::None {
238            metadata: AuthMetadata::default(),
239        };
240        let s = serde_json::to_string(&none_env).unwrap();
241        assert!(s.contains("\"kind\":\"none\""));
242        let _back: ResolvedAuthEnvelope = serde_json::from_str(&s).unwrap();
243    }
244
245    // Minimal stub lease to exercise object-safety of the trait.
246    struct StubLease {
247        kind: ResolvedAuthKind,
248        metadata: AuthMetadata,
249        source_label: String,
250    }
251
252    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
253    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
254    impl AuthLease for StubLease {
255        fn kind(&self) -> &ResolvedAuthKind {
256            &self.kind
257        }
258        fn metadata(&self) -> &AuthMetadata {
259            &self.metadata
260        }
261        fn expires_at(&self) -> Option<DateTime<Utc>> {
262            None
263        }
264        fn source_label(&self) -> &str {
265            &self.source_label
266        }
267        async fn refresh(&self, _reason: AuthRefreshReason) -> Result<(), AuthError> {
268            Ok(())
269        }
270    }
271
272    #[tokio::test]
273    async fn stub_lease_is_object_safe() {
274        let lease: Arc<dyn AuthLease> = Arc::new(StubLease {
275            kind: ResolvedAuthKind::None,
276            metadata: AuthMetadata::default(),
277            source_label: "stub".into(),
278        });
279        assert_eq!(lease.source_label(), "stub");
280        assert!(matches!(lease.kind(), ResolvedAuthKind::None));
281        assert!(lease.refresh(AuthRefreshReason::Manual).await.is_ok());
282    }
283}