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