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