Skip to main content

yeti_types/plugins/
token.rs

1//! JWT mint extension.
2//!
3//! Replaces the legacy `TokenClaimsExtender` trait in `yeti-auth`.
4//! Plugins that want to add extra claims to issued JWTs (an Okta
5//! plugin embedding `groups[]`, an Azure AD plugin embedding
6//! `appRoles[]`, etc.) register a
7//! `tower::Service<TokenRequest, Response = TokenResponse>`.
8//!
9//! The host runs the registered pipeline as part of every JWT mint
10//! (login, magic-link consume, refresh). Each plugin's Service
11//! returns a (possibly modified) `TokenResponse`; the next plugin
12//! receives that response as its input.
13//!
14//! The actual `JwtClaims` type lives in `yeti-auth::auth_types`. To
15//! keep `yeti-types` zero-dep on yeti crates (per the crate's
16//! own README), this module uses `serde_json::Value` as the on-the-
17//! wire representation. Adapter glue in yeti-auth converts to/from
18//! the typed `JwtClaims`.
19
20use serde::{Deserialize, Deserializer, Serialize, Serializer};
21use serde_json::Value;
22use tower::util::BoxCloneSyncService;
23
24use crate::error::YetiError;
25
26/// Wire helper for `serde_json::Map<String, Value>` fields that cross
27/// the WIT boundary (bincode payload). See [`super::oauth::value_as_json_string`]
28/// for the single-value case and the underlying rationale.
29///
30/// The map serializes as `HashMap<String, String>` where each value is
31/// a JSON-encoded representation of the original `Value`. On
32/// deserialize, each per-key JSON string is parsed back into a `Value`
33/// and the original `Map` shape is reconstructed.
34mod value_map_as_json_strings {
35    use super::{Deserialize, Deserializer, Serialize, Serializer, Value};
36    use std::collections::HashMap;
37
38    pub(super) fn serialize<S>(
39        map: &serde_json::Map<String, Value>,
40        serializer: S,
41    ) -> Result<S::Ok, S::Error>
42    where
43        S: Serializer,
44    {
45        let mut string_map: HashMap<&str, String> = HashMap::with_capacity(map.len());
46        for (k, v) in map {
47            let encoded = serde_json::to_string(v).map_err(serde::ser::Error::custom)?;
48            string_map.insert(k.as_str(), encoded);
49        }
50        string_map.serialize(serializer)
51    }
52
53    pub(super) fn deserialize<'de, D>(
54        deserializer: D,
55    ) -> Result<serde_json::Map<String, Value>, D::Error>
56    where
57        D: Deserializer<'de>,
58    {
59        let string_map: HashMap<String, String> = HashMap::deserialize(deserializer)?;
60        let mut out = serde_json::Map::with_capacity(string_map.len());
61        for (k, v_str) in string_map {
62            let v: Value = serde_json::from_str(&v_str).map_err(serde::de::Error::custom)?;
63            out.insert(k, v);
64        }
65        Ok(out)
66    }
67}
68
69/// Versioned hook chain name for JWT mint extension services
70/// (ADR-009). See [`super::oauth::OAUTH_HOOK_CHAIN_NAME`]
71/// for the rationale behind placing the constant here.
72pub const TOKEN_HOOK_CHAIN_NAME: &str = "yeti.auth.token.v1";
73
74/// Tower-shaped JWT-mint extension service. Plugins register
75/// `BoxCloneSyncService::new(service_fn(...))` against this type;
76/// yeti-auth chains every registered service per JWT mint, threading
77/// the `TokenRequest` (with its `extra` claims accumulator) through
78/// each in turn before signing.
79pub type TokenService = BoxCloneSyncService<TokenRequest, TokenResponse, YetiError>;
80
81/// Input to the token-mint pipeline. Carries the username being
82/// minted for, the apps the token will be scoped to, and a place
83/// for plugins to drop extra claims.
84///
85/// `Serialize` + `Deserialize` derives carry the wire shape
86/// (cross-component hook chains via WIT, bincode payload). The `extra` claim accumulator
87/// rides the wire as a `HashMap<String, String>` (each value
88/// JSON-encoded) so that bincode can round-trip `serde_json::Value`
89/// payloads without hitting `DeserializeAnyNotSupported`. The in-process
90/// Rust API still presents the field as a `serde_json::Map<String, Value>`.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct TokenRequest {
93    /// JWT subject (typically the username / email).
94    pub username: String,
95    /// App ids this token grants access to. Plugins may inspect
96    /// this to make per-app decisions.
97    pub app_ids: Vec<String>,
98    /// Mutable claim accumulator. Plugins read existing claims here
99    /// and mutate to attach their own. Yeti-canonical claims (`sub`,
100    /// `exp`, `apps`, etc.) are populated by the host before the
101    /// pipeline runs and after it finishes; plugins should treat
102    /// them as read-only inputs and write to keys they own.
103    #[serde(with = "value_map_as_json_strings")]
104    pub extra: serde_json::Map<String, Value>,
105}
106
107/// Output of the token-mint pipeline — the (possibly mutated)
108/// `TokenRequest` ready to be folded back into the canonical
109/// `JwtClaims` and signed.
110pub type TokenResponse = TokenRequest;