main
rs 205 lines 6.53 KB
Raw
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
14 use crate::auth::ApiKey;
15 use crate::error::Result;
16 use std::fmt;
17 use 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)]
25 pub 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
32 impl 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
42 impl 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.
56 pub 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
69 impl 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
77 impl 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)]
98 pub struct StaticToken {
99 value: String,
100 lifetime: Duration,
101 }
102
103 impl 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
120 impl 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
129 impl 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.
145 pub trait IntoTokenSource {
146 fn into_token_source(self) -> Box<dyn TokenSource>;
147 }
148
149 impl<T: TokenSource + 'static> IntoTokenSource for T {
150 fn into_token_source(self) -> Box<dyn TokenSource> {
151 Box::new(self)
152 }
153 }
154
155 impl IntoTokenSource for Box<dyn TokenSource> {
156 fn into_token_source(self) -> Box<dyn TokenSource> {
157 self
158 }
159 }
160
161 #[cfg(test)]
162 mod 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 }