Phase 1 foundation: backend, web profile, and mobile app
CI / policy (push) Successful in 0s
CI / mobile (push) Successful in 47s
CI / profile (push) Successful in 49s
CI / backend (push) Failing after 49s

Greenfield implementation of CardClaws Phase 1 across three surfaces.

Backend (Rust/Axum workspace, 67 tests):
- cardclaws-types/config/db/auth/api/wallet crates
- Auth: register, login + lockout, magic link, refresh rotation, Apple verify
- Cards: CRUD, tier-limited publish, duplicate, public handle lookup
- Assets: R2 presigned uploads; vCard export
- Apple Wallet .pkpass pipeline (PKCS#7 signer behind apple-signing feature)
- Analytics ingest + summary with daily-salted IP hashing
- Migrations 0001 (incl. cardclaws_sessions) + 0002 analytics

Web profile (Astro SSR): cardclaws.com/[handle] hero + flip, contact actions,
client-built vCard, visit attribution. Verified end-to-end.

Mobile (Expo SDK 51): auth, card list/create, builder v1 (bg/text/logo,
palette, undo/redo), Skia/Reanimated viewer (flip + ambient). 22 logic tests.

CI: policy/backend/profile/mobile jobs; LOC + no-placeholder lint.

Review fixes baked in: `back` (not `cardclaws`) key; strip image is a bundled
manifest file (not a URL); sessions table added; NFC reframed; test doubles
allowed for external services.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 10:17:26 -05:00
co-authored by Claude Opus 4.8
commit c30d3afeec
128 changed files with 36279 additions and 0 deletions
@@ -0,0 +1,27 @@
[package]
name = "cardclaws-auth"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
cardclaws-types = { workspace = true }
jsonwebtoken = { workspace = true }
argon2 = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
base64 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
@@ -0,0 +1,138 @@
//! Sign in with Apple: verify the identity token (a JWT signed by Apple, RS256)
//! against Apple's published JWKS.
//!
//! The JWKS fetch is behind a [`JwkProvider`] trait so tests can supply keys
//! without network access (and so the real provider can cache them).
use async_trait::async_trait;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use thiserror::Error;
const APPLE_ISSUER: &str = "https://appleid.apple.com";
const APPLE_JWKS_URL: &str = "https://appleid.apple.com/auth/keys";
#[derive(Debug, Error)]
pub enum AppleAuthError {
#[error("malformed identity token")]
MalformedToken,
#[error("no matching Apple signing key for kid")]
UnknownKey,
#[error("identity token verification failed")]
Verification,
#[error("failed to fetch Apple keys: {0}")]
Fetch(String),
}
/// A single JSON Web Key (RSA) from Apple's JWKS.
#[derive(Debug, Clone, Deserialize)]
pub struct AppleJwk {
pub kid: String,
pub n: String,
pub e: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppleJwks {
pub keys: Vec<AppleJwk>,
}
impl AppleJwks {
fn find(&self, kid: &str) -> Option<&AppleJwk> {
self.keys.iter().find(|k| k.kid == kid)
}
}
/// Verified claims we care about from the Apple identity token.
#[derive(Debug, Clone, Deserialize)]
pub struct AppleClaims {
/// Stable, unique Apple user identifier.
pub sub: String,
pub email: Option<String>,
}
/// Source of Apple's signing keys.
#[async_trait]
pub trait JwkProvider: Send + Sync {
async fn jwks(&self) -> Result<AppleJwks, AppleAuthError>;
}
/// Production provider that fetches the JWKS over HTTPS.
pub struct HttpJwkProvider {
client: reqwest::Client,
}
impl HttpJwkProvider {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
}
impl Default for HttpJwkProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl JwkProvider for HttpJwkProvider {
async fn jwks(&self) -> Result<AppleJwks, AppleAuthError> {
self.client
.get(APPLE_JWKS_URL)
.send()
.await
.map_err(|e| AppleAuthError::Fetch(e.to_string()))?
.json::<AppleJwks>()
.await
.map_err(|e| AppleAuthError::Fetch(e.to_string()))
}
}
/// Verify an Apple identity token. `audience` is the app's client_id (the
/// Services ID / bundle id) the token must be addressed to.
pub async fn verify_identity_token(
provider: &dyn JwkProvider,
token: &str,
audience: &str,
) -> Result<AppleClaims, AppleAuthError> {
let header = decode_header(token).map_err(|_| AppleAuthError::MalformedToken)?;
let kid = header.kid.ok_or(AppleAuthError::MalformedToken)?;
let jwks = provider.jwks().await?;
let jwk = jwks.find(&kid).ok_or(AppleAuthError::UnknownKey)?;
let key =
DecodingKey::from_rsa_components(&jwk.n, &jwk.e).map_err(|_| AppleAuthError::UnknownKey)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[APPLE_ISSUER]);
validation.set_audience(&[audience]);
decode::<AppleClaims>(token, &key, &validation)
.map(|d| d.claims)
.map_err(|_| AppleAuthError::Verification)
}
#[cfg(test)]
mod tests {
use super::*;
struct EmptyProvider;
#[async_trait]
impl JwkProvider for EmptyProvider {
async fn jwks(&self) -> Result<AppleJwks, AppleAuthError> {
Ok(AppleJwks { keys: vec![] })
}
}
#[tokio::test]
async fn malformed_token_rejected() {
let err = verify_identity_token(&EmptyProvider, "garbage", "com.cardclaws.app")
.await
.unwrap_err();
assert!(matches!(err, AppleAuthError::MalformedToken));
}
}
@@ -0,0 +1,95 @@
//! Access-token (JWT) encode/decode. HS256 over the configured secret. Access
//! tokens are short-lived (15 min, PRD §6.8.1).
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use thiserror::Error;
use uuid::Uuid;
use cardclaws_types::auth::AccessClaims;
use cardclaws_types::Tier;
/// Access-token lifetime in seconds (15 minutes).
pub const ACCESS_TOKEN_TTL_SECS: i64 = 15 * 60;
#[derive(Debug, Error)]
pub enum JwtError {
#[error("failed to encode token")]
Encode,
#[error("invalid or expired token")]
Invalid,
}
/// Holds the symmetric signing key. Cheap to clone (key bytes are shared).
#[derive(Clone)]
pub struct JwtKeys {
encoding: EncodingKey,
decoding: DecodingKey,
}
impl JwtKeys {
pub fn new(secret: &str) -> Self {
Self {
encoding: EncodingKey::from_secret(secret.as_bytes()),
decoding: DecodingKey::from_secret(secret.as_bytes()),
}
}
/// Mint an access token for a user at a given tier.
pub fn encode_access(&self, user_id: Uuid, tier: Tier) -> Result<String, JwtError> {
let now = Utc::now().timestamp();
let claims = AccessClaims {
sub: user_id,
tier,
iat: now,
exp: now + ACCESS_TOKEN_TTL_SECS,
};
encode(&Header::default(), &claims, &self.encoding).map_err(|_| JwtError::Encode)
}
/// Validate an access token and return its claims. Expiry is enforced.
pub fn decode_access(&self, token: &str) -> Result<AccessClaims, JwtError> {
let mut validation = Validation::default();
// Access tokens carry no `aud` claim; only `exp` is enforced.
validation.validate_aud = false;
decode::<AccessClaims>(token, &self.decoding, &validation)
.map(|data| data.claims)
.map_err(|_| JwtError::Invalid)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_then_decode_roundtrips() {
let keys = JwtKeys::new("super-secret");
let id = Uuid::new_v4();
let token = keys.encode_access(id, Tier::Pro).unwrap();
let claims = keys.decode_access(&token).unwrap();
assert_eq!(claims.sub, id);
assert_eq!(claims.tier, Tier::Pro);
assert!(claims.exp > claims.iat);
}
#[test]
fn wrong_secret_rejected() {
let keys = JwtKeys::new("secret-a");
let other = JwtKeys::new("secret-b");
let token = keys.encode_access(Uuid::new_v4(), Tier::Free).unwrap();
assert!(matches!(
other.decode_access(&token),
Err(JwtError::Invalid)
));
}
#[test]
fn garbage_token_rejected() {
let keys = JwtKeys::new("secret");
assert!(matches!(
keys.decode_access("not.a.jwt"),
Err(JwtError::Invalid)
));
}
}
@@ -0,0 +1,15 @@
//! Authentication primitives: password hashing (Argon2id), JWT access tokens,
//! opaque refresh/magic-link tokens, and Sign in with Apple verification.
//!
//! This crate is intentionally storage-agnostic — it produces and validates
//! credentials but does not touch the database or Redis. The API service wires
//! these primitives to `cardclaws-db` and the session store.
pub mod apple;
pub mod jwt;
pub mod magic_link;
pub mod password;
pub mod token;
pub use jwt::{JwtError, JwtKeys};
pub use password::PasswordError;
@@ -0,0 +1,33 @@
//! Magic-link tokens: single-use, 32 bytes of entropy, 15-minute expiry
//! (PRD §20.1). Storage (Redis, keyed by hash → email) lives in the API service;
//! this module just mints the token and its lookup hash.
use crate::token::{generate_opaque_token, hash_token};
/// Magic-link validity window in seconds (15 minutes).
pub const MAGIC_LINK_TTL_SECS: u64 = 15 * 60;
/// A freshly minted magic-link token. `token` is emailed to the user; `hash` is
/// the Redis key the verify step looks up.
pub struct MintedToken {
pub token: String,
pub hash: String,
}
pub fn mint() -> MintedToken {
let token = generate_opaque_token();
let hash = hash_token(&token);
MintedToken { token, hash }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::token::hash_token;
#[test]
fn minted_hash_matches_token() {
let m = mint();
assert_eq!(m.hash, hash_token(&m.token));
}
}
@@ -0,0 +1,67 @@
//! Argon2id password hashing with the parameters mandated by PRD §20.1
//! (time cost 3, memory 64 MiB, parallelism 4).
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PasswordError {
#[error("password hashing failed")]
Hash,
#[error("invalid stored hash")]
InvalidHash,
}
fn argon2() -> Argon2<'static> {
// m_cost is in KiB: 65536 KiB = 64 MiB.
let params = Params::new(65_536, 3, 4, None).expect("static argon2 params are valid");
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
}
/// Hash a plaintext password into a PHC string suitable for storage.
pub fn hash_password(plaintext: &str) -> Result<String, PasswordError> {
let salt = SaltString::generate(&mut OsRng);
argon2()
.hash_password(plaintext.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|_| PasswordError::Hash)
}
/// Verify a plaintext password against a stored PHC hash. Returns `Ok(false)`
/// for a well-formed hash that simply does not match; `Err` only for a
/// malformed stored hash.
pub fn verify_password(plaintext: &str, stored_hash: &str) -> Result<bool, PasswordError> {
let parsed = PasswordHash::new(stored_hash).map_err(|_| PasswordError::InvalidHash)?;
Ok(argon2()
.verify_password(plaintext.as_bytes(), &parsed)
.is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_then_verify_roundtrips() {
let hash = hash_password("correct horse battery staple").unwrap();
assert!(verify_password("correct horse battery staple", &hash).unwrap());
assert!(!verify_password("wrong password", &hash).unwrap());
}
#[test]
fn distinct_salts_produce_distinct_hashes() {
let a = hash_password("same").unwrap();
let b = hash_password("same").unwrap();
assert_ne!(a, b, "each hash must use a fresh random salt");
}
#[test]
fn malformed_hash_errors() {
assert!(matches!(
verify_password("x", "not-a-phc-string"),
Err(PasswordError::InvalidHash)
));
}
}
@@ -0,0 +1,53 @@
//! Opaque token generation and hashing for refresh tokens and magic links.
//!
//! The plaintext token is returned to the client exactly once; only its SHA-256
//! hash is ever persisted, so a database leak cannot be replayed.
use base64::Engine;
use rand::RngCore;
use sha2::{Digest, Sha256};
/// Generate a cryptographically-random, URL-safe opaque token (32 bytes of
/// entropy → 43-char base64url string).
pub fn generate_opaque_token() -> String {
let mut bytes = [0u8; 32];
let mut rng = rand::rngs::OsRng;
rng.fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
/// Hash a token for storage/lookup. Deterministic (no per-call salt) because we
/// must be able to look the token up by hash.
pub fn hash_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
hex(&digest)
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_are_unique_and_url_safe() {
let a = generate_opaque_token();
let b = generate_opaque_token();
assert_ne!(a, b);
assert!(!a.contains('+') && !a.contains('/') && !a.contains('='));
}
#[test]
fn hash_is_deterministic_and_64_hex_chars() {
let t = "some-token";
assert_eq!(hash_token(t), hash_token(t));
assert_eq!(hash_token(t).len(), 64);
assert_ne!(hash_token("a"), hash_token("b"));
}
}