use crate::{AuthTokenResponse, ServiceError};
pub fn validate_email(email: &str) -> Result<String, ServiceError> {
let email = email.trim().to_lowercase();
if email.is_empty() || !email.contains('@') || email.len() > 254 {
return Err(ServiceError::BadRequest("invalid email address".into()));
}
Ok(email)
}
const MIN_PASSWORD_CHARS: usize = 12;
const MAX_PASSWORD_CHARS: usize = 128;
pub fn validate_password(password: &str) -> Result<(), ServiceError> {
if password.len() < MIN_PASSWORD_CHARS {
return Err(ServiceError::BadRequest(
"password must be at least 12 characters".into(),
));
}
if password.len() > MAX_PASSWORD_CHARS {
return Err(ServiceError::BadRequest(
"password must be at most 128 characters".into(),
));
}
Ok(())
}
pub fn validate_nickname(nickname: &str) -> Result<String, ServiceError> {
let trimmed = nickname.trim().to_string();
if trimmed.is_empty() || trimmed.len() > 64 {
return Err(ServiceError::BadRequest(
"nickname must be 1-64 characters".into(),
));
}
Ok(trimmed)
}
pub const API_KEY_GRACE_DAYS: i64 = 7;
pub fn generate_api_key() -> String {
format!("osk_{}", uuid::Uuid::new_v4().simple())
}
pub fn hash_api_key(api_key: &str) -> String {
crate::crypto::hash_token(api_key)
}
pub fn key_prefix(api_key: &str) -> String {
api_key.chars().take(12).collect()
}
pub fn grace_until_sqlite(now_unix: u64) -> Result<String, ServiceError> {
let base = chrono::DateTime::from_timestamp(now_unix as i64, 0)
.ok_or_else(|| ServiceError::Internal("invalid timestamp".into()))?;
Ok((base + chrono::Duration::days(API_KEY_GRACE_DAYS))
.format("%Y-%m-%d %H:%M:%S")
.to_string())
}
pub enum AuthToken {
Jwt(String),
ApiKey(String),
}
pub fn resolve_auth_token(
token: &str,
jwt_secret: &str,
now: u64,
) -> Result<AuthToken, ServiceError> {
if token.starts_with("osk_") {
return Ok(AuthToken::ApiKey(token.to_string()));
}
if jwt_secret.is_empty() {
return Err(ServiceError::Unauthorized(
"JWT authentication not configured".into(),
));
}
let user_id = crate::crypto::verify_jwt(token, jwt_secret, now)?;
Ok(AuthToken::Jwt(user_id))
}
pub struct TokenBundle {
pub access_token: String,
pub refresh_token: String,
pub token_hash: String,
pub token_id: String,
pub expires_at: String,
pub response: AuthTokenResponse,
}
pub fn prepare_token_bundle(
jwt_secret: &str,
user_id: &str,
nickname: &str,
now_unix: u64,
) -> Result<TokenBundle, ServiceError> {
use crate::crypto;
let access_token = crypto::sign_jwt(user_id, jwt_secret, now_unix);
let refresh_token = crypto::generate_token()?;
let token_hash = crypto::hash_token(&refresh_token);
let token_id = uuid::Uuid::new_v4().to_string();
let base = chrono::DateTime::from_timestamp(now_unix as i64, 0)
.ok_or_else(|| ServiceError::Internal("invalid timestamp".into()))?;
let expires_at = base
.checked_add_signed(chrono::Duration::seconds(
crypto::REFRESH_EXPIRY_SECS as i64,
))
.ok_or_else(|| ServiceError::Internal("timestamp overflow".into()))?
.format("%Y-%m-%d %H:%M:%S")
.to_string();
let response = AuthTokenResponse {
access_token: access_token.clone(),
refresh_token: refresh_token.clone(),
expires_in: crypto::JWT_EXPIRY_SECS,
user_id: user_id.to_string(),
nickname: nickname.to_string(),
};
Ok(TokenBundle {
access_token,
refresh_token,
token_hash,
token_id,
expires_at,
response,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_email() {
assert_eq!(
validate_email(" USER@Example.Com ").expect("email should normalize"),
"user@example.com"
);
assert!(validate_email("").is_err());
assert!(validate_email("not-an-email").is_err());
}
#[test]
fn test_validate_password_bounds() {
assert!(validate_password("x".repeat(11).as_str()).is_err());
assert!(validate_password("x".repeat(12).as_str()).is_ok());
assert!(validate_password("x".repeat(128).as_str()).is_ok());
assert!(validate_password("x".repeat(129).as_str()).is_err());
}
#[test]
fn test_validate_nickname() {
assert!(validate_nickname("alice").is_ok());
assert_eq!(validate_nickname(" bob ").unwrap(), "bob");
assert!(validate_nickname("").is_err());
assert!(validate_nickname(" ").is_err());
assert!(validate_nickname(&"x".repeat(65)).is_err());
assert!(validate_nickname(&"x".repeat(64)).is_ok());
}
#[test]
fn test_generate_api_key_prefix_and_length() {
let key = generate_api_key();
assert!(key.starts_with("osk_"));
assert_eq!(key.len(), 36);
}
#[test]
fn test_hash_api_key_is_stable() {
let raw = "osk_1234567890abcdef";
let hash_a = hash_api_key(raw);
let hash_b = hash_api_key(raw);
assert_eq!(hash_a, hash_b);
assert_ne!(hash_a, raw);
assert_eq!(hash_a.len(), 64);
}
#[test]
fn test_key_prefix_truncates_to_twelve_chars() {
assert_eq!(key_prefix("osk_abcdefghijklmnopqrstuvwxyz"), "osk_abcdefgh");
assert_eq!(key_prefix("short"), "short");
}
#[test]
fn test_grace_until_sqlite() {
assert_eq!(
grace_until_sqlite(0).expect("epoch should be valid"),
"1970-01-08 00:00:00"
);
let err = grace_until_sqlite(i64::MIN as u64).expect_err("out-of-range ts must fail");
assert!(matches!(err, ServiceError::Internal(_)));
}
#[test]
fn test_resolve_auth_token_success_paths() {
match resolve_auth_token("osk_live_token", "", 0).expect("api keys should bypass jwt") {
AuthToken::ApiKey(token) => assert_eq!(token, "osk_live_token"),
AuthToken::Jwt(_) => panic!("api key should not resolve as jwt"),
}
let now = 1_700_000_000_u64;
let secret = "jwt-secret";
let jwt = crate::crypto::sign_jwt("user-1", secret, now);
match resolve_auth_token(&jwt, secret, now).expect("valid jwt should resolve") {
AuthToken::Jwt(user_id) => assert_eq!(user_id, "user-1"),
AuthToken::ApiKey(_) => panic!("jwt should not resolve as api key"),
}
}
#[test]
fn test_resolve_auth_token_error_paths() {
let missing_secret = match resolve_auth_token("definitely-not-an-api-key", "", 0) {
Ok(_) => panic!("expected missing jwt secret to fail"),
Err(err) => err,
};
assert!(matches!(missing_secret, ServiceError::Unauthorized(_)));
let invalid_jwt = match resolve_auth_token("definitely-not-a-jwt", "jwt-secret", 0) {
Ok(_) => panic!("expected invalid jwt to fail"),
Err(err) => err,
};
assert!(matches!(invalid_jwt, ServiceError::Unauthorized(_)));
}
#[test]
fn test_prepare_token_bundle_success() {
let now = 1_700_000_000_u64;
let bundle = prepare_token_bundle("jwt-secret", "user-2", "alice", now)
.expect("token bundle should be created");
assert_eq!(bundle.response.user_id, "user-2");
assert_eq!(bundle.response.nickname, "alice");
assert_eq!(bundle.response.access_token, bundle.access_token);
assert_eq!(bundle.response.refresh_token, bundle.refresh_token);
assert_eq!(
bundle.token_hash,
crate::crypto::hash_token(&bundle.refresh_token)
);
assert!(uuid::Uuid::parse_str(&bundle.token_id).is_ok());
assert_eq!(bundle.expires_at.len(), 19);
}
#[test]
fn test_prepare_token_bundle_rejects_invalid_timestamp() {
let err = match prepare_token_bundle("jwt-secret", "user-2", "alice", i64::MIN as u64) {
Ok(_) => panic!("expected out-of-range timestamp to fail"),
Err(err) => err,
};
assert!(matches!(err, ServiceError::Internal(_)));
}
}