Skip to main content

uselesskey_core_token_shape/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Token shape generation primitives for test fixtures.
4//!
5//! Generates realistic-looking API keys, bearer tokens, and OAuth access
6//! tokens from any [`RngCore`] source — including a seeded RNG for
7//! deterministic tests.
8//!
9//! # Examples
10//!
11//! ```
12//! use rand_chacha::ChaCha20Rng;
13//! use rand_core::SeedableRng;
14//! use uselesskey_core_token_shape::{generate_token, TokenKind, authorization_scheme};
15//!
16//! let mut rng = ChaCha20Rng::from_seed([42u8; 32]);
17//!
18//! // Generate an API key (prefixed with `uk_test_`)
19//! let api_key = generate_token("my-service", TokenKind::ApiKey, &mut rng);
20//! assert!(api_key.starts_with("uk_test_"));
21//!
22//! // Generate a bearer token (base64url-encoded random bytes)
23//! let bearer = generate_token("my-service", TokenKind::Bearer, &mut rng);
24//! assert_eq!(authorization_scheme(TokenKind::Bearer), "Bearer");
25//!
26//! // Generate an OAuth access token (JWT-shaped: header.payload.signature)
27//! let oauth = generate_token("my-service", TokenKind::OAuthAccessToken, &mut rng);
28//! assert_eq!(oauth.matches('.').count(), 2);
29//! ```
30
31use base64::Engine as _;
32use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33use rand_core::RngCore;
34
35use serde_json::json;
36pub use uselesskey_core_base62::random_base62;
37
38/// Prefix used for API-key token fixtures.
39pub const API_KEY_PREFIX: &str = "uk_test_";
40
41/// Number of random base62 characters used in API-key fixtures.
42pub const API_KEY_RANDOM_LEN: usize = 32;
43
44/// Number of raw random bytes in opaque bearer tokens.
45pub const BEARER_RANDOM_BYTES: usize = 32;
46
47/// Number of random bytes used for OAuth `jti`.
48pub const OAUTH_JTI_BYTES: usize = 16;
49
50/// Number of random bytes used for OAuth signature-like segment.
51pub const OAUTH_SIGNATURE_BYTES: usize = 32;
52
53/// Token shape kind.
54pub use uselesskey_token_spec::TokenSpec as TokenKind;
55
56/// Generate a token value for the provided shape kind.
57pub fn generate_token(label: &str, kind: TokenKind, rng: &mut impl RngCore) -> String {
58    match kind {
59        TokenKind::ApiKey => generate_api_key(rng),
60        TokenKind::Bearer => generate_bearer_token(rng),
61        TokenKind::OAuthAccessToken => generate_oauth_access_token(label, rng),
62    }
63}
64
65/// Return HTTP authorization scheme for the token kind.
66pub fn authorization_scheme(kind: TokenKind) -> &'static str {
67    kind.authorization_scheme()
68}
69
70/// Generate an API-key style token fixture (`uk_test_<base62>`).
71pub fn generate_api_key(rng: &mut impl RngCore) -> String {
72    let mut out = String::from(API_KEY_PREFIX);
73    out.push_str(&random_base62(rng, API_KEY_RANDOM_LEN));
74    out
75}
76
77/// Generate an opaque bearer token fixture (base64url of 32 random bytes).
78pub fn generate_bearer_token(rng: &mut impl RngCore) -> String {
79    let mut bytes = [0u8; BEARER_RANDOM_BYTES];
80    rng.fill_bytes(&mut bytes);
81    URL_SAFE_NO_PAD.encode(bytes)
82}
83
84/// Generate an OAuth access token fixture in JWT shape (`header.payload.signature`).
85pub fn generate_oauth_access_token(label: &str, rng: &mut impl RngCore) -> String {
86    let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
87
88    let mut jti = [0u8; OAUTH_JTI_BYTES];
89    rng.fill_bytes(&mut jti);
90
91    let payload = json!({
92        "iss": "uselesskey",
93        "sub": label,
94        "aud": "tests",
95        "scope": "fixture.read",
96        "jti": URL_SAFE_NO_PAD.encode(jti),
97        "exp": 2_000_000_000u64,
98    });
99    let payload_json = serde_json::to_vec(&payload).expect("payload JSON");
100    let payload_segment = URL_SAFE_NO_PAD.encode(payload_json);
101
102    let mut signature = [0u8; OAUTH_SIGNATURE_BYTES];
103    rng.fill_bytes(&mut signature);
104    let signature_segment = URL_SAFE_NO_PAD.encode(signature);
105
106    format!("{header}.{payload_segment}.{signature_segment}")
107}
108
109#[cfg(test)]
110mod tests {
111    use base64::Engine as _;
112    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
113    use proptest::prelude::*;
114    use rand_chacha::{ChaCha20Rng, rand_core::RngCore};
115    use rand_core::SeedableRng;
116
117    use super::{
118        API_KEY_PREFIX, API_KEY_RANDOM_LEN, BEARER_RANDOM_BYTES, TokenKind, authorization_scheme,
119        generate_api_key, generate_bearer_token, generate_oauth_access_token, generate_token,
120    };
121    use uselesskey_core_base62::random_base62;
122
123    #[test]
124    fn api_key_shape_is_stable() {
125        let mut rng = ChaCha20Rng::from_seed([7u8; 32]);
126        let value = generate_api_key(&mut rng);
127
128        assert!(value.starts_with(API_KEY_PREFIX));
129        let suffix = value
130            .strip_prefix(API_KEY_PREFIX)
131            .expect("API key prefix should be present");
132        assert_eq!(suffix.len(), API_KEY_RANDOM_LEN);
133        assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
134    }
135
136    #[test]
137    fn bearer_shape_decodes_to_32_bytes() {
138        let mut rng = ChaCha20Rng::from_seed([9u8; 32]);
139        let value = generate_bearer_token(&mut rng);
140        let decoded = URL_SAFE_NO_PAD.decode(value).expect("base64url decode");
141        assert_eq!(decoded.len(), BEARER_RANDOM_BYTES);
142    }
143
144    #[test]
145    fn oauth_shape_has_three_segments_and_subject() {
146        let mut rng = ChaCha20Rng::from_seed([11u8; 32]);
147        let value = generate_oauth_access_token("issuer", &mut rng);
148        let parts: Vec<&str> = value.split('.').collect();
149        assert_eq!(parts.len(), 3);
150
151        let payload = URL_SAFE_NO_PAD
152            .decode(parts[1])
153            .expect("decode payload segment");
154        let json: serde_json::Value = serde_json::from_slice(&payload).expect("parse payload");
155        assert_eq!(json["sub"], "issuer");
156        assert_eq!(json["iss"], "uselesskey");
157    }
158
159    #[test]
160    fn authorization_scheme_matches_kind() {
161        assert_eq!(authorization_scheme(TokenKind::ApiKey), "ApiKey");
162        assert_eq!(authorization_scheme(TokenKind::Bearer), "Bearer");
163        assert_eq!(authorization_scheme(TokenKind::OAuthAccessToken), "Bearer");
164    }
165
166    #[test]
167    fn generate_token_varies_by_kind() {
168        let seed = [13u8; 32];
169
170        let mut rng = ChaCha20Rng::from_seed(seed);
171        let api = generate_token("label", TokenKind::ApiKey, &mut rng);
172
173        let mut rng = ChaCha20Rng::from_seed(seed);
174        let bearer = generate_token("label", TokenKind::Bearer, &mut rng);
175
176        let mut rng = ChaCha20Rng::from_seed(seed);
177        let oauth = generate_token("label", TokenKind::OAuthAccessToken, &mut rng);
178
179        assert_ne!(api, bearer);
180        assert_ne!(api, oauth);
181        assert_ne!(bearer, oauth);
182    }
183
184    #[test]
185    fn random_base62_length_and_charset() {
186        let mut rng = ChaCha20Rng::from_seed([17u8; 32]);
187        let value = random_base62(&mut rng, 64);
188        assert_eq!(value.len(), 64);
189        assert!(value.chars().all(|c| c.is_ascii_alphanumeric()));
190    }
191
192    #[test]
193    fn random_base62_rejects_biased_bytes() {
194        struct ByteSeqRng {
195            bytes: [u8; 5],
196            pos: usize,
197        }
198
199        impl ByteSeqRng {
200            fn next_byte(&mut self) -> u8 {
201                let b = self.bytes[self.pos % self.bytes.len()];
202                self.pos += 1;
203                b
204            }
205        }
206
207        impl RngCore for ByteSeqRng {
208            fn next_u32(&mut self) -> u32 {
209                u32::from_le_bytes([
210                    self.next_byte(),
211                    self.next_byte(),
212                    self.next_byte(),
213                    self.next_byte(),
214                ])
215            }
216
217            fn next_u64(&mut self) -> u64 {
218                u64::from_le_bytes([
219                    self.next_byte(),
220                    self.next_byte(),
221                    self.next_byte(),
222                    self.next_byte(),
223                    self.next_byte(),
224                    self.next_byte(),
225                    self.next_byte(),
226                    self.next_byte(),
227                ])
228            }
229
230            fn fill_bytes(&mut self, dst: &mut [u8]) {
231                for b in dst {
232                    *b = self.next_byte();
233                }
234            }
235
236            fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), rand_core::Error> {
237                self.fill_bytes(dst);
238                Ok(())
239            }
240        }
241
242        let mut rng = ByteSeqRng {
243            bytes: [0, 61, 62, 123, 255],
244            pos: 0,
245        };
246        let value = random_base62(&mut rng, 5);
247
248        // byte 255 >= 248 is rejected; 0 % 62 = 0 => 'A', 61 % 62 = 61 => '9',
249        // 62 % 62 = 0 => 'A', 123 % 62 = 61 => '9', (255 rejected),
250        // next cycle: 0 => 'A'
251        assert_eq!(value.len(), 5);
252        assert_eq!(&value[..4], "A9A9");
253        // Fifth char comes from byte 0 (after 255 rejected) -> 'A'
254        assert_eq!(value, "A9A9A");
255    }
256
257    #[test]
258    fn random_base62_constant_rng_terminates() {
259        struct ConstantRng(u8);
260
261        impl RngCore for ConstantRng {
262            fn next_u32(&mut self) -> u32 {
263                u32::from(self.0) * 0x0101_0101
264            }
265
266            fn next_u64(&mut self) -> u64 {
267                u64::from(self.0) * 0x0101_0101_0101_0101
268            }
269
270            fn fill_bytes(&mut self, dest: &mut [u8]) {
271                dest.fill(self.0);
272            }
273
274            fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
275                self.fill_bytes(dest);
276                Ok(())
277            }
278        }
279
280        let mut rng = ConstantRng(0xFF);
281        let value = random_base62(&mut rng, 32);
282        assert_eq!(value.len(), 32);
283
284        // 255 % 62 = 7 -> 'H'
285        assert!(value.chars().all(|c| c == 'H'));
286    }
287
288    proptest! {
289        #[test]
290        fn api_key_same_seed_stable(seed in any::<[u8; 32]>()) {
291            let mut first = ChaCha20Rng::from_seed(seed);
292            let mut second = ChaCha20Rng::from_seed(seed);
293            let a = generate_api_key(&mut first);
294            let b = generate_api_key(&mut second);
295            prop_assert_eq!(a, b);
296        }
297
298        #[test]
299        fn bearer_token_always_43_chars(seed in any::<[u8; 32]>()) {
300            let mut rng = ChaCha20Rng::from_seed(seed);
301            let token = generate_bearer_token(&mut rng);
302            prop_assert_eq!(token.len(), 43);
303        }
304
305        #[test]
306        fn oauth_has_three_segments(seed in any::<[u8; 32]>(), label in "[a-z0-9_-]{1,16}") {
307            let mut rng = ChaCha20Rng::from_seed(seed);
308            let token = generate_oauth_access_token(&label, &mut rng);
309            prop_assert_eq!(token.matches('.').count(), 2);
310        }
311    }
312}