Skip to main content

tonin_client/
auth.rs

1//! Auth types shared between server and client.
2//!
3//! These types are the contract between:
4//! - the inbound `AuthLayer` (in `tonin-core::auth::layer`) which
5//!   produces an `AuthCtx` from a verified token, and
6//! - the outbound client SDK which copies the bearer token onto
7//!   downstream requests via [`AuthCtx::propagate`].
8//!
9//! Custom verifiers (Okta, Cognito, API keys, cookies) are implemented
10//! on the server side via the `TokenVerifier` trait — but they all
11//! return the same `AuthCtx` defined here.
12
13use std::collections::HashMap;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use serde::{Deserialize, Serialize};
17use tonic::{Request, Status};
18
19/// A token as extracted from a request, before verification.
20///
21/// The framework doesn't assume JWT — the value could be a session ID,
22/// API key, or anything else a server-side `TokenVerifier` knows how to
23/// handle.
24#[derive(Clone, Debug)]
25pub struct RawToken {
26    pub value: String,
27    /// Hint for verifiers that handle multiple token formats.
28    /// Conventions: `"bearer-jwt"`, `"api-key"`, `"session-cookie"`,
29    /// `"basic-auth"`, etc.
30    pub kind: &'static str,
31}
32
33/// Identity + claims for the current request. The single concrete type
34/// that flows through the framework — outbound clients accept this, the
35/// server-side auth layer produces it. Custom claims live in
36/// [`Self::extra`].
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub struct AuthCtx {
39    /// `sub` claim. User ID for users, service ID for services.
40    pub subject: String,
41    pub issuer: String,
42    pub audience: String,
43    pub scopes: Vec<String>,
44    pub kind: PrincipalKind,
45    /// The verbatim token. Used by [`AuthCtx::propagate`] for outbound calls.
46    pub raw_token: String,
47    /// Unix-seconds expiry. f64 to stay JSON-compatible with the
48    /// Python and TS sides (which use `number`). `0.0` means "no
49    /// expiry recorded" (e.g. an anonymous context). Use
50    /// [`AuthCtx::expires_at_systime`] / [`AuthCtx::set_expires_at_systime`]
51    /// when interop with `std::time::SystemTime` is convenient.
52    pub expires_at: f64,
53    /// Claims not mapped to typed fields. Verifiers populate this with
54    /// anything custom (e.g. tenant_id, role, agent_on_behalf_of).
55    #[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    /// No auth was attempted (service is in opt-out mode).
66    Anonymous,
67}
68
69impl AuthCtx {
70    /// Returns an empty `AuthCtx` for opt-out / no-auth flows.
71    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    /// Wrap a bearer token without verification. For client-side code
85    /// that already has a token (e.g., from a login flow) and wants to
86    /// hand it to the framework's outbound propagation.
87    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    /// Pull `AuthCtx` from a tonic request's extensions, populated by
97    /// the inbound auth layer. Returns [`Self::anonymous`] if no layer
98    /// ran.
99    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    /// Copy the bearer token onto an outbound request so the caller's
107    /// identity rides along to the next service.
108    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    /// Authorize a single scope. Returns `PermissionDenied` if missing.
118    /// Convenient for `Status` returns from handlers.
119    #[allow(clippy::result_large_err)] // tonic::Status is the canonical error type for gRPC handlers
120    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    /// Returns `true` if the token has passed its recorded expiry.
136    ///
137    /// `false` for an anonymous context (`expires_at == 0.0`) — an
138    /// unset expiry is treated as "no expiry recorded", not as expired.
139    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    /// Convert `expires_at` (unix seconds) into a `SystemTime`. Returns
150    /// `UNIX_EPOCH` for an anonymous / unset context (`expires_at == 0.0`).
151    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    /// Set `expires_at` from a `SystemTime`. Convenience for verifiers
160    /// that already hold a `SystemTime` (e.g. JWT `iat + max_age`).
161    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        // expires_at far in the future
284        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; // 1970-01-01 — definitely in the past
296        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        // No extra entries — should return current context unchanged (no panic)
379        let otel_cx = authctx_to_baggage(&ctx);
380        let _ = otel_cx;
381    }
382
383    /// Lock in the JSON wire shape across language boundaries.
384    ///
385    /// If this test breaks, the Python/TS sides need an update too —
386    /// run `cargo run --bin gen-shared-types` and re-check
387    /// `python/tonin-client/tests/test_wire_compat.py`.
388    #[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        // Field-name presence (snake_case, no rename).
403        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        // Critical contract: expires_at is a JSON number (not a struct).
419        assert!(v["expires_at"].is_number());
420        // Critical contract: kind serializes as lowercase string.
421        assert_eq!(v["kind"], serde_json::json!("user"));
422    }
423}