@@ -12,3 +12,9 @@ uuid.workspace = true
|
||||
serde.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
argon2.workspace = true
|
||||
rand.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
|
||||
31
crates/adapters/auth/src/api_token_secret.rs
Normal file
31
crates/adapters/auth/src/api_token_secret.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use base64::Engine;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use domain::api_token::TokenDigest;
|
||||
|
||||
const SECRET_PREFIX: &str = "kmood_";
|
||||
const SECRET_BYTES: usize = 32;
|
||||
|
||||
pub struct ApiTokenSecret;
|
||||
|
||||
impl domain::ports::ApiTokenSecretPort for ApiTokenSecret {
|
||||
fn mint(&self) -> String {
|
||||
let mut bytes = [0u8; SECRET_BYTES];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
|
||||
let body = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||
|
||||
format!("{SECRET_PREFIX}{body}")
|
||||
}
|
||||
|
||||
fn digest(&self, secret: &str) -> TokenDigest {
|
||||
let digested = Sha256::digest(secret.as_bytes());
|
||||
|
||||
TokenDigest::from_persistence(hex(&digested))
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod api_token_secret;
|
||||
mod jwt_service;
|
||||
mod password_hasher;
|
||||
|
||||
pub use api_token_secret::ApiTokenSecret;
|
||||
pub use jwt_service::JwtAuthService;
|
||||
pub use password_hasher::Argon2PasswordHasher;
|
||||
|
||||
62
crates/adapters/auth/tests/api_token_secret_test.rs
Normal file
62
crates/adapters/auth/tests/api_token_secret_test.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use domain::ports::ApiTokenSecretPort;
|
||||
|
||||
use auth::ApiTokenSecret;
|
||||
|
||||
const HEX_CHARACTERS_IN_A_SHA256: usize = 64;
|
||||
|
||||
#[test]
|
||||
fn a_minted_secret_announces_itself_so_a_scanner_can_spot_it() {
|
||||
let minted = ApiTokenSecret.mint();
|
||||
|
||||
assert!(minted.starts_with("kmood_"), "got {minted}");
|
||||
assert!(minted.len() > 40, "a secret needs real entropy: {minted}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_two_secrets_are_alike() {
|
||||
let secrets: Vec<String> = (0..50).map(|_| ApiTokenSecret.mint()).collect();
|
||||
let mut unique = secrets.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
|
||||
assert_eq!(unique.len(), secrets.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_secret_always_digests_the_same_way() {
|
||||
let secret = ApiTokenSecret.mint();
|
||||
|
||||
assert_eq!(
|
||||
ApiTokenSecret.digest(&secret).value(),
|
||||
ApiTokenSecret.digest(&secret).value()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_secrets_digest_differently() {
|
||||
let first = ApiTokenSecret.digest("kmood_one");
|
||||
let second = ApiTokenSecret.digest("kmood_two");
|
||||
|
||||
assert_ne!(first.value(), second.value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_digest_gives_nothing_of_the_secret_away() {
|
||||
let secret = ApiTokenSecret.mint();
|
||||
let digest = ApiTokenSecret.digest(&secret);
|
||||
|
||||
assert_eq!(digest.value().len(), HEX_CHARACTERS_IN_A_SHA256);
|
||||
assert!(!digest.value().contains(&secret));
|
||||
assert!(!secret.contains(digest.value()));
|
||||
assert!(digest.value().chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_digest_matches_the_published_value_for_a_known_input() {
|
||||
let digest = ApiTokenSecret.digest("abc");
|
||||
|
||||
assert_eq!(
|
||||
digest.value(),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user