Skip to main content

uselesskey_token/srp/
shape.rs

1//! Token shape generation primitives for test fixtures.
2//!
3//! Generates realistic-looking API keys, bearer tokens, OAuth access tokens,
4//! and scanner-safe negative token shapes from deterministic seed material.
5//!
6//! # Examples
7//!
8//! ```
9//! use uselesskey_token::srp::shape::{
10//!     NegativeToken, authorization_scheme, generate_negative_token, generate_token, TokenKind,
11//! };
12//! use uselesskey_core::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//! // Generate a scanner-safe negative token for validator error paths.
29//! let expired = generate_negative_token(
30//!     "my-service",
31//!     TokenKind::OAuthAccessToken,
32//!     seed,
33//!     NegativeToken::ExpiredClaims,
34//! );
35//! assert_eq!(expired.matches('.').count(), 2);
36//! ```
37
38use 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
48/// Prefix used for API-key token fixtures.
49pub const API_KEY_PREFIX: &str = "uk_test_";
50
51/// Number of random base62 characters used in API-key fixtures.
52pub const API_KEY_RANDOM_LEN: usize = 32;
53
54/// Number of raw random bytes in opaque bearer tokens.
55pub const BEARER_RANDOM_BYTES: usize = 32;
56
57/// Number of random bytes used for OAuth `jti`.
58pub const OAUTH_JTI_BYTES: usize = 16;
59
60/// Number of random bytes used for OAuth signature-like segment.
61pub 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
67/// Token shape kind.
68pub use super::spec::TokenSpec as TokenKind;
69
70/// Negative token shape variants for downstream parser and validator tests.
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum NegativeToken {
73    /// Emit a JWT-like value with the wrong number of dot-separated segments.
74    MalformedJwtSegmentCount,
75    /// Replace one JWT segment with scanner-safe invalid base64url text.
76    BadBase64UrlSegment,
77    /// Encode a JWT header that is JSON, but not a header object.
78    InvalidJwtHeaderShape,
79    /// Remove `alg` from the JWT header.
80    MissingAlg,
81    /// Set the JWT header algorithm to `none`.
82    AlgNone,
83    /// Omit `kid` from the JWT header while keeping key-selection context.
84    MissingKid,
85    /// Emit different `kid` values in the header and payload.
86    MismatchedKid,
87    /// Set an already-expired `exp` claim.
88    ExpiredClaims,
89    /// Set a future `nbf` claim.
90    NotYetValidClaims,
91    /// Replace the expected issuer claim.
92    BadIssuer,
93    /// Replace the expected audience claim.
94    BadAudience,
95    /// Emit a bearer-like token that is not valid base64url.
96    MalformedBearer,
97    /// Emit an API-key near miss that is close to, but not, `uk_test_`.
98    NearMissApiKey,
99}
100
101impl NegativeToken {
102    /// Stable cache/disposition name for this negative token variant.
103    pub const fn variant_name(&self) -> &'static str {
104        match self {
105            Self::MalformedJwtSegmentCount => "malformed_jwt_segment_count",
106            Self::BadBase64UrlSegment => "bad_base64url_segment",
107            Self::InvalidJwtHeaderShape => "invalid_jwt_header_shape",
108            Self::MissingAlg => "missing_alg",
109            Self::AlgNone => "alg_none",
110            Self::MissingKid => "missing_kid",
111            Self::MismatchedKid => "mismatched_kid",
112            Self::ExpiredClaims => "expired_claims",
113            Self::NotYetValidClaims => "not_yet_valid_claims",
114            Self::BadIssuer => "bad_issuer",
115            Self::BadAudience => "bad_audience",
116            Self::MalformedBearer => "malformed_bearer",
117            Self::NearMissApiKey => "near_miss_api_key",
118        }
119    }
120
121    /// Stable taxonomy identifier for docs, manifests, and receipts.
122    pub const fn stable_id(&self) -> &'static str {
123        match self {
124            Self::MalformedJwtSegmentCount => "jwt_bad_segment_count",
125            Self::BadBase64UrlSegment => "jwt_malformed_base64url",
126            Self::InvalidJwtHeaderShape => "jwt_invalid_header_shape",
127            Self::MissingAlg => "jwt_missing_alg",
128            Self::AlgNone => "jwt_alg_none",
129            Self::MissingKid => "jwt_missing_kid",
130            Self::MismatchedKid => "jwt_mismatched_kid",
131            Self::ExpiredClaims => "jwt_expired",
132            Self::NotYetValidClaims => "jwt_not_yet_valid",
133            Self::BadIssuer => "jwt_bad_issuer",
134            Self::BadAudience => "jwt_bad_audience",
135            Self::MalformedBearer => "token_malformed_bearer",
136            Self::NearMissApiKey => "token_near_miss",
137        }
138    }
139}
140
141/// Generate a token value for the provided shape kind.
142pub fn generate_token(label: &str, kind: TokenKind, seed: Seed) -> String {
143    match kind {
144        TokenKind::ApiKey => generate_api_key(seed),
145        TokenKind::Bearer => generate_bearer_token(seed),
146        TokenKind::OAuthAccessToken => generate_oauth_access_token(label, seed),
147    }
148}
149
150/// Generate a scanner-safe negative token value for parser and validator tests.
151pub fn generate_negative_token(
152    label: &str,
153    kind: TokenKind,
154    seed: Seed,
155    variant: NegativeToken,
156) -> String {
157    match variant {
158        NegativeToken::MalformedJwtSegmentCount => malformed_jwt_segment_count(label, seed),
159        NegativeToken::BadBase64UrlSegment => bad_base64url_segment(label, seed),
160        NegativeToken::InvalidJwtHeaderShape => invalid_jwt_header_shape(label, seed),
161        NegativeToken::MissingAlg => missing_alg(label, seed),
162        NegativeToken::AlgNone => alg_none(label, seed),
163        NegativeToken::MissingKid => missing_kid(label, seed),
164        NegativeToken::MismatchedKid => mismatched_kid(label, seed),
165        NegativeToken::ExpiredClaims => token_with_payload_claim(label, seed, "exp", json!(1u64)),
166        NegativeToken::NotYetValidClaims => not_yet_valid_claims(label, seed),
167        NegativeToken::BadIssuer => {
168            token_with_payload_claim(label, seed, "iss", json!("wrong-issuer"))
169        }
170        NegativeToken::BadAudience => {
171            token_with_payload_claim(label, seed, "aud", json!("wrong-audience"))
172        }
173        NegativeToken::MalformedBearer => malformed_bearer(seed),
174        NegativeToken::NearMissApiKey => near_miss_api_key(kind, seed),
175    }
176}
177
178/// Return HTTP authorization scheme for the token kind.
179pub fn authorization_scheme(kind: TokenKind) -> &'static str {
180    kind.authorization_scheme()
181}
182
183/// Generate an API-key style token fixture (`uk_test_<base62>`).
184pub fn generate_api_key(seed: Seed) -> String {
185    let mut out = String::from(API_KEY_PREFIX);
186    out.push_str(&random_base62(seed, API_KEY_RANDOM_LEN));
187    out
188}
189
190/// Generate an opaque bearer token fixture (base64url of 32 random bytes).
191pub fn generate_bearer_token(seed: Seed) -> String {
192    let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
193    let mut bytes = [0u8; BEARER_RANDOM_BYTES];
194    rng.fill_bytes(&mut bytes);
195    URL_SAFE_NO_PAD.encode(bytes)
196}
197
198/// Generate an OAuth access token fixture in JWT shape (`header.payload.signature`).
199pub fn generate_oauth_access_token(label: &str, seed: Seed) -> String {
200    let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
201    let mut rng = ChaCha20Rng::from_seed(*seed.bytes());
202
203    let mut jti = [0u8; OAUTH_JTI_BYTES];
204    rng.fill_bytes(&mut jti);
205
206    let payload = json!({
207        "iss": "uselesskey",
208        "sub": label,
209        "aud": "tests",
210        "scope": "fixture.read",
211        "jti": URL_SAFE_NO_PAD.encode(jti),
212        "exp": 2_000_000_000u64,
213    });
214    let payload_json = serde_json::to_vec(&payload).expect("payload JSON");
215    let payload_segment = URL_SAFE_NO_PAD.encode(payload_json);
216
217    let mut signature = [0u8; OAUTH_SIGNATURE_BYTES];
218    rng.fill_bytes(&mut signature);
219    let signature_segment = URL_SAFE_NO_PAD.encode(signature);
220
221    format!("{header}.{payload_segment}.{signature_segment}")
222}
223
224fn malformed_jwt_segment_count(label: &str, seed: Seed) -> String {
225    let parts = JwtParts::generated(label, seed);
226    format!("{}.{}", parts.header, parts.payload)
227}
228
229fn bad_base64url_segment(label: &str, seed: Seed) -> String {
230    JwtParts::generated(label, seed)
231        .with_payload_segment(SCANNER_SAFE_INVALID_TOKEN_SEGMENT.to_string())
232        .into_token()
233}
234
235fn invalid_jwt_header_shape(label: &str, seed: Seed) -> String {
236    JwtParts::generated(label, seed)
237        .with_header_segment(encode_json(&json!(["not-a-header"])))
238        .into_token()
239}
240
241fn missing_alg(label: &str, seed: Seed) -> String {
242    JwtParts::generated(label, seed)
243        .with_header_segment(encode_json(&json!({ "typ": "JWT" })))
244        .into_token()
245}
246
247fn alg_none(label: &str, seed: Seed) -> String {
248    token_with_header_claim(label, seed, "alg", json!("none"))
249}
250
251fn missing_kid(label: &str, seed: Seed) -> String {
252    let parts = JwtParts::generated(label, seed);
253    let mut payload = parts.payload_object();
254    payload.insert("kid".to_string(), json!("expected-kid"));
255
256    parts.with_payload_object(payload).into_token()
257}
258
259fn mismatched_kid(label: &str, seed: Seed) -> String {
260    let mut header = jwt_header();
261    header.insert("kid".to_string(), json!("unknown-kid"));
262
263    let parts = JwtParts::generated(label, seed);
264    let mut payload = parts.payload_object();
265    payload.insert("kid".to_string(), json!("expected-kid"));
266
267    parts
268        .with_header_object(header)
269        .with_payload_object(payload)
270        .into_token()
271}
272
273fn not_yet_valid_claims(label: &str, seed: Seed) -> String {
274    let parts = JwtParts::generated(label, seed);
275    let mut claims = parts.payload_object();
276    claims.insert("nbf".to_string(), json!(4_000_000_000u64));
277    claims.insert("exp".to_string(), json!(4_100_000_000u64));
278
279    parts.with_payload_object(claims).into_token()
280}
281
282fn token_with_header_claim(label: &str, seed: Seed, claim: &str, value: Value) -> String {
283    let mut header = jwt_header();
284    header.insert(claim.to_string(), value);
285
286    JwtParts::generated(label, seed)
287        .with_header_object(header)
288        .into_token()
289}
290
291fn token_with_payload_claim(label: &str, seed: Seed, claim: &str, value: Value) -> String {
292    let parts = JwtParts::generated(label, seed);
293    let mut claims = parts.payload_object();
294    claims.insert(claim.to_string(), value);
295
296    parts.with_payload_object(claims).into_token()
297}
298
299fn malformed_bearer(seed: Seed) -> String {
300    let mut value = generate_bearer_token(seed);
301    value.replace_range(0..1, "!");
302    value
303}
304
305fn near_miss_api_key(_kind: TokenKind, seed: Seed) -> String {
306    let valid = generate_api_key(seed);
307    let suffix = valid.strip_prefix(API_KEY_PREFIX).unwrap_or(&valid);
308
309    format!("{NEAR_MISS_API_KEY_PREFIX}{suffix}")
310}
311
312#[derive(Debug)]
313struct JwtParts {
314    header: String,
315    payload: String,
316    signature: String,
317}
318
319impl JwtParts {
320    fn generated(label: &str, seed: Seed) -> Self {
321        let token = generate_oauth_access_token(label, seed);
322        let mut parts = token.split('.');
323        let header = parts.next().expect("JWT header segment").to_string();
324        let payload = parts.next().expect("JWT payload segment").to_string();
325        let signature = parts.next().expect("JWT signature segment").to_string();
326        assert!(
327            parts.next().is_none(),
328            "JWT should have exactly three segments"
329        );
330
331        Self {
332            header,
333            payload,
334            signature,
335        }
336    }
337
338    fn with_header_segment(mut self, header: String) -> Self {
339        self.header = header;
340        self
341    }
342
343    fn with_payload_segment(mut self, payload: String) -> Self {
344        self.payload = payload;
345        self
346    }
347
348    fn with_header_object(self, header: Map<String, Value>) -> Self {
349        self.with_header_segment(encode_object(&header))
350    }
351
352    fn with_payload_object(self, payload: Map<String, Value>) -> Self {
353        self.with_payload_segment(encode_object(&payload))
354    }
355
356    fn payload_object(&self) -> Map<String, Value> {
357        decode_object(&self.payload)
358    }
359
360    fn into_token(self) -> String {
361        format!("{}.{}.{}", self.header, self.payload, self.signature)
362    }
363}
364
365fn jwt_header() -> Map<String, Value> {
366    Map::from_iter([
367        ("alg".to_string(), json!("RS256")),
368        ("typ".to_string(), json!("JWT")),
369    ])
370}
371
372fn decode_object(segment: &str) -> Map<String, Value> {
373    let bytes = URL_SAFE_NO_PAD
374        .decode(segment)
375        .expect("decode generated JWT JSON segment");
376    let value: Value = serde_json::from_slice(&bytes).expect("parse generated JWT JSON segment");
377    value
378        .as_object()
379        .expect("generated JWT JSON segment should be an object")
380        .clone()
381}
382
383fn encode_object(value: &Map<String, Value>) -> String {
384    encode_json(&Value::Object(value.clone()))
385}
386
387fn encode_json(value: &Value) -> String {
388    let json = serde_json::to_vec(value).expect("serialize token JSON");
389    URL_SAFE_NO_PAD.encode(json)
390}
391
392#[cfg(test)]
393mod tests {
394    use base64::Engine as _;
395    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
396    use proptest::prelude::*;
397    use uselesskey_core::Seed;
398
399    use super::{
400        API_KEY_PREFIX, API_KEY_RANDOM_LEN, BEARER_RANDOM_BYTES, NEAR_MISS_API_KEY_PREFIX,
401        NegativeToken, SCANNER_SAFE_INVALID_TOKEN_SEGMENT, TokenKind, authorization_scheme,
402        generate_api_key, generate_bearer_token, generate_negative_token,
403        generate_oauth_access_token, generate_token, random_base62,
404    };
405
406    #[test]
407    fn api_key_shape_is_stable() {
408        let value = generate_api_key(Seed::new([7u8; 32]));
409
410        assert!(value.starts_with(API_KEY_PREFIX));
411        let suffix = value
412            .strip_prefix(API_KEY_PREFIX)
413            .expect("API key prefix should be present");
414        assert_eq!(suffix.len(), API_KEY_RANDOM_LEN);
415        assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
416    }
417
418    #[test]
419    fn bearer_shape_decodes_to_32_bytes() {
420        let value = generate_bearer_token(Seed::new([9u8; 32]));
421        let decoded = URL_SAFE_NO_PAD.decode(value).expect("base64url decode");
422        assert_eq!(decoded.len(), BEARER_RANDOM_BYTES);
423    }
424
425    #[test]
426    fn oauth_shape_has_three_segments_and_subject() {
427        let value = generate_oauth_access_token("issuer", Seed::new([11u8; 32]));
428        let parts: Vec<&str> = value.split('.').collect();
429        assert_eq!(parts.len(), 3);
430
431        let payload = URL_SAFE_NO_PAD
432            .decode(parts[1])
433            .expect("decode payload segment");
434        let json: serde_json::Value = serde_json::from_slice(&payload).expect("parse payload");
435        assert_eq!(json["sub"], "issuer");
436        assert_eq!(json["iss"], "uselesskey");
437    }
438
439    #[test]
440    fn authorization_scheme_matches_kind() {
441        assert_eq!(authorization_scheme(TokenKind::ApiKey), "ApiKey");
442        assert_eq!(authorization_scheme(TokenKind::Bearer), "Bearer");
443        assert_eq!(authorization_scheme(TokenKind::OAuthAccessToken), "Bearer");
444    }
445
446    #[test]
447    fn generate_token_varies_by_kind() {
448        let seed = [13u8; 32];
449
450        let api = generate_token("label", TokenKind::ApiKey, Seed::new(seed));
451        let bearer = generate_token("label", TokenKind::Bearer, Seed::new(seed));
452        let oauth = generate_token("label", TokenKind::OAuthAccessToken, Seed::new(seed));
453
454        assert_ne!(api, bearer);
455        assert_ne!(api, oauth);
456        assert_ne!(bearer, oauth);
457    }
458
459    #[test]
460    fn negative_token_variant_names_are_stable() {
461        assert_eq!(
462            NegativeToken::MalformedJwtSegmentCount.variant_name(),
463            "malformed_jwt_segment_count"
464        );
465        assert_eq!(
466            NegativeToken::BadBase64UrlSegment.variant_name(),
467            "bad_base64url_segment"
468        );
469        assert_eq!(
470            NegativeToken::InvalidJwtHeaderShape.variant_name(),
471            "invalid_jwt_header_shape"
472        );
473        assert_eq!(NegativeToken::MissingAlg.variant_name(), "missing_alg");
474        assert_eq!(NegativeToken::AlgNone.variant_name(), "alg_none");
475        assert_eq!(NegativeToken::MissingKid.variant_name(), "missing_kid");
476        assert_eq!(
477            NegativeToken::MismatchedKid.variant_name(),
478            "mismatched_kid"
479        );
480        assert_eq!(
481            NegativeToken::ExpiredClaims.variant_name(),
482            "expired_claims"
483        );
484        assert_eq!(
485            NegativeToken::NotYetValidClaims.variant_name(),
486            "not_yet_valid_claims"
487        );
488        assert_eq!(NegativeToken::BadIssuer.variant_name(), "bad_issuer");
489        assert_eq!(NegativeToken::BadAudience.variant_name(), "bad_audience");
490        assert_eq!(
491            NegativeToken::MalformedBearer.variant_name(),
492            "malformed_bearer"
493        );
494        assert_eq!(
495            NegativeToken::NearMissApiKey.variant_name(),
496            "near_miss_api_key"
497        );
498    }
499
500    #[test]
501    fn negative_token_stable_ids_match_taxonomy() {
502        assert_eq!(
503            NegativeToken::MalformedJwtSegmentCount.stable_id(),
504            "jwt_bad_segment_count"
505        );
506        assert_eq!(
507            NegativeToken::BadBase64UrlSegment.stable_id(),
508            "jwt_malformed_base64url"
509        );
510        assert_eq!(
511            NegativeToken::InvalidJwtHeaderShape.stable_id(),
512            "jwt_invalid_header_shape"
513        );
514        assert_eq!(NegativeToken::MissingAlg.stable_id(), "jwt_missing_alg");
515        assert_eq!(NegativeToken::AlgNone.stable_id(), "jwt_alg_none");
516        assert_eq!(NegativeToken::MissingKid.stable_id(), "jwt_missing_kid");
517        assert_eq!(
518            NegativeToken::MismatchedKid.stable_id(),
519            "jwt_mismatched_kid"
520        );
521        assert_eq!(NegativeToken::ExpiredClaims.stable_id(), "jwt_expired");
522        assert_eq!(
523            NegativeToken::NotYetValidClaims.stable_id(),
524            "jwt_not_yet_valid"
525        );
526        assert_eq!(NegativeToken::BadIssuer.stable_id(), "jwt_bad_issuer");
527        assert_eq!(NegativeToken::BadAudience.stable_id(), "jwt_bad_audience");
528        assert_eq!(
529            NegativeToken::MalformedBearer.stable_id(),
530            "token_malformed_bearer"
531        );
532        assert_eq!(NegativeToken::NearMissApiKey.stable_id(), "token_near_miss");
533    }
534
535    #[test]
536    fn negative_api_key_near_miss_is_scanner_safe() {
537        let value = generate_negative_token(
538            "svc",
539            TokenKind::ApiKey,
540            Seed::new([19u8; 32]),
541            NegativeToken::NearMissApiKey,
542        );
543
544        assert!(value.starts_with(NEAR_MISS_API_KEY_PREFIX));
545        assert!(!value.starts_with(API_KEY_PREFIX));
546        assert_eq!(
547            value.len(),
548            NEAR_MISS_API_KEY_PREFIX.len() + API_KEY_RANDOM_LEN
549        );
550    }
551
552    #[test]
553    fn negative_malformed_bearer_is_not_base64url() {
554        let value = generate_negative_token(
555            "svc",
556            TokenKind::Bearer,
557            Seed::new([23u8; 32]),
558            NegativeToken::MalformedBearer,
559        );
560
561        assert_ne!(value, SCANNER_SAFE_INVALID_TOKEN_SEGMENT);
562        assert!(value.contains('!'));
563        assert_eq!(value.len(), 43);
564        assert!(URL_SAFE_NO_PAD.decode(value).is_err());
565    }
566
567    #[test]
568    fn negative_jwt_segment_count_keeps_two_decodable_segments() {
569        let value = generate_negative_token(
570            "svc",
571            TokenKind::OAuthAccessToken,
572            Seed::new([31u8; 32]),
573            NegativeToken::MalformedJwtSegmentCount,
574        );
575        let parts = jwt_parts(&value);
576
577        assert_eq!(parts.len(), 2);
578        assert_eq!(decode_object_segment(parts[0])["alg"], "RS256");
579        assert_eq!(decode_object_segment(parts[0])["typ"], "JWT");
580        assert_eq!(decode_object_segment(parts[1])["sub"], "svc");
581    }
582
583    #[test]
584    fn negative_bad_base64url_replaces_payload_only() {
585        let value = generate_negative_token(
586            "svc",
587            TokenKind::OAuthAccessToken,
588            Seed::new([32u8; 32]),
589            NegativeToken::BadBase64UrlSegment,
590        );
591        let parts = jwt_parts(&value);
592
593        assert_eq!(parts.len(), 3);
594        assert_eq!(decode_object_segment(parts[0])["alg"], "RS256");
595        assert_eq!(parts[1], SCANNER_SAFE_INVALID_TOKEN_SEGMENT);
596        assert!(URL_SAFE_NO_PAD.decode(parts[1]).is_err());
597        assert!(!parts[2].is_empty());
598    }
599
600    #[test]
601    fn negative_invalid_header_shape_keeps_payload_and_signature() {
602        let value = generate_negative_token(
603            "svc",
604            TokenKind::OAuthAccessToken,
605            Seed::new([33u8; 32]),
606            NegativeToken::InvalidJwtHeaderShape,
607        );
608        let parts = jwt_parts(&value);
609
610        assert_eq!(parts.len(), 3);
611        assert_eq!(
612            decode_json_segment(parts[0]),
613            serde_json::json!(["not-a-header"])
614        );
615        assert_eq!(decode_object_segment(parts[1])["sub"], "svc");
616        assert!(!parts[2].is_empty());
617    }
618
619    #[test]
620    fn negative_missing_alg_keeps_typ_and_claims() {
621        let value = generate_negative_token(
622            "svc",
623            TokenKind::OAuthAccessToken,
624            Seed::new([34u8; 32]),
625            NegativeToken::MissingAlg,
626        );
627        let parts = jwt_parts(&value);
628        let header = decode_object_segment(parts[0]);
629
630        assert_eq!(parts.len(), 3);
631        assert!(!header.contains_key("alg"));
632        assert_eq!(header["typ"], "JWT");
633        assert_eq!(decode_object_segment(parts[1])["sub"], "svc");
634    }
635
636    #[test]
637    fn negative_alg_none_changes_alg_only() {
638        let value = generate_negative_token(
639            "svc",
640            TokenKind::OAuthAccessToken,
641            Seed::new([35u8; 32]),
642            NegativeToken::AlgNone,
643        );
644        let parts = jwt_parts(&value);
645        let header = decode_object_segment(parts[0]);
646
647        assert_eq!(parts.len(), 3);
648        assert_eq!(header["alg"], "none");
649        assert_eq!(header["typ"], "JWT");
650        assert_eq!(decode_object_segment(parts[1])["sub"], "svc");
651    }
652
653    #[test]
654    fn negative_mismatched_kid_keeps_header_and_payload_context() {
655        let value = generate_negative_token(
656            "svc",
657            TokenKind::OAuthAccessToken,
658            Seed::new([36u8; 32]),
659            NegativeToken::MismatchedKid,
660        );
661        let parts = jwt_parts(&value);
662        let header = decode_object_segment(parts[0]);
663        let payload = decode_object_segment(parts[1]);
664
665        assert_eq!(parts.len(), 3);
666        assert_eq!(header["alg"], "RS256");
667        assert_eq!(header["typ"], "JWT");
668        assert_eq!(header["kid"], "unknown-kid");
669        assert_eq!(payload["sub"], "svc");
670        assert_eq!(payload["kid"], "expected-kid");
671        assert_ne!(header["kid"], payload["kid"]);
672    }
673
674    #[test]
675    fn negative_missing_kid_keeps_payload_key_context() {
676        let value = generate_negative_token(
677            "svc",
678            TokenKind::OAuthAccessToken,
679            Seed::new([41u8; 32]),
680            NegativeToken::MissingKid,
681        );
682        let parts = jwt_parts(&value);
683        let header = decode_object_segment(parts[0]);
684        let payload = decode_object_segment(parts[1]);
685
686        assert_eq!(parts.len(), 3);
687        assert_eq!(header["alg"], "RS256");
688        assert_eq!(header["typ"], "JWT");
689        assert!(header.get("kid").is_none());
690        assert_eq!(payload["sub"], "svc");
691        assert_eq!(payload["kid"], "expected-kid");
692    }
693
694    #[test]
695    fn negative_not_yet_valid_keeps_future_window_and_subject() {
696        let value = generate_negative_token(
697            "svc",
698            TokenKind::OAuthAccessToken,
699            Seed::new([37u8; 32]),
700            NegativeToken::NotYetValidClaims,
701        );
702        let parts = jwt_parts(&value);
703        let header = decode_object_segment(parts[0]);
704        let payload = decode_object_segment(parts[1]);
705
706        assert_eq!(parts.len(), 3);
707        assert_eq!(header["alg"], "RS256");
708        assert_eq!(payload["sub"], "svc");
709        assert_eq!(payload["nbf"], 4_000_000_000u64);
710        assert_eq!(payload["exp"], 4_100_000_000u64);
711    }
712
713    #[test]
714    fn negative_expired_claims_only_replaces_expiration() {
715        let seed = Seed::new([38u8; 32]);
716        let original = generate_oauth_access_token("svc", seed);
717        let value = generate_negative_token(
718            "svc",
719            TokenKind::OAuthAccessToken,
720            seed,
721            NegativeToken::ExpiredClaims,
722        );
723        let original_parts = jwt_parts(&original);
724        let parts = jwt_parts(&value);
725        let payload = decode_object_segment(parts[1]);
726
727        assert_eq!(parts.len(), 3);
728        assert_eq!(parts[0], original_parts[0]);
729        assert_eq!(parts[2], original_parts[2]);
730        assert_eq!(payload["iss"], "uselesskey");
731        assert_eq!(payload["sub"], "svc");
732        assert_eq!(payload["aud"], "tests");
733        assert_eq!(payload["exp"], 1u64);
734    }
735
736    #[test]
737    fn negative_bad_issuer_and_audience_preserve_other_claims() {
738        let seed = Seed::new([39u8; 32]);
739        let issuer = generate_negative_token(
740            "svc",
741            TokenKind::OAuthAccessToken,
742            seed,
743            NegativeToken::BadIssuer,
744        );
745        let audience = generate_negative_token(
746            "svc",
747            TokenKind::OAuthAccessToken,
748            seed,
749            NegativeToken::BadAudience,
750        );
751        let issuer_payload = decode_object_segment(jwt_parts(&issuer)[1]);
752        let audience_payload = decode_object_segment(jwt_parts(&audience)[1]);
753
754        assert_eq!(issuer_payload["iss"], "wrong-issuer");
755        assert_eq!(issuer_payload["aud"], "tests");
756        assert_eq!(issuer_payload["sub"], "svc");
757        assert_eq!(audience_payload["iss"], "uselesskey");
758        assert_eq!(audience_payload["aud"], "wrong-audience");
759        assert_eq!(audience_payload["sub"], "svc");
760    }
761
762    #[test]
763    fn near_miss_api_key_uses_same_suffix_for_all_kinds() {
764        let seed = Seed::new([40u8; 32]);
765        let api = generate_negative_token(
766            "svc",
767            TokenKind::ApiKey,
768            seed,
769            NegativeToken::NearMissApiKey,
770        );
771        let bearer = generate_negative_token(
772            "svc",
773            TokenKind::Bearer,
774            seed,
775            NegativeToken::NearMissApiKey,
776        );
777        let oauth = generate_negative_token(
778            "svc",
779            TokenKind::OAuthAccessToken,
780            seed,
781            NegativeToken::NearMissApiKey,
782        );
783
784        assert_eq!(api, bearer);
785        assert_eq!(api, oauth);
786        assert_eq!(
787            api.strip_prefix(NEAR_MISS_API_KEY_PREFIX),
788            generate_api_key(seed).strip_prefix(API_KEY_PREFIX)
789        );
790    }
791
792    fn jwt_parts(value: &str) -> Vec<&str> {
793        value.split('.').collect()
794    }
795
796    fn decode_object_segment(segment: &str) -> serde_json::Map<String, serde_json::Value> {
797        decode_json_segment(segment)
798            .as_object()
799            .expect("JWT segment should decode to an object")
800            .clone()
801    }
802
803    fn decode_json_segment(segment: &str) -> serde_json::Value {
804        let bytes = URL_SAFE_NO_PAD.decode(segment).expect("decode JWT segment");
805        serde_json::from_slice(&bytes).expect("parse JWT segment JSON")
806    }
807
808    #[test]
809    fn random_base62_length_and_charset() {
810        let value = random_base62(Seed::new([17u8; 32]), 64);
811        assert_eq!(value.len(), 64);
812        assert!(value.chars().all(|c| c.is_ascii_alphanumeric()));
813    }
814
815    proptest! {
816        #[test]
817        fn api_key_same_seed_stable(seed in any::<[u8; 32]>()) {
818            let a = generate_api_key(Seed::new(seed));
819            let b = generate_api_key(Seed::new(seed));
820            prop_assert_eq!(a, b);
821        }
822
823        #[test]
824        fn bearer_token_always_43_chars(seed in any::<[u8; 32]>()) {
825            let token = generate_bearer_token(Seed::new(seed));
826            prop_assert_eq!(token.len(), 43);
827        }
828
829        #[test]
830        fn oauth_has_three_segments(seed in any::<[u8; 32]>(), label in "[a-z0-9_-]{1,16}") {
831            let token = generate_oauth_access_token(&label, Seed::new(seed));
832            prop_assert_eq!(token.matches('.').count(), 2);
833        }
834    }
835}