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]
116 pub fn session_version(&self) -> Option<i64> {
117 self.inner.get("sv").and_then(JsonValue::as_i64)
118 }
119
120 #[must_use]
127 pub fn magic_link_id(&self) -> Option<&str> {
128 self.inner.get("mlt").and_then(JsonValue::as_str)
129 }
130}
131
132pub 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 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 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
196fn 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
216pub 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
239pub 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
280pub(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
300pub(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 #[test]
331 fn parse_valid_hex_key() {
332 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 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 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 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 #[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 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 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 #[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}