Phase 2 backend: Google Wallet pass + profile contact form
- cardclaws-wallet::google — inline GenericObject + RS256 `savetowallet` JWT
(Rs256Signer via jsonwebtoken; FakeGoogleSigner test double)
- POST /v1/cards/{id}/wallet/google → { saveUrl }
- POST /v1/profile/{handle}/contact — validate, rate-limit, record
contact_form_submission, email the owner (HTML-escaped, Resend)
- WalletConfig gains google issuer id + service account email
84 backend tests; fmt + clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4770efd861
commit
8ae1d8f263
@@ -23,6 +23,10 @@ sha1 = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
image = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# Google Wallet "save" JWT (RS256). jsonwebtoken is pure-Rust, so this is always
|
||||
# compiled (unlike the openssl-backed Apple signer).
|
||||
jsonwebtoken = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
openssl = { version = "0.10", features = ["vendored"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Signs the Google Wallet `savetowallet` JWT (RS256) with the issuer service
|
||||
//! account key. A fake signer (test double) lets the link-assembly logic be
|
||||
//! verified without a real key.
|
||||
|
||||
use base64::Engine;
|
||||
use jsonwebtoken::{Algorithm, EncodingKey, Header};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::WalletError;
|
||||
|
||||
pub trait GoogleWalletSigner: Send + Sync {
|
||||
/// Sign the claims into a compact JWT string.
|
||||
fn sign(&self, claims: &Value) -> Result<String, WalletError>;
|
||||
}
|
||||
|
||||
/// Production signer: RS256 over the service account private key (PEM).
|
||||
pub struct Rs256Signer {
|
||||
key: EncodingKey,
|
||||
}
|
||||
|
||||
impl Rs256Signer {
|
||||
pub fn from_pem(pem: &[u8]) -> Result<Self, WalletError> {
|
||||
let key =
|
||||
EncodingKey::from_rsa_pem(pem).map_err(|e| WalletError::Signing(e.to_string()))?;
|
||||
Ok(Self { key })
|
||||
}
|
||||
}
|
||||
|
||||
impl GoogleWalletSigner for Rs256Signer {
|
||||
fn sign(&self, claims: &Value) -> Result<String, WalletError> {
|
||||
jsonwebtoken::encode(&Header::new(Algorithm::RS256), claims, &self.key)
|
||||
.map_err(|e| WalletError::Signing(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Test double: emits a structurally-valid `header.payload.signature` token with
|
||||
/// a non-cryptographic signature, so tests can decode and assert the payload.
|
||||
pub struct FakeGoogleSigner;
|
||||
|
||||
impl GoogleWalletSigner for FakeGoogleSigner {
|
||||
fn sign(&self, claims: &Value) -> Result<String, WalletError> {
|
||||
let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||
let header = b64(br#"{"alg":"RS256","typ":"JWT"}"#);
|
||||
let payload =
|
||||
b64(&serde_json::to_vec(claims).map_err(|e| WalletError::Signing(e.to_string()))?);
|
||||
Ok(format!("{header}.{payload}.fakesig"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn fake_signer_payload_is_decodable() {
|
||||
let claims = json!({ "aud": "google", "typ": "savetowallet" });
|
||||
let jwt = FakeGoogleSigner.sign(&claims).unwrap();
|
||||
let parts: Vec<&str> = jwt.split('.').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.unwrap();
|
||||
let decoded: Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(decoded["aud"], "google");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Google Wallet generic pass (PRD §10.3). The "Add to Google Wallet" button
|
||||
//! deep-links to `https://pay.google.com/gp/v/save/{JWT}`, where the JWT is an
|
||||
//! RS256-signed `savetowallet` token carrying the GenericObject inline (so no
|
||||
//! Google Wallet API round-trip is needed to mint the link).
|
||||
|
||||
pub mod jwt_signer;
|
||||
pub mod object_builder;
|
||||
|
||||
use crate::error::WalletError;
|
||||
use jwt_signer::GoogleWalletSigner;
|
||||
|
||||
const SAVE_URL_PREFIX: &str = "https://pay.google.com/gp/v/save/";
|
||||
|
||||
/// Inputs for one Google Wallet object (mirrors the Apple `PassInput`).
|
||||
pub struct GoogleInput {
|
||||
/// Service account email (the JWT `iss`).
|
||||
pub issuer_email: String,
|
||||
/// Fully-qualified class id, e.g. `3388000000022195611.cardclaws_generic`.
|
||||
pub class_id: String,
|
||||
/// Fully-qualified object id, e.g. `3388000000022195611.<card-id>`.
|
||||
pub object_id: String,
|
||||
pub holder_name: String,
|
||||
pub title: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub profile_url: String,
|
||||
pub background_hex: String,
|
||||
}
|
||||
|
||||
/// Build the full "Add to Google Wallet" save URL.
|
||||
pub fn build_save_link(
|
||||
input: &GoogleInput,
|
||||
signer: &dyn GoogleWalletSigner,
|
||||
) -> Result<String, WalletError> {
|
||||
let claims = object_builder::build_claims(input);
|
||||
let jwt = signer.sign(&claims)?;
|
||||
Ok(format!("{SAVE_URL_PREFIX}{jwt}"))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Builds the `savetowallet` JWT claims, including the inline GenericObject.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::GoogleInput;
|
||||
|
||||
/// Build the JWT claims for the save link. The GenericObject is embedded under
|
||||
/// `payload.genericObjects` so the link is self-contained.
|
||||
pub fn build_claims(input: &GoogleInput) -> Value {
|
||||
json!({
|
||||
"iss": input.issuer_email,
|
||||
"aud": "google",
|
||||
"typ": "savetowallet",
|
||||
"origins": [],
|
||||
"payload": {
|
||||
"genericObjects": [ build_object(input) ]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_object(input: &GoogleInput) -> Value {
|
||||
let subheader = match (&input.title, &input.company) {
|
||||
(Some(t), Some(c)) => format!("{t} · {c}"),
|
||||
(Some(t), None) => t.clone(),
|
||||
(None, Some(c)) => c.clone(),
|
||||
(None, None) => String::new(),
|
||||
};
|
||||
|
||||
json!({
|
||||
"id": input.object_id,
|
||||
"classId": input.class_id,
|
||||
"state": "ACTIVE",
|
||||
"hexBackgroundColor": input.background_hex,
|
||||
"cardTitle": {
|
||||
"defaultValue": { "language": "en", "value": "CardClaws" }
|
||||
},
|
||||
"header": {
|
||||
"defaultValue": { "language": "en", "value": input.holder_name }
|
||||
},
|
||||
"subheader": {
|
||||
"defaultValue": { "language": "en", "value": subheader }
|
||||
},
|
||||
"barcode": {
|
||||
"type": "QR_CODE",
|
||||
"value": input.profile_url,
|
||||
"alternateText": input.profile_url
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input() -> GoogleInput {
|
||||
GoogleInput {
|
||||
issuer_email: "[email protected]".into(),
|
||||
class_id: "338800.cardclaws_generic".into(),
|
||||
object_id: "338800.card-abc".into(),
|
||||
holder_name: "Omar Sobh".into(),
|
||||
title: Some("Founder".into()),
|
||||
company: Some("RedClaw".into()),
|
||||
profile_url: "https://cardclaws.com/omar".into(),
|
||||
background_hex: "#101014".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claims_have_savetowallet_shape() {
|
||||
let c = build_claims(&input());
|
||||
assert_eq!(c["aud"], "google");
|
||||
assert_eq!(c["typ"], "savetowallet");
|
||||
assert_eq!(c["iss"], "[email protected]");
|
||||
let obj = &c["payload"]["genericObjects"][0];
|
||||
assert_eq!(obj["id"], "338800.card-abc");
|
||||
assert_eq!(obj["classId"], "338800.cardclaws_generic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn barcode_is_qr_to_profile() {
|
||||
let c = build_claims(&input());
|
||||
let barcode = &c["payload"]["genericObjects"][0]["barcode"];
|
||||
assert_eq!(barcode["type"], "QR_CODE");
|
||||
assert_eq!(barcode["value"], "https://cardclaws.com/omar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_is_holder_and_subheader_combines_title_company() {
|
||||
let c = build_claims(&input());
|
||||
let obj = &c["payload"]["genericObjects"][0];
|
||||
assert_eq!(obj["header"]["defaultValue"]["value"], "Omar Sobh");
|
||||
assert_eq!(
|
||||
obj["subheader"]["defaultValue"]["value"],
|
||||
"Founder · RedClaw"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod apple;
|
||||
pub mod error;
|
||||
pub mod google;
|
||||
pub mod strip_renderer;
|
||||
|
||||
pub use error::WalletError;
|
||||
|
||||
Reference in New Issue
Block a user