1use 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#[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#[derive(Clone)]
34pub enum ResolvedAuthKind {
35 InlineSecret(Arc<String>),
42 StaticHeaders(Vec<(String, String)>),
48 DynamicAuthorizer(Arc<dyn HttpAuthorizer>),
51 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#[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 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
110pub struct HttpAuthorizationRequest<'a> {
112 pub method: &'a str,
113 pub url: &'a str,
114 pub headers: &'a mut Vec<(String, String)>,
115}
116
117#[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 fn expires_at(&self) -> Option<DateTime<Utc>> {
129 None
130 }
131}
132
133#[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#[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 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 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 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}