Skip to main content

uselesskey_token/
token.rs

1use std::fmt;
2use std::sync::Arc;
3
4use uselesskey_core::Factory;
5
6use crate::TokenSpec;
7use crate::srp::shape::{NegativeToken, generate_negative_token, generate_token};
8
9/// Cache domain for token fixtures.
10///
11/// Keep this stable: changing it changes deterministic outputs.
12pub const DOMAIN_TOKEN_FIXTURE: &str = "uselesskey:token:fixture";
13
14/// A token fixture with a generated value.
15///
16/// Created via [`TokenFactoryExt::token()`]. Provides access to
17/// the generated token value and an HTTP `Authorization` header.
18///
19/// # Examples
20///
21/// ```
22/// # use uselesskey_core::{Factory, Seed};
23/// # use uselesskey_token::{TokenFactoryExt, TokenSpec};
24/// let fx = Factory::deterministic(Seed::from_env_value("test-seed").unwrap());
25/// let tok = fx.token("api-key", TokenSpec::api_key());
26/// assert!(tok.value().starts_with("uk_test_"));
27/// ```
28#[derive(Clone)]
29pub struct TokenFixture {
30    factory: Factory,
31    label: String,
32    spec: TokenSpec,
33    inner: Arc<Inner>,
34}
35
36struct Inner {
37    value: String,
38}
39
40impl fmt::Debug for TokenFixture {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.debug_struct("TokenFixture")
43            .field("label", &self.label)
44            .field("spec", &self.spec)
45            .finish_non_exhaustive()
46    }
47}
48
49/// Extension trait to hang token helpers off the core [`Factory`].
50pub trait TokenFactoryExt {
51    /// Generate (or retrieve from cache) a token fixture.
52    ///
53    /// The `label` identifies this token within your test suite.
54    /// In deterministic mode, `seed + label + spec` always produces the same token.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// # use uselesskey_core::{Factory, Seed};
60    /// # use uselesskey_token::{TokenFactoryExt, TokenSpec};
61    /// let fx = Factory::deterministic(Seed::from_env_value("test-seed").unwrap());
62    /// let tok = fx.token("billing", TokenSpec::bearer());
63    /// assert!(!tok.value().is_empty());
64    /// ```
65    fn token(&self, label: impl AsRef<str>, spec: TokenSpec) -> TokenFixture;
66
67    /// Generate a token fixture with an explicit variant.
68    ///
69    /// Different variants for the same `(label, spec)` produce different tokens.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// # use uselesskey_core::{Factory, Seed};
75    /// # use uselesskey_token::{TokenFactoryExt, TokenSpec};
76    /// let fx = Factory::deterministic(Seed::from_env_value("test-seed").unwrap());
77    /// let good = fx.token("svc", TokenSpec::api_key());
78    /// let alt = fx.token_with_variant("svc", TokenSpec::api_key(), "alt");
79    /// assert_ne!(good.value(), alt.value());
80    /// ```
81    fn token_with_variant(
82        &self,
83        label: impl AsRef<str>,
84        spec: TokenSpec,
85        variant: impl AsRef<str>,
86    ) -> TokenFixture;
87}
88
89impl TokenFactoryExt for Factory {
90    fn token(&self, label: impl AsRef<str>, spec: TokenSpec) -> TokenFixture {
91        TokenFixture::new(self.clone(), label.as_ref(), spec)
92    }
93
94    fn token_with_variant(
95        &self,
96        label: impl AsRef<str>,
97        spec: TokenSpec,
98        variant: impl AsRef<str>,
99    ) -> TokenFixture {
100        let label = label.as_ref();
101        let variant = variant.as_ref();
102        let factory = self.clone();
103        let inner = load_inner(&factory, label, spec, variant);
104        TokenFixture {
105            factory,
106            label: label.to_string(),
107            spec,
108            inner,
109        }
110    }
111}
112
113impl TokenFixture {
114    fn new(factory: Factory, label: &str, spec: TokenSpec) -> Self {
115        let inner = load_inner(&factory, label, spec, "good");
116        Self {
117            factory,
118            label: label.to_string(),
119            spec,
120            inner,
121        }
122    }
123
124    #[allow(
125        dead_code,
126        reason = "reserved for future variant-based negative fixtures"
127    )]
128    fn load_variant(&self, variant: &str) -> Arc<Inner> {
129        load_inner(&self.factory, &self.label, self.spec, variant)
130    }
131
132    /// Returns the spec used to create this token.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// # use uselesskey_core::Factory;
138    /// # use uselesskey_token::{TokenFactoryExt, TokenSpec};
139    /// let fx = Factory::random();
140    /// let tok = fx.token("svc", TokenSpec::api_key());
141    /// assert_eq!(tok.spec(), TokenSpec::api_key());
142    /// ```
143    pub fn spec(&self) -> TokenSpec {
144        self.spec
145    }
146
147    /// Returns the label used to create this token.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// # use uselesskey_core::Factory;
153    /// # use uselesskey_token::{TokenFactoryExt, TokenSpec};
154    /// let fx = Factory::random();
155    /// let tok = fx.token("my-svc", TokenSpec::api_key());
156    /// assert_eq!(tok.label(), "my-svc");
157    /// ```
158    pub fn label(&self) -> &str {
159        &self.label
160    }
161
162    /// Access the token value.
163    ///
164    /// # Examples
165    ///
166    /// ```
167    /// # use uselesskey_core::{Factory, Seed};
168    /// # use uselesskey_token::{TokenFactoryExt, TokenSpec};
169    /// let fx = Factory::deterministic(Seed::from_env_value("test-seed").unwrap());
170    /// let tok = fx.token("svc", TokenSpec::api_key());
171    /// let val = tok.value();
172    /// assert!(val.starts_with("uk_test_"));
173    /// ```
174    pub fn value(&self) -> &str {
175        &self.inner.value
176    }
177
178    /// Returns an HTTP `Authorization` header value for this token.
179    ///
180    /// - API keys use `ApiKey <token>`
181    /// - Bearer and OAuth access tokens use `Bearer <token>`
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// # use uselesskey_core::{Factory, Seed};
187    /// # use uselesskey_token::{TokenFactoryExt, TokenSpec};
188    /// let fx = Factory::deterministic(Seed::from_env_value("test-seed").unwrap());
189    ///
190    /// let bearer = fx.token("svc", TokenSpec::bearer());
191    /// assert!(bearer.authorization_header().starts_with("Bearer "));
192    ///
193    /// let api = fx.token("svc", TokenSpec::api_key());
194    /// assert!(api.authorization_header().starts_with("ApiKey "));
195    /// ```
196    pub fn authorization_header(&self) -> String {
197        let scheme = self.spec.authorization_scheme();
198        format!("{scheme} {}", self.value())
199    }
200
201    /// Generate a scanner-safe negative token value for parser and validator tests.
202    ///
203    /// The generated value is cached by `(label, spec, variant)` and is stable in
204    /// deterministic mode without changing the positive token fixture.
205    ///
206    /// # Examples
207    ///
208    /// ```
209    /// # use uselesskey_core::{Factory, Seed};
210    /// # use uselesskey_token::{NegativeToken, TokenFactoryExt, TokenSpec};
211    /// let fx = Factory::deterministic(Seed::from_env_value("test-seed").unwrap());
212    /// let oauth = fx.token("issuer", TokenSpec::oauth_access_token());
213    /// let expired = oauth.negative_value(NegativeToken::ExpiredClaims);
214    /// assert_eq!(expired.matches('.').count(), 2);
215    /// ```
216    pub fn negative_value(&self, variant: NegativeToken) -> String {
217        load_negative_inner(&self.factory, &self.label, self.spec, variant)
218            .value
219            .clone()
220    }
221}
222
223fn load_inner(factory: &Factory, label: &str, spec: TokenSpec, variant: &str) -> Arc<Inner> {
224    let spec_bytes = spec.stable_bytes();
225
226    factory.get_or_init(DOMAIN_TOKEN_FIXTURE, label, &spec_bytes, variant, |seed| {
227        let value = generate_token(label, spec, seed);
228        Inner { value }
229    })
230}
231
232fn load_negative_inner(
233    factory: &Factory,
234    label: &str,
235    spec: TokenSpec,
236    variant: NegativeToken,
237) -> Arc<Inner> {
238    let spec_bytes = spec.stable_bytes();
239    let cache_variant = format!("negative:{}", variant.variant_name());
240
241    factory.get_or_init(
242        DOMAIN_TOKEN_FIXTURE,
243        label,
244        &spec_bytes,
245        &cache_variant,
246        |seed| {
247            let value = generate_negative_token(label, spec, seed, variant);
248            Inner { value }
249        },
250    )
251}
252
253#[cfg(test)]
254mod tests {
255    use base64::Engine as _;
256    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
257
258    use super::*;
259    use uselesskey_core::Seed;
260
261    #[test]
262    fn deterministic_token_is_stable() {
263        let fx = Factory::deterministic(Seed::from_env_value("token-det").unwrap());
264        let t1 = fx.token("svc", TokenSpec::api_key());
265        let t2 = fx.token("svc", TokenSpec::api_key());
266        assert_eq!(t1.value(), t2.value());
267    }
268
269    #[test]
270    fn random_mode_still_caches_per_identity() {
271        let fx = Factory::random();
272        let t1 = fx.token("svc", TokenSpec::bearer());
273        let t2 = fx.token("svc", TokenSpec::bearer());
274        assert_eq!(t1.value(), t2.value());
275    }
276
277    #[test]
278    fn different_labels_produce_different_tokens() {
279        let fx = Factory::deterministic(Seed::from_env_value("token-label").unwrap());
280        let a = fx.token("a", TokenSpec::bearer());
281        let b = fx.token("b", TokenSpec::bearer());
282        assert_ne!(a.value(), b.value());
283    }
284
285    #[test]
286    fn api_key_shape_is_realistic() {
287        let fx = Factory::random();
288        let token = fx.token("svc", TokenSpec::api_key());
289
290        assert!(token.value().starts_with("uk_test_"));
291        let suffix = &token.value()["uk_test_".len()..];
292        assert_eq!(suffix.len(), 32);
293        assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
294    }
295
296    #[test]
297    fn bearer_header_uses_bearer_scheme() {
298        let fx = Factory::random();
299        let token = fx.token("svc", TokenSpec::bearer());
300        let header = token.authorization_header();
301        assert!(header.starts_with("Bearer "));
302        assert!(header.ends_with(token.value()));
303    }
304
305    #[test]
306    fn oauth_token_has_three_segments_and_json_header() {
307        let fx = Factory::deterministic(Seed::from_env_value("token-oauth").unwrap());
308        let token = fx.token("issuer", TokenSpec::oauth_access_token());
309
310        let parts: Vec<&str> = token.value().split('.').collect();
311        assert_eq!(parts.len(), 3);
312
313        let header_bytes = URL_SAFE_NO_PAD
314            .decode(parts[0])
315            .expect("decode JWT header segment");
316        let payload_bytes = URL_SAFE_NO_PAD
317            .decode(parts[1])
318            .expect("decode JWT payload segment");
319
320        let header: serde_json::Value = serde_json::from_slice(&header_bytes).expect("header json");
321        let payload: serde_json::Value =
322            serde_json::from_slice(&payload_bytes).expect("payload json");
323
324        assert_eq!(header["alg"], "RS256");
325        assert_eq!(header["typ"], "JWT");
326        assert_eq!(payload["sub"], "issuer");
327        assert_eq!(payload["iss"], "uselesskey");
328    }
329
330    #[test]
331    fn different_variants_produce_different_tokens() {
332        let fx = Factory::deterministic(Seed::from_env_value("token-variant").unwrap());
333        let token = fx.token("svc", TokenSpec::bearer());
334        let other = token.load_variant("other");
335
336        assert_ne!(token.value(), other.value.as_str());
337    }
338
339    #[test]
340    fn token_with_variant_uses_custom_variant() {
341        let fx = Factory::deterministic(Seed::from_env_value("token-variant2").unwrap());
342        let good = fx.token("svc", TokenSpec::api_key());
343        let custom = fx.token_with_variant("svc", TokenSpec::api_key(), "custom");
344
345        assert_ne!(good.value(), custom.value());
346    }
347
348    #[test]
349    fn negative_value_is_cached_and_stable() {
350        let fx = Factory::deterministic(Seed::from_env_value("token-negative").unwrap());
351        let token = fx.token("issuer", TokenSpec::oauth_access_token());
352
353        let a = token.negative_value(NegativeToken::ExpiredClaims);
354        let b = token.negative_value(NegativeToken::ExpiredClaims);
355
356        assert_eq!(a, b);
357        assert_ne!(a, token.value());
358        assert_eq!(a.matches('.').count(), 2);
359    }
360
361    #[test]
362    fn negative_api_key_near_miss_keeps_positive_fixture_unchanged() {
363        let fx = Factory::deterministic(Seed::from_env_value("token-negative-api").unwrap());
364        let token = fx.token("billing", TokenSpec::api_key());
365
366        let near_miss = token.negative_value(NegativeToken::NearMissApiKey);
367
368        assert!(token.value().starts_with("uk_test_"));
369        assert!(near_miss.starts_with("uk_tset_"));
370        assert!(!near_miss.starts_with("uk_test_"));
371        assert_ne!(near_miss, token.value());
372    }
373
374    #[test]
375    fn debug_does_not_include_token_value() {
376        let fx = Factory::random();
377        let token = fx.token("debug-label", TokenSpec::api_key());
378        let dbg = format!("{token:?}");
379        assert!(dbg.contains("TokenFixture"));
380        assert!(dbg.contains("debug-label"));
381        assert!(!dbg.contains(token.value()));
382    }
383
384    #[test]
385    fn random_base62_uses_full_alphabet() {
386        let fx = Factory::deterministic(Seed::from_env_value("base62-test").unwrap());
387        let t = fx.token("alphabet-test", TokenSpec::api_key());
388        let value = t.value();
389        // API key format: "uk_test_{32 random base62 chars}".
390        // Strip the prefix to inspect only the random suffix.
391        let suffix = value.strip_prefix("uk_test_").expect("API key prefix");
392        // With / instead of %, only A-E would appear (byte[0] / 62 yields 0..=4).
393        // With %, the full base62 alphabet is used. A 32-char random suffix must
394        // contain characters beyond the first five uppercase letters.
395        assert!(
396            suffix
397                .chars()
398                .any(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
399            "random suffix should use full base62 alphabet, got: {suffix}"
400        );
401    }
402}