| 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 | } |