Skip to main content

authx_plugins/anonymous/
service.rs

1use argon2::password_hash::{rand_core::OsRng, SaltString};
2use argon2::{Argon2, PasswordHasher};
3use chrono::Utc;
4use tracing::instrument;
5use uuid::Uuid;
6
7use authx_core::{
8    crypto::sha256_hex,
9    error::{AuthError, Result},
10    events::{AuthEvent, EventBus},
11    models::{
12        CreateCredential, CreateSession, CreateUser, CredentialKind, Session, UpdateUser, User,
13    },
14};
15use authx_storage::ports::{CredentialRepository, SessionRepository, UserRepository};
16
17/// The guest auth credentials returned from `create_guest`.
18#[derive(Debug)]
19pub struct GuestSession {
20    pub user: User,
21    pub session: Session,
22    /// Raw session token — show to client once.
23    pub token: String,
24}
25
26/// Anonymous / guest authentication service.
27///
28/// Guest accounts are real `User` rows with a synthetic email and
29/// `metadata: {"guest": true}`. They can be upgraded later.
30pub struct AnonymousService<S> {
31    storage: S,
32    events: EventBus,
33    session_ttl_secs: i64,
34    argon2: Argon2<'static>,
35}
36
37impl<S> AnonymousService<S>
38where
39    S: UserRepository + CredentialRepository + SessionRepository + Clone + Send + Sync + 'static,
40{
41    pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
42        use argon2::{Algorithm, Params, Version};
43        let params = Params::new(65536, 3, 4, None).expect("valid argon2 params");
44        Self {
45            storage,
46            events,
47            session_ttl_secs,
48            argon2: Argon2::new(Algorithm::Argon2id, Version::V0x13, params),
49        }
50    }
51
52    /// Create an anonymous guest session. The returned `User` row has a synthetic
53    /// `guest_<uuid>@authx.guest` email and `metadata: {"guest": true}`.
54    #[instrument(skip(self), fields(ip = %ip))]
55    pub async fn create_guest(&self, ip: &str) -> Result<GuestSession> {
56        let guest_id = Uuid::new_v4();
57        let email = format!("guest_{}@authx.guest", guest_id);
58
59        let user = UserRepository::create(
60            &self.storage,
61            CreateUser {
62                email: email.clone(),
63                username: None,
64                metadata: Some(serde_json::json!({ "guest": true })),
65            },
66        )
67        .await?;
68
69        let raw: [u8; 32] = rand::Rng::gen(&mut rand::thread_rng());
70        let raw_str = hex::encode(raw);
71        let token_hash = sha256_hex(raw_str.as_bytes());
72
73        let session = SessionRepository::create(
74            &self.storage,
75            CreateSession {
76                user_id: user.id,
77                token_hash,
78                device_info: serde_json::Value::Null,
79                ip_address: ip.to_owned(),
80                org_id: None,
81                expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
82            },
83        )
84        .await?;
85
86        self.events
87            .emit(AuthEvent::UserCreated { user: user.clone() });
88        self.events.emit(AuthEvent::SignIn {
89            user: user.clone(),
90            session: session.clone(),
91        });
92        tracing::info!(user_id = %user.id, "guest session created");
93        Ok(GuestSession {
94            user,
95            session,
96            token: raw_str,
97        })
98    }
99
100    /// Upgrade a guest account to a real account by setting a real email + password.
101    ///
102    /// The user row is updated in-place; the guest session remains valid.
103    #[instrument(skip(self, password), fields(guest_user_id = %guest_user_id))]
104    pub async fn upgrade(&self, guest_user_id: Uuid, email: &str, password: &str) -> Result<User> {
105        let user = UserRepository::find_by_id(&self.storage, guest_user_id)
106            .await?
107            .ok_or(AuthError::UserNotFound)?;
108
109        // Only upgrade actual guest accounts.
110        let is_guest = user
111            .metadata
112            .get("guest")
113            .and_then(|v| v.as_bool())
114            .unwrap_or(false);
115        if !is_guest {
116            return Err(AuthError::Forbidden("not a guest account".into()));
117        }
118
119        if password.len() < 8 {
120            return Err(AuthError::WeakPassword);
121        }
122        let salt = SaltString::generate(&mut OsRng);
123        let hash = self
124            .argon2
125            .hash_password(password.as_bytes(), &salt)
126            .map_err(|e| AuthError::Internal(format!("argon2 hash: {e}")))?
127            .to_string();
128
129        let updated = UserRepository::update(
130            &self.storage,
131            guest_user_id,
132            UpdateUser {
133                email: Some(email.to_owned()),
134                metadata: Some(serde_json::json!({ "guest": false })),
135                ..Default::default()
136            },
137        )
138        .await?;
139
140        CredentialRepository::create(
141            &self.storage,
142            CreateCredential {
143                user_id: guest_user_id,
144                kind: CredentialKind::Password,
145                credential_hash: hash,
146                metadata: None,
147            },
148        )
149        .await?;
150
151        self.events.emit(AuthEvent::UserUpdated {
152            user: updated.clone(),
153        });
154        tracing::info!(user_id = %guest_user_id, email = %email, "guest account upgraded");
155        Ok(updated)
156    }
157}