Skip to main content

reifydb_auth/service/
authenticate.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::collections::HashMap;
5
6use reifydb_catalog::catalog::Catalog;
7use reifydb_core::interface::{
8	auth::{AuthStep, AuthenticationProvider},
9	catalog::{authentication::Authentication, identity::Identity},
10};
11use reifydb_transaction::transaction::{Transaction, query::QueryTransaction};
12use reifydb_value::{
13	error::Error,
14	reifydb_assertions,
15	value::{Value, identity::IdentityId},
16};
17use tracing::instrument;
18
19use super::{AuthResponse, AuthService, generate_session_token, solana::SOLANA_PUBLIC_KEY_ATTRIBUTE};
20use crate::error::AuthError;
21
22impl AuthService {
23	#[instrument(name = "auth::authenticate", level = "debug", skip(self, credentials))]
24	pub fn authenticate(&self, method: &str, credentials: HashMap<String, String>) -> Result<AuthResponse, Error> {
25		if let Some(challenge_id) = credentials.get("challenge_id").cloned() {
26			return self.authenticate_challenge_response(method, &challenge_id, credentials);
27		}
28		if method == "token" {
29			return self.authenticate_token(credentials);
30		}
31		if method == "github" {
32			return self.begin_github_login();
33		}
34		self.authenticate_with_provider(method, credentials)
35	}
36
37	fn authenticate_with_provider(
38		&self,
39		method: &str,
40		credentials: HashMap<String, String>,
41	) -> Result<AuthResponse, Error> {
42		let identifier = credentials.get("identifier").map(|s| s.as_str()).unwrap_or("");
43		let mut txn = self.engine.begin_query()?;
44		let catalog = self.engine.catalog();
45
46		let Some(ident) = self.resolve_provider_identity(&mut txn, &catalog, method, identifier)? else {
47			drop(txn);
48			return self.handle_missing_identity(method, identifier, &credentials);
49		};
50		if !ident.enabled {
51			return Ok(AuthResponse::Failed {
52				reason: "identity is disabled".to_string(),
53			});
54		}
55
56		let Some(stored_auth) = self.load_stored_auth(&mut txn, &catalog, ident.id, method)? else {
57			return Ok(invalid_credentials());
58		};
59
60		self.run_provider_and_respond(&stored_auth, &credentials, ident.id, identifier, method)
61	}
62
63	#[inline]
64	fn resolve_provider_identity(
65		&self,
66		txn: &mut QueryTransaction,
67		catalog: &Catalog,
68		method: &str,
69		identifier: &str,
70	) -> Result<Option<Identity>, Error> {
71		if let Some(u) = catalog.find_identity_by_name(&mut Transaction::Query(txn), identifier)? {
72			return Ok(Some(u));
73		}
74		if method == "solana" {
75			return catalog.find_identity_by_attribute_value(
76				&mut Transaction::Query(txn),
77				SOLANA_PUBLIC_KEY_ATTRIBUTE,
78				&Value::Utf8(identifier.to_string()),
79			);
80		}
81		Ok(None)
82	}
83
84	fn load_stored_auth(
85		&self,
86		txn: &mut QueryTransaction,
87		catalog: &Catalog,
88		identity: IdentityId,
89		method: &str,
90	) -> Result<Option<Authentication>, Error> {
91		catalog.find_authentication_by_identity_and_method(&mut Transaction::Query(txn), identity, method)
92	}
93
94	#[inline]
95	fn run_provider_and_respond(
96		&self,
97		stored_auth: &Authentication,
98		credentials: &HashMap<String, String>,
99		identity: IdentityId,
100		identifier: &str,
101		method: &str,
102	) -> Result<AuthResponse, Error> {
103		let provider = self.provider_for(method)?;
104		let step = provider.authenticate(&stored_auth.properties, credentials)?;
105		self.respond_to_initial_auth_step(step, identity, identifier, method)
106	}
107
108	#[inline]
109	fn handle_missing_identity(
110		&self,
111		method: &str,
112		identifier: &str,
113		credentials: &HashMap<String, String>,
114	) -> Result<AuthResponse, Error> {
115		if method == "solana"
116			&& let Some(public_key) = credentials.get("public_key").cloned()
117		{
118			return self.begin_solana_provision(identifier, &public_key, credentials);
119		}
120		Ok(invalid_credentials())
121	}
122
123	#[inline]
124	fn respond_to_initial_auth_step(
125		&self,
126		step: AuthStep,
127		identity: IdentityId,
128		identifier: &str,
129		method: &str,
130	) -> Result<AuthResponse, Error> {
131		match step {
132			AuthStep::Authenticated => self.finalize_authentication(identity),
133			AuthStep::Failed => Ok(invalid_credentials()),
134			AuthStep::Rejected {
135				reason,
136			} => Ok(AuthResponse::Failed {
137				reason,
138			}),
139			AuthStep::Challenge {
140				payload,
141			} => Ok(self.issue_challenge(identifier, method, payload)),
142		}
143	}
144
145	#[inline]
146	pub(super) fn finalize_authentication(&self, identity: IdentityId) -> Result<AuthResponse, Error> {
147		reifydb_assertions! {
148			assert!(
149				identity != IdentityId::default(),
150				"authentication finalized for the nil placeholder identity instead of a resolved one, so an unauthenticated principal would receive a valid session token and gain authorization (identity={:?})",
151				identity
152			);
153		}
154		let token = generate_session_token(&self.rng);
155		self.persist_token(&token, identity)?;
156		Ok(AuthResponse::Authenticated {
157			identity,
158			token,
159		})
160	}
161
162	#[inline]
163	fn issue_challenge(&self, identifier: &str, method: &str, payload: HashMap<String, String>) -> AuthResponse {
164		let challenge_id = self.challenges.create(
165			identifier.to_string(),
166			method.to_string(),
167			payload.clone(),
168			None,
169			&self.clock,
170			&self.rng,
171		);
172		AuthResponse::Challenge {
173			challenge_id,
174			payload,
175		}
176	}
177
178	#[inline]
179	fn provider_for(&self, method: &str) -> Result<&dyn AuthenticationProvider, Error> {
180		self.auth_registry.get(method).ok_or_else(|| {
181			Error::from(AuthError::UnknownMethod {
182				method: method.to_string(),
183			})
184		})
185	}
186
187	fn authenticate_token(&self, credentials: HashMap<String, String>) -> Result<AuthResponse, Error> {
188		let token_value = match credentials.get("token") {
189			Some(t) if !t.is_empty() => t,
190			_ => return Ok(invalid_credentials()),
191		};
192
193		match self.validate_token(token_value)? {
194			Some(token) => self.finalize_authentication(token.identity),
195			None => Ok(invalid_credentials()),
196		}
197	}
198
199	fn authenticate_challenge_response(
200		&self,
201		method: &str,
202		challenge_id: &str,
203		mut credentials: HashMap<String, String>,
204	) -> Result<AuthResponse, Error> {
205		let Some(challenge) = self.challenges.consume(challenge_id) else {
206			return Ok(AuthResponse::Failed {
207				reason: "invalid or expired challenge".to_string(),
208			});
209		};
210
211		if method != challenge.method {
212			return Ok(AuthResponse::Failed {
213				reason: "challenge was issued for a different method".to_string(),
214			});
215		}
216
217		if challenge.method == "github" {
218			return self.complete_github_login(&challenge, &credentials);
219		}
220
221		if let Some(public_key) = challenge.pending_public_key.as_deref() {
222			return self.complete_solana_provision(
223				&challenge.identifier,
224				public_key,
225				&challenge.payload,
226				&credentials,
227			);
228		}
229
230		merge_challenge_payload(&mut credentials, &challenge.payload);
231
232		let mut txn = self.engine.begin_query()?;
233		let catalog = self.engine.catalog();
234
235		let Some(ident) =
236			self.resolve_challenge_identity(&mut txn, &catalog, &challenge.identifier, &challenge.method)?
237		else {
238			return Ok(invalid_credentials());
239		};
240
241		let Some(stored_auth) = self.load_stored_auth(&mut txn, &catalog, ident.id, &challenge.method)? else {
242			return Ok(invalid_credentials());
243		};
244
245		self.run_challenge_provider_and_respond(
246			&stored_auth,
247			&challenge.payload,
248			&credentials,
249			ident.id,
250			&challenge.method,
251		)
252	}
253
254	#[inline]
255	fn resolve_challenge_identity(
256		&self,
257		txn: &mut QueryTransaction,
258		catalog: &Catalog,
259		identifier: &str,
260		method: &str,
261	) -> Result<Option<Identity>, Error> {
262		let resolved = match catalog.find_identity_by_name(&mut Transaction::Query(txn), identifier)? {
263			Some(u) if u.enabled => Some(u),
264			Some(_) => None,
265			None if method == "solana" => {
266				match catalog.find_identity_by_attribute_value(
267					&mut Transaction::Query(txn),
268					SOLANA_PUBLIC_KEY_ATTRIBUTE,
269					&Value::Utf8(identifier.to_string()),
270				)? {
271					Some(u) if u.enabled => Some(u),
272					_ => None,
273				}
274			}
275			None => None,
276		};
277		reifydb_assertions! {
278			if let Some(ref ident) = resolved {
279				assert!(
280					ident.enabled,
281					"challenge identity resolution returned a disabled identity (id={:?}, name={}); a disabled principal must never advance to provider authentication or it could obtain a session token",
282					ident.id,
283					ident.name
284				);
285			}
286		}
287		Ok(resolved)
288	}
289
290	#[inline]
291	fn run_challenge_provider_and_respond(
292		&self,
293		stored_auth: &Authentication,
294		challenge_payload: &HashMap<String, String>,
295		credentials: &HashMap<String, String>,
296		identity: IdentityId,
297		method: &str,
298	) -> Result<AuthResponse, Error> {
299		let provider = self.provider_for(method)?;
300		let step = provider.verify_challenge(&stored_auth.properties, challenge_payload, credentials)?;
301		respond_to_challenge_step(step, identity, self)
302	}
303}
304
305#[inline]
306fn merge_challenge_payload(credentials: &mut HashMap<String, String>, payload: &HashMap<String, String>) {
307	for (k, v) in payload {
308		credentials.insert(k.clone(), v.clone());
309	}
310	credentials.remove("challenge_id");
311}
312
313#[inline]
314fn respond_to_challenge_step(
315	step: AuthStep,
316	identity: IdentityId,
317	service: &AuthService,
318) -> Result<AuthResponse, Error> {
319	match step {
320		AuthStep::Authenticated => service.finalize_authentication(identity),
321		AuthStep::Failed => Ok(invalid_credentials()),
322		AuthStep::Rejected {
323			reason,
324		} => Ok(AuthResponse::Failed {
325			reason,
326		}),
327		AuthStep::Challenge {
328			..
329		} => Ok(AuthResponse::Failed {
330			reason: "nested challenges are not supported".to_string(),
331		}),
332	}
333}
334
335#[inline]
336fn invalid_credentials() -> AuthResponse {
337	AuthResponse::Failed {
338		reason: "invalid credentials".to_string(),
339	}
340}