Skip to main content

reifydb_auth/service/
authenticate.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2025 ReifyDB
3
4use std::collections::HashMap;
5
6use reifydb_core::interface::auth::{AuthStep, AuthenticationProvider};
7use reifydb_transaction::transaction::Transaction;
8use reifydb_type::{error::Error, value::identity::IdentityId};
9use tracing::instrument;
10
11use super::{AuthResponse, AuthService, generate_session_token};
12use crate::error::AuthError;
13
14impl AuthService {
15	/// Authenticate an identity with the given method and credentials.
16	///
17	/// For single-step methods (password, token), returns `Authenticated` or `Failed`.
18	/// For challenge-response methods, may return `Challenge` first, then `Authenticated`
19	/// on the second call with the challenge response.
20	///
21	/// For Solana authentication, if the identity does not exist and the credentials contain
22	/// a `public_key`, the identity and authentication method are auto-provisioned before
23	/// proceeding with the challenge-response flow.
24	#[instrument(name = "auth::authenticate", level = "debug", skip(self, credentials))]
25	pub fn authenticate(&self, method: &str, credentials: HashMap<String, String>) -> Result<AuthResponse, Error> {
26		if let Some(challenge_id) = credentials.get("challenge_id").cloned() {
27			return self.authenticate_challenge_response(&challenge_id, credentials);
28		}
29		if method == "token" {
30			return self.authenticate_token(credentials);
31		}
32		self.authenticate_with_provider(method, credentials)
33	}
34
35	fn authenticate_with_provider(
36		&self,
37		method: &str,
38		credentials: HashMap<String, String>,
39	) -> Result<AuthResponse, Error> {
40		let identifier = credentials.get("identifier").map(|s| s.as_str()).unwrap_or("");
41		let mut txn = self.engine.begin_query()?;
42		let catalog = self.engine.catalog();
43
44		let ident = match catalog.find_identity_by_name(&mut Transaction::Query(&mut txn), identifier)? {
45			Some(u) => u,
46			None => {
47				drop(txn);
48				return self.handle_missing_identity(method, identifier, &credentials);
49			}
50		};
51		if !ident.enabled {
52			return Ok(AuthResponse::Failed {
53				reason: "identity is disabled".to_string(),
54			});
55		}
56
57		let Some(stored_auth) = catalog.find_authentication_by_identity_and_method(
58			&mut Transaction::Query(&mut txn),
59			ident.id,
60			method,
61		)?
62		else {
63			return Ok(invalid_credentials());
64		};
65
66		let provider = self.provider_for(method)?;
67		let step = provider.authenticate(&stored_auth.properties, &credentials)?;
68		self.respond_to_initial_auth_step(step, ident.id, identifier, method)
69	}
70
71	#[inline]
72	fn handle_missing_identity(
73		&self,
74		method: &str,
75		identifier: &str,
76		credentials: &HashMap<String, String>,
77	) -> Result<AuthResponse, Error> {
78		if method == "solana"
79			&& let Some(public_key) = credentials.get("public_key").cloned()
80		{
81			return self.auto_provision_solana(identifier, &public_key, credentials);
82		}
83		Ok(invalid_credentials())
84	}
85
86	#[inline]
87	fn respond_to_initial_auth_step(
88		&self,
89		step: AuthStep,
90		identity: IdentityId,
91		identifier: &str,
92		method: &str,
93	) -> Result<AuthResponse, Error> {
94		match step {
95			AuthStep::Authenticated => self.finalize_authentication(identity),
96			AuthStep::Failed => Ok(invalid_credentials()),
97			AuthStep::Challenge {
98				payload,
99			} => Ok(self.issue_challenge(identifier, method, payload)),
100		}
101	}
102
103	#[inline]
104	fn finalize_authentication(&self, identity: IdentityId) -> Result<AuthResponse, Error> {
105		let token = generate_session_token(&self.rng);
106		self.persist_token(&token, identity)?;
107		Ok(AuthResponse::Authenticated {
108			identity,
109			token,
110		})
111	}
112
113	#[inline]
114	fn issue_challenge(&self, identifier: &str, method: &str, payload: HashMap<String, String>) -> AuthResponse {
115		let challenge_id = self.challenges.create(
116			identifier.to_string(),
117			method.to_string(),
118			payload.clone(),
119			&self.clock,
120			&self.rng,
121		);
122		AuthResponse::Challenge {
123			challenge_id,
124			payload,
125		}
126	}
127
128	#[inline]
129	fn provider_for(&self, method: &str) -> Result<&dyn AuthenticationProvider, Error> {
130		self.auth_registry.get(method).ok_or_else(|| {
131			Error::from(AuthError::UnknownMethod {
132				method: method.to_string(),
133			})
134		})
135	}
136
137	fn authenticate_token(&self, credentials: HashMap<String, String>) -> Result<AuthResponse, Error> {
138		let token_value = match credentials.get("token") {
139			Some(t) if !t.is_empty() => t,
140			_ => return Ok(invalid_credentials()),
141		};
142
143		match self.validate_token(token_value) {
144			Some(token) => self.finalize_authentication(token.identity),
145			None => Ok(invalid_credentials()),
146		}
147	}
148
149	/// Complete a challenge-response authentication flow.
150	fn authenticate_challenge_response(
151		&self,
152		challenge_id: &str,
153		mut credentials: HashMap<String, String>,
154	) -> Result<AuthResponse, Error> {
155		let Some(challenge) = self.challenges.consume(challenge_id) else {
156			return Ok(AuthResponse::Failed {
157				reason: "invalid or expired challenge".to_string(),
158			});
159		};
160
161		merge_challenge_payload(&mut credentials, &challenge.payload);
162
163		let mut txn = self.engine.begin_query()?;
164		let catalog = self.engine.catalog();
165
166		let ident = match catalog
167			.find_identity_by_name(&mut Transaction::Query(&mut txn), &challenge.identifier)?
168		{
169			Some(u) if u.enabled => u,
170			_ => return Ok(invalid_credentials()),
171		};
172
173		let Some(stored_auth) = catalog.find_authentication_by_identity_and_method(
174			&mut Transaction::Query(&mut txn),
175			ident.id,
176			&challenge.method,
177		)?
178		else {
179			return Ok(invalid_credentials());
180		};
181
182		let provider = self.provider_for(&challenge.method)?;
183		let step = provider.authenticate(&stored_auth.properties, &credentials)?;
184		respond_to_challenge_step(step, ident.id, self)
185	}
186}
187
188#[inline]
189fn merge_challenge_payload(credentials: &mut HashMap<String, String>, payload: &HashMap<String, String>) {
190	for (k, v) in payload {
191		credentials.entry(k.clone()).or_insert_with(|| v.clone());
192	}
193	credentials.remove("challenge_id");
194}
195
196#[inline]
197fn respond_to_challenge_step(
198	step: AuthStep,
199	identity: IdentityId,
200	service: &AuthService,
201) -> Result<AuthResponse, Error> {
202	match step {
203		AuthStep::Authenticated => service.finalize_authentication(identity),
204		AuthStep::Failed => Ok(invalid_credentials()),
205		AuthStep::Challenge {
206			..
207		} => Ok(AuthResponse::Failed {
208			reason: "nested challenges are not supported".to_string(),
209		}),
210	}
211}
212
213#[inline]
214fn invalid_credentials() -> AuthResponse {
215	AuthResponse::Failed {
216		reason: "invalid credentials".to_string(),
217	}
218}