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#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct PublicKey {
24 bytes: [u8; ED25519_PUBLIC_KEY_SIZE],
25}
26
27impl PublicKey {
28 #[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
43pub 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#[derive(Debug, Clone)]
66pub struct VerifiedClaims {
67 iss: String,
68 aud: String,
69 inner: JsonValue,
70}
71
72impl VerifiedClaims {
73 #[must_use]
75 pub fn iss(&self) -> &str {
76 &self.iss
77 }
78
79 #[must_use]
81 pub fn aud(&self) -> &str {
82 &self.aud
83 }
84
85 #[must_use]
87 pub fn sub(&self) -> Option<&str> {
88 self.inner.get("sub").and_then(|v| v.as_str())
89 }
90
91 #[must_use]
93 pub fn get_claim(&self, key: &str) -> Option<&JsonValue> {
94 self.inner.get(key)
95 }
96
97 #[must_use]
99 pub fn as_json(&self) -> &JsonValue {
100 &self.inner
101 }
102
103 #[must_use]
111 pub fn session_version(&self) -> Option<i64> {
112 self.inner.get("sv").and_then(JsonValue::as_i64)
113 }
114
115 #[must_use]
122 pub fn magic_link_id(&self) -> Option<&str> {
123 self.inner.get("mlt").and_then(JsonValue::as_str)
124 }
125}
126
127pub 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 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 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
191fn 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
211pub 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
234pub 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
274pub(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
294pub(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 #[test]
325 fn parse_valid_hex_key() {
326 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 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 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 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 #[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 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 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 #[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}