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;