Skip to main content

pas_client/
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 signature.
189///
190/// # Errors
191///
192/// Returns `Error::Token` if the token format is invalid or the footer
193/// does not contain a `kid` claim.
194pub fn extract_kid_from_token(token_str: &str) -> Result<KeyId, Error> {
195    let footer_bytes = extract_footer_from_token(token_str)?;
196    extract_kid_from_untrusted_footer(&footer_bytes)
197}
198
199/// Extracts the key ID (kid) from an untrusted token's footer.
200pub(crate) fn extract_kid_from_untrusted_footer(footer_bytes: &[u8]) -> Result<KeyId, Error> {
201    if footer_bytes.is_empty() {
202        return Err(TokenError::MissingFooter.into());
203    }
204
205    let footer_str =
206        std::str::from_utf8(footer_bytes).map_err(|_| TokenError::InvalidFooter)?;
207
208    let footer_json: JsonValue =
209        serde_json::from_str(footer_str).map_err(|_| TokenError::InvalidFooter)?;
210
211    let kid = footer_json
212        .get("kid")
213        .and_then(|v| v.as_str())
214        .ok_or(TokenError::MissingClaim("kid"))?
215        .to_owned();
216
217    Ok(KeyId(kid))
218}
219
220/// Extracts the footer bytes from a PASETO token string.
221///
222/// Token format: `v4.public.<payload>.<footer>`
223pub(crate) fn extract_footer_from_token(token_str: &str) -> Result<Vec<u8>, Error> {
224    let rest = token_str
225        .strip_prefix(TOKEN_PREFIX)
226        .ok_or(TokenError::InvalidFormat)?;
227
228    let (_payload, footer_b64) = rest
229        .rsplit_once('.')
230        .ok_or(TokenError::InvalidFormat)?;
231
232    if footer_b64.is_empty() {
233        return Ok(Vec::new());
234    }
235
236    URL_SAFE_NO_PAD
237        .decode(footer_b64)
238        .map_err(|_| TokenError::InvalidFooter.into())
239}
240
241#[cfg(test)]
242#[allow(clippy::unwrap_used)]
243mod tests {
244    use super::*;
245    use static_assertions::assert_impl_all;
246
247    assert_impl_all!(PublicKey: Send, Sync);
248    assert_impl_all!(VerifiedClaims: Send, Sync);
249
250    // ── parse_public_key_hex ─────────────────────────────────────
251
252    #[test]
253    fn parse_valid_hex_key() {
254        // 32 bytes = 64 hex chars
255        let hex = "a".repeat(64);
256        let key = parse_public_key_hex(&hex).unwrap();
257        assert_eq!(key.as_bytes().len(), 32);
258    }
259
260    #[test]
261    fn parse_invalid_hex() {
262        let result = parse_public_key_hex("not-hex");
263        assert!(result.is_err());
264    }
265
266    #[test]
267    fn parse_wrong_length() {
268        // 16 bytes = 32 hex chars (too short)
269        let hex = "ab".repeat(16);
270        let result = parse_public_key_hex(&hex);
271        assert!(result.is_err());
272        let err_msg = result.unwrap_err().to_string();
273        assert!(err_msg.contains("invalid key length"));
274    }
275
276    // ── verify_v4_public_access_token ────────────────────────────
277
278    fn generate_test_token(
279        issuer: &str,
280        audience: &str,
281    ) -> (PublicKey, String) {
282        use pasetors::claims::Claims;
283        use pasetors::footer::Footer;
284        use pasetors::keys::{AsymmetricKeyPair, Generate};
285
286        let kp = AsymmetricKeyPair::<V4>::generate().unwrap();
287
288        let mut claims = Claims::new().unwrap();
289        claims.issuer(issuer).unwrap();
290        claims.audience(audience).unwrap();
291        claims.subject("test-sub").unwrap();
292
293        let footer_json = serde_json::json!({"kid": "test-key-1"}).to_string();
294        let mut footer = Footer::new();
295        footer.parse_string(&footer_json).unwrap();
296
297        let token =
298            pasetors::public::sign(&kp.secret, &claims, Some(&footer), None).unwrap();
299
300        let pk_bytes = kp.public.as_bytes();
301        let hex = hex::encode(pk_bytes);
302        let public_key = parse_public_key_hex(&hex).unwrap();
303
304        (public_key, token)
305    }
306
307    #[test]
308    fn verify_valid_token() {
309        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
310
311        let claims =
312            verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "ppoppo/*").unwrap();
313
314        assert_eq!(claims.iss(), "accounts.ppoppo.com");
315        assert_eq!(claims.aud(), "ppoppo/*");
316        assert_eq!(claims.sub(), Some("test-sub"));
317    }
318
319    #[test]
320    fn verify_wrong_issuer() {
321        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
322
323        let result = verify_v4_public_access_token(&pk, &token, "wrong-issuer", "ppoppo/*");
324        assert!(result.is_err());
325        let err_msg = result.unwrap_err().to_string();
326        assert!(err_msg.contains("iss"));
327    }
328
329    #[test]
330    fn verify_wrong_audience() {
331        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
332
333        let result =
334            verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "wrong-aud");
335        assert!(result.is_err());
336        let err_msg = result.unwrap_err().to_string();
337        assert!(err_msg.contains("aud"));
338    }
339
340    #[test]
341    fn verify_wrong_key_fails() {
342        let (_pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
343
344        // Generate a different key
345        let different_hex = "bb".repeat(32);
346        let wrong_pk = parse_public_key_hex(&different_hex).unwrap();
347
348        let result =
349            verify_v4_public_access_token(&wrong_pk, &token, "accounts.ppoppo.com", "ppoppo/*");
350        assert!(result.is_err());
351    }
352
353    #[test]
354    fn verify_invalid_format() {
355        let hex = "aa".repeat(32);
356        let pk = parse_public_key_hex(&hex).unwrap();
357
358        let result = verify_v4_public_access_token(&pk, "not-a-token", "iss", "aud");
359        assert!(matches!(
360            result,
361            Err(Error::Token(TokenError::InvalidFormat))
362        ));
363    }
364
365    // ── extract_kid_from_token ───────────────────────────────────
366
367    #[test]
368    fn extract_kid_from_valid_token() {
369        let (_pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
370
371        let kid = extract_kid_from_token(&token).unwrap();
372        assert_eq!(kid.to_string(), "test-key-1");
373    }
374
375    #[test]
376    fn extract_kid_invalid_format() {
377        let result = extract_kid_from_token("invalid");
378        assert!(result.is_err());
379    }
380
381    // ── VerifiedClaims ───────────────────────────────────────────
382
383    #[test]
384    fn verified_claims_accessors() {
385        let (pk, token) = generate_test_token("accounts.ppoppo.com", "ppoppo/*");
386
387        let claims =
388            verify_v4_public_access_token(&pk, &token, "accounts.ppoppo.com", "ppoppo/*").unwrap();
389
390        assert!(claims.get_claim("iss").is_some());
391        assert!(claims.get_claim("nonexistent").is_none());
392        assert!(claims.as_json().is_object());
393    }
394}