fix: token hash mismatch — hash hex string, not raw bytes

Critical auth bug: token creation hashed the raw 32 bytes
(hasher.update(token_bytes)) but authentication hashed the hex-encoded
string (hash_token(candidate) where candidate is the hex string the
user sends). This meant newly created tokens could never authenticate.

Fixed all 4 token creation sites (users, tokens, invitations create,
invitations accept) to use hash_token(&plaintext_token) which hashes
the hex string consistently with the auth lookup path.

Removed now-unused sha2::Digest imports from handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-25 00:04:51 -07:00
co-authored by Claude Opus 4.6
parent df42a389a2
commit 34883aaa6c
3 changed files with 11 additions and 29 deletions
+5 -19
View File
@@ -5,7 +5,6 @@ use std::sync::Arc;
use axum::{Json, extract::State, http::StatusCode};
use rand::RngCore;
use rand::rngs::OsRng;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
@@ -37,11 +36,7 @@ pub async fn invitations_create_handler(
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
// SHA-256 hash for storage — plaintext is never persisted.
let mut hasher = Sha256::new();
hasher.update(token_bytes);
let hash: [u8; 32] = hasher.finalize().into();
let hash = crate::channels::web::auth::hash_token(&plaintext_token);
let invitation_id = Uuid::new_v4();
let invitation = InvitationRecord {
@@ -129,15 +124,9 @@ pub async fn invitations_accept_handler(
.to_string();
// Hash the provided token to look up the invitation.
let token_bytes = hex::decode(invite_token).map_err(|_| {
(
StatusCode::BAD_REQUEST,
"Invalid invite token format".to_string(),
)
})?;
let mut hasher = Sha256::new();
hasher.update(&token_bytes);
let hash: [u8; 32] = hasher.finalize().into();
// Hash the plaintext token string (not decoded bytes) — must match
// how it was hashed during invitation creation via hash_token().
let hash = crate::channels::web::auth::hash_token(invite_token);
// Look up the invitation by hash.
let invitation = store
@@ -187,10 +176,7 @@ pub async fn invitations_accept_handler(
let mut api_token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut api_token_bytes);
let plaintext_api_token = hex::encode(api_token_bytes);
let mut api_hasher = Sha256::new();
api_hasher.update(api_token_bytes);
let api_hash: [u8; 32] = api_hasher.finalize().into();
let api_hash = crate::channels::web::auth::hash_token(&plaintext_api_token);
let api_prefix = &plaintext_api_token[..8];
+3 -6
View File
@@ -9,7 +9,6 @@ use axum::{
};
use rand::RngCore;
use rand::rngs::OsRng;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::channels::web::auth::AuthenticatedUser;
@@ -41,14 +40,12 @@ pub async fn tokens_create_handler(
expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days as i64));
// Generate 32 random bytes for the token.
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
// NOT the raw bytes — must match hash_token() in auth.rs.
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
// SHA-256 hash for storage — plaintext is never persisted.
let mut hasher = Sha256::new();
hasher.update(token_bytes);
let hash: [u8; 32] = hasher.finalize().into();
let hash = crate::channels::web::auth::hash_token(&plaintext_token);
// First 8 chars of the hex token as a prefix for identification.
let token_prefix = &plaintext_token[..8];
+3 -4
View File
@@ -9,7 +9,6 @@ use axum::{
};
use rand::RngCore;
use rand::rngs::OsRng;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
@@ -71,12 +70,12 @@ pub async fn users_create_handler(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Generate a first API token so the new user can authenticate immediately.
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
// NOT the raw bytes — must match hash_token() in auth.rs.
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
let mut hasher = Sha256::new();
hasher.update(token_bytes);
let token_hash: [u8; 32] = hasher.finalize().into();
let token_hash = crate::channels::web::auth::hash_token(&plaintext_token);
let token_prefix = &plaintext_token[..8];
let _token_record = store