Let Client take any bearer token source
Client::new now takes impl IntoTokenSource instead of a concrete ApiKey. ApiKey implements TokenSource, so existing Client::new(api_key) calls compile unchanged. StaticToken covers CI jobs that inject a JWT minted elsewhere, and tests that want a fixed string. The client still caches the token and re-mints it shortly before the lifetime the source reports. Debug output for MintedToken and StaticToken redacts the token value, and TokenSource::describe only ever shows the key id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 8, 2026 at 23:11 UTC
c1b759ff14b157dce0d2d09c6d29dc5e7385382a
3 files changed
+173
-8
crates/core/src/client.rs
+12
-8
@@ -1,6 +1,6 @@
1
-use crate::auth::ApiKey;
1
use crate::error::{Error, Result};
2
use crate::jsonapi::{path_and_query, ErrorDocument, ListDocument, Resource};
3
+use crate::token::{IntoTokenSource, TokenSource};
4
use reqwest::{Method, StatusCode};
5
use serde::de::DeserializeOwned;
6
use serde::Serialize;
@@ -55,16 +55,20 @@ struct CachedToken {
55
/// them directly.
56
pub struct Client {
57
http: reqwest::Client,
58
- api_key: ApiKey,
58
+ token_source: Box<dyn TokenSource>,
59
base_url: String,
60
token: Mutex<Option<CachedToken>>,
61
}
62
63
impl Client {
64
- pub fn new(api_key: ApiKey) -> Self {
64
+ /// Build a client over anything that can mint a bearer token. An
65
+ /// [`ApiKey`](crate::ApiKey) is the usual argument and still works
66
+ /// unchanged, since it converts into a [`TokenSource`]; a
67
+ /// [`StaticToken`](crate::StaticToken) or any custom source works too.
68
+ pub fn new(token_source: impl IntoTokenSource) -> Self {
69
Self {
70
http: reqwest::Client::new(),
67
- api_key,
71
+ token_source: token_source.into_token_source(),
72
base_url: BASE_URL.to_string(),
73
token: Mutex::new(None),
74
}
@@ -89,13 +93,13 @@ impl Client {
93
}
94
}
95
92
- let value = self.api_key.token()?;
96
+ let minted = self.token_source.mint()?;
97
*guard = Some(CachedToken {
94
- value: value.clone(),
98
+ value: minted.value.clone(),
99
minted_at: Instant::now(),
96
- lifetime: Duration::from_secs(self.api_key.lifetime_secs()),
100
+ lifetime: minted.lifetime,
101
});
98
- Ok(value)
102
+ Ok(minted.value)
103
}
104
105
/// Send a request and decode a JSON:API response body into `T`. Use
crates/core/src/lib.rs
+2
@@ -17,7 +17,9 @@ pub mod auth;
17
pub mod client;
18
pub mod error;
19
pub mod jsonapi;
20
+pub mod token;
21
22
pub use auth::ApiKey;
23
pub use client::Client;
24
pub use error::{Error, Result};
25
+pub use token::{IntoTokenSource, MintedToken, StaticToken, TokenSource};
crates/core/src/token.rs
new
+159
@@ -0,0 +1,159 @@
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`](crate::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
+}