1use std::collections::HashMap;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use serde::{Deserialize, Serialize};
17use tonic::{Request, Status};
18
19#[derive(Clone, Debug)]
25pub struct RawToken {
26 pub value: String,
27 pub kind: &'static str,
31}
32
33#[derive(Clone, Debug, Serialize, Deserialize)]
38pub struct AuthCtx {
39 pub subject: String,
41 pub issuer: String,
42 pub audience: String,
43 pub scopes: Vec<String>,
44 pub kind: PrincipalKind,
45 pub raw_token: String,
47 pub expires_at: f64,
53 #[serde(default)]
56 pub extra: HashMap<String, serde_json::Value>,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum PrincipalKind {
62 User,
63 Service,
64 Agent,
65 Anonymous,
67}
68
69impl AuthCtx {
70 pub fn anonymous() -> Self {
72 Self {
73 subject: String::new(),
74 issuer: String::new(),
75 audience: String::new(),
76 scopes: Vec::new(),
77 kind: PrincipalKind::Anonymous,
78 raw_token: String::new(),
79 expires_at: 0.0,
80 extra: HashMap::new(),
81 }
82 }
83
84 pub fn from_bearer(token: impl Into<String>) -> Self {
88 let token = token.into();
89 Self {
90 raw_token: token,
91 kind: PrincipalKind::User,
92 ..Self::anonymous()
93 }
94 }
95
96 pub fn from<T>(req: &Request<T>) -> Self {
100 req.extensions()
101 .get::<AuthCtx>()
102 .cloned()
103 .unwrap_or_else(Self::anonymous)
104 }
105
106 pub fn propagate<T>(&self, req: &mut Request<T>) {
109 if self.raw_token.is_empty() {
110 return;
111 }
112 if let Ok(value) = format!("Bearer {}", self.raw_token).parse() {
113 req.metadata_mut().insert("authorization", value);
114 }
115 }
116
117 #[allow(clippy::result_large_err)] pub fn require_scope(&self, scope: &str) -> Result<(), Status> {
121 if self.scopes.iter().any(|s| s == scope) {
122 Ok(())
123 } else {
124 Err(AuthError::InsufficientScope {
125 required: scope.into(),
126 }
127 .into())
128 }
129 }
130
131 pub fn is_anonymous(&self) -> bool {
132 matches!(self.kind, PrincipalKind::Anonymous)
133 }
134
135 pub fn is_expired(&self) -> bool {
140 if self.expires_at <= 0.0 {
141 return false;
142 }
143 SystemTime::now()
144 .duration_since(UNIX_EPOCH)
145 .map(|d| d.as_secs_f64() > self.expires_at)
146 .unwrap_or(false)
147 }
148
149 pub fn expires_at_systime(&self) -> SystemTime {
152 if self.expires_at <= 0.0 {
153 UNIX_EPOCH
154 } else {
155 UNIX_EPOCH + std::time::Duration::from_secs_f64(self.expires_at)
156 }
157 }
158
159 pub fn set_expires_at_systime(&mut self, t: SystemTime) {
162 self.expires_at = t
163 .duration_since(UNIX_EPOCH)
164 .map(|d| d.as_secs_f64())
165 .unwrap_or(0.0);
166 }
167}
168
169pub fn authctx_to_baggage(ctx: &AuthCtx) -> opentelemetry::Context {
170 use opentelemetry::baggage::BaggageExt;
171 use opentelemetry::{Context, KeyValue};
172
173 let current = Context::current();
174
175 let pairs: Vec<KeyValue> = ctx
176 .extra
177 .iter()
178 .filter_map(|(k, v)| {
179 if k.starts_with("test-") || k == "route" {
180 v.as_str().map(|s| KeyValue::new(k.clone(), s.to_string()))
181 } else {
182 None
183 }
184 })
185 .collect();
186
187 if pairs.is_empty() {
188 return current;
189 }
190
191 current.with_baggage(pairs)
192}
193
194#[derive(Debug, thiserror::Error)]
195pub enum AuthError {
196 #[error("no token in request")]
197 MissingToken,
198 #[error("token signature invalid")]
199 Signature,
200 #[error("token expired")]
201 Expired,
202 #[error("audience mismatch: expected {expected}, got {got}")]
203 Audience { expected: String, got: String },
204 #[error("issuer mismatch: expected {expected}, got {got}")]
205 Issuer { expected: String, got: String },
206 #[error("token verification failed: {0}")]
207 Verification(String),
208 #[error("insufficient scope: required {required}")]
209 InsufficientScope { required: String },
210 #[error("configuration error: {0}")]
211 Config(String),
212 #[error("transport error contacting auth backend: {0}")]
213 Transport(String),
214}
215
216impl From<AuthError> for Status {
217 fn from(e: AuthError) -> Status {
218 match e {
219 AuthError::InsufficientScope { .. } => Status::permission_denied(e.to_string()),
220 AuthError::Config(_) | AuthError::Transport(_) => Status::internal(e.to_string()),
221 _ => Status::unauthenticated(e.to_string()),
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn anonymous_authctx_is_anonymous() {
232 let a = AuthCtx::anonymous();
233 assert!(a.is_anonymous());
234 assert_eq!(a.kind, PrincipalKind::Anonymous);
235 }
236
237 #[test]
238 fn from_bearer_carries_token() {
239 let a = AuthCtx::from_bearer("abc.def.ghi");
240 assert_eq!(a.raw_token, "abc.def.ghi");
241 assert_eq!(a.kind, PrincipalKind::User);
242 }
243
244 #[test]
245 fn propagate_writes_authorization_header() {
246 let a = AuthCtx::from_bearer("abc.def.ghi");
247 let mut req = Request::new(());
248 a.propagate(&mut req);
249 let v = req.metadata().get("authorization").unwrap();
250 assert_eq!(v.to_str().unwrap(), "Bearer abc.def.ghi");
251 }
252
253 #[test]
254 fn propagate_anonymous_is_noop() {
255 let a = AuthCtx::anonymous();
256 let mut req = Request::new(());
257 a.propagate(&mut req);
258 assert!(req.metadata().get("authorization").is_none());
259 }
260
261 #[test]
262 fn require_scope_ok_when_present() {
263 let mut a = AuthCtx::anonymous();
264 a.scopes = vec!["read:billing".into()];
265 assert!(a.require_scope("read:billing").is_ok());
266 }
267
268 #[test]
269 fn require_scope_err_when_missing() {
270 let a = AuthCtx::anonymous();
271 let err = a.require_scope("admin").unwrap_err();
272 assert_eq!(err.code(), tonic::Code::PermissionDenied);
273 }
274
275 #[test]
276 fn is_expired_false_for_anonymous() {
277 assert!(!AuthCtx::anonymous().is_expired());
278 }
279
280 #[test]
281 fn is_expired_false_when_future() {
282 let mut ctx = AuthCtx::anonymous();
283 ctx.expires_at = SystemTime::now()
285 .duration_since(UNIX_EPOCH)
286 .unwrap()
287 .as_secs_f64()
288 + 3600.0;
289 assert!(!ctx.is_expired());
290 }
291
292 #[test]
293 fn is_expired_true_when_past() {
294 let mut ctx = AuthCtx::anonymous();
295 ctx.expires_at = 1.0; assert!(ctx.is_expired());
297 }
298
299 #[test]
300 fn auth_error_maps_to_correct_status() {
301 let s: Status = AuthError::Signature.into();
302 assert_eq!(s.code(), tonic::Code::Unauthenticated);
303
304 let s: Status = AuthError::InsufficientScope {
305 required: "admin".into(),
306 }
307 .into();
308 assert_eq!(s.code(), tonic::Code::PermissionDenied);
309
310 let s: Status = AuthError::Config("missing env".into()).into();
311 assert_eq!(s.code(), tonic::Code::Internal);
312 }
313
314 #[test]
315 fn authctx_to_baggage_injects_string_extras() {
316 let mut ctx = AuthCtx::anonymous();
317 ctx.extra.insert(
318 "test-id".into(),
319 serde_json::Value::String("abc-123".into()),
320 );
321 ctx.extra
322 .insert("route".into(), serde_json::Value::String("preview".into()));
323
324 let otel_cx = authctx_to_baggage(&ctx);
325 use opentelemetry::baggage::BaggageExt;
326 let baggage = otel_cx.baggage();
327 assert_eq!(baggage.get("test-id").map(|v| v.as_str()), Some("abc-123"));
328 assert_eq!(baggage.get("route").map(|v| v.as_str()), Some("preview"));
329 }
330
331 #[test]
332 fn authctx_to_baggage_skips_non_string_values() {
333 let mut ctx = AuthCtx::anonymous();
334 ctx.extra
335 .insert("test-count".into(), serde_json::Value::Number(42.into()));
336 ctx.extra.insert(
337 "test-name".into(),
338 serde_json::Value::String("alice".into()),
339 );
340
341 let otel_cx = authctx_to_baggage(&ctx);
342 use opentelemetry::baggage::BaggageExt;
343 let baggage = otel_cx.baggage();
344 assert!(baggage.get("test-count").is_none());
345 assert_eq!(baggage.get("test-name").map(|v| v.as_str()), Some("alice"));
346 }
347
348 #[test]
349 fn authctx_to_baggage_enforces_allowlist() {
350 let mut ctx = AuthCtx::anonymous();
351 ctx.extra.insert(
352 "test-id".into(),
353 serde_json::Value::String("uuid-123".into()),
354 );
355 ctx.extra.insert(
356 "route".into(),
357 serde_json::Value::String("production".into()),
358 );
359 ctx.extra.insert(
360 "user-id".into(),
361 serde_json::Value::String("secret-123".into()),
362 );
363
364 let otel_cx = authctx_to_baggage(&ctx);
365 use opentelemetry::baggage::BaggageExt;
366 let baggage = otel_cx.baggage();
367 assert!(baggage.get("test-id").is_some());
368 assert!(baggage.get("route").is_some());
369 assert!(
370 baggage.get("user-id").is_none(),
371 "user-id must NOT propagate (security allowlist)"
372 );
373 }
374
375 #[test]
376 fn authctx_to_baggage_empty_extra_returns_current_context() {
377 let ctx = AuthCtx::anonymous();
378 let otel_cx = authctx_to_baggage(&ctx);
380 let _ = otel_cx;
381 }
382
383 #[test]
389 fn authctx_json_shape_is_stable_for_polyglot_consumers() {
390 let mut ctx = AuthCtx::anonymous();
391 ctx.subject = "alice".into();
392 ctx.issuer = "https://issuer.example".into();
393 ctx.audience = "my-svc".into();
394 ctx.scopes = vec!["read:billing".into(), "write:billing".into()];
395 ctx.kind = PrincipalKind::User;
396 ctx.raw_token = "abc.def.ghi".into();
397 ctx.expires_at = 1_735_689_600.0;
398 ctx.extra
399 .insert("tenant_id".into(), serde_json::json!("acme"));
400
401 let v = serde_json::to_value(&ctx).unwrap();
402 for f in [
404 "subject",
405 "issuer",
406 "audience",
407 "scopes",
408 "kind",
409 "raw_token",
410 "expires_at",
411 "extra",
412 ] {
413 assert!(
414 v.get(f).is_some(),
415 "missing field `{f}` in serialized AuthCtx JSON shape"
416 );
417 }
418 assert!(v["expires_at"].is_number());
420 assert_eq!(v["kind"], serde_json::json!("user"));
422 }
423}