Phase 1 foundation: backend, web profile, and mobile app
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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user