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    /// Returns the `sv` (session_version) claim when present.
104    ///
105    /// Human-entity tokens only; `None` for AI-agent tokens, delegated /
106    /// dependent tokens (Token Exchange), and legacy tokens issued before
107    /// the claim existed. Legacy-admit contract: consumers receiving `None`
108    /// MUST treat the token as admissible (skip the sv gate) — see
109    /// `session_version::validate_sv`. Feature: #005 break-glass.
110    #[must_use]
111    pub fn session_version(&self) -> Option<i64> {
112        self.inner.get("sv").and_then(JsonValue::as_i64)
113    }
114
115    /// Returns the `mlt` (magic-link token id) claim when present.
116    ///
117    /// Magic-link-path tokens only. Internal to PAS — no Resource Server
118    /// use. Exposed for symmetry with `session_version()` and for SDK
119    /// consumers that want to introspect their access tokens for
120    /// audit/debug purposes. Feature: #005 break-glass.
121    #[must_use]
122    pub fn magic_link_id(&self) -> Option<&str> {
123        self.inner.get("mlt").and_then(JsonValue::as_str)
124    }
125}
126
127/// Verifies a PASETO v4.public access token.
128///
129/// # Errors
130///
131/// Returns `Error::Token` if the token format is invalid, the signature
132/// verification fails, or the `iss`/`aud` claims do not match the expected values.
133pub fn verify_v4_public_access_token(
134    public_key: &PublicKey,
135    token_str: &str,
136    expected_issuer: &str,
137    expected_audience: &str,
138) -> Result<VerifiedClaims, Error> {
139    if !token_str.starts_with(TOKEN_PREFIX) {
140        return Err(TokenError::InvalidFormat.into());
141    }
142
143    let pk = AsymmetricPublicKey::<V4>::from(&public_key.bytes[..])
144        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
145
146    let validation_rules = ClaimsValidationRules::new();
147
148    let untrusted_token = UntrustedToken::<Public, V4>::try_from(token_str)
149        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
150
151    let trusted_token = public::verify(&pk, &untrusted_token, &validation_rules, None, None)
152        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
153
154    let payload = trusted_token
155        .payload_claims()
156        .ok_or(TokenError::MissingPayload)?;
157    let payload_str = payload
158        .to_string()
159        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
160    let json_value: JsonValue = serde_json::from_str(&payload_str)
161        .map_err(|e| TokenError::VerificationFailed(e.to_string()))?;
162
163    // Reject expired tokens when `exp` claim is present
164    if let Some(exp_str) = json_value.get("exp").and_then(|v| v.as_str()) {
165        let exp_time = OffsetDateTime::parse(exp_str, &Rfc3339)
166            .map_err(|e| TokenError::VerificationFailed(format!("invalid exp format: {e}")))?;
167        if exp_time < OffsetDateTime::now_utc() {
168            return Err(TokenError::Expired.into());
169        }
170    }
171
172    // Reject tokens not yet valid (nbf = not before)
173    if let Some(nbf_str) = json_value.get("nbf").and_then(|v| v.as_str()) {
174        let nbf_time = OffsetDateTime::parse(nbf_str, &Rfc3339)
175            .map_err(|e| TokenError::VerificationFailed(format!("invalid nbf format: {e}")))?;
176        if nbf_time > OffsetDateTime::now_utc() {
177            return Err(TokenError::VerificationFailed("token not yet valid (nbf)".into()).into());
178        }
179    }
180
181    let iss = validate_claim(&json_value, "iss", expected_issuer)?;
182    let aud = validate_claim(&json_value, "aud", expected_audience)?;
183
184    Ok(VerifiedClaims {
185        iss,
186        aud,
187        inner: json_value,
188    })
189}
190
191/// Validates a JSON claim matches expected value; returns the actual value on success.
192fn validate_claim(
193    claims: &JsonValue,
194    key: &'static str,
195    expected: &str,
196) -> Result<String, TokenError> {
197    let actual = claims
198        .get(key)
199        .and_then(|v| v.as_str())
200        .ok_or(TokenError::MissingClaim(key))?;
201    if actual != expected {
202        return Err(TokenError::ClaimMismatch {
203            claim: key,
204            expected: expected.to_string(),
205            actual: actual.to_string(),
206        });
207    }
208    Ok(actual.to_string())
209}
210
211/// Extract key ID from a PASETO token **without verifying the signature**.
212///
213/// # ⚠️ Untrusted by design
214///
215/// The returned [`KeyId`] is read from the token footer **before** signature
216/// verification. An attacker can craft a token with any `kid` they want.
217/// **The only safe use of this value is to look up a public key in a trusted
218/// keyset and then verify the signature with it.** Do not use the returned
219/// `kid` for trust decisions, audit logging, metrics, or caching.
220///
221/// Most callers should use [`verify_v4_with_keyset`] instead, which performs
222/// the kid-lookup-then-verify dance atomically and never exposes the
223/// untrusted kid to caller code.
224///
225/// # Errors
226///
227/// Returns `Error::Token` if the token format is invalid or the footer
228/// does not contain a `kid` claim.
229pub fn extract_unverified_kid(token_str: &str) -> Result<KeyId, Error> {
230    let footer_bytes = extract_footer_from_token(token_str)?;
231    extract_kid_from_untrusted_footer(&footer_bytes)
232}
233
234/// Verify a PASETO v4.public token against a [`WellKnownPasetoDocument`].
235///
236/// Performs the safe sequence atomically:
237///
238/// 1. Extract the (untrusted) `kid` from the token footer.
239/// 2. Look up the matching key in `keyset.keys`. Reject if absent or
240///    `status: Revoked`.
241/// 3. Verify the signature with that key.
242/// 4. Validate `iss` and `aud` against the supplied expectations.
243///
244/// `Retiring` keys are accepted (they're still valid for verification, just
245/// not for new issuance). Only `Revoked` keys are refused.
246///
247/// # Errors
248///
249/// Returns `Error::Token` if any step fails. The error variant indicates
250/// which step (invalid format, missing kid, key not in set, key revoked,
251/// signature invalid, or claim mismatch).
252pub fn verify_v4_with_keyset(
253    keyset: &crate::well_known::WellKnownPasetoDocument,
254    token_str: &str,
255    expected_issuer: &str,
256    expected_audience: &str,
257) -> Result<VerifiedClaims, Error> {
258    let kid = extract_unverified_kid(token_str)?;
259
260    let key_meta = keyset
261        .keys
262        .iter()
263        .find(|k| k.kid == kid)
264        .ok_or_else(|| TokenError::VerificationFailed(format!("kid '{kid}' not in keyset")))?;
265
266    if key_meta.status == crate::well_known::WellKnownKeyStatus::Revoked {
267        return Err(TokenError::VerificationFailed(format!("kid '{kid}' is revoked")).into());
268    }
269
270    let public_key = PublicKey::try_from(key_meta)?;
271    verify_v4_public_access_token(&public_key, token_str, expected_issuer, expected_audience)
272}
273
274/// Extracts the key ID (kid) from an untrusted token's footer.
275pub(crate) fn extract_kid_from_untrusted_footer(footer_bytes: &[u8]) -> Result<KeyId, Error> {
276    if footer_bytes.is_empty() {
277        return Err(TokenError::MissingFooter.into());
278    }
279
280    let footer_str = std::str::from_utf8(footer_bytes).map_err(|_| TokenError::InvalidFooter)?;
281
282    let footer_json: JsonValue =
283        serde_json::from_str(footer_str).map_err(|_| TokenError::InvalidFooter)?;
284
285    let kid = footer_json
286        .get("kid")
287        .and_then(|v| v.as_str())
288        .ok_or(TokenError::MissingClaim("kid"))?
289        .to_owned();
290
291    Ok(KeyId(kid))
292}
293
294/// Extracts the footer bytes from a PASETO token string.
295///
296/// Token format: `v4.public.<payload>.<footer>`
297pub(crate) fn extract_footer_from_token(token_str: &str) -> Result<Vec<u8>, Error> {
298    let rest = token_str
299        .strip_prefix(TOKEN_PREFIX)
300        .ok_or(TokenError::InvalidFormat)?;
301
302    let (_payload, footer_b64) = rest.rsplit_once('.').ok_or(TokenError::InvalidFormat)?;
303
304    if footer_b64.is_empty() {
305        return Ok(Vec::new());
306    }
307
308    URL_SAFE_NO_PAD
309        .decode(footer_b64)
310        .map_err(|_| TokenError::InvalidFooter.into())
311}
312
313#[cfg(test)]
314#[allow(clippy::unwrap_used)]
315mod tests {
316    use super::*;
317    use static_assertions::assert_impl_all;
318
319    assert_impl_all!(PublicKey: Send, Sync);
320    assert_impl_all!(VerifiedClaims: Send, Sync);
321
322    // ── parse_public_key_hex ─────────────────────────────────────
323
324    #[test]
325    fn parse_valid_hex_key() {
326        // 32 bytes = 64 hex chars
327        let hex = "a".repeat(64);
328        let key = parse_public_key_hex(&hex).unwrap();
329        assert_eq!(key.as_bytes().len(), 32);
330    }
331
332    #[test]
333    fn parse_invalid_hex() {
334        let result = parse_public_key_hex("not-hex");
335        assert!(result.is_err());
336    }
337
338    #[test]
339    fn parse_wrong_length() {
340        // 16 bytes = 32 hex chars (too short)
341        let hex = "ab".repeat(16);
342        let result = parse_public_key_hex(&hex);
343        assert!(result.is_err());
344        let err_msg = result.unwrap_err().to_string();
345        assert!(err_msg.contains("invalid key length"));
346    }
347
348    // ── verify_v4_public_access_token ────────────────────────────
349
350    fn generate_test_token(issuer: &str, audience: &str) -> (PublicKey, String) {
351        use pasetors::claims::Claims;
352        use pasetors::footer::Footer;
353        use pasetors::keys::{AsymmetricKeyPair, Generate};
354
355        let kp = AsymmetricKeyPair::<V4>::generate().unwrap();
356
357        let mut claims = Claims::new().unwrap();
358        claims.issuer(issuer).unwrap();
359        claims.audience(audience).unwrap();
360        claims.subject("test-sub").unwrap();
361
362        let footer_json = serde_json::json!({"kid": "test-key-1"}).to_string();
363        let mut footer = Footer::new();
364        footer.parse_string(&footer_json).unwrap();
365
366        let token = pasetors::public::sign(&kp.secret, &claims, Some(&footer), None).unwrap();
367
368        let pk_bytes = kp.public.as_bytes();
369        let hex = hex::encode(pk_bytes);
370        let public_key = parse_public_key_hex(&hex).unwrap();
371
372        (public_key, token)
373    }
374
375    #[test]
376    fn verify_valid_token() {
377        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
378
379        let claims =
380            verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "ppoppo/*").unwrap();
381
382        assert_eq!(claims.iss(), "accounts.ppoppo.com");
383        assert_eq!(claims.aud(), "ppoppo/*");
384        assert_eq!(claims.sub(), Some("test-sub"));
385    }
386
387    #[test]
388    fn verify_wrong_issuer() {
389        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
390
391        let result = verify_v4_public_access_token(&pk, &token, "wrong-issuer", "ppoppo/*");
392        assert!(result.is_err());
393        let err_msg = result.unwrap_err().to_string();
394        assert!(err_msg.contains("iss"));
395    }
396
397    #[test]
398    fn verify_wrong_audience() {
399        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
400
401        let result = verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "wrong-aud");
402        assert!(result.is_err());
403        let err_msg = result.unwrap_err().to_string();
404        assert!(err_msg.contains("aud"));
405    }
406
407    #[test]
408    fn verify_wrong_key_fails() {
409        let (_pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
410
411        // Generate a different key
412        let different_hex = "bb".repeat(32);
413        let wrong_pk = parse_public_key_hex(&different_hex).unwrap();
414
415        let result =
416            verify_v4_public_access_token(&wrong_pk, &token, "accounts.ppoppo.com", "ppoppo/*");
417        assert!(result.is_err());
418    }
419
420    #[test]
421    fn verify_invalid_format() {
422        let hex = "aa".repeat(32);
423        let pk = parse_public_key_hex(&hex).unwrap();
424
425        let result = verify_v4_public_access_token(&pk, "not-a-token", "iss", "aud");
426        assert!(matches!(
427            result,
428            Err(Error::Token(TokenError::InvalidFormat))
429        ));
430    }
431
432    // ── extract_unverified_kid ───────────────────────────────────
433
434    #[test]
435    fn extract_kid_from_valid_token() {
436        let (_pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
437
438        let kid = extract_unverified_kid(&token).unwrap();
439        assert_eq!(kid.to_string(), "test-key-1");
440    }
441
442    #[test]
443    fn extract_kid_invalid_format() {
444        let result = extract_unverified_kid("invalid");
445        assert!(result.is_err());
446    }
447
448    // ── verify_v4_with_keyset ────────────────────────────────────
449
450    fn keyset_with(pk: &PublicKey, kid: &str, status: crate::well_known::WellKnownKeyStatus) -> crate::well_known::WellKnownPasetoDocument {
451        use crate::well_known::{WellKnownPasetoDocument, WellKnownPasetoKey};
452        WellKnownPasetoDocument {
453            issuer: "accounts.ppoppo.com".into(),
454            version: "v4.public".into(),
455            keys: vec![WellKnownPasetoKey {
456                kid: KeyId(kid.into()),
457                public_key_hex: hex::encode(pk.as_bytes()),
458                status,
459                created_at: time::OffsetDateTime::now_utc(),
460            }],
461            cache_ttl_seconds: 3600,
462        }
463    }
464
465    #[test]
466    fn verify_with_keyset_active_key_succeeds() {
467        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
468        let keyset = keyset_with(&pk, "test-key-1", crate::well_known::WellKnownKeyStatus::Active);
469
470        let claims = verify_v4_with_keyset(&keyset, &token, "accounts.ppoppo.com", "ppoppo/*").unwrap();
471        assert_eq!(claims.iss(), "accounts.ppoppo.com");
472    }
473
474    #[test]
475    fn verify_with_keyset_retiring_key_succeeds() {
476        // Retiring keys still verify — they're just not used for new issuance.
477        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
478        let keyset = keyset_with(&pk, "test-key-1", crate::well_known::WellKnownKeyStatus::Retiring);
479
480        let result = verify_v4_with_keyset(&keyset, &token, "accounts.ppoppo.com", "ppoppo/*");
481        assert!(result.is_ok(), "retiring keys should still verify: {result:?}");
482    }
483
484    #[test]
485    fn verify_with_keyset_revoked_key_fails() {
486        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
487        let keyset = keyset_with(&pk, "test-key-1", crate::well_known::WellKnownKeyStatus::Revoked);
488
489        let result = verify_v4_with_keyset(&keyset, &token, "accounts.ppoppo.com", "ppoppo/*");
490        assert!(result.is_err(), "revoked key MUST fail verification");
491        assert!(result.unwrap_err().to_string().contains("revoked"));
492    }
493
494    #[test]
495    fn verify_with_keyset_unknown_kid_fails() {
496        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
497        let keyset = keyset_with(&pk, "different-kid", crate::well_known::WellKnownKeyStatus::Active);
498
499        let result = verify_v4_with_keyset(&keyset, &token, "accounts.ppoppo.com", "ppoppo/*");
500        assert!(result.is_err());
501        assert!(result.unwrap_err().to_string().contains("not in keyset"));
502    }
503
504    // ── VerifiedClaims ───────────────────────────────────────────
505
506    #[test]
507    fn verified_claims_accessors() {
508        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
509
510        let claims =
511            verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "ppoppo/*").unwrap();
512
513        assert!(claims.get_claim("iss").is_some());
514        assert!(claims.get_claim("nonexistent").is_none());
515        assert!(claims.as_json().is_object());
516    }
517}