Skip to main content

pas_external/
token.rs

1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use pasetors::claims::ClaimsValidationRules;
4use pasetors::keys::AsymmetricPublicKey;
5use pasetors::token::UntrustedToken;
6use pasetors::version4::V4;
7use pasetors::{Public, public};
8use serde_json::Value as JsonValue;
9use time::OffsetDateTime;
10use time::format_description::well_known::Rfc3339;
11
12use crate::error::{Error, TokenError};
13use crate::types::KeyId;
14
15const TOKEN_PREFIX: &str = "v4.public.";
16const ED25519_PUBLIC_KEY_SIZE: usize = 32;
17
18/// Ed25519 public key (32 bytes) for token verification.
19///
20/// Independent implementation from `pas-token` — only needs hex parsing
21/// and PASETO verification, no PASERK key ID computation.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct PublicKey {
24    bytes: [u8; ED25519_PUBLIC_KEY_SIZE],
25}
26
27impl PublicKey {
28    /// Get the raw key bytes.
29    #[must_use]
30    pub fn as_bytes(&self) -> &[u8; ED25519_PUBLIC_KEY_SIZE] {
31        &self.bytes
32    }
33}
34
35impl TryFrom<&crate::well_known::WellKnownPasetoKey> for PublicKey {
36    type Error = Error;
37
38    fn try_from(key: &crate::well_known::WellKnownPasetoKey) -> Result<Self, Error> {
39        parse_public_key_hex(&key.public_key_hex)
40    }
41}
42
43/// Parses a hex-encoded Ed25519 public key (32 bytes) into a `PublicKey`.
44///
45/// # Errors
46///
47/// Returns `Error::Token` if the hex is invalid or the key length is not 32 bytes.
48pub fn parse_public_key_hex(public_key_hex: &str) -> Result<PublicKey, Error> {
49    let bytes: [u8; ED25519_PUBLIC_KEY_SIZE] = hex::decode(public_key_hex)
50        .map_err(|e| TokenError::VerificationFailed(format!("invalid hex: {e}")))?
51        .try_into()
52        .map_err(|v: Vec<u8>| {
53            TokenError::VerificationFailed(format!(
54                "invalid key length: expected {ED25519_PUBLIC_KEY_SIZE}, got {}",
55                v.len()
56            ))
57        })?;
58    Ok(PublicKey { bytes })
59}
60
61/// Verified claims from a PASETO token.
62///
63/// After successful verification, `iss` and `aud` are stored as owned fields.
64/// Access them via typed accessors instead of raw JSON lookup.
65#[derive(Debug, Clone)]
66pub struct VerifiedClaims {
67    iss: String,
68    aud: String,
69    inner: JsonValue,
70}
71
72impl VerifiedClaims {
73    /// Issuer claim (guaranteed present after verification).
74    #[must_use]
75    pub fn iss(&self) -> &str {
76        &self.iss
77    }
78
79    /// Audience claim (guaranteed present after verification).
80    #[must_use]
81    pub fn aud(&self) -> &str {
82        &self.aud
83    }
84
85    /// Subject claim.
86    #[must_use]
87    pub fn sub(&self) -> Option<&str> {
88        self.inner.get("sub").and_then(|v| v.as_str())
89    }
90
91    /// Gets a claim value by key (for dynamic/extra claims).
92    #[must_use]
93    pub fn get_claim(&self, key: &str) -> Option<&JsonValue> {
94        self.inner.get(key)
95    }
96
97    /// Gets the inner JSON value.
98    #[must_use]
99    pub fn as_json(&self) -> &JsonValue {
100        &self.inner
101    }
102}
103
104/// Verifies a PASETO v4.public access token.
105///
106/// # Errors
107///
108/// Returns `Error::Token` if the token format is invalid, the signature
109/// verification fails, or the `iss`/`aud` claims do not match the expected values.
110pub fn verify_v4_public_access_token(
111    public_key: &PublicKey,
112    token_str: &str,
113    expected_issuer: &str,
114    expected_audience: &str,
115) -> Result<VerifiedClaims, Error> {
116    if !token_str.starts_with(TOKEN_PREFIX) {
117        return Err(TokenError::InvalidFormat.into());
118    }
119
120    let pk = AsymmetricPublicKey::<V4>::from(&public_key.bytes[..])
121        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
122
123    let validation_rules = ClaimsValidationRules::new();
124
125    let untrusted_token = UntrustedToken::<Public, V4>::try_from(token_str)
126        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
127
128    let trusted_token = public::verify(&pk, &untrusted_token, &validation_rules, None, None)
129        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
130
131    let payload = trusted_token
132        .payload_claims()
133        .ok_or(TokenError::MissingPayload)?;
134    let payload_str = payload
135        .to_string()
136        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
137    let json_value: JsonValue = serde_json::from_str(&payload_str)
138        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
139
140    // Reject expired tokens when `exp` claim is present
141    if let Some(exp_str) = json_value.get("exp").and_then(|v| v.as_str()) {
142        let exp_time = OffsetDateTime::parse(exp_str, &Rfc3339)
143            .map_err(|e| TokenError::VerificationFailed(format!("invalid exp format: {e}")))?;
144        if exp_time < OffsetDateTime::now_utc() {
145            return Err(TokenError::Expired.into());
146        }
147    }
148
149    // Reject tokens not yet valid (nbf = not before)
150    if let Some(nbf_str) = json_value.get("nbf").and_then(|v| v.as_str()) {
151        let nbf_time = OffsetDateTime::parse(nbf_str, &Rfc3339)
152            .map_err(|e| TokenError::VerificationFailed(format!("invalid nbf format: {e}")))?;
153        if nbf_time > OffsetDateTime::now_utc() {
154            return Err(TokenError::VerificationFailed("token not yet valid (nbf)".into()).into());
155        }
156    }
157
158    let iss = validate_claim(&json_value, "iss", expected_issuer)?;
159    let aud = validate_claim(&json_value, "aud", expected_audience)?;
160
161    Ok(VerifiedClaims {
162        iss,
163        aud,
164        inner: json_value,
165    })
166}
167
168/// Validates a JSON claim matches expected value; returns the actual value on success.
169fn validate_claim(
170    claims: &JsonValue,
171    key: &'static str,
172    expected: &str,
173) -> Result<String, TokenError> {
174    let actual = claims
175        .get(key)
176        .and_then(|v| v.as_str())
177        .ok_or(TokenError::MissingClaim(key))?;
178    if actual != expected {
179        return Err(TokenError::ClaimMismatch {
180            claim: key,
181            expected: expected.to_string(),
182            actual: actual.to_string(),
183        });
184    }
185    Ok(actual.to_string())
186}
187
188/// Extract key ID from a PASETO token **without verifying the signature**.
189///
190/// # ⚠️ Untrusted by design
191///
192/// The returned [`KeyId`] is read from the token footer **before** signature
193/// verification. An attacker can craft a token with any `kid` they want.
194/// **The only safe use of this value is to look up a public key in a trusted
195/// keyset and then verify the signature with it.** Do not use the returned
196/// `kid` for trust decisions, audit logging, metrics, or caching.
197///
198/// Most callers should use [`verify_v4_with_keyset`] instead, which performs
199/// the kid-lookup-then-verify dance atomically and never exposes the
200/// untrusted kid to caller code.
201///
202/// # Errors
203///
204/// Returns `Error::Token` if the token format is invalid or the footer
205/// does not contain a `kid` claim.
206pub fn extract_unverified_kid(token_str: &str) -> Result<KeyId, Error> {
207    let footer_bytes = extract_footer_from_token(token_str)?;
208    extract_kid_from_untrusted_footer(&footer_bytes)
209}
210
211/// Verify a PASETO v4.public token against a [`WellKnownPasetoDocument`].
212///
213/// Performs the safe sequence atomically:
214///
215/// 1. Extract the (untrusted) `kid` from the token footer.
216/// 2. Look up the matching key in `keyset.keys`. Reject if absent or
217///    `status: Revoked`.
218/// 3. Verify the signature with that key.
219/// 4. Validate `iss` and `aud` against the supplied expectations.
220///
221/// `Retiring` keys are accepted (they're still valid for verification, just
222/// not for new issuance). Only `Revoked` keys are refused.
223///
224/// # Errors
225///
226/// Returns `Error::Token` if any step fails. The error variant indicates
227/// which step (invalid format, missing kid, key not in set, key revoked,
228/// signature invalid, or claim mismatch).
229pub fn verify_v4_with_keyset(
230    keyset: &crate::well_known::WellKnownPasetoDocument,
231    token_str: &str,
232    expected_issuer: &str,
233    expected_audience: &str,
234) -> Result<VerifiedClaims, Error> {
235    let kid = extract_unverified_kid(token_str)?;
236
237    let key_meta = keyset
238        .keys
239        .iter()
240        .find(|k| k.kid == kid)
241        .ok_or_else(|| TokenError::VerificationFailed(format!("kid '{kid}' not in keyset")))?;
242
243    if key_meta.status == crate::well_known::WellKnownKeyStatus::Revoked {
244        return Err(TokenError::VerificationFailed(format!("kid '{kid}' is revoked")).into());
245    }
246
247    let public_key = PublicKey::try_from(key_meta)?;
248    verify_v4_public_access_token(&public_key, token_str, expected_issuer, expected_audience)
249}
250
251/// Extracts the key ID (kid) from an untrusted token's footer.
252pub(crate) fn extract_kid_from_untrusted_footer(footer_bytes: &[u8]) -> Result<KeyId, Error> {
253    if footer_bytes.is_empty() {
254        return Err(TokenError::MissingFooter.into());
255    }
256
257    let footer_str = std::str::from_utf8(footer_bytes).map_err(|_| TokenError::InvalidFooter)?;
258
259    let footer_json: JsonValue =
260        serde_json::from_str(footer_str).map_err(|_| TokenError::InvalidFooter)?;
261
262    let kid = footer_json
263        .get("kid")
264        .and_then(|v| v.as_str())
265        .ok_or(TokenError::MissingClaim("kid"))?
266        .to_owned();
267
268    Ok(KeyId(kid))
269}
270
271/// Extracts the footer bytes from a PASETO token string.
272///
273/// Token format: `v4.public.<payload>.<footer>`
274pub(crate) fn extract_footer_from_token(token_str: &str) -> Result<Vec<u8>, Error> {
275    let rest = token_str
276        .strip_prefix(TOKEN_PREFIX)
277        .ok_or(TokenError::InvalidFormat)?;
278
279    let (_payload, footer_b64) = rest.rsplit_once('.').ok_or(TokenError::InvalidFormat)?;
280
281    if footer_b64.is_empty() {
282        return Ok(Vec::new());
283    }
284
285    URL_SAFE_NO_PAD
286        .decode(footer_b64)
287        .map_err(|_| TokenError::InvalidFooter.into())
288}
289
290#[cfg(test)]
291#[allow(clippy::unwrap_used)]
292mod tests {
293    use super::*;
294    use static_assertions::assert_impl_all;
295
296    assert_impl_all!(PublicKey: Send, Sync);
297    assert_impl_all!(VerifiedClaims: Send, Sync);
298
299    // ── parse_public_key_hex ─────────────────────────────────────
300
301    #[test]
302    fn parse_valid_hex_key() {
303        // 32 bytes = 64 hex chars
304        let hex = "a".repeat(64);
305        let key = parse_public_key_hex(&hex).unwrap();
306        assert_eq!(key.as_bytes().len(), 32);
307    }
308
309    #[test]
310    fn parse_invalid_hex() {
311        let result = parse_public_key_hex("not-hex");
312        assert!(result.is_err());
313    }
314
315    #[test]
316    fn parse_wrong_length() {
317        // 16 bytes = 32 hex chars (too short)
318        let hex = "ab".repeat(16);
319        let result = parse_public_key_hex(&hex);
320        assert!(result.is_err());
321        let err_msg = result.unwrap_err().to_string();
322        assert!(err_msg.contains("invalid key length"));
323    }
324
325    // ── verify_v4_public_access_token ────────────────────────────
326
327    fn generate_test_token(issuer: &str, audience: &str) -> (PublicKey, String) {
328        use pasetors::claims::Claims;
329        use pasetors::footer::Footer;
330        use pasetors::keys::{AsymmetricKeyPair, Generate};
331
332        let kp = AsymmetricKeyPair::<V4>::generate().unwrap();
333
334        let mut claims = Claims::new().unwrap();
335        claims.issuer(issuer).unwrap();
336        claims.audience(audience).unwrap();
337        claims.subject("test-sub").unwrap();
338
339        let footer_json = serde_json::json!({"kid": "test-key-1"}).to_string();
340        let mut footer = Footer::new();
341        footer.parse_string(&footer_json).unwrap();
342
343        let token = pasetors::public::sign(&kp.secret, &claims, Some(&footer), None).unwrap();
344
345        let pk_bytes = kp.public.as_bytes();
346        let hex = hex::encode(pk_bytes);
347        let public_key = parse_public_key_hex(&hex).unwrap();
348
349        (public_key, token)
350    }
351
352    #[test]
353    fn verify_valid_token() {
354        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
355
356        let claims =
357            verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "ppoppo/*").unwrap();
358
359        assert_eq!(claims.iss(), "accounts.ppoppo.com");
360        assert_eq!(claims.aud(), "ppoppo/*");
361        assert_eq!(claims.sub(), Some("test-sub"));
362    }
363
364    #[test]
365    fn verify_wrong_issuer() {
366        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
367
368        let result = verify_v4_public_access_token(&pk, &token, "wrong-issuer", "ppoppo/*");
369        assert!(result.is_err());
370        let err_msg = result.unwrap_err().to_string();
371        assert!(err_msg.contains("iss"));
372    }
373
374    #[test]
375    fn verify_wrong_audience() {
376        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
377
378        let result = verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "wrong-aud");
379        assert!(result.is_err());
380        let err_msg = result.unwrap_err().to_string();
381        assert!(err_msg.contains("aud"));
382    }
383
384    #[test]
385    fn verify_wrong_key_fails() {
386        let (_pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
387
388        // Generate a different key
389        let different_hex = "bb".repeat(32);
390        let wrong_pk = parse_public_key_hex(&different_hex).unwrap();
391
392        let result =
393            verify_v4_public_access_token(&wrong_pk, &token, "accounts.ppoppo.com", "ppoppo/*");
394        assert!(result.is_err());
395    }
396
397    #[test]
398    fn verify_invalid_format() {
399        let hex = "aa".repeat(32);
400        let pk = parse_public_key_hex(&hex).unwrap();
401
402        let result = verify_v4_public_access_token(&pk, "not-a-token", "iss", "aud");
403        assert!(matches!(
404            result,
405            Err(Error::Token(TokenError::InvalidFormat))
406        ));
407    }
408
409    // ── extract_unverified_kid ───────────────────────────────────
410
411    #[test]
412    fn extract_kid_from_valid_token() {
413        let (_pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
414
415        let kid = extract_unverified_kid(&token).unwrap();
416        assert_eq!(kid.to_string(), "test-key-1");
417    }
418
419    #[test]
420    fn extract_kid_invalid_format() {
421        let result = extract_unverified_kid("invalid");
422        assert!(result.is_err());
423    }
424
425    // ── verify_v4_with_keyset ────────────────────────────────────
426
427    fn keyset_with(pk: &PublicKey, kid: &str, status: crate::well_known::WellKnownKeyStatus) -> crate::well_known::WellKnownPasetoDocument {
428        use crate::well_known::{WellKnownPasetoDocument, WellKnownPasetoKey};
429        WellKnownPasetoDocument {
430            issuer: "accounts.ppoppo.com".into(),
431            version: "v4.public".into(),
432            keys: vec![WellKnownPasetoKey {
433                kid: KeyId(kid.into()),
434                public_key_hex: hex::encode(pk.as_bytes()),
435                status,
436                created_at: time::OffsetDateTime::now_utc(),
437            }],
438            cache_ttl_seconds: 3600,
439        }
440    }
441
442    #[test]
443    fn verify_with_keyset_active_key_succeeds() {
444        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
445        let keyset = keyset_with(&pk, "test-key-1", crate::well_known::WellKnownKeyStatus::Active);
446
447        let claims = verify_v4_with_keyset(&keyset, &token, "accounts.ppoppo.com", "ppoppo/*").unwrap();
448        assert_eq!(claims.iss(), "accounts.ppoppo.com");
449    }
450
451    #[test]
452    fn verify_with_keyset_retiring_key_succeeds() {
453        // Retiring keys still verify — they're just not used for new issuance.
454        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
455        let keyset = keyset_with(&pk, "test-key-1", crate::well_known::WellKnownKeyStatus::Retiring);
456
457        let result = verify_v4_with_keyset(&keyset, &token, "accounts.ppoppo.com", "ppoppo/*");
458        assert!(result.is_ok(), "retiring keys should still verify: {result:?}");
459    }
460
461    #[test]
462    fn verify_with_keyset_revoked_key_fails() {
463        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
464        let keyset = keyset_with(&pk, "test-key-1", crate::well_known::WellKnownKeyStatus::Revoked);
465
466        let result = verify_v4_with_keyset(&keyset, &token, "accounts.ppoppo.com", "ppoppo/*");
467        assert!(result.is_err(), "revoked key MUST fail verification");
468        assert!(result.unwrap_err().to_string().contains("revoked"));
469    }
470
471    #[test]
472    fn verify_with_keyset_unknown_kid_fails() {
473        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
474        let keyset = keyset_with(&pk, "different-kid", crate::well_known::WellKnownKeyStatus::Active);
475
476        let result = verify_v4_with_keyset(&keyset, &token, "accounts.ppoppo.com", "ppoppo/*");
477        assert!(result.is_err());
478        assert!(result.unwrap_err().to_string().contains("not in keyset"));
479    }
480
481    // ── VerifiedClaims ───────────────────────────────────────────
482
483    #[test]
484    fn verified_claims_accessors() {
485        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
486
487        let claims =
488            verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "ppoppo/*").unwrap();
489
490        assert!(claims.get_claim("iss").is_some());
491        assert!(claims.get_claim("nonexistent").is_none());
492        assert!(claims.as_json().is_object());
493    }
494}