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 deterministic seed material.
7//!
8//! # Examples
9//!
10//! ```
11//! use uselesskey_core_token_shape::{generate_token, TokenKind, authorization_scheme};
12//! use uselesskey_core_seed::Seed;
13//!
14//! let seed = Seed::new([42u8; 32]);
15//!
16//! // Generate an API key (prefixed with `uk_test_`)
17//! let api_key = generate_token("my-service", TokenKind::ApiKey, seed);
18//! assert!(api_key.starts_with("uk_test_"));
19//!
20//! // Generate a bearer token (base64url-encoded random bytes)
21//! let bearer = generate_token("my-service", TokenKind::Bearer, seed);
22//! assert_eq!(authorization_scheme(TokenKind::Bearer), "Bearer");
23//!
24//! // Generate an OAuth access token (JWT-shaped: header.payload.signature)
25//! let oauth = generate_token("my-service", TokenKind::OAuthAccessToken, seed);
26//! assert_eq!(oauth.matches('.').count(), 2);
27//! ```
28
29use base64::Engine as _;
30use base64::engine::general_purpose::URL_SAFE_NO_PAD;
31use rand_chacha10::ChaCha20Rng;
32use rand_core10::{Rng, SeedableRng};
33
34use serde_json::json;
35pub use uselesskey_core_base62::random_base62;
36use uselesskey_core_seed::Seed;
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, seed: Seed) -> String {
58    match kind {
59        TokenKind::ApiKey => generate_api_key(seed),
60        TokenKind::Bearer => generate_bearer_token(seed),
61        TokenKind::OAuthAccessToken => generate_oauth_access_token(label, seed),
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(seed: Seed) -> String {
72    let mut out = String::from(API_KEY_PREFIX);
73    out.push_str(&random_base62(seed, 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(seed: Seed) -> String {
79    let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
80    let mut bytes = [0u8; BEARER_RANDOM_BYTES];
81    rng.fill_bytes(&mut bytes);
82    URL_SAFE_NO_PAD.encode(bytes)
83}
84
85/// Generate an OAuth access token fixture in JWT shape (`header.payload.signature`).
86pub fn generate_oauth_access_token(label: &str, seed: Seed) -> String {
87    let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
88    let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
89
90    let mut jti = [0u8; OAUTH_JTI_BYTES];
91    rng.fill_bytes(&mut jti);
92
93    let payload = json!({
94        "iss": "uselesskey",
95        "sub": label,
96        "aud": "tests",
97        "scope": "fixture.read",
98        "jti": URL_SAFE_NO_PAD.encode(jti),
99        "exp": 2_000_000_000u64,
100    });
101    let payload_json = serde_json::to_vec(&payload).expect("payload JSON");
102    let payload_segment = URL_SAFE_NO_PAD.encode(payload_json);
103
104    let mut signature = [0u8; OAUTH_SIGNATURE_BYTES];
105    rng.fill_bytes(&mut signature);
106    let signature_segment = URL_SAFE_NO_PAD.encode(signature);
107
108    format!("{header}.{payload_segment}.{signature_segment}")
109}
110
111#[cfg(test)]
112mod tests {
113    use base64::Engine as _;
114    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
115    use proptest::prelude::*;
116    use uselesskey_core_seed::Seed;
117
118    use super::{
119        API_KEY_PREFIX, API_KEY_RANDOM_LEN, BEARER_RANDOM_BYTES, TokenKind, authorization_scheme,
120        generate_api_key, generate_bearer_token, generate_oauth_access_token, generate_token,
121    };
122    use uselesskey_core_base62::random_base62;
123
124    #[test]
125    fn api_key_shape_is_stable() {
126        let value = generate_api_key(Seed::new([7u8; 32]));
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 value = generate_bearer_token(Seed::new([9u8; 32]));
139        let decoded = URL_SAFE_NO_PAD.decode(value).expect("base64url decode");
140        assert_eq!(decoded.len(), BEARER_RANDOM_BYTES);
141    }
142
143    #[test]
144    fn oauth_shape_has_three_segments_and_subject() {
145        let value = generate_oauth_access_token("issuer", Seed::new([11u8; 32]));
146        let parts: Vec<&str> = value.split('.').collect();
147        assert_eq!(parts.len(), 3);
148
149        let payload = URL_SAFE_NO_PAD
150            .decode(parts[1])
151            .expect("decode payload segment");
152        let json: serde_json::Value = serde_json::from_slice(&payload).expect("parse payload");
153        assert_eq!(json["sub"], "issuer");
154        assert_eq!(json["iss"], "uselesskey");
155    }
156
157    #[test]
158    fn authorization_scheme_matches_kind() {
159        assert_eq!(authorization_scheme(TokenKind::ApiKey), "ApiKey");
160        assert_eq!(authorization_scheme(TokenKind::Bearer), "Bearer");
161        assert_eq!(authorization_scheme(TokenKind::OAuthAccessToken), "Bearer");
162    }
163
164    #[test]
165    fn generate_token_varies_by_kind() {
166        let seed = [13u8; 32];
167
168        let api = generate_token("label", TokenKind::ApiKey, Seed::new(seed));
169        let bearer = generate_token("label", TokenKind::Bearer, Seed::new(seed));
170        let oauth = generate_token("label", TokenKind::OAuthAccessToken, Seed::new(seed));
171
172        assert_ne!(api, bearer);
173        assert_ne!(api, oauth);
174        assert_ne!(bearer, oauth);
175    }
176
177    #[test]
178    fn random_base62_length_and_charset() {
179        let value = random_base62(Seed::new([17u8; 32]), 64);
180        assert_eq!(value.len(), 64);
181        assert!(value.chars().all(|c| c.is_ascii_alphanumeric()));
182    }
183
184    proptest! {
185        #[test]
186        fn api_key_same_seed_stable(seed in any::<[u8; 32]>()) {
187            let a = generate_api_key(Seed::new(seed));
188            let b = generate_api_key(Seed::new(seed));
189            prop_assert_eq!(a, b);
190        }
191
192        #[test]
193        fn bearer_token_always_43_chars(seed in any::<[u8; 32]>()) {
194            let token = generate_bearer_token(Seed::new(seed));
195            prop_assert_eq!(token.len(), 43);
196        }
197
198        #[test]
199        fn oauth_has_three_segments(seed in any::<[u8; 32]>(), label in "[a-z0-9_-]{1,16}") {
200            let token = generate_oauth_access_token(&label, Seed::new(seed));
201            prop_assert_eq!(token.matches('.').count(), 2);
202        }
203    }
204}