1use base64::Engine as _;
39use base64::engine::general_purpose::URL_SAFE_NO_PAD;
40use rand_chacha10::ChaCha20Rng;
41use rand_core10::{Rng, SeedableRng};
42
43use serde_json::{Map, Value, json};
44use uselesskey_core::Seed;
45
46pub use super::base62::random_base62;
47
48pub const API_KEY_PREFIX: &str = "uk_test_";
50
51pub const API_KEY_RANDOM_LEN: usize = 32;
53
54pub const BEARER_RANDOM_BYTES: usize = 32;
56
57pub const OAUTH_JTI_BYTES: usize = 16;
59
60pub const OAUTH_SIGNATURE_BYTES: usize = 32;
62
63const SCANNER_SAFE_INVALID_TOKEN_SEGMENT: &str = "not_base64url!*";
64
65const NEAR_MISS_API_KEY_PREFIX: &str = "uk_tset_";
66
67pub use super::spec::TokenSpec as TokenKind;
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum NegativeToken {
73 MalformedJwtSegmentCount,
75 BadBase64UrlSegment,
77 InvalidJwtHeaderShape,
79 MissingAlg,
81 AlgNone,
83 MismatchedKid,
85 ExpiredClaims,
87 NotYetValidClaims,
89 BadIssuer,
91 BadAudience,
93 MalformedBearer,
95 NearMissApiKey,
97}
98
99impl NegativeToken {
100 pub const fn variant_name(&self) -> &'static str {
102 match self {
103 Self::MalformedJwtSegmentCount => "malformed_jwt_segment_count",
104 Self::BadBase64UrlSegment => "bad_base64url_segment",
105 Self::InvalidJwtHeaderShape => "invalid_jwt_header_shape",
106 Self::MissingAlg => "missing_alg",
107 Self::AlgNone => "alg_none",
108 Self::MismatchedKid => "mismatched_kid",
109 Self::ExpiredClaims => "expired_claims",
110 Self::NotYetValidClaims => "not_yet_valid_claims",
111 Self::BadIssuer => "bad_issuer",
112 Self::BadAudience => "bad_audience",
113 Self::MalformedBearer => "malformed_bearer",
114 Self::NearMissApiKey => "near_miss_api_key",
115 }
116 }
117}
118
119pub fn generate_token(label: &str, kind: TokenKind, seed: Seed) -> String {
121 match kind {
122 TokenKind::ApiKey => generate_api_key(seed),
123 TokenKind::Bearer => generate_bearer_token(seed),
124 TokenKind::OAuthAccessToken => generate_oauth_access_token(label, seed),
125 }
126}
127
128pub fn generate_negative_token(
130 label: &str,
131 kind: TokenKind,
132 seed: Seed,
133 variant: NegativeToken,
134) -> String {
135 match variant {
136 NegativeToken::MalformedJwtSegmentCount => malformed_jwt_segment_count(label, seed),
137 NegativeToken::BadBase64UrlSegment => bad_base64url_segment(label, seed),
138 NegativeToken::InvalidJwtHeaderShape => invalid_jwt_header_shape(label, seed),
139 NegativeToken::MissingAlg => missing_alg(label, seed),
140 NegativeToken::AlgNone => alg_none(label, seed),
141 NegativeToken::MismatchedKid => mismatched_kid(label, seed),
142 NegativeToken::ExpiredClaims => token_with_payload_claim(label, seed, "exp", json!(1u64)),
143 NegativeToken::NotYetValidClaims => not_yet_valid_claims(label, seed),
144 NegativeToken::BadIssuer => {
145 token_with_payload_claim(label, seed, "iss", json!("wrong-issuer"))
146 }
147 NegativeToken::BadAudience => {
148 token_with_payload_claim(label, seed, "aud", json!("wrong-audience"))
149 }
150 NegativeToken::MalformedBearer => malformed_bearer(seed),
151 NegativeToken::NearMissApiKey => near_miss_api_key(kind, seed),
152 }
153}
154
155pub fn authorization_scheme(kind: TokenKind) -> &'static str {
157 kind.authorization_scheme()
158}
159
160pub fn generate_api_key(seed: Seed) -> String {
162 let mut out = String::from(API_KEY_PREFIX);
163 out.push_str(&random_base62(seed, API_KEY_RANDOM_LEN));
164 out
165}
166
167pub fn generate_bearer_token(seed: Seed) -> String {
169 let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
170 let mut bytes = [0u8; BEARER_RANDOM_BYTES];
171 rng.fill_bytes(&mut bytes);
172 URL_SAFE_NO_PAD.encode(bytes)
173}
174
175pub fn generate_oauth_access_token(label: &str, seed: Seed) -> String {
177 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
178 let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
179
180 let mut jti = [0u8; OAUTH_JTI_BYTES];
181 rng.fill_bytes(&mut jti);
182
183 let payload = json!({
184 "iss": "uselesskey",
185 "sub": label,
186 "aud": "tests",
187 "scope": "fixture.read",
188 "jti": URL_SAFE_NO_PAD.encode(jti),
189 "exp": 2_000_000_000u64,
190 });
191 let payload_json = serde_json::to_vec(&payload).expect("payload JSON");
192 let payload_segment = URL_SAFE_NO_PAD.encode(payload_json);
193
194 let mut signature = [0u8; OAUTH_SIGNATURE_BYTES];
195 rng.fill_bytes(&mut signature);
196 let signature_segment = URL_SAFE_NO_PAD.encode(signature);
197
198 format!("{header}.{payload_segment}.{signature_segment}")
199}
200
201fn malformed_jwt_segment_count(label: &str, seed: Seed) -> String {
202 let [header, payload, _signature] = oauth_parts(label, seed);
203 format!("{header}.{payload}")
204}
205
206fn bad_base64url_segment(label: &str, seed: Seed) -> String {
207 let [header, _payload, signature] = oauth_parts(label, seed);
208 format!("{header}.{SCANNER_SAFE_INVALID_TOKEN_SEGMENT}.{signature}")
209}
210
211fn invalid_jwt_header_shape(label: &str, seed: Seed) -> String {
212 let [_header, payload, signature] = oauth_parts(label, seed);
213 let header = encode_json(&json!(["not-a-header"]));
214 format!("{header}.{payload}.{signature}")
215}
216
217fn missing_alg(label: &str, seed: Seed) -> String {
218 let [_header, payload, signature] = oauth_parts(label, seed);
219 let header = encode_json(&json!({ "typ": "JWT" }));
220 format!("{header}.{payload}.{signature}")
221}
222
223fn alg_none(label: &str, seed: Seed) -> String {
224 token_with_header_claim(label, seed, "alg", json!("none"))
225}
226
227fn mismatched_kid(label: &str, seed: Seed) -> String {
228 let [_header, payload, signature] = oauth_parts(label, seed);
229 let mut header = jwt_header();
230 header.insert("kid".to_string(), json!("unknown-kid"));
231
232 let mut payload = decode_object(&payload);
233 payload.insert("kid".to_string(), json!("expected-kid"));
234
235 format!(
236 "{}.{}.{}",
237 encode_object(&header),
238 encode_object(&payload),
239 signature
240 )
241}
242
243fn not_yet_valid_claims(label: &str, seed: Seed) -> String {
244 let [_header, payload, signature] = oauth_parts(label, seed);
245 let mut claims = decode_object(&payload);
246 claims.insert("nbf".to_string(), json!(4_000_000_000u64));
247 claims.insert("exp".to_string(), json!(4_100_000_000u64));
248
249 format!(
250 "{}.{}.{}",
251 encode_object(&jwt_header()),
252 encode_object(&claims),
253 signature
254 )
255}
256
257fn token_with_header_claim(label: &str, seed: Seed, claim: &str, value: Value) -> String {
258 let [_header, payload, signature] = oauth_parts(label, seed);
259 let mut header = jwt_header();
260 header.insert(claim.to_string(), value);
261
262 format!("{}.{}.{}", encode_object(&header), payload, signature)
263}
264
265fn token_with_payload_claim(label: &str, seed: Seed, claim: &str, value: Value) -> String {
266 let [_header, payload, signature] = oauth_parts(label, seed);
267 let mut claims = decode_object(&payload);
268 claims.insert(claim.to_string(), value);
269
270 format!(
271 "{}.{}.{}",
272 encode_object(&jwt_header()),
273 encode_object(&claims),
274 signature
275 )
276}
277
278fn malformed_bearer(seed: Seed) -> String {
279 let mut value = generate_bearer_token(seed);
280 value.replace_range(0..1, "!");
281 value
282}
283
284fn near_miss_api_key(_kind: TokenKind, seed: Seed) -> String {
285 let valid = generate_api_key(seed);
286 let suffix = valid.strip_prefix(API_KEY_PREFIX).unwrap_or(&valid);
287
288 format!("{NEAR_MISS_API_KEY_PREFIX}{suffix}")
289}
290
291fn oauth_parts(label: &str, seed: Seed) -> [String; 3] {
292 let token = generate_oauth_access_token(label, seed);
293 let mut parts = token.split('.');
294 let header = parts.next().expect("JWT header segment").to_string();
295 let payload = parts.next().expect("JWT payload segment").to_string();
296 let signature = parts.next().expect("JWT signature segment").to_string();
297 assert!(
298 parts.next().is_none(),
299 "JWT should have exactly three segments"
300 );
301
302 [header, payload, signature]
303}
304
305fn jwt_header() -> Map<String, Value> {
306 Map::from_iter([
307 ("alg".to_string(), json!("RS256")),
308 ("typ".to_string(), json!("JWT")),
309 ])
310}
311
312fn decode_object(segment: &str) -> Map<String, Value> {
313 let bytes = URL_SAFE_NO_PAD
314 .decode(segment)
315 .expect("decode generated JWT JSON segment");
316 let value: Value = serde_json::from_slice(&bytes).expect("parse generated JWT JSON segment");
317 value
318 .as_object()
319 .expect("generated JWT JSON segment should be an object")
320 .clone()
321}
322
323fn encode_object(value: &Map<String, Value>) -> String {
324 encode_json(&Value::Object(value.clone()))
325}
326
327fn encode_json(value: &Value) -> String {
328 let json = serde_json::to_vec(value).expect("serialize token JSON");
329 URL_SAFE_NO_PAD.encode(json)
330}
331
332#[cfg(test)]
333mod tests {
334 use base64::Engine as _;
335 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
336 use proptest::prelude::*;
337 use uselesskey_core::Seed;
338
339 use super::{
340 API_KEY_PREFIX, API_KEY_RANDOM_LEN, BEARER_RANDOM_BYTES, NEAR_MISS_API_KEY_PREFIX,
341 NegativeToken, SCANNER_SAFE_INVALID_TOKEN_SEGMENT, TokenKind, authorization_scheme,
342 generate_api_key, generate_bearer_token, generate_negative_token,
343 generate_oauth_access_token, generate_token, random_base62,
344 };
345
346 #[test]
347 fn api_key_shape_is_stable() {
348 let value = generate_api_key(Seed::new([7u8; 32]));
349
350 assert!(value.starts_with(API_KEY_PREFIX));
351 let suffix = value
352 .strip_prefix(API_KEY_PREFIX)
353 .expect("API key prefix should be present");
354 assert_eq!(suffix.len(), API_KEY_RANDOM_LEN);
355 assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
356 }
357
358 #[test]
359 fn bearer_shape_decodes_to_32_bytes() {
360 let value = generate_bearer_token(Seed::new([9u8; 32]));
361 let decoded = URL_SAFE_NO_PAD.decode(value).expect("base64url decode");
362 assert_eq!(decoded.len(), BEARER_RANDOM_BYTES);
363 }
364
365 #[test]
366 fn oauth_shape_has_three_segments_and_subject() {
367 let value = generate_oauth_access_token("issuer", Seed::new([11u8; 32]));
368 let parts: Vec<&str> = value.split('.').collect();
369 assert_eq!(parts.len(), 3);
370
371 let payload = URL_SAFE_NO_PAD
372 .decode(parts[1])
373 .expect("decode payload segment");
374 let json: serde_json::Value = serde_json::from_slice(&payload).expect("parse payload");
375 assert_eq!(json["sub"], "issuer");
376 assert_eq!(json["iss"], "uselesskey");
377 }
378
379 #[test]
380 fn authorization_scheme_matches_kind() {
381 assert_eq!(authorization_scheme(TokenKind::ApiKey), "ApiKey");
382 assert_eq!(authorization_scheme(TokenKind::Bearer), "Bearer");
383 assert_eq!(authorization_scheme(TokenKind::OAuthAccessToken), "Bearer");
384 }
385
386 #[test]
387 fn generate_token_varies_by_kind() {
388 let seed = [13u8; 32];
389
390 let api = generate_token("label", TokenKind::ApiKey, Seed::new(seed));
391 let bearer = generate_token("label", TokenKind::Bearer, Seed::new(seed));
392 let oauth = generate_token("label", TokenKind::OAuthAccessToken, Seed::new(seed));
393
394 assert_ne!(api, bearer);
395 assert_ne!(api, oauth);
396 assert_ne!(bearer, oauth);
397 }
398
399 #[test]
400 fn negative_token_variant_names_are_stable() {
401 assert_eq!(
402 NegativeToken::MalformedJwtSegmentCount.variant_name(),
403 "malformed_jwt_segment_count"
404 );
405 assert_eq!(
406 NegativeToken::BadBase64UrlSegment.variant_name(),
407 "bad_base64url_segment"
408 );
409 assert_eq!(
410 NegativeToken::InvalidJwtHeaderShape.variant_name(),
411 "invalid_jwt_header_shape"
412 );
413 assert_eq!(NegativeToken::MissingAlg.variant_name(), "missing_alg");
414 assert_eq!(NegativeToken::AlgNone.variant_name(), "alg_none");
415 assert_eq!(
416 NegativeToken::MismatchedKid.variant_name(),
417 "mismatched_kid"
418 );
419 assert_eq!(
420 NegativeToken::ExpiredClaims.variant_name(),
421 "expired_claims"
422 );
423 assert_eq!(
424 NegativeToken::NotYetValidClaims.variant_name(),
425 "not_yet_valid_claims"
426 );
427 assert_eq!(NegativeToken::BadIssuer.variant_name(), "bad_issuer");
428 assert_eq!(NegativeToken::BadAudience.variant_name(), "bad_audience");
429 assert_eq!(
430 NegativeToken::MalformedBearer.variant_name(),
431 "malformed_bearer"
432 );
433 assert_eq!(
434 NegativeToken::NearMissApiKey.variant_name(),
435 "near_miss_api_key"
436 );
437 }
438
439 #[test]
440 fn negative_api_key_near_miss_is_scanner_safe() {
441 let value = generate_negative_token(
442 "svc",
443 TokenKind::ApiKey,
444 Seed::new([19u8; 32]),
445 NegativeToken::NearMissApiKey,
446 );
447
448 assert!(value.starts_with(NEAR_MISS_API_KEY_PREFIX));
449 assert!(!value.starts_with(API_KEY_PREFIX));
450 assert_eq!(
451 value.len(),
452 NEAR_MISS_API_KEY_PREFIX.len() + API_KEY_RANDOM_LEN
453 );
454 }
455
456 #[test]
457 fn negative_malformed_bearer_is_not_base64url() {
458 let value = generate_negative_token(
459 "svc",
460 TokenKind::Bearer,
461 Seed::new([23u8; 32]),
462 NegativeToken::MalformedBearer,
463 );
464
465 assert_ne!(value, SCANNER_SAFE_INVALID_TOKEN_SEGMENT);
466 assert!(value.contains('!'));
467 assert_eq!(value.len(), 43);
468 assert!(URL_SAFE_NO_PAD.decode(value).is_err());
469 }
470
471 #[test]
472 fn negative_jwt_segment_count_keeps_two_decodable_segments() {
473 let value = generate_negative_token(
474 "svc",
475 TokenKind::OAuthAccessToken,
476 Seed::new([31u8; 32]),
477 NegativeToken::MalformedJwtSegmentCount,
478 );
479 let parts = jwt_parts(&value);
480
481 assert_eq!(parts.len(), 2);
482 assert_eq!(decode_object_segment(parts[0])["alg"], "RS256");
483 assert_eq!(decode_object_segment(parts[0])["typ"], "JWT");
484 assert_eq!(decode_object_segment(parts[1])["sub"], "svc");
485 }
486
487 #[test]
488 fn negative_bad_base64url_replaces_payload_only() {
489 let value = generate_negative_token(
490 "svc",
491 TokenKind::OAuthAccessToken,
492 Seed::new([32u8; 32]),
493 NegativeToken::BadBase64UrlSegment,
494 );
495 let parts = jwt_parts(&value);
496
497 assert_eq!(parts.len(), 3);
498 assert_eq!(decode_object_segment(parts[0])["alg"], "RS256");
499 assert_eq!(parts[1], SCANNER_SAFE_INVALID_TOKEN_SEGMENT);
500 assert!(URL_SAFE_NO_PAD.decode(parts[1]).is_err());
501 assert!(!parts[2].is_empty());
502 }
503
504 #[test]
505 fn negative_invalid_header_shape_keeps_payload_and_signature() {
506 let value = generate_negative_token(
507 "svc",
508 TokenKind::OAuthAccessToken,
509 Seed::new([33u8; 32]),
510 NegativeToken::InvalidJwtHeaderShape,
511 );
512 let parts = jwt_parts(&value);
513
514 assert_eq!(parts.len(), 3);
515 assert_eq!(
516 decode_json_segment(parts[0]),
517 serde_json::json!(["not-a-header"])
518 );
519 assert_eq!(decode_object_segment(parts[1])["sub"], "svc");
520 assert!(!parts[2].is_empty());
521 }
522
523 #[test]
524 fn negative_missing_alg_keeps_typ_and_claims() {
525 let value = generate_negative_token(
526 "svc",
527 TokenKind::OAuthAccessToken,
528 Seed::new([34u8; 32]),
529 NegativeToken::MissingAlg,
530 );
531 let parts = jwt_parts(&value);
532 let header = decode_object_segment(parts[0]);
533
534 assert_eq!(parts.len(), 3);
535 assert!(!header.contains_key("alg"));
536 assert_eq!(header["typ"], "JWT");
537 assert_eq!(decode_object_segment(parts[1])["sub"], "svc");
538 }
539
540 #[test]
541 fn negative_alg_none_changes_alg_only() {
542 let value = generate_negative_token(
543 "svc",
544 TokenKind::OAuthAccessToken,
545 Seed::new([35u8; 32]),
546 NegativeToken::AlgNone,
547 );
548 let parts = jwt_parts(&value);
549 let header = decode_object_segment(parts[0]);
550
551 assert_eq!(parts.len(), 3);
552 assert_eq!(header["alg"], "none");
553 assert_eq!(header["typ"], "JWT");
554 assert_eq!(decode_object_segment(parts[1])["sub"], "svc");
555 }
556
557 #[test]
558 fn negative_mismatched_kid_keeps_header_and_payload_context() {
559 let value = generate_negative_token(
560 "svc",
561 TokenKind::OAuthAccessToken,
562 Seed::new([36u8; 32]),
563 NegativeToken::MismatchedKid,
564 );
565 let parts = jwt_parts(&value);
566 let header = decode_object_segment(parts[0]);
567 let payload = decode_object_segment(parts[1]);
568
569 assert_eq!(parts.len(), 3);
570 assert_eq!(header["alg"], "RS256");
571 assert_eq!(header["typ"], "JWT");
572 assert_eq!(header["kid"], "unknown-kid");
573 assert_eq!(payload["sub"], "svc");
574 assert_eq!(payload["kid"], "expected-kid");
575 assert_ne!(header["kid"], payload["kid"]);
576 }
577
578 #[test]
579 fn negative_not_yet_valid_keeps_future_window_and_subject() {
580 let value = generate_negative_token(
581 "svc",
582 TokenKind::OAuthAccessToken,
583 Seed::new([37u8; 32]),
584 NegativeToken::NotYetValidClaims,
585 );
586 let parts = jwt_parts(&value);
587 let header = decode_object_segment(parts[0]);
588 let payload = decode_object_segment(parts[1]);
589
590 assert_eq!(parts.len(), 3);
591 assert_eq!(header["alg"], "RS256");
592 assert_eq!(payload["sub"], "svc");
593 assert_eq!(payload["nbf"], 4_000_000_000u64);
594 assert_eq!(payload["exp"], 4_100_000_000u64);
595 }
596
597 fn jwt_parts(value: &str) -> Vec<&str> {
598 value.split('.').collect()
599 }
600
601 fn decode_object_segment(segment: &str) -> serde_json::Map<String, serde_json::Value> {
602 decode_json_segment(segment)
603 .as_object()
604 .expect("JWT segment should decode to an object")
605 .clone()
606 }
607
608 fn decode_json_segment(segment: &str) -> serde_json::Value {
609 let bytes = URL_SAFE_NO_PAD.decode(segment).expect("decode JWT segment");
610 serde_json::from_slice(&bytes).expect("parse JWT segment JSON")
611 }
612
613 #[test]
614 fn random_base62_length_and_charset() {
615 let value = random_base62(Seed::new([17u8; 32]), 64);
616 assert_eq!(value.len(), 64);
617 assert!(value.chars().all(|c| c.is_ascii_alphanumeric()));
618 }
619
620 proptest! {
621 #[test]
622 fn api_key_same_seed_stable(seed in any::<[u8; 32]>()) {
623 let a = generate_api_key(Seed::new(seed));
624 let b = generate_api_key(Seed::new(seed));
625 prop_assert_eq!(a, b);
626 }
627
628 #[test]
629 fn bearer_token_always_43_chars(seed in any::<[u8; 32]>()) {
630 let token = generate_bearer_token(Seed::new(seed));
631 prop_assert_eq!(token.len(), 43);
632 }
633
634 #[test]
635 fn oauth_has_three_segments(seed in any::<[u8; 32]>(), label in "[a-z0-9_-]{1,16}") {
636 let token = generate_oauth_access_token(&label, Seed::new(seed));
637 prop_assert_eq!(token.matches('.').count(), 2);
638 }
639 }
640}