Skip to main content

smbcloud_ascapi_core/
token.rs

1//! The bearer-token abstraction the [`Client`](crate::Client) mints from.
2//!
3//! App Store Connect wants a short-lived ES256 JWT on every request. The
4//! usual way to produce one is an [`ApiKey`] signing its own
5//! claims, but it isn't the only way: CI often injects a token minted
6//! elsewhere, and tests want a fixed string. [`TokenSource`] is the seam
7//! that lets all three flow through the same client without the client
8//! caring which it holds.
9//!
10//! [`Client::new`](crate::Client::new) takes `impl IntoTokenSource`, so
11//! `Client::new(api_key)` keeps working unchanged while a
12//! [`StaticToken`] or any custom source is equally accepted.
13
14use crate::auth::ApiKey;
15use crate::error::Result;
16use std::fmt;
17use std::time::Duration;
18
19/// A freshly minted bearer token and how long it stays valid.
20///
21/// The client caches the `value` and re-mints shortly before `lifetime`
22/// elapses, so a source is free to return a long or short window as suits
23/// it. Apple caps real JWTs at 20 minutes; a static token can claim more.
24#[derive(Clone)]
25pub struct MintedToken {
26    /// The bearer token string, sent verbatim as `Authorization: Bearer`.
27    pub value: String,
28    /// How long the token remains valid from the moment it was minted.
29    pub lifetime: Duration,
30}
31
32impl MintedToken {
33    /// Convenience constructor from a token string and a lifetime in seconds.
34    pub fn new(value: impl Into<String>, lifetime_secs: u64) -> Self {
35        Self {
36            value: value.into(),
37            lifetime: Duration::from_secs(lifetime_secs),
38        }
39    }
40}
41
42impl fmt::Debug for MintedToken {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        // Never print the token material itself.
45        f.debug_struct("MintedToken")
46            .field("value", &"<redacted>")
47            .field("lifetime", &self.lifetime)
48            .finish()
49    }
50}
51
52/// Anything that can produce a bearer token for the client.
53///
54/// Implementors must be `Send + Sync` because the client caches tokens
55/// behind a mutex and is shared across tasks.
56pub trait TokenSource: Send + Sync {
57    /// Mint a fresh token. The client calls this lazily and caches the
58    /// result until shortly before it expires, so this need not be cheap.
59    fn mint(&self) -> Result<MintedToken>;
60
61    /// A short, secret-free description for diagnostics and MCP
62    /// transcripts. Must never contain key or token material — it may be
63    /// logged or surfaced to a user. Defaults to the type's shape.
64    fn describe(&self) -> String {
65        "token source".to_string()
66    }
67}
68
69impl fmt::Debug for dyn TokenSource {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.debug_struct("TokenSource")
72            .field("describe", &self.describe())
73            .finish()
74    }
75}
76
77impl TokenSource for ApiKey {
78    fn mint(&self) -> Result<MintedToken> {
79        Ok(MintedToken {
80            value: self.token()?,
81            lifetime: Duration::from_secs(self.lifetime_secs()),
82        })
83    }
84
85    fn describe(&self) -> String {
86        // key_id is an identifier, not a secret, and is invaluable when
87        // diagnosing which of several keys a request was signed with.
88        format!("api key {}", self.key_id())
89    }
90}
91
92/// A fixed, already-minted bearer token.
93///
94/// For CI that injects a token from elsewhere, or tests that want a known
95/// string. Reports a generous default lifetime so the client doesn't try
96/// to re-mint it; override with [`StaticToken::with_lifetime_secs`].
97#[derive(Clone)]
98pub struct StaticToken {
99    value: String,
100    lifetime: Duration,
101}
102
103impl StaticToken {
104    /// Wrap a pre-minted token string. Defaults to a 20 minute lifetime,
105    /// matching Apple's ceiling for real JWTs.
106    pub fn new(value: impl Into<String>) -> Self {
107        Self {
108            value: value.into(),
109            lifetime: Duration::from_secs(20 * 60),
110        }
111    }
112
113    /// Override how long the client treats the token as valid.
114    pub fn with_lifetime_secs(mut self, secs: u64) -> Self {
115        self.lifetime = Duration::from_secs(secs);
116        self
117    }
118}
119
120impl fmt::Debug for StaticToken {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.debug_struct("StaticToken")
123            .field("value", &"<redacted>")
124            .field("lifetime", &self.lifetime)
125            .finish()
126    }
127}
128
129impl TokenSource for StaticToken {
130    fn mint(&self) -> Result<MintedToken> {
131        Ok(MintedToken {
132            value: self.value.clone(),
133            lifetime: self.lifetime,
134        })
135    }
136
137    fn describe(&self) -> String {
138        "static token".to_string()
139    }
140}
141
142/// Conversion into a boxed [`TokenSource`], so [`Client::new`](crate::Client::new)
143/// can accept an [`ApiKey`], a [`StaticToken`], a `Box<dyn TokenSource>`, or
144/// any custom source with one signature.
145pub trait IntoTokenSource {
146    fn into_token_source(self) -> Box<dyn TokenSource>;
147}
148
149impl<T: TokenSource + 'static> IntoTokenSource for T {
150    fn into_token_source(self) -> Box<dyn TokenSource> {
151        Box::new(self)
152    }
153}
154
155impl IntoTokenSource for Box<dyn TokenSource> {
156    fn into_token_source(self) -> Box<dyn TokenSource> {
157        self
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn a_static_token_mints_its_value_with_apples_twenty_minute_ceiling() {
167        let minted = StaticToken::new("eyJ.static").mint().unwrap();
168        assert_eq!(minted.value, "eyJ.static");
169        assert_eq!(minted.lifetime, Duration::from_secs(20 * 60));
170    }
171
172    #[test]
173    fn a_static_token_lifetime_can_be_overridden() {
174        let minted = StaticToken::new("eyJ.static")
175            .with_lifetime_secs(90)
176            .mint()
177            .unwrap();
178        assert_eq!(minted.lifetime, Duration::from_secs(90));
179    }
180
181    /// Debug output ends up in logs and MCP transcripts, so the token
182    /// value must never appear in it.
183    #[test]
184    fn debug_output_never_contains_the_token() {
185        let token = StaticToken::new("eyJ.secret");
186        let minted = token.mint().unwrap();
187        let boxed: Box<dyn TokenSource> = Box::new(token.clone());
188
189        for rendered in [
190            format!("{token:?}"),
191            format!("{minted:?}"),
192            format!("{boxed:?}"),
193        ] {
194            assert!(!rendered.contains("eyJ.secret"), "leaked: {rendered}");
195        }
196        assert_eq!(boxed.describe(), "static token");
197    }
198
199    #[test]
200    fn a_boxed_source_converts_into_a_token_source() {
201        let boxed: Box<dyn TokenSource> = Box::new(StaticToken::new("eyJ.boxed"));
202        let source = boxed.into_token_source();
203        assert_eq!(source.mint().unwrap().value, "eyJ.boxed");
204    }
205}