fraiseql_auth/multi_provider.rs
1//! Multi-provider authentication — unified entry point for social login.
2//!
3//! Enables `GET /auth/v1/authorize?provider=github&redirect_uri=...` with
4//! automatic provider resolution and state-encoded provider tracking through
5//! the OAuth callback.
6
7use std::{collections::HashMap, sync::Arc};
8
9use axum::{
10 Json,
11 extract::{Query, State},
12 http::StatusCode,
13 response::{IntoResponse, Redirect, Response},
14};
15use serde::{Deserialize, Serialize};
16use url::Url;
17
18use crate::{
19 account_linking::{AccountStore, TrustedEmailProviders},
20 audit::logger::{AuditEventType, SecretType, get_audit_logger},
21 handlers::generate_secure_state,
22 provider::OAuthProvider,
23 session::SessionStore,
24 state_store::StateStore,
25};
26
27/// Maximum length for the `redirect_uri` query parameter.
28const MAX_REDIRECT_URI_BYTES: usize = 2_048;
29
30/// Maximum length for the `provider` query parameter.
31const MAX_PROVIDER_NAME_BYTES: usize = 128;
32
33/// Separator between the provider name and the bound `redirect_uri` in the stored CSRF
34/// state value (#427). A newline cannot appear in a provider name or a (percent-encoded)
35/// URI, so this round-trips unambiguously; a value with no separator is a legacy
36/// provider-only entry with no bound redirect.
37const STATE_VALUE_SEPARATOR: char = '\n';
38
39/// Returns `true` if `candidate` is permitted by the `redirect_uri` allow-list (#427).
40///
41/// A candidate is allowed when some allow-list entry has the **same scheme, host, and
42/// port**, and the entry's path is a **path-boundary prefix** of the candidate's path
43/// (an exact path, or the entry ends at a `/` boundary). Host comparison is exact:
44/// `https://app.example.com` does **not** match `https://app.example.com.evil.com`, and
45/// an entry path of `/cb` does not match `/cbEVIL`. An empty allow-list permits nothing;
46/// an unparseable candidate is rejected.
47#[must_use]
48pub fn is_redirect_uri_allowed(candidate: &str, allowlist: &[String]) -> bool {
49 let Ok(candidate_url) = Url::parse(candidate) else {
50 return false;
51 };
52 allowlist.iter().any(|entry| {
53 Url::parse(entry).is_ok_and(|entry_url| redirect_uri_matches(&candidate_url, &entry_url))
54 })
55}
56
57/// Match a single candidate URL against a single allow-list entry URL (see
58/// [`is_redirect_uri_allowed`]).
59fn redirect_uri_matches(candidate: &Url, entry: &Url) -> bool {
60 if candidate.scheme() != entry.scheme()
61 || candidate.host_str() != entry.host_str()
62 || candidate.port_or_known_default() != entry.port_or_known_default()
63 {
64 return false;
65 }
66 let (candidate_path, entry_path) = (candidate.path(), entry.path());
67 candidate_path == entry_path
68 || candidate_path
69 .strip_prefix(entry_path)
70 .is_some_and(|rest| entry_path.ends_with('/') || rest.starts_with('/'))
71}
72
73/// Encode the CSRF-state stored value, optionally binding a validated `redirect_uri`.
74fn encode_state_value(provider: &str, redirect_uri: Option<&str>) -> String {
75 match redirect_uri {
76 Some(uri) => format!("{provider}{STATE_VALUE_SEPARATOR}{uri}"),
77 None => provider.to_string(),
78 }
79}
80
81/// Decode the CSRF-state stored value into `(provider, bound_redirect_uri)`.
82fn decode_state_value(value: &str) -> (String, Option<String>) {
83 match value.split_once(STATE_VALUE_SEPARATOR) {
84 Some((provider, uri)) => (provider.to_string(), Some(uri.to_string())),
85 None => (value.to_string(), None),
86 }
87}
88
89/// Build the fragment-delivery redirect URL for the implicit-style token hand-off (#427).
90///
91/// Tokens are placed in the URL fragment (`#…`), which browsers neither send to servers nor
92/// include in the `Referer` header — the standard OAuth implicit-flow delivery tradeoff (the
93/// tokens remain visible in browser history). The `redirect_uri` has already been validated
94/// against the allow-list before reaching here.
95fn build_redirect_with_tokens(
96 redirect_uri: &str,
97 access_token: &str,
98 refresh_token: &str,
99 expires_in: u64,
100 provider: &str,
101) -> String {
102 format!(
103 "{redirect_uri}#access_token={}&token_type=Bearer&expires_in={expires_in}&refresh_token={}&provider={}",
104 urlencoding::encode(access_token),
105 urlencoding::encode(refresh_token),
106 urlencoding::encode(provider),
107 )
108}
109
110/// Shared state for the multi-provider auth endpoints.
111#[derive(Clone)]
112pub struct MultiProviderAuthState {
113 /// OAuth providers keyed by name (e.g., "github", "google").
114 providers: HashMap<String, Arc<dyn OAuthProvider>>,
115 /// CSRF state store (in-memory or Redis).
116 state_store: Arc<dyn StateStore>,
117 /// Session backend for creating sessions after successful auth.
118 session_store: Arc<dyn SessionStore>,
119 /// Optional user store for account linking (same email → same user).
120 user_store: Option<Arc<dyn AccountStore>>,
121 /// Providers trusted to assert email verification for cross-provider auto-linking (#368).
122 ///
123 /// A provider's `email_verified = true` is honored for merging onto an existing
124 /// email-keyed account **only** when the provider is in this set; otherwise the claim is
125 /// downgraded to unverified and the identity is keyed on `(provider, provider_id)`, so an
126 /// untrusted IdP can never collapse into another user's account (fail-closed, mirrors H26).
127 /// Defaults to [`TrustedEmailProviders::builtin_default`] (`google` + `apple`).
128 trusted_email_providers: TrustedEmailProviders,
129 /// Allow-list of permitted `redirect_uri` values (#427).
130 ///
131 /// When **empty** (the default), `callback` returns the session tokens as JSON and no
132 /// server-side redirect is performed — there is no open-redirect surface because the
133 /// client-supplied `redirect_uri` is never used as a redirect target. When **non-empty**,
134 /// `authorize` rejects any `redirect_uri` not matched by the list (400), binds the
135 /// validated URI to the CSRF state token, and `callback` performs an implicit-style
136 /// fragment redirect to it. Entries are matched by scheme + host + port + path-boundary
137 /// prefix (see [`is_redirect_uri_allowed`]).
138 redirect_uri_allowlist: Vec<String>,
139}
140
141impl MultiProviderAuthState {
142 /// Create a new multi-provider auth state.
143 pub fn new(state_store: Arc<dyn StateStore>, session_store: Arc<dyn SessionStore>) -> Self {
144 Self {
145 providers: HashMap::new(),
146 state_store,
147 session_store,
148 user_store: None,
149 trusted_email_providers: TrustedEmailProviders::default(),
150 redirect_uri_allowlist: Vec::new(),
151 }
152 }
153
154 /// Set the user store for account linking.
155 ///
156 /// When set, the callback handler uses [`AccountStore::link_or_create_user`] to
157 /// resolve provider identities to local users, enabling automatic account
158 /// linking when the same email appears across different providers.
159 pub fn with_user_store(mut self, user_store: Arc<dyn AccountStore>) -> Self {
160 self.user_store = Some(user_store);
161 self
162 }
163
164 /// Set which providers are trusted to assert email verification for cross-provider
165 /// auto-linking (#368).
166 ///
167 /// Defaults to [`TrustedEmailProviders::builtin_default`] (`google` + `apple`). Pass
168 /// [`TrustedEmailProviders::none`] for a high-assurance "trust no one" posture, or
169 /// build a custom set with [`TrustedEmailProviders::trust`] / `distrust`. A provider not
170 /// in the set never auto-links on email even when it claims `email_verified = true`.
171 #[must_use = "builder method returns the modified state"]
172 pub fn with_trusted_email_providers(mut self, trusted: TrustedEmailProviders) -> Self {
173 self.trusted_email_providers = trusted;
174 self
175 }
176
177 /// Configure the allow-list of permitted `redirect_uri` values (#427).
178 ///
179 /// Enabling this turns on the server-side redirect flow: `authorize` rejects any
180 /// `redirect_uri` not on the list, and `callback` redirects the browser to the
181 /// validated URI with the tokens delivered in the URL fragment (OAuth implicit style).
182 /// With no allow-list configured, the legacy JSON-token response is preserved.
183 #[must_use = "builder method returns the modified state"]
184 pub fn with_redirect_uri_allowlist(mut self, allowlist: Vec<String>) -> Self {
185 self.redirect_uri_allowlist = allowlist;
186 self
187 }
188
189 /// Register an OAuth provider under the given name.
190 pub fn register_provider(&mut self, name: impl Into<String>, provider: Arc<dyn OAuthProvider>) {
191 self.providers.insert(name.into(), provider);
192 }
193
194 /// List the names of all registered providers.
195 #[must_use]
196 pub fn provider_names(&self) -> Vec<String> {
197 let mut names: Vec<String> = self.providers.keys().cloned().collect();
198 names.sort();
199 names
200 }
201
202 /// Look up a provider by name.
203 #[must_use]
204 pub fn get_provider(&self, name: &str) -> Option<&Arc<dyn OAuthProvider>> {
205 self.providers.get(name)
206 }
207}
208
209// ---------------------------------------------------------------------------
210// Query / response types
211// ---------------------------------------------------------------------------
212
213/// Query parameters for `GET /auth/v1/authorize`.
214#[derive(Debug, Deserialize)]
215pub struct AuthorizeQuery {
216 /// Provider name (e.g., "github", "google").
217 pub provider: String,
218 /// Client application callback URI.
219 pub redirect_uri: String,
220}
221
222/// Query parameters for `GET /auth/v1/callback`.
223#[derive(Debug, Deserialize)]
224pub struct CallbackQuery {
225 /// Authorization code from the provider.
226 pub code: Option<String>,
227 /// CSRF state token.
228 pub state: Option<String>,
229 /// Provider error code.
230 pub error: Option<String>,
231 /// Provider error description.
232 pub error_description: Option<String>,
233}
234
235/// Response for `GET /auth/v1/providers`.
236#[derive(Debug, Serialize)]
237pub struct ProvidersResponse {
238 /// Available provider names.
239 pub providers: Vec<String>,
240}
241
242/// Token response returned after a successful callback.
243#[derive(Debug, Serialize)]
244pub struct AuthTokenResponse {
245 /// Access token for API requests.
246 pub access_token: String,
247 /// Refresh token (if available).
248 #[serde(skip_serializing_if = "Option::is_none")]
249 pub refresh_token: Option<String>,
250 /// Token type (always "Bearer").
251 pub token_type: String,
252 /// Seconds until the access token expires.
253 pub expires_in: u64,
254 /// Provider that authenticated the user.
255 pub provider: String,
256}
257
258impl AuthTokenResponse {
259 /// Returns a builder for `AuthTokenResponse`.
260 #[must_use = "builder does nothing until .build() is called"]
261 pub fn builder() -> AuthTokenResponseBuilder {
262 AuthTokenResponseBuilder::default()
263 }
264}
265
266/// Builder for [`AuthTokenResponse`].
267#[derive(Debug, Default)]
268pub struct AuthTokenResponseBuilder {
269 access_token: Option<String>,
270 refresh_token: Option<String>,
271 token_type: Option<String>,
272 expires_in: Option<u64>,
273 provider: Option<String>,
274}
275
276impl AuthTokenResponseBuilder {
277 /// Sets the access token.
278 pub fn access_token(mut self, access_token: impl Into<String>) -> Self {
279 self.access_token = Some(access_token.into());
280 self
281 }
282
283 /// Sets the refresh token.
284 pub fn refresh_token(mut self, refresh_token: impl Into<String>) -> Self {
285 self.refresh_token = Some(refresh_token.into());
286 self
287 }
288
289 /// Sets the token type (typically `"Bearer"`).
290 pub fn token_type(mut self, token_type: impl Into<String>) -> Self {
291 self.token_type = Some(token_type.into());
292 self
293 }
294
295 /// Sets the number of seconds until the access token expires.
296 #[must_use = "builder method returns modified builder"]
297 pub const fn expires_in(mut self, expires_in: u64) -> Self {
298 self.expires_in = Some(expires_in);
299 self
300 }
301
302 /// Sets the provider that authenticated the user.
303 pub fn provider(mut self, provider: impl Into<String>) -> Self {
304 self.provider = Some(provider.into());
305 self
306 }
307
308 /// Builds the [`AuthTokenResponse`].
309 ///
310 /// # Errors
311 ///
312 /// Returns an error string if any required field (`access_token`, `token_type`,
313 /// `expires_in`, or `provider`) was not set.
314 pub fn build(self) -> Result<AuthTokenResponse, String> {
315 Ok(AuthTokenResponse {
316 access_token: self
317 .access_token
318 .ok_or("AuthTokenResponse: access_token is required")?,
319 refresh_token: self.refresh_token,
320 token_type: self.token_type.ok_or("AuthTokenResponse: token_type is required")?,
321 expires_in: self.expires_in.ok_or("AuthTokenResponse: expires_in is required")?,
322 provider: self.provider.ok_or("AuthTokenResponse: provider is required")?,
323 })
324 }
325}
326
327// ---------------------------------------------------------------------------
328// Helpers
329// ---------------------------------------------------------------------------
330
331fn json_error(status: StatusCode, message: &str) -> Response {
332 (status, Json(serde_json::json!({ "error": message }))).into_response()
333}
334
335/// Effective email-verified flag for account linking (#368): a provider's claimed
336/// verification is honored only when the provider is trusted to assert it. An untrusted
337/// provider's `email_verified = true` is downgraded to `false`, so the identity is keyed on
338/// `(provider, provider_id)` and can never collapse into an existing email-keyed account.
339fn effective_email_verified(
340 trusted: &TrustedEmailProviders,
341 provider: &str,
342 claimed_verified: bool,
343) -> bool {
344 claimed_verified && trusted.is_trusted(provider)
345}
346
347// ---------------------------------------------------------------------------
348// GET /auth/v1/providers
349// ---------------------------------------------------------------------------
350
351/// List available authentication providers.
352///
353/// # Responses
354///
355/// - `200` JSON `{ providers: ["github", "google", ...] }`
356pub async fn list_providers(
357 State(state): State<Arc<MultiProviderAuthState>>,
358) -> Json<ProvidersResponse> {
359 Json(ProvidersResponse {
360 providers: state.provider_names(),
361 })
362}
363
364// ---------------------------------------------------------------------------
365// GET /auth/v1/authorize
366// ---------------------------------------------------------------------------
367
368/// Initiate the OAuth flow for a specific provider.
369///
370/// Generates a CSRF state token, stores it with the provider name, then
371/// redirects to the provider's authorization URL.
372///
373/// # Query parameters
374///
375/// - `provider` — **required**: provider name (must match a registered provider).
376/// - `redirect_uri` — **required**: client application callback URI. Always validated for presence
377/// and length. When a redirect-URI allow-list is configured (#427, via
378/// [`MultiProviderAuthState::with_redirect_uri_allowlist`]), it must additionally match the
379/// allow-list (else `400`); the validated URI is bound to the CSRF state and [`callback`]
380/// redirects the browser to it with the tokens in the fragment. With no allow-list configured,
381/// the URI is **not** used as a redirect target and [`callback`] returns the session tokens as
382/// JSON — so there is no open-redirect surface in that mode.
383///
384/// # Responses
385///
386/// - `302` — redirect to the provider's authorization endpoint.
387/// - `400` — missing or invalid parameters, unknown provider.
388///
389/// # Errors
390///
391/// Returns a `400` JSON error if the provider is unknown, redirect_uri is empty/oversized,
392/// or the state store is at capacity.
393pub async fn authorize(
394 State(state): State<Arc<MultiProviderAuthState>>,
395 Query(q): Query<AuthorizeQuery>,
396) -> Response {
397 // Validate provider name length
398 if q.provider.len() > MAX_PROVIDER_NAME_BYTES {
399 return json_error(StatusCode::BAD_REQUEST, "provider name exceeds maximum length");
400 }
401
402 // Validate redirect_uri
403 if q.redirect_uri.is_empty() {
404 return json_error(StatusCode::BAD_REQUEST, "redirect_uri is required");
405 }
406 if q.redirect_uri.len() > MAX_REDIRECT_URI_BYTES {
407 return json_error(StatusCode::BAD_REQUEST, "redirect_uri exceeds maximum length");
408 }
409
410 // #427: when an allow-list is configured the `redirect_uri` must match it, and is then
411 // bound to the CSRF state for a server-side redirect in `callback`. With no allow-list
412 // the URI is never used as a redirect target (JSON-token response), so there is no
413 // open-redirect surface and only presence/length are enforced.
414 let bound_redirect_uri = if state.redirect_uri_allowlist.is_empty() {
415 None
416 } else if is_redirect_uri_allowed(&q.redirect_uri, &state.redirect_uri_allowlist) {
417 Some(q.redirect_uri.clone())
418 } else {
419 return json_error(StatusCode::BAD_REQUEST, "redirect_uri is not allow-listed");
420 };
421
422 // Look up provider
423 let Some(provider) = state.get_provider(&q.provider) else {
424 return json_error(StatusCode::BAD_REQUEST, &format!("unknown provider: {}", q.provider));
425 };
426
427 // Generate state and store with provider name (and the bound redirect_uri, if any)
428 let state_value = generate_secure_state();
429
430 let Ok(now) = std::time::SystemTime::now()
431 .duration_since(std::time::UNIX_EPOCH)
432 .map(|d| d.as_secs())
433 else {
434 return json_error(StatusCode::INTERNAL_SERVER_ERROR, "system clock error");
435 };
436
437 let expiry = now + 600; // 10 minutes
438
439 let state_payload = encode_state_value(&q.provider, bound_redirect_uri.as_deref());
440 if let Err(e) = state.state_store.store(state_value.clone(), state_payload, expiry).await {
441 tracing::error!("state store failed: {e}");
442 return json_error(
443 StatusCode::INTERNAL_SERVER_ERROR,
444 "authorization flow could not be started",
445 );
446 }
447
448 // Generate authorization URL
449 let authorization_url = provider.authorization_url(&state_value);
450
451 Redirect::to(&authorization_url).into_response()
452}
453
454// ---------------------------------------------------------------------------
455// GET /auth/v1/callback
456// ---------------------------------------------------------------------------
457
458/// Complete the OAuth flow after the provider redirects back.
459///
460/// Validates the state token, resolves the provider from the stored state,
461/// exchanges the authorization code for tokens, retrieves user info, and
462/// creates a session.
463///
464/// # Query parameters
465///
466/// - `code` — authorization code from the provider.
467/// - `state` — CSRF state token.
468///
469/// # Responses
470///
471/// - `200` JSON `{ access_token, refresh_token?, token_type, expires_in, provider }`
472/// - `400` — invalid state, missing parameters, or provider error.
473/// - `502` — token exchange with the provider failed.
474///
475/// # Errors
476///
477/// Returns `400` if the state is invalid/expired, code is missing, or the provider
478/// returned an error. Returns `502` if the token exchange or user info fetch fails.
479#[allow(clippy::cognitive_complexity)] // Reason: OAuth callback with state validation, token exchange, user info, and session creation
480pub async fn callback(
481 State(state): State<Arc<MultiProviderAuthState>>,
482 Query(q): Query<CallbackQuery>,
483) -> Response {
484 // Surface provider errors
485 if let Some(err) = q.error {
486 let desc = q.error_description.as_deref().unwrap_or("(no description)");
487 tracing::warn!(provider_error = %err, description = %desc, "OAuth provider returned error");
488 let client_message = match err.as_str() {
489 "access_denied" => "Access was denied",
490 "login_required" => "Authentication is required",
491 "invalid_request" | "invalid_scope" => "Invalid authorization request",
492 "server_error" | "temporarily_unavailable" => "Authorization server error",
493 _ => "Authorization failed",
494 };
495 return json_error(StatusCode::BAD_REQUEST, client_message);
496 }
497
498 // Validate required parameters
499 let (Some(code), Some(state_token)) = (q.code, q.state) else {
500 return json_error(StatusCode::BAD_REQUEST, "missing code or state parameter");
501 };
502
503 // Consume state (atomic remove) and decode the provider name + any bound redirect_uri.
504 let Ok((state_payload, expiry)) = state.state_store.retrieve(&state_token).await else {
505 return json_error(StatusCode::BAD_REQUEST, "invalid or expired state token");
506 };
507 let (provider_name, bound_redirect_uri) = decode_state_value(&state_payload);
508
509 // Check state expiry. Fail-closed: if the clock cannot be read, reject rather than
510 // treat the (possibly expired) CSRF state as valid (matches the authorize path).
511 let Ok(now) = std::time::SystemTime::now()
512 .duration_since(std::time::UNIX_EPOCH)
513 .map(|d| d.as_secs())
514 else {
515 return json_error(StatusCode::INTERNAL_SERVER_ERROR, "system clock error");
516 };
517
518 if now > expiry {
519 return json_error(StatusCode::BAD_REQUEST, "state token expired");
520 }
521
522 // Look up provider
523 let Some(provider) = state.get_provider(&provider_name) else {
524 tracing::error!(provider = %provider_name, "provider from state not found in registry");
525 return json_error(StatusCode::INTERNAL_SERVER_ERROR, "provider configuration error");
526 };
527
528 // Exchange code for tokens
529 let token_response = match provider.exchange_code(&code).await {
530 Ok(t) => t,
531 Err(e) => {
532 tracing::error!(error = %e, "token exchange failed");
533 return json_error(StatusCode::BAD_GATEWAY, "token exchange with provider failed");
534 },
535 };
536
537 // Get user info from provider
538 let user_info = match provider.user_info(&token_response.access_token).await {
539 Ok(u) => u,
540 Err(e) => {
541 tracing::error!(error = %e, "user info fetch failed");
542 return json_error(StatusCode::BAD_GATEWAY, "failed to retrieve user information");
543 },
544 };
545
546 // #368: gate the provider's email-verified claim on the trust policy. An untrusted
547 // provider's `email_verified = true` is downgraded to unverified so it can never
548 // auto-link onto another user's email-keyed account (account takeover). Trusted
549 // providers (default: google + apple) keep their claim and link as before.
550 let provider_trusted = state.trusted_email_providers.is_trusted(&provider_name);
551 let email_verified = effective_email_verified(
552 &state.trusted_email_providers,
553 &provider_name,
554 user_info.email_verified,
555 );
556 if user_info.email_verified && !provider_trusted {
557 get_audit_logger().log_failure(
558 AuditEventType::AuthFailure,
559 SecretType::StateToken,
560 None,
561 "social_callback",
562 &format!(
563 "untrusted_provider_email_downgraded:{provider_name} — email_verified claim not \
564 honored for account linking"
565 ),
566 );
567 tracing::warn!(
568 provider = %provider_name,
569 "provider asserted email_verified but is not in the trusted-email set; treating email \
570 as unverified for account linking (#368)"
571 );
572 }
573
574 // Resolve local user ID — use AccountStore for account linking when available,
575 // otherwise fall back to raw provider user ID.
576 let local_user_id = if let Some(account_store) = &state.user_store {
577 match account_store
578 .link_or_create_user(
579 user_info.email.as_deref(),
580 email_verified,
581 &provider_name,
582 &user_info.id,
583 )
584 .await
585 {
586 Ok(result) => result.user_id,
587 Err(e) => {
588 tracing::error!(error = %e, "account store lookup failed");
589 return json_error(StatusCode::INTERNAL_SERVER_ERROR, "user resolution failed");
590 },
591 }
592 } else {
593 user_info.id.clone()
594 };
595
596 // Create session (7-day expiry)
597 let session_expiry = now + (7 * 24 * 60 * 60);
598 let session_tokens = match state
599 .session_store
600 .create_session(&local_user_id, session_expiry)
601 .await
602 {
603 Ok(t) => t,
604 Err(e) => {
605 tracing::error!(error = %e, "session creation failed");
606 return json_error(StatusCode::INTERNAL_SERVER_ERROR, "session could not be created");
607 },
608 };
609
610 // #427: if a validated redirect_uri was bound at authorize time, hand the tokens off via
611 // an implicit-style fragment redirect; otherwise return the legacy JSON token response.
612 if let Some(redirect_uri) = bound_redirect_uri {
613 let location = build_redirect_with_tokens(
614 &redirect_uri,
615 &session_tokens.access_token,
616 &session_tokens.refresh_token,
617 session_tokens.expires_in,
618 &provider_name,
619 );
620 return Redirect::to(&location).into_response();
621 }
622
623 Json(AuthTokenResponse {
624 access_token: session_tokens.access_token,
625 refresh_token: Some(session_tokens.refresh_token),
626 token_type: "Bearer".to_string(),
627 expires_in: session_tokens.expires_in,
628 provider: provider_name,
629 })
630 .into_response()
631}
632
633// ---------------------------------------------------------------------------
634// Tests
635// ---------------------------------------------------------------------------
636
637#[cfg(test)]
638mod tests;