uselesskey_core_token_shape/
lib.rs1#![forbid(unsafe_code)]
2
3use 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
38pub const API_KEY_PREFIX: &str = "uk_test_";
40
41pub const API_KEY_RANDOM_LEN: usize = 32;
43
44pub const BEARER_RANDOM_BYTES: usize = 32;
46
47pub const OAUTH_JTI_BYTES: usize = 16;
49
50pub const OAUTH_SIGNATURE_BYTES: usize = 32;
52
53pub use uselesskey_token_spec::TokenSpec as TokenKind;
55
56pub 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
65pub fn authorization_scheme(kind: TokenKind) -> &'static str {
67 kind.authorization_scheme()
68}
69
70pub 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
77pub 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
84pub 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 assert_eq!(value.len(), 5);
252 assert_eq!(&value[..4], "A9A9");
253 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 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}