cratefield_push_auth/lib.rs
1//! `cratefield-push-auth`: the provider tokens the push adapters present
2//! (issue #178).
3//!
4//! APNs signs an ES256 JWT with the `.p8` key from the Apple developer
5//! portal. VAPID (RFC 8292) is the *same* construction with different claims
6//! — `aud` is the push-service origin, `sub` a `mailto:`, `exp` at most 24
7//! hours out — and the public key travels alongside it as `k=`. Google's
8//! service-account flow signs an **RS256** JWT and trades it for a bearer
9//! token. Three adapters, one signer, one cache; extracted here so the next
10//! two adapters do not each grow their own copy of
11//! `base64url(header).base64url(claims).base64url(sig)`.
12//!
13//! Everything is pure Rust and builds for `wasm32-unknown-unknown`: no
14//! `jsonwebtoken`, no `ring`, no OpenSSL. Both signatures are deterministic
15//! (RFC 6979 for ES256, PKCS#1 v1.5 for RS256), so no RNG is needed on a
16//! Workers isolate.
17//!
18//! Verifying tokens is deliberately absent — that is the auth service's job.
19#![doc = include_str!("../README.md")]
20#![forbid(unsafe_code)]
21
22mod cache;
23mod es256;
24mod rs256;
25
26pub use cache::CachedToken;
27pub use es256::Es256Signer;
28pub use rs256::Rs256Signer;
29
30use base64::Engine;
31use base64::engine::general_purpose::URL_SAFE_NO_PAD;
32use serde_json::Value;
33
34/// A private key did not parse.
35#[derive(Debug, thiserror::Error)]
36pub enum KeyError {
37 /// The PEM was not the expected kind of private key. The detail is the
38 /// underlying parser's message; it never contains key material.
39 #[error("invalid {kind} private key: {detail}")]
40 Parse {
41 /// `ES256` or `RS256` — which signer refused it.
42 kind: &'static str,
43 detail: String,
44 },
45}
46
47impl KeyError {
48 fn parse(kind: &'static str, detail: impl std::fmt::Display) -> Self {
49 KeyError::Parse {
50 kind,
51 detail: detail.to_string(),
52 }
53 }
54}
55
56/// The JWS signing input: `base64url(header) "." base64url(claims)`
57/// (RFC 7515 §5.1). Compact serialisation, so the exact bytes signed are the
58/// exact bytes sent.
59fn signing_input(header: &Value, claims: &Value) -> String {
60 format!(
61 "{}.{}",
62 URL_SAFE_NO_PAD.encode(header.to_string()),
63 URL_SAFE_NO_PAD.encode(claims.to_string()),
64 )
65}