1#[cfg(feature = "oauth2")]
57use std::collections::HashMap;
58use std::future::Future;
59use std::pin::Pin;
60use std::sync::Arc;
61use std::task::{Context, Poll};
62#[cfg(feature = "oauth2")]
63use std::time::Duration;
64
65use axum::extract::FromRequestParts;
66use axum::response::{IntoResponse, Response};
67use http::StatusCode;
68use http::request::Parts;
69#[cfg(feature = "oauth2")]
70use jsonwebtoken::jwk::JwkSet;
71#[cfg(feature = "oauth2")]
72use serde::Deserialize;
73#[cfg(feature = "oauth2")]
74use url::Url;
75
76pub mod password;
77pub use password::{
78 BreachCheck, PasswordConfig, PasswordFailure, PasswordPolicy, PasswordValidation,
79 validate_password,
80};
81
82pub mod remember;
83pub use remember::{
84 DEFAULT_ROTATION_GRACE_SECS, RememberConfig, RememberCredential, RememberDecision,
85 RememberRecord, build_remember_clear_cookie, build_remember_cookie, constant_time_eq,
86 default_rotation_grace, evaluate_remember, format_remember_cookie_value,
87 generate_remember_credential, generate_token, hash_remember_token, parse_remember_cookie_value,
88 verify_remember_token,
89};
90
91const DEFAULT_BCRYPT_COST: u32 = 12;
95
96pub async fn hash_password(password: &str) -> crate::AutumnResult<String> {
115 let password = password.to_string();
116 tokio::task::spawn_blocking(move || {
117 bcrypt::hash(password, DEFAULT_BCRYPT_COST)
118 .map_err(|e| crate::AutumnError::from(std::io::Error::other(e.to_string())))
119 })
120 .await
121 .map_err(|e| crate::AutumnError::from(std::io::Error::other(e.to_string())))?
122}
123
124pub async fn verify_password(password: &str, hash: &str) -> crate::AutumnResult<bool> {
144 let password = password.to_string();
145
146 let is_valid_format = hash.len() == 60 && hash.starts_with('$');
149
150 let hash_to_verify = if is_valid_format {
151 hash.to_string()
152 } else {
153 "$2b$12$KIXe8K4j1sH6/xH.x9d71uJ5Jk8t6O4m6Q110g4H8y1r6J6O6O6O6".to_string()
155 };
156
157 let result = tokio::task::spawn_blocking(move || bcrypt::verify(&password, &hash_to_verify))
158 .await
159 .map_err(|e| crate::AutumnError::from(std::io::Error::other(e.to_string())))?;
160
161 if !is_valid_format {
162 return Ok(false);
163 }
164
165 result.map_err(|e| crate::AutumnError::from(std::io::Error::other(e.to_string())))
166}
167
168#[doc(hidden)]
181pub async fn __check_secured(
182 session: &crate::session::Session,
183 roles: &[&str],
184) -> crate::AutumnResult<()> {
185 __check_secured_with_key(session, "user_id", roles).await
186}
187
188#[doc(hidden)]
194pub async fn __check_secured_with_key(
195 session: &crate::session::Session,
196 auth_session_key: &str,
197 roles: &[&str],
198) -> crate::AutumnResult<()> {
199 let Some(user_id) = session.get(auth_session_key).await else {
201 return Err(crate::AutumnError::unauthorized_msg(
202 "authentication required",
203 ));
204 };
205
206 if crate::current::Current::actor().is_none() {
221 crate::current::Current::set_actor(user_id.clone());
222 }
223 crate::log::context::set_user_id(user_id);
224
225 if !roles.is_empty() {
228 let user_role = session.get("role").await.unwrap_or_default();
229 if !roles.iter().any(|&r| r == user_role) {
230 return Err(crate::AutumnError::forbidden_msg(
231 "insufficient permissions",
232 ));
233 }
234 }
235
236 Ok(())
237}
238
239#[doc(hidden)]
250#[allow(clippy::unused_async)]
251pub async fn __check_secured_scopes(
252 granted: Option<&ApiTokenScopes>,
253 required_scopes: &[&str],
254) -> crate::AutumnResult<()> {
255 if required_scopes.is_empty() {
256 return Ok(());
257 }
258 let granted: &[String] = granted.map_or(&[], |g| g.0.as_slice());
259 if required_scopes
260 .iter()
261 .all(|req| granted.iter().any(|g| g == req))
262 {
263 Ok(())
264 } else {
265 Err(crate::AutumnError::forbidden_msg("insufficient scope"))
266 }
267}
268
269pub struct Auth<T>(pub T);
298
299impl<T, S> FromRequestParts<S> for Auth<T>
300where
301 T: Clone + Send + Sync + 'static,
302 S: Send + Sync,
303{
304 type Rejection = AuthRejection;
305
306 fn from_request_parts(
307 parts: &mut Parts,
308 _state: &S,
309 ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
310 let user = parts.extensions.get::<T>().cloned();
311 async move { user.map_or_else(|| Err(AuthRejection), |user| Ok(Self(user))) }
312 }
313}
314
315#[derive(Debug)]
317pub struct AuthRejection;
318
319impl IntoResponse for AuthRejection {
320 fn into_response(self) -> Response {
321 crate::AutumnError::unauthorized_msg("authentication required").into_response()
322 }
323}
324
325impl std::fmt::Display for AuthRejection {
326 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327 f.write_str("authentication required")
328 }
329}
330
331#[derive(Clone)]
356pub struct RequireAuth {
357 session_key: Arc<str>,
358}
359
360impl RequireAuth {
361 pub fn new(session_key: impl Into<String>) -> Self {
363 Self {
364 session_key: Arc::from(session_key.into()),
365 }
366 }
367}
368
369impl<S> tower::Layer<S> for RequireAuth {
370 type Service = RequireAuthService<S>;
371
372 fn layer(&self, inner: S) -> Self::Service {
373 RequireAuthService {
374 inner,
375 session_key: Arc::clone(&self.session_key),
376 }
377 }
378}
379
380#[derive(Clone)]
382pub struct RequireAuthService<S> {
383 inner: S,
384 session_key: Arc<str>,
385}
386
387impl<S, ResBody> tower::Service<axum::extract::Request> for RequireAuthService<S>
388where
389 S: tower::Service<axum::extract::Request, Response = Response<ResBody>>
390 + Clone
391 + Send
392 + 'static,
393 S::Future: Send + 'static,
394 S::Error: Send + 'static,
395 ResBody: From<String> + Default + Send + 'static,
396{
397 type Response = Response<ResBody>;
398 type Error = S::Error;
399 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
400
401 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
402 self.inner.poll_ready(cx)
403 }
404
405 fn call(&mut self, mut req: axum::extract::Request) -> Self::Future {
406 let session_key = Arc::clone(&self.session_key);
407 let mut inner = self.inner.clone();
408 std::mem::swap(&mut self.inner, &mut inner);
409
410 Box::pin(async move {
411 let session = req.extensions().get::<crate::session::Session>().cloned();
413
414 let user_id = if let Some(ref session) = session {
415 session.get(&session_key).await
416 } else {
417 None
418 };
419
420 if let Some(user_id) = user_id {
421 req.extensions_mut()
424 .insert(crate::security::RateLimitPrincipal(user_id.clone()));
425 if crate::current::Current::actor().is_none() {
439 crate::current::Current::set_actor(user_id.clone());
440 }
441 crate::log::context::set_user_id(user_id);
442 inner.call(req).await
443 } else {
444 let body = crate::error::problem_details_json_string(
445 StatusCode::UNAUTHORIZED,
446 "authentication required",
447 None,
448 None,
449 req.extensions()
450 .get::<crate::middleware::RequestId>()
451 .map(std::string::ToString::to_string),
452 Some(req.uri().path().to_owned()),
453 true,
454 );
455 let response = Response::builder()
456 .status(StatusCode::UNAUTHORIZED)
457 .header(http::header::CONTENT_TYPE, "application/problem+json")
458 .body(ResBody::from(body))
459 .unwrap_or_default();
460 Ok(response)
461 }
462 })
463 }
464}
465
466#[derive(Debug, Clone, serde::Deserialize)]
477pub struct AuthConfig {
478 #[serde(default = "default_bcrypt_cost")]
480 pub bcrypt_cost: u32,
481
482 #[serde(default = "default_session_key")]
484 pub session_key: String,
485
486 #[cfg(feature = "oauth2")]
489 #[serde(default)]
490 pub oauth2: OAuth2Config,
491
492 #[cfg(feature = "oauth2")]
498 #[serde(default)]
499 pub oauth_linking_policy: OAuthLinkingPolicy,
500
501 #[cfg(feature = "webauthn")]
512 #[serde(default)]
513 pub webauthn: WebAuthnConfig,
514
515 #[serde(default)]
534 pub lockout: LockoutConfig,
535
536 #[serde(default)]
549 pub step_up: StepUpConfig,
550
551 #[serde(default)]
566 pub sessions: SessionTrackingConfig,
567
568 #[serde(default)]
580 pub password: PasswordConfig,
581
582 #[serde(default)]
597 pub remember: RememberConfig,
598
599 #[serde(default)]
612 pub magic_link: MagicLinkConfig,
613}
614
615#[derive(Debug, Clone, serde::Deserialize)]
620pub struct LockoutConfig {
621 #[serde(default = "default_lockout_enabled")]
625 pub enabled: bool,
626
627 #[serde(default = "default_lockout_threshold")]
630 pub threshold: i32,
631
632 #[serde(default = "default_lockout_window_secs")]
636 pub window_secs: u64,
637
638 #[serde(default = "default_lockout_cooloff_secs")]
642 pub cooloff_secs: u64,
643}
644
645const fn default_lockout_enabled() -> bool {
646 true
647}
648
649const fn default_lockout_threshold() -> i32 {
650 10
651}
652
653const fn default_lockout_window_secs() -> u64 {
654 60
655}
656
657const fn default_lockout_cooloff_secs() -> u64 {
658 900
659}
660
661impl Default for LockoutConfig {
662 fn default() -> Self {
663 Self {
664 enabled: default_lockout_enabled(),
665 threshold: default_lockout_threshold(),
666 window_secs: default_lockout_window_secs(),
667 cooloff_secs: default_lockout_cooloff_secs(),
668 }
669 }
670}
671#[derive(Debug, Clone, serde::Deserialize)]
676pub struct StepUpConfig {
677 #[serde(default = "default_step_up_max_age_secs")]
683 pub default_max_age_secs: u64,
684}
685
686const fn default_step_up_max_age_secs() -> u64 {
687 crate::step_up::DEFAULT_MAX_AGE_SECS
688}
689
690impl Default for StepUpConfig {
691 fn default() -> Self {
692 Self {
693 default_max_age_secs: crate::step_up::DEFAULT_MAX_AGE_SECS,
694 }
695 }
696}
697
698#[derive(Debug, Clone, Copy, serde::Deserialize)]
711pub struct SessionTrackingConfig {
712 #[serde(default = "default_true_flag")]
719 pub revoke_on_credential_change: bool,
720
721 #[serde(default = "default_last_seen_update_secs")]
728 pub last_seen_update_secs: u64,
729}
730
731const fn default_true_flag() -> bool {
732 true
733}
734
735const fn default_last_seen_update_secs() -> u64 {
736 60
737}
738
739impl Default for SessionTrackingConfig {
740 fn default() -> Self {
741 Self {
742 revoke_on_credential_change: true,
743 last_seen_update_secs: default_last_seen_update_secs(),
744 }
745 }
746}
747
748#[derive(Debug, Clone, Copy, serde::Deserialize)]
760pub struct MagicLinkConfig {
761 #[serde(default = "default_magic_link_ttl_minutes")]
771 pub ttl_minutes: u64,
772
773 #[serde(default = "default_magic_link_email_cooldown_secs")]
786 pub email_cooldown_secs: u64,
787}
788
789const fn default_magic_link_ttl_minutes() -> u64 {
790 15
791}
792
793const fn default_magic_link_email_cooldown_secs() -> u64 {
794 60
795}
796
797impl Default for MagicLinkConfig {
798 fn default() -> Self {
799 Self {
800 ttl_minutes: default_magic_link_ttl_minutes(),
801 email_cooldown_secs: default_magic_link_email_cooldown_secs(),
802 }
803 }
804}
805
806#[cfg(feature = "webauthn")]
810#[derive(Debug, Clone, serde::Deserialize)]
811pub struct WebAuthnConfig {
812 #[serde(default = "default_rp_id")]
814 pub rp_id: String,
815 #[serde(default = "default_rp_name")]
817 pub rp_name: String,
818 #[serde(default = "default_rp_origin")]
820 pub rp_origin: String,
821}
822
823#[cfg(feature = "webauthn")]
824impl Default for WebAuthnConfig {
825 fn default() -> Self {
826 Self {
827 rp_id: default_rp_id(),
828 rp_name: default_rp_name(),
829 rp_origin: default_rp_origin(),
830 }
831 }
832}
833
834#[cfg(feature = "webauthn")]
835const fn default_rp_id() -> String {
836 String::new()
837}
838
839#[cfg(feature = "webauthn")]
840fn default_rp_name() -> String {
841 "My App".to_owned()
842}
843
844#[cfg(feature = "webauthn")]
845const fn default_rp_origin() -> String {
846 String::new()
847}
848
849const fn default_bcrypt_cost() -> u32 {
850 DEFAULT_BCRYPT_COST
851}
852
853fn default_session_key() -> String {
854 "user_id".to_owned()
855}
856
857#[cfg(feature = "oauth2")]
858const fn default_provider_scope() -> String {
859 String::new()
860}
861
862#[cfg(feature = "oauth2")]
863const OAUTH_HTTP_TIMEOUT_SECS: u64 = 15;
864
865#[cfg(feature = "oauth2")]
866#[derive(Debug, Clone, Default, serde::Deserialize)]
881pub struct OAuth2Config {
882 #[serde(flatten)]
884 pub providers: HashMap<String, OAuth2ProviderConfig>,
885}
886
887#[cfg(feature = "oauth2")]
888#[derive(Debug, Clone, serde::Deserialize)]
890pub struct OAuth2ProviderConfig {
891 #[serde(default)]
893 pub client_id: String,
894 #[serde(default)]
896 pub client_secret: String,
897 #[serde(default)]
899 pub authorize_url: String,
900 #[serde(default)]
902 pub token_url: String,
903 #[serde(default)]
905 pub userinfo_url: Option<String>,
906 #[serde(default)]
908 pub redirect_uri: String,
909 #[serde(default = "default_provider_scope")]
911 pub scope: String,
912 #[serde(default)]
914 pub issuer: Option<String>,
915 #[serde(default)]
917 pub jwks_url: Option<String>,
918 #[serde(default)]
924 pub discovery_url: Option<String>,
925}
926
927#[cfg(feature = "oauth2")]
928#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, Default)]
939#[serde(rename_all = "snake_case")]
940pub enum OAuthLinkingPolicy {
941 #[default]
944 CreateAccount,
945 RequireLocalSignupFirst,
949}
950
951#[cfg(feature = "oauth2")]
952#[must_use]
976pub fn provider_preset(name: &str) -> Option<OAuth2ProviderConfig> {
977 match name {
978 "google" => Some(OAuth2ProviderConfig {
979 client_id: String::new(),
980 client_secret: String::new(),
981 authorize_url: "https://accounts.google.com/o/oauth2/v2/auth".into(),
982 token_url: "https://oauth2.googleapis.com/token".into(),
983 userinfo_url: Some("https://openidconnect.googleapis.com/v1/userinfo".into()),
984 redirect_uri: String::new(),
985 scope: "openid profile email".into(),
986 issuer: Some("https://accounts.google.com".into()),
987 jwks_url: Some("https://www.googleapis.com/oauth2/v3/certs".into()),
988 discovery_url: Some("https://accounts.google.com".into()),
989 }),
990 "github" => Some(OAuth2ProviderConfig {
991 client_id: String::new(),
992 client_secret: String::new(),
993 authorize_url: "https://github.com/login/oauth/authorize".into(),
994 token_url: "https://github.com/login/oauth/access_token".into(),
995 userinfo_url: Some("https://api.github.com/user".into()),
996 redirect_uri: String::new(),
997 scope: "read:user user:email".into(),
998 issuer: None,
999 jwks_url: None,
1000 discovery_url: None,
1001 }),
1002 "microsoft" => Some(OAuth2ProviderConfig {
1003 client_id: String::new(),
1004 client_secret: String::new(),
1005 authorize_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".into(),
1006 token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token".into(),
1007 userinfo_url: None,
1008 redirect_uri: String::new(),
1009 scope: "openid profile email".into(),
1010 issuer: Some("https://login.microsoftonline.com/common/v2.0".into()),
1018 jwks_url: Some("https://login.microsoftonline.com/common/discovery/v2.0/keys".into()),
1019 discovery_url: Some("https://login.microsoftonline.com/common/v2.0".into()),
1020 }),
1021 _ => None,
1022 }
1023}
1024
1025#[cfg(feature = "oauth2")]
1026#[derive(Debug, Clone, Deserialize)]
1028pub struct OAuth2Callback {
1029 pub code: String,
1031 pub state: String,
1033}
1034
1035#[cfg(feature = "oauth2")]
1036#[derive(Debug, Clone)]
1038pub struct OidcIdentity {
1039 pub subject: String,
1041 pub email: Option<String>,
1043 pub name: Option<String>,
1045 pub preferred_username: Option<String>,
1047 pub raw_claims: serde_json::Value,
1049}
1050
1051#[cfg(feature = "oauth2")]
1052#[derive(Debug, Deserialize)]
1053struct OAuth2TokenResponse {
1054 access_token: String,
1055 #[allow(dead_code)]
1056 token_type: Option<String>,
1057 id_token: Option<String>,
1058}
1059
1060#[cfg(feature = "oauth2")]
1061pub async fn oauth2_authorize_url(
1070 session: &crate::session::Session,
1071 provider_name: &str,
1072 provider: &OAuth2ProviderConfig,
1073) -> crate::AutumnResult<String> {
1074 use base64::Engine as _;
1075 use sha2::Digest as _;
1076
1077 let state = uuid::Uuid::new_v4().to_string();
1078 let nonce = uuid::Uuid::new_v4().to_string();
1079
1080 let mut verifier_bytes = [0u8; 32];
1082 getrandom::getrandom(&mut verifier_bytes).map_err(|e| {
1083 crate::AutumnError::service_unavailable_msg(format!("pkce rng failed: {e}"))
1084 })?;
1085 let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(verifier_bytes);
1086 let digest = sha2::Sha256::digest(code_verifier.as_bytes());
1088 let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
1089
1090 session
1091 .insert(format!("oauth2:{provider_name}:state"), state.clone())
1092 .await;
1093 session
1094 .insert(format!("oauth2:{provider_name}:nonce"), nonce.clone())
1095 .await;
1096 session
1097 .insert(
1098 format!("oauth2:{provider_name}:code_verifier"),
1099 code_verifier,
1100 )
1101 .await;
1102
1103 let mut url = Url::parse(&provider.authorize_url)
1104 .map_err(|e| crate::AutumnError::bad_request_msg(format!("invalid authorize_url: {e}")))?;
1105 {
1106 let mut q = url.query_pairs_mut();
1107 q.append_pair("response_type", "code");
1108 q.append_pair("client_id", &provider.client_id);
1109 q.append_pair("redirect_uri", &provider.redirect_uri);
1110 if !provider.scope.trim().is_empty() {
1111 q.append_pair("scope", &provider.scope);
1112 }
1113 q.append_pair("state", &state);
1114 q.append_pair("nonce", &nonce);
1115 q.append_pair("code_challenge", &code_challenge);
1116 q.append_pair("code_challenge_method", "S256");
1117 }
1118 Ok(url.into())
1119}
1120
1121#[cfg(feature = "oauth2")]
1122pub async fn oauth2_finish_login(
1138 session: &crate::session::Session,
1139 provider_name: &str,
1140 provider: &OAuth2ProviderConfig,
1141 callback: &OAuth2Callback,
1142) -> crate::AutumnResult<OidcIdentity> {
1143 validate_callback_state(session, provider_name, callback).await?;
1144 let code_verifier = session
1146 .remove(&format!("oauth2:{provider_name}:code_verifier"))
1147 .await
1148 .ok_or_else(|| {
1149 crate::AutumnError::unauthorized_msg("oauth2 code_verifier missing from session")
1150 })?;
1151 let token = exchange_oauth2_token(provider, callback, code_verifier).await?;
1152 let (claims, source) = load_identity_claims(provider, &token).await?;
1153 validate_oidc_nonce(session, provider_name, &claims, source).await?;
1154 let subject = extract_subject(&claims, source)?;
1155 finalize_oauth2_session(session, provider_name, subject, claims).await
1156}
1157
1158#[cfg(feature = "oauth2")]
1159async fn validate_callback_state(
1160 session: &crate::session::Session,
1161 provider_name: &str,
1162 callback: &OAuth2Callback,
1163) -> crate::AutumnResult<()> {
1164 let state_key = format!("oauth2:{provider_name}:state");
1165 let expected_state = session.get(&state_key).await.ok_or_else(|| {
1169 crate::AutumnError::unauthorized_msg("oauth2 state missing; restart login")
1170 })?;
1171 if subtle::ConstantTimeEq::ct_eq(expected_state.as_bytes(), callback.state.as_bytes())
1172 .unwrap_u8()
1173 != 1
1174 {
1175 return Err(crate::AutumnError::unauthorized_msg(
1176 "oauth2 state mismatch",
1177 ));
1178 }
1179 session.remove(&state_key).await;
1181 Ok(())
1182}
1183
1184#[cfg(feature = "oauth2")]
1185async fn exchange_oauth2_token(
1186 provider: &OAuth2ProviderConfig,
1187 callback: &OAuth2Callback,
1188 code_verifier: String,
1189) -> crate::AutumnResult<OAuth2TokenResponse> {
1190 let form_fields: Vec<(&str, String)> = vec![
1192 ("grant_type", "authorization_code".to_owned()),
1193 ("code", callback.code.clone()),
1194 ("redirect_uri", provider.redirect_uri.clone()),
1195 ("client_id", provider.client_id.clone()),
1196 ("client_secret", provider.client_secret.clone()),
1197 ("code_verifier", code_verifier),
1198 ];
1199 let token_response = oauth_http_client()?
1200 .post(&provider.token_url)
1201 .header(reqwest::header::ACCEPT, "application/json")
1202 .form(&form_fields)
1203 .send()
1204 .await
1205 .map_err(|e| {
1206 crate::AutumnError::service_unavailable_msg(format!("token request failed: {e}"))
1207 })?
1208 .error_for_status()
1209 .map_err(|e| crate::AutumnError::unauthorized_msg(format!("token exchange failed: {e}")))?;
1210
1211 let token_content_type = token_response
1212 .headers()
1213 .get(reqwest::header::CONTENT_TYPE)
1214 .and_then(|v| v.to_str().ok())
1215 .map(str::to_owned);
1216 let token_body = token_response.text().await.map_err(|e| {
1217 crate::AutumnError::bad_request_msg(format!("invalid token response body: {e}"))
1218 })?;
1219 parse_oauth2_token_response(token_content_type.as_deref(), &token_body)
1220}
1221
1222#[cfg(feature = "oauth2")]
1223async fn load_identity_claims(
1224 provider: &OAuth2ProviderConfig,
1225 token: &OAuth2TokenResponse,
1226) -> crate::AutumnResult<(serde_json::Value, IdentitySource)> {
1227 if let Some(id_token) = token.id_token.as_deref() {
1228 return Ok((
1229 validate_and_decode_id_token(id_token, provider).await?,
1230 IdentitySource::IdToken,
1231 ));
1232 }
1233 if let Some(userinfo_url) = &provider.userinfo_url {
1234 let claims = oauth_http_client()?
1235 .get(userinfo_url)
1236 .header(
1237 reqwest::header::USER_AGENT,
1238 concat!("autumn-web/", env!("CARGO_PKG_VERSION")),
1239 )
1240 .bearer_auth(&token.access_token)
1241 .send()
1242 .await
1243 .map_err(|e| {
1244 crate::AutumnError::service_unavailable_msg(format!("userinfo request failed: {e}"))
1245 })?
1246 .error_for_status()
1247 .map_err(|e| crate::AutumnError::unauthorized_msg(format!("userinfo failed: {e}")))?
1248 .json()
1249 .await
1250 .map_err(|e| {
1251 crate::AutumnError::bad_request_msg(format!("invalid userinfo payload: {e}"))
1252 })?;
1253 return Ok((claims, IdentitySource::UserInfo));
1254 }
1255 Err(crate::AutumnError::bad_request_msg(
1256 "provider must return id_token or configure userinfo_url",
1257 ))
1258}
1259
1260#[cfg(feature = "oauth2")]
1261async fn validate_oidc_nonce(
1262 session: &crate::session::Session,
1263 provider_name: &str,
1264 claims: &serde_json::Value,
1265 source: IdentitySource,
1266) -> crate::AutumnResult<()> {
1267 let nonce_key = format!("oauth2:{provider_name}:nonce");
1268 let stored_nonce = session.remove(&nonce_key).await;
1269 if source == IdentitySource::IdToken {
1270 let expected_nonce = stored_nonce.ok_or_else(|| {
1274 crate::AutumnError::unauthorized_msg("oauth2 nonce missing from session")
1275 })?;
1276 let actual_nonce = claims
1277 .get("nonce")
1278 .and_then(serde_json::Value::as_str)
1279 .ok_or_else(|| crate::AutumnError::unauthorized_msg("missing oidc nonce claim"))?;
1280 if subtle::ConstantTimeEq::ct_eq(expected_nonce.as_bytes(), actual_nonce.as_bytes())
1281 .unwrap_u8()
1282 != 1
1283 {
1284 return Err(crate::AutumnError::unauthorized_msg("oidc nonce mismatch"));
1285 }
1286 }
1287 Ok(())
1288}
1289
1290#[cfg(feature = "oauth2")]
1291async fn finalize_oauth2_session(
1292 session: &crate::session::Session,
1293 provider_name: &str,
1294 subject: String,
1295 claims: serde_json::Value,
1296) -> crate::AutumnResult<OidcIdentity> {
1297 session.insert("auth_provider", provider_name).await;
1300 session.rotate_id().await;
1301 Ok(OidcIdentity {
1302 subject,
1303 email: claims
1304 .get("email")
1305 .and_then(serde_json::Value::as_str)
1306 .map(str::to_owned),
1307 name: claims
1308 .get("name")
1309 .and_then(serde_json::Value::as_str)
1310 .map(str::to_owned),
1311 preferred_username: claims
1312 .get("preferred_username")
1313 .and_then(serde_json::Value::as_str)
1314 .map(str::to_owned),
1315 raw_claims: claims,
1316 })
1317}
1318
1319#[cfg(feature = "oauth2")]
1320fn parse_oauth2_token_response(
1321 content_type: Option<&str>,
1322 body: &str,
1323) -> crate::AutumnResult<OAuth2TokenResponse> {
1324 let looks_like_json = content_type.is_some_and(|v| v.contains("application/json"))
1325 || body.trim_start().starts_with('{');
1326 if looks_like_json {
1327 return serde_json::from_str(body).map_err(|e| {
1328 crate::AutumnError::bad_request_msg(format!("invalid json token response: {e}"))
1329 });
1330 }
1331
1332 let mut access_token = None;
1333 let mut token_type = None;
1334 let mut id_token = None;
1335
1336 for (k, v) in url::form_urlencoded::parse(body.as_bytes()) {
1337 match k.as_ref() {
1338 "access_token" => access_token = Some(v.into_owned()),
1339 "token_type" => token_type = Some(v.into_owned()),
1340 "id_token" => id_token = Some(v.into_owned()),
1341 _ => {}
1342 }
1343 }
1344
1345 let access_token = access_token.ok_or_else(|| {
1346 crate::AutumnError::bad_request_msg("token response missing access_token")
1347 })?;
1348
1349 Ok(OAuth2TokenResponse {
1350 access_token,
1351 token_type,
1352 id_token,
1353 })
1354}
1355
1356#[cfg(feature = "oauth2")]
1357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1358enum IdentitySource {
1359 IdToken,
1360 UserInfo,
1361}
1362
1363#[cfg(feature = "oauth2")]
1364fn extract_subject(
1365 claims: &serde_json::Value,
1366 source: IdentitySource,
1367) -> crate::AutumnResult<String> {
1368 if let Some(sub) = claims.get("sub").and_then(serde_json::Value::as_str) {
1369 return Ok(sub.to_owned());
1370 }
1371
1372 if source == IdentitySource::UserInfo {
1373 if let Some(id) = claims.get("id").and_then(serde_json::Value::as_i64) {
1374 return Ok(id.to_string());
1375 }
1376 if let Some(id) = claims.get("id").and_then(serde_json::Value::as_str) {
1377 return Ok(id.to_owned());
1378 }
1379 return Err(crate::AutumnError::bad_request_msg(
1380 "missing identity claim: expected sub or id from userinfo",
1381 ));
1382 }
1383
1384 Err(crate::AutumnError::bad_request_msg("missing sub claim"))
1385}
1386
1387#[cfg(feature = "oauth2")]
1394fn jwk_allowed_algorithms(
1395 jwk: &jsonwebtoken::jwk::Jwk,
1396) -> crate::AutumnResult<Vec<jsonwebtoken::Algorithm>> {
1397 use jsonwebtoken::Algorithm;
1398 use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve, KeyAlgorithm};
1399
1400 if let Some(key_alg) = jwk.common.key_algorithm {
1402 let alg = match key_alg {
1403 KeyAlgorithm::RS256 => Algorithm::RS256,
1404 KeyAlgorithm::RS384 => Algorithm::RS384,
1405 KeyAlgorithm::RS512 => Algorithm::RS512,
1406 KeyAlgorithm::PS256 => Algorithm::PS256,
1407 KeyAlgorithm::PS384 => Algorithm::PS384,
1408 KeyAlgorithm::PS512 => Algorithm::PS512,
1409 KeyAlgorithm::ES256 => Algorithm::ES256,
1410 KeyAlgorithm::ES384 => Algorithm::ES384,
1411 KeyAlgorithm::EdDSA => Algorithm::EdDSA,
1412 other => {
1415 return Err(crate::AutumnError::unauthorized_msg(format!(
1416 "jwk algorithm {other} not allowed for id_token verification"
1417 )));
1418 }
1419 };
1420 return Ok(vec![alg]);
1421 }
1422
1423 match &jwk.algorithm {
1426 AlgorithmParameters::RSA(_) => Ok(vec![
1427 Algorithm::RS256,
1428 Algorithm::RS384,
1429 Algorithm::RS512,
1430 Algorithm::PS256,
1431 Algorithm::PS384,
1432 Algorithm::PS512,
1433 ]),
1434 AlgorithmParameters::EllipticCurve(params) => match params.curve {
1435 EllipticCurve::P256 => Ok(vec![Algorithm::ES256]),
1436 EllipticCurve::P384 => Ok(vec![Algorithm::ES384]),
1437 ref other => Err(crate::AutumnError::unauthorized_msg(format!(
1438 "unsupported jwk curve {other:?} for id_token verification"
1439 ))),
1440 },
1441 AlgorithmParameters::OctetKeyPair(_) => Ok(vec![Algorithm::EdDSA]),
1442 AlgorithmParameters::OctetKey(_) => Err(crate::AutumnError::unauthorized_msg(
1443 "symmetric jwk not allowed for id_token verification",
1444 )),
1445 }
1446}
1447
1448#[cfg(feature = "oauth2")]
1449async fn validate_and_decode_id_token(
1450 token: &str,
1451 provider: &OAuth2ProviderConfig,
1452) -> crate::AutumnResult<serde_json::Value> {
1453 let issuer = provider
1454 .issuer
1455 .as_deref()
1456 .ok_or_else(|| crate::AutumnError::bad_request_msg("provider.issuer required for oidc"))?;
1457 let jwks_url = provider.jwks_url.as_deref().ok_or_else(|| {
1458 crate::AutumnError::bad_request_msg("provider.jwks_url required for oidc")
1459 })?;
1460
1461 let header = jsonwebtoken::decode_header(token).map_err(|e| {
1462 crate::AutumnError::unauthorized_msg(format!("invalid id_token header: {e}"))
1463 })?;
1464 let kid = header
1465 .kid
1466 .as_deref()
1467 .ok_or_else(|| crate::AutumnError::unauthorized_msg("id_token header missing kid"))?;
1468 let alg = header.alg;
1469
1470 let jwks: JwkSet = oauth_http_client()?
1471 .get(jwks_url)
1472 .send()
1473 .await
1474 .map_err(|e| {
1475 crate::AutumnError::service_unavailable_msg(format!("jwks request failed: {e}"))
1476 })?
1477 .error_for_status()
1478 .map_err(|e| crate::AutumnError::unauthorized_msg(format!("jwks fetch failed: {e}")))?
1479 .json()
1480 .await
1481 .map_err(|e| crate::AutumnError::bad_request_msg(format!("invalid jwks response: {e}")))?;
1482
1483 let jwk = jwks
1484 .keys
1485 .iter()
1486 .find(|k| k.common.key_id.as_deref() == Some(kid))
1487 .ok_or_else(|| crate::AutumnError::unauthorized_msg("no jwk matched id_token kid"))?;
1488 let decoding_key = jsonwebtoken::DecodingKey::from_jwk(jwk)
1489 .map_err(|e| crate::AutumnError::unauthorized_msg(format!("invalid jwk key: {e}")))?;
1490
1491 let allowed_algs = jwk_allowed_algorithms(jwk)?;
1497 if !allowed_algs.contains(&alg) {
1498 return Err(crate::AutumnError::unauthorized_msg(format!(
1499 "id_token alg {alg:?} not permitted by matching jwk"
1500 )));
1501 }
1502 let mut validation = jsonwebtoken::Validation::new(alg);
1503 validation.algorithms = allowed_algs;
1504 let mut issuers = vec![issuer.to_owned()];
1505 let is_multi_tenant = issuer.contains("/common/")
1506 || issuer.contains("/organizations/")
1507 || issuer.contains("/consumers/");
1508 if let (true, true, Some(payload_b64)) = (
1509 issuer.contains("login.microsoftonline.com"),
1510 is_multi_tenant,
1511 token.split('.').nth(1),
1512 ) {
1513 use base64::Engine as _;
1514 let extract_microsoft_iss = || -> Option<String> {
1515 let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1516 .decode(payload_b64)
1517 .ok()?;
1518 let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
1519 let unverified_iss = claims.get("iss")?.as_str()?;
1520 if unverified_iss.starts_with("https://login.microsoftonline.com/")
1521 && unverified_iss.ends_with("/v2.0")
1522 {
1523 Some(unverified_iss.to_owned())
1524 } else {
1525 None
1526 }
1527 };
1528 if let Some(unverified_iss) = extract_microsoft_iss() {
1529 issuers.push(unverified_iss);
1530 }
1531 }
1532 let issuer_refs: Vec<&str> = issuers.iter().map(String::as_str).collect();
1533 validation.set_issuer(&issuer_refs);
1534 validation.set_audience(std::slice::from_ref(&provider.client_id));
1535 validation.required_spec_claims = ["exp", "iss", "aud", "sub"]
1536 .into_iter()
1537 .map(str::to_owned)
1538 .collect();
1539 validation.validate_exp = true;
1540 validation.validate_nbf = true;
1541
1542 let claims = jsonwebtoken::decode::<serde_json::Value>(token, &decoding_key, &validation)
1543 .map_err(|e| crate::AutumnError::unauthorized_msg(format!("invalid id_token: {e}")))?;
1544 Ok(claims.claims)
1545}
1546
1547#[cfg(feature = "oauth2")]
1548#[derive(Clone)]
1549pub struct HttpClient {
1550 inner: reqwest::Client,
1551}
1552
1553#[cfg(feature = "oauth2")]
1554pub struct HttpRequestBuilder {
1555 client: reqwest::Client,
1556 builder: reqwest::RequestBuilder,
1557}
1558
1559#[cfg(feature = "oauth2")]
1560#[allow(
1561 clippy::must_use_candidate,
1562 clippy::missing_const_for_fn,
1563 clippy::return_self_not_must_use,
1564 clippy::missing_errors_doc,
1565 clippy::redundant_closure_for_method_calls
1566)]
1567impl HttpClient {
1568 #[must_use]
1569 pub const fn new(inner: reqwest::Client) -> Self {
1570 Self { inner }
1571 }
1572
1573 #[must_use]
1574 pub fn post(&self, url: &str) -> HttpRequestBuilder {
1575 HttpRequestBuilder {
1576 client: self.inner.clone(),
1577 builder: self.inner.post(url),
1578 }
1579 }
1580
1581 #[must_use]
1582 pub fn get(&self, url: &str) -> HttpRequestBuilder {
1583 HttpRequestBuilder {
1584 client: self.inner.clone(),
1585 builder: self.inner.get(url),
1586 }
1587 }
1588}
1589
1590#[cfg(feature = "oauth2")]
1591#[allow(
1592 clippy::must_use_candidate,
1593 clippy::missing_const_for_fn,
1594 clippy::return_self_not_must_use,
1595 clippy::missing_errors_doc,
1596 clippy::redundant_closure_for_method_calls
1597)]
1598impl HttpRequestBuilder {
1599 #[must_use]
1600 pub fn header<K, V>(mut self, key: K, value: V) -> Self
1601 where
1602 reqwest::header::HeaderName: TryFrom<K>,
1603 <reqwest::header::HeaderName as TryFrom<K>>::Error: Into<http::Error>,
1604 reqwest::header::HeaderValue: TryFrom<V>,
1605 <reqwest::header::HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
1606 {
1607 self.builder = self.builder.header(key, value);
1608 self
1609 }
1610
1611 #[must_use]
1612 pub fn bearer_auth<T>(mut self, token: T) -> Self
1613 where
1614 T: std::fmt::Display,
1615 {
1616 self.builder = self.builder.bearer_auth(token);
1617 self
1618 }
1619
1620 #[must_use]
1621 pub fn form<T: serde::Serialize + ?Sized>(mut self, form: &T) -> Self {
1622 self.builder = self.builder.form(form);
1623 self
1624 }
1625
1626 pub async fn send(self) -> Result<reqwest::Response, reqwest::Error> {
1633 let req = self.builder.build()?;
1634 let interceptors = crate::interceptor::ACTIVE_HTTP_INTERCEPTORS
1635 .try_with(Clone::clone)
1636 .unwrap_or_default();
1637 run_http_chain(req, interceptors, self.client.clone(), 0).await
1638 }
1639}
1640
1641#[cfg(feature = "oauth2")]
1642fn run_http_chain(
1643 req: reqwest::Request,
1644 interceptors: Vec<Arc<dyn crate::interceptor::HttpInterceptor>>,
1645 client: reqwest::Client,
1646 idx: usize,
1647) -> std::pin::Pin<
1648 Box<
1649 dyn std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>
1650 + Send
1651 + 'static,
1652 >,
1653> {
1654 Box::pin(async move {
1655 if idx < interceptors.len() {
1656 let interceptor = interceptors[idx].clone();
1657 let next_interceptors = interceptors.clone();
1658 let next_client = client.clone();
1659 let next_fn = move |r: reqwest::Request| {
1660 run_http_chain(r, next_interceptors.clone(), next_client.clone(), idx + 1)
1661 };
1662 let fut = interceptor.intercept(req, &next_fn);
1663 fut.await
1664 } else {
1665 client.execute(req).await
1666 }
1667 })
1668}
1669
1670#[cfg(feature = "oauth2")]
1671fn oauth_http_client() -> crate::AutumnResult<HttpClient> {
1672 let client = reqwest::Client::builder()
1673 .timeout(Duration::from_secs(OAUTH_HTTP_TIMEOUT_SECS))
1674 .build()
1675 .map_err(|e| {
1676 crate::AutumnError::service_unavailable_msg(format!(
1677 "failed to build oauth http client: {e}"
1678 ))
1679 })?;
1680 Ok(HttpClient::new(client))
1681}
1682
1683impl Default for AuthConfig {
1684 fn default() -> Self {
1685 Self {
1686 bcrypt_cost: default_bcrypt_cost(),
1687 session_key: default_session_key(),
1688 #[cfg(feature = "oauth2")]
1689 oauth2: OAuth2Config::default(),
1690 #[cfg(feature = "oauth2")]
1691 oauth_linking_policy: OAuthLinkingPolicy::default(),
1692 #[cfg(feature = "webauthn")]
1693 webauthn: WebAuthnConfig::default(),
1694 lockout: LockoutConfig::default(),
1695 step_up: StepUpConfig::default(),
1696 sessions: SessionTrackingConfig::default(),
1697 password: PasswordConfig::default(),
1698 remember: RememberConfig::default(),
1699 magic_link: MagicLinkConfig::default(),
1700 }
1701 }
1702}
1703
1704#[derive(Debug, Clone, PartialEq, Eq, Default)]
1716pub struct VerifiedToken {
1717 pub principal_id: String,
1719 pub scopes: Vec<String>,
1721 pub name: String,
1724 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1727}
1728
1729#[derive(Debug, Clone, PartialEq, Eq)]
1735pub struct TokenMetadata {
1736 pub id: String,
1738 pub name: String,
1740 pub principal_id: String,
1742 pub scopes: Vec<String>,
1744 pub created_at: chrono::DateTime<chrono::Utc>,
1746 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1748 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
1750 pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
1752}
1753
1754#[derive(Debug, Clone, Default)]
1757pub struct IssueTokenSpec<'a> {
1758 pub principal_id: &'a str,
1760 pub name: &'a str,
1762 pub scopes: &'a [String],
1764 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1766}
1767
1768pub trait ApiTokenStore: Send + Sync + 'static {
1787 fn issue<'a>(
1792 &'a self,
1793 principal_id: &'a str,
1794 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>>;
1795
1796 fn verify<'a>(
1799 &'a self,
1800 raw_token: &'a str,
1801 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>>;
1802
1803 fn revoke<'a>(
1805 &'a self,
1806 raw_token: &'a str,
1807 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'a>>;
1808
1809 fn issue_scoped<'a>(
1815 &'a self,
1816 spec: IssueTokenSpec<'a>,
1817 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
1818 Box::pin(async move { self.issue(spec.principal_id).await })
1819 }
1820
1821 fn verify_scoped<'a>(
1826 &'a self,
1827 raw_token: &'a str,
1828 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<VerifiedToken>>> + Send + 'a>> {
1829 Box::pin(async move {
1830 Ok(self
1831 .verify(raw_token)
1832 .await?
1833 .map(|principal_id| VerifiedToken {
1834 principal_id,
1835 scopes: Vec::new(),
1836 name: String::new(),
1837 expires_at: None,
1838 }))
1839 })
1840 }
1841
1842 fn list<'a>(
1847 &'a self,
1848 principal_id: &'a str,
1849 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Vec<TokenMetadata>>> + Send + 'a>> {
1850 let _ = principal_id;
1851 Box::pin(async move { Ok(Vec::new()) })
1852 }
1853
1854 fn rotate<'a>(
1862 &'a self,
1863 raw_token: &'a str,
1864 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>> {
1865 Box::pin(async move {
1866 match self.verify_scoped(raw_token).await? {
1867 Some(vt) => {
1868 self.revoke(raw_token).await?;
1869 let scopes = vt.scopes.clone();
1870 let raw = self
1871 .issue_scoped(IssueTokenSpec {
1872 principal_id: &vt.principal_id,
1873 name: &vt.name,
1874 scopes: &scopes,
1875 expires_at: vt.expires_at,
1876 })
1877 .await?;
1878 Ok(Some(raw))
1879 }
1880 None => Ok(None),
1881 }
1882 })
1883 }
1884}
1885
1886#[must_use]
1901pub fn hash_api_token(raw: &str) -> String {
1902 use sha2::Digest as _;
1903 sha2::Sha256::digest(raw.as_bytes())
1904 .iter()
1905 .fold(String::with_capacity(64), |mut s, b| {
1906 use std::fmt::Write as _;
1907 let _ = write!(s, "{b:02x}");
1908 s
1909 })
1910}
1911
1912#[must_use]
1916pub fn generate_raw_token() -> String {
1917 let u1 = uuid::Uuid::new_v4();
1918 let u2 = uuid::Uuid::new_v4();
1919 format!("{}{}", u1.simple(), u2.simple())
1920}
1921
1922#[derive(Debug, Clone)]
1944struct StoredToken {
1945 id: u64,
1946 principal_id: String,
1947 name: String,
1948 scopes: Vec<String>,
1949 created_at: chrono::DateTime<chrono::Utc>,
1950 expires_at: Option<chrono::DateTime<chrono::Utc>>,
1951 last_used_at: Option<chrono::DateTime<chrono::Utc>>,
1952 revoked_at: Option<chrono::DateTime<chrono::Utc>>,
1953}
1954
1955#[derive(Clone)]
1956pub struct InMemoryApiTokenStore {
1957 tokens: Arc<std::sync::RwLock<std::collections::HashMap<String, StoredToken>>>,
1959 next_id: Arc<std::sync::atomic::AtomicU64>,
1960 clock: Arc<dyn crate::time::ClockSource>,
1961}
1962
1963impl Default for InMemoryApiTokenStore {
1964 fn default() -> Self {
1965 Self {
1966 tokens: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
1967 next_id: Arc::new(std::sync::atomic::AtomicU64::new(1)),
1968 clock: Arc::new(crate::time::SystemClock),
1969 }
1970 }
1971}
1972
1973impl InMemoryApiTokenStore {
1974 #[must_use]
1980 pub fn with_clock(mut self, clock: Arc<dyn crate::time::ClockSource>) -> Self {
1981 self.clock = clock;
1982 self
1983 }
1984
1985 #[must_use]
2011 pub fn with_token(self, raw_token: &str, principal_id: &str) -> Self {
2012 self.with_scoped_token(raw_token, principal_id, &[])
2013 }
2014
2015 #[must_use]
2022 pub fn with_scoped_token(self, raw_token: &str, principal_id: &str, scopes: &[String]) -> Self {
2023 self.store_raw_token(
2024 raw_token,
2025 &IssueTokenSpec {
2026 principal_id,
2027 scopes,
2028 ..Default::default()
2029 },
2030 );
2031 self
2032 }
2033
2034 pub fn from_env(var: &str, principal_id: &str) -> crate::AutumnResult<Self> {
2048 let raw = std::env::var(var).unwrap_or_default();
2049 if raw.trim().is_empty() {
2050 return Err(crate::AutumnError::internal_server_error_msg(format!(
2051 "InMemoryApiTokenStore::from_env: environment variable `{var}` is unset or empty"
2052 )));
2053 }
2054 Ok(Self::default().with_token(&raw, principal_id))
2055 }
2056
2057 fn insert_token(&self, spec: &IssueTokenSpec<'_>) -> String {
2059 let raw = generate_raw_token();
2060 self.store_raw_token(&raw, spec);
2061 raw
2062 }
2063
2064 fn store_raw_token(&self, raw_token: &str, spec: &IssueTokenSpec<'_>) {
2080 if raw_token.trim().is_empty() {
2081 tracing::warn!(
2082 "InMemoryApiTokenStore: ignoring a blank (empty or whitespace-only) seed token; \
2083 no credential was stored"
2084 );
2085 return;
2086 }
2087 let hash = hash_api_token(raw_token);
2088 let id = self
2089 .next_id
2090 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2091 let stored = StoredToken {
2092 id,
2093 principal_id: spec.principal_id.to_owned(),
2094 name: spec.name.to_owned(),
2095 scopes: spec.scopes.to_vec(),
2096 created_at: self.clock.now(),
2097 expires_at: spec.expires_at,
2098 last_used_at: None,
2099 revoked_at: None,
2100 };
2101 self.tokens
2102 .write()
2103 .expect("api token store lock poisoned")
2104 .insert(hash, stored);
2105 }
2106
2107 fn resolve_used(&self, raw_token: &str) -> Option<VerifiedToken> {
2111 let hash = hash_api_token(raw_token);
2112 let now = self.clock.now();
2113 let mut guard = self.tokens.write().expect("api token store lock poisoned");
2114 let stored = guard.get_mut(&hash)?;
2115 if stored.revoked_at.is_some() || stored.expires_at.is_some_and(|exp| exp <= now) {
2116 return None;
2117 }
2118 stored.last_used_at = Some(now);
2119 let verified = VerifiedToken {
2120 principal_id: stored.principal_id.clone(),
2121 scopes: stored.scopes.clone(),
2122 name: stored.name.clone(),
2123 expires_at: stored.expires_at,
2124 };
2125 drop(guard);
2126 Some(verified)
2127 }
2128}
2129
2130impl ApiTokenStore for InMemoryApiTokenStore {
2131 fn issue<'a>(
2132 &'a self,
2133 principal_id: &'a str,
2134 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
2135 Box::pin(async move {
2136 Ok(self.insert_token(&IssueTokenSpec {
2137 principal_id,
2138 ..Default::default()
2139 }))
2140 })
2141 }
2142
2143 fn verify<'a>(
2144 &'a self,
2145 raw_token: &'a str,
2146 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>> {
2147 Box::pin(async move { Ok(self.resolve_used(raw_token).map(|vt| vt.principal_id)) })
2148 }
2149
2150 fn revoke<'a>(
2151 &'a self,
2152 raw_token: &'a str,
2153 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'a>> {
2154 Box::pin(async move {
2155 let hash = hash_api_token(raw_token);
2156 let now = self.clock.now();
2157 let mut guard = self.tokens.write().expect("api token store lock poisoned");
2158 if let Some(stored) = guard.get_mut(&hash) {
2159 stored.revoked_at.get_or_insert(now);
2160 }
2161 drop(guard);
2162 Ok(())
2163 })
2164 }
2165
2166 fn issue_scoped<'a>(
2167 &'a self,
2168 spec: IssueTokenSpec<'a>,
2169 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
2170 Box::pin(async move { Ok(self.insert_token(&spec)) })
2171 }
2172
2173 fn verify_scoped<'a>(
2174 &'a self,
2175 raw_token: &'a str,
2176 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<VerifiedToken>>> + Send + 'a>> {
2177 Box::pin(async move { Ok(self.resolve_used(raw_token)) })
2178 }
2179
2180 fn list<'a>(
2181 &'a self,
2182 principal_id: &'a str,
2183 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Vec<TokenMetadata>>> + Send + 'a>> {
2184 Box::pin(async move {
2185 let mut out: Vec<TokenMetadata> = {
2186 let guard = self.tokens.read().expect("api token store lock poisoned");
2187 guard
2188 .values()
2189 .filter(|s| s.principal_id == principal_id)
2190 .map(|s| TokenMetadata {
2191 id: s.id.to_string(),
2192 name: s.name.clone(),
2193 principal_id: s.principal_id.clone(),
2194 scopes: s.scopes.clone(),
2195 created_at: s.created_at,
2196 expires_at: s.expires_at,
2197 last_used_at: s.last_used_at,
2198 revoked_at: s.revoked_at,
2199 })
2200 .collect()
2201 };
2202 out.sort_by(|a, b| {
2203 a.id.parse::<u64>()
2204 .unwrap_or(0)
2205 .cmp(&b.id.parse().unwrap_or(0))
2206 });
2207 Ok(out)
2208 })
2209 }
2210}
2211
2212pub async fn issue_api_token(
2220 store: &dyn ApiTokenStore,
2221 principal_id: &str,
2222) -> crate::AutumnResult<String> {
2223 store.issue(principal_id).await
2224}
2225
2226pub async fn revoke_api_token(
2234 store: &dyn ApiTokenStore,
2235 raw_token: &str,
2236) -> crate::AutumnResult<()> {
2237 store.revoke(raw_token).await
2238}
2239
2240pub async fn issue_scoped_api_token(
2251 store: &dyn ApiTokenStore,
2252 spec: IssueTokenSpec<'_>,
2253) -> crate::AutumnResult<String> {
2254 store.issue_scoped(spec).await
2255}
2256
2257pub async fn list_api_tokens(
2266 store: &dyn ApiTokenStore,
2267 principal_id: &str,
2268) -> crate::AutumnResult<Vec<TokenMetadata>> {
2269 store.list(principal_id).await
2270}
2271
2272pub async fn rotate_api_token(
2281 store: &dyn ApiTokenStore,
2282 raw_token: &str,
2283) -> crate::AutumnResult<Option<String>> {
2284 store.rotate(raw_token).await
2285}
2286
2287#[derive(Clone)]
2290struct ApiTokenPrincipal(String);
2291
2292#[derive(Debug, Clone, Default, PartialEq, Eq)]
2315pub struct ApiTokenScopes(pub Vec<String>);
2316
2317#[derive(Debug, Clone)]
2335pub struct ApiToken(pub String);
2336
2337impl<S> FromRequestParts<S> for ApiToken
2338where
2339 S: Send + Sync,
2340{
2341 type Rejection = AuthRejection;
2342
2343 fn from_request_parts(
2344 parts: &mut Parts,
2345 _state: &S,
2346 ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
2347 let principal = parts.extensions.get::<ApiTokenPrincipal>().cloned();
2348 async move { principal.map(|p| Self(p.0)).ok_or(AuthRejection) }
2349 }
2350}
2351
2352#[derive(Clone)]
2380pub struct RequireApiToken {
2381 store: Arc<dyn ApiTokenStore>,
2382}
2383
2384impl RequireApiToken {
2385 #[must_use]
2390 pub fn new<S: ApiTokenStore + 'static>(store: Arc<S>) -> Self {
2391 Self { store }
2392 }
2393}
2394
2395impl<S> tower::Layer<S> for RequireApiToken {
2396 type Service = RequireApiTokenService<S>;
2397
2398 fn layer(&self, inner: S) -> Self::Service {
2399 RequireApiTokenService {
2400 inner,
2401 store: Arc::clone(&self.store),
2402 }
2403 }
2404}
2405
2406#[derive(Clone)]
2408pub struct RequireApiTokenService<S> {
2409 inner: S,
2410 store: Arc<dyn ApiTokenStore>,
2411}
2412
2413impl<S, ResBody> tower::Service<axum::extract::Request> for RequireApiTokenService<S>
2414where
2415 S: tower::Service<axum::extract::Request, Response = Response<ResBody>>
2416 + Clone
2417 + Send
2418 + 'static,
2419 S::Future: Send + 'static,
2420 S::Error: Send + 'static,
2421 ResBody: From<String> + Default + Send + 'static,
2422{
2423 type Response = Response<ResBody>;
2424 type Error = S::Error;
2425 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
2426
2427 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2428 self.inner.poll_ready(cx)
2429 }
2430
2431 fn call(&mut self, mut req: axum::extract::Request) -> Self::Future {
2432 let store = Arc::clone(&self.store);
2433 let mut inner = self.inner.clone();
2434 std::mem::swap(&mut self.inner, &mut inner);
2435
2436 Box::pin(async move {
2437 let raw_token = req
2439 .headers()
2440 .get(http::header::AUTHORIZATION)
2441 .and_then(|v| v.to_str().ok())
2442 .and_then(parse_bearer_token)
2443 .map(str::to_owned);
2444
2445 let Some(raw_token) = raw_token else {
2446 let (request_id, instance) = api_token_problem_context(&req);
2447 return Ok(api_token_unauthorized_response(request_id, instance));
2448 };
2449
2450 match store.verify_scoped(&raw_token).await {
2451 Ok(Some(verified)) => {
2452 let VerifiedToken {
2453 principal_id,
2454 scopes,
2455 ..
2456 } = verified;
2457 req.extensions_mut()
2460 .insert(crate::security::RateLimitPrincipal(principal_id.clone()));
2461 req.extensions_mut().insert(ApiTokenScopes(scopes));
2467 if crate::current::Current::actor().is_none() {
2479 crate::current::Current::set_actor(principal_id.clone());
2480 }
2481 req.extensions_mut().insert(ApiTokenPrincipal(principal_id));
2482 inner.call(req).await
2483 }
2484 Ok(None) => {
2485 let (request_id, instance) = api_token_problem_context(&req);
2486 Ok(api_token_unauthorized_response(request_id, instance))
2487 }
2488 Err(err) => {
2489 let (request_id, instance) = api_token_problem_context(&req);
2490 Ok(api_token_error_response(&err, request_id, instance))
2491 }
2492 }
2493 })
2494 }
2495}
2496
2497fn parse_bearer_token(header: &str) -> Option<&str> {
2498 let (scheme, token) = header.split_once(' ')?;
2499 scheme.eq_ignore_ascii_case("Bearer").then_some(token)
2500}
2501
2502fn api_token_unauthorized_response<ResBody: From<String> + Default>(
2504 request_id: Option<String>,
2505 instance: Option<String>,
2506) -> Response<ResBody> {
2507 let body = crate::error::problem_details_json_string(
2508 StatusCode::UNAUTHORIZED,
2509 "authentication required",
2510 None,
2511 None,
2512 request_id,
2513 instance,
2514 true,
2515 );
2516 Response::builder()
2517 .status(StatusCode::UNAUTHORIZED)
2518 .header(http::header::CONTENT_TYPE, "application/problem+json")
2519 .body(ResBody::from(body))
2520 .unwrap_or_default()
2521}
2522
2523fn api_token_error_response<ResBody: From<String> + Default>(
2525 err: &crate::AutumnError,
2526 request_id: Option<String>,
2527 instance: Option<String>,
2528) -> Response<ResBody> {
2529 let status = err.status();
2530 let message = err.to_string();
2531 let body = crate::error::problem_details_json_string(
2532 status,
2533 message.clone(),
2534 None,
2535 None,
2536 request_id,
2537 instance,
2538 true,
2539 );
2540 let mut response = Response::builder()
2541 .status(status)
2542 .header(http::header::CONTENT_TYPE, "application/problem+json")
2543 .body(ResBody::from(body))
2544 .unwrap_or_default();
2545 response
2546 .extensions_mut()
2547 .insert(crate::middleware::AutumnErrorInfo {
2548 status,
2549 message,
2550 details: None,
2551 problem_type: None,
2552 backtrace_string: None,
2553 });
2554 response
2555}
2556
2557fn api_token_problem_context(req: &axum::extract::Request) -> (Option<String>, Option<String>) {
2558 (
2559 req.extensions()
2560 .get::<crate::middleware::RequestId>()
2561 .map(std::string::ToString::to_string),
2562 Some(req.uri().path().to_owned()),
2563 )
2564}
2565
2566#[cfg(feature = "db")]
2590pub const API_TOKEN_MIGRATIONS: diesel_migrations::EmbeddedMigrations =
2591 diesel_migrations::embed_migrations!("migrations");
2592
2593#[cfg(feature = "db")]
2594mod db_store {
2595 use std::future::Future;
2596 use std::pin::Pin;
2597
2598 use std::sync::Arc;
2599
2600 use chrono::{DateTime, NaiveDateTime, Utc};
2601 use diesel::OptionalExtension as _;
2602 use diesel::prelude::*;
2603 use diesel_async::AsyncPgConnection;
2604 use diesel_async::RunQueryDsl;
2605 use diesel_async::pooled_connection::deadpool::Pool;
2606
2607 use super::{
2608 ApiTokenStore, IssueTokenSpec, TokenMetadata, VerifiedToken, generate_raw_token,
2609 hash_api_token,
2610 };
2611 use crate::error::AutumnError;
2612 use crate::time::{ClockSource, SystemClock};
2613
2614 diesel::table! {
2615 api_tokens (id) {
2616 id -> Int8,
2617 token_hash -> Text,
2618 principal_id -> Text,
2619 created_at -> Timestamp,
2620 revoked_at -> Nullable<Timestamp>,
2621 name -> Text,
2622 scopes -> Jsonb,
2623 expires_at -> Nullable<Timestamp>,
2624 last_used_at -> Nullable<Timestamp>,
2625 }
2626 }
2627
2628 #[derive(Insertable)]
2629 #[diesel(table_name = api_tokens)]
2630 struct NewApiToken<'a> {
2631 token_hash: &'a str,
2632 principal_id: &'a str,
2633 name: &'a str,
2634 scopes: serde_json::Value,
2635 expires_at: Option<NaiveDateTime>,
2636 }
2637
2638 #[derive(Queryable)]
2640 struct TokenRow {
2641 id: i64,
2642 name: String,
2643 principal_id: String,
2644 scopes: serde_json::Value,
2645 created_at: NaiveDateTime,
2646 expires_at: Option<NaiveDateTime>,
2647 last_used_at: Option<NaiveDateTime>,
2648 revoked_at: Option<NaiveDateTime>,
2649 }
2650
2651 const fn to_utc(naive: NaiveDateTime) -> DateTime<Utc> {
2652 DateTime::from_naive_utc_and_offset(naive, Utc)
2653 }
2654
2655 fn scopes_to_json(scopes: &[String]) -> serde_json::Value {
2656 serde_json::Value::Array(
2657 scopes
2658 .iter()
2659 .map(|s| serde_json::Value::String(s.clone()))
2660 .collect(),
2661 )
2662 }
2663
2664 #[must_use]
2665 pub fn scopes_from_json(value: &serde_json::Value) -> Vec<String> {
2666 value
2667 .as_array()
2668 .map(|arr| {
2669 arr.iter()
2670 .filter_map(|v| v.as_str().map(str::to_owned))
2671 .collect()
2672 })
2673 .unwrap_or_default()
2674 }
2675
2676 #[derive(Clone)]
2704 pub struct DbApiTokenStore {
2705 pool: Pool<AsyncPgConnection>,
2706 clock: Arc<dyn ClockSource>,
2707 }
2708
2709 impl DbApiTokenStore {
2710 #[must_use]
2712 pub fn new(pool: Pool<AsyncPgConnection>) -> Self {
2713 Self {
2714 pool,
2715 clock: Arc::new(SystemClock),
2716 }
2717 }
2718
2719 #[must_use]
2724 pub fn with_clock(mut self, clock: Arc<dyn ClockSource>) -> Self {
2725 self.clock = clock;
2726 self
2727 }
2728
2729 async fn conn(
2730 &self,
2731 ) -> crate::AutumnResult<diesel_async::pooled_connection::deadpool::Object<AsyncPgConnection>>
2732 {
2733 self.pool
2734 .get()
2735 .await
2736 .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))
2737 }
2738 }
2739
2740 impl ApiTokenStore for DbApiTokenStore {
2741 fn issue<'a>(
2742 &'a self,
2743 principal_id: &'a str,
2744 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
2745 self.issue_scoped(IssueTokenSpec {
2746 principal_id,
2747 ..Default::default()
2748 })
2749 }
2750
2751 fn verify<'a>(
2752 &'a self,
2753 raw_token: &'a str,
2754 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>>
2755 {
2756 Box::pin(async move {
2757 Ok(self
2758 .verify_scoped(raw_token)
2759 .await?
2760 .map(|vt| vt.principal_id))
2761 })
2762 }
2763
2764 fn revoke<'a>(
2765 &'a self,
2766 raw_token: &'a str,
2767 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'a>> {
2768 Box::pin(async move {
2769 let hash = hash_api_token(raw_token);
2770 let now = self.clock.now().naive_utc();
2771 let mut conn = self.conn().await?;
2772 diesel::update(api_tokens::table)
2773 .filter(api_tokens::token_hash.eq(&hash))
2774 .filter(api_tokens::revoked_at.is_null())
2775 .set(api_tokens::revoked_at.eq(Some(now)))
2776 .execute(&mut conn)
2777 .await
2778 .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2779 Ok(())
2780 })
2781 }
2782
2783 fn issue_scoped<'a>(
2784 &'a self,
2785 spec: IssueTokenSpec<'a>,
2786 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<String>> + Send + 'a>> {
2787 Box::pin(async move {
2788 let raw = generate_raw_token();
2789 let hash = hash_api_token(&raw);
2790 let mut conn = self.conn().await?;
2791 diesel::insert_into(api_tokens::table)
2792 .values(NewApiToken {
2793 token_hash: &hash,
2794 principal_id: spec.principal_id,
2795 name: spec.name,
2796 scopes: scopes_to_json(spec.scopes),
2797 expires_at: spec.expires_at.map(|dt| dt.naive_utc()),
2798 })
2799 .execute(&mut conn)
2800 .await
2801 .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2802 Ok(raw)
2803 })
2804 }
2805
2806 fn rotate<'a>(
2807 &'a self,
2808 raw_token: &'a str,
2809 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>>
2810 {
2811 Box::pin(async move {
2812 #[derive(diesel::QueryableByName)]
2813 struct CountRow {
2814 #[diesel(sql_type = diesel::sql_types::BigInt)]
2815 count: i64,
2816 }
2817
2818 let old_hash = hash_api_token(raw_token);
2819 let new_raw = generate_raw_token();
2820 let new_hash = hash_api_token(&new_raw);
2821 let now = self.clock.now().naive_utc();
2822 let mut conn = self.conn().await?;
2823 let row: CountRow = diesel::sql_query(
2830 "WITH rotated AS ( \
2831 UPDATE api_tokens \
2832 SET revoked_at = $3 \
2833 WHERE token_hash = $1 AND revoked_at IS NULL \
2834 AND (expires_at IS NULL OR expires_at > $3) \
2835 RETURNING principal_id, name, scopes, expires_at \
2836 ), \
2837 inserted AS ( \
2838 INSERT INTO api_tokens (token_hash, principal_id, name, scopes, expires_at) \
2839 SELECT $2, principal_id, name, scopes, expires_at FROM rotated \
2840 RETURNING 1 \
2841 ) \
2842 SELECT COUNT(*)::bigint AS count FROM inserted",
2843 )
2844 .bind::<diesel::sql_types::Text, _>(&old_hash)
2845 .bind::<diesel::sql_types::Text, _>(&new_hash)
2846 .bind::<diesel::sql_types::Timestamp, _>(now)
2847 .get_result(&mut conn)
2848 .await
2849 .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2850 if row.count == 0 {
2851 Ok(None)
2852 } else {
2853 Ok(Some(new_raw))
2854 }
2855 })
2856 }
2857
2858 fn verify_scoped<'a>(
2859 &'a self,
2860 raw_token: &'a str,
2861 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Option<VerifiedToken>>> + Send + 'a>>
2862 {
2863 Box::pin(async move {
2864 let hash = hash_api_token(raw_token);
2865 let now = self.clock.now().naive_utc();
2866 let mut conn = self.conn().await?;
2867 let row: Option<(
2869 i64,
2870 String,
2871 String,
2872 Option<NaiveDateTime>,
2873 serde_json::Value,
2874 )> = api_tokens::table
2875 .filter(api_tokens::token_hash.eq(&hash))
2876 .filter(api_tokens::revoked_at.is_null())
2877 .filter(
2878 api_tokens::expires_at
2879 .is_null()
2880 .or(api_tokens::expires_at.gt(now)),
2881 )
2882 .select((
2883 api_tokens::id,
2884 api_tokens::principal_id,
2885 api_tokens::name,
2886 api_tokens::expires_at,
2887 api_tokens::scopes,
2888 ))
2889 .first(&mut conn)
2890 .await
2891 .optional()
2892 .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2893
2894 let Some((id, principal_id, name, expires_at_naive, scopes_json)) = row else {
2895 return Ok(None);
2896 };
2897 let threshold = now - chrono::Duration::minutes(5);
2900 let _ = diesel::update(
2901 api_tokens::table.filter(api_tokens::id.eq(id)).filter(
2902 api_tokens::last_used_at
2903 .is_null()
2904 .or(api_tokens::last_used_at.lt(threshold)),
2905 ),
2906 )
2907 .set(api_tokens::last_used_at.eq(Some(now)))
2908 .execute(&mut conn)
2909 .await;
2910 Ok(Some(VerifiedToken {
2911 principal_id,
2912 scopes: scopes_from_json(&scopes_json),
2913 name,
2914 expires_at: expires_at_naive.map(to_utc),
2915 }))
2916 })
2917 }
2918
2919 fn list<'a>(
2920 &'a self,
2921 principal_id: &'a str,
2922 ) -> Pin<Box<dyn Future<Output = crate::AutumnResult<Vec<TokenMetadata>>> + Send + 'a>>
2923 {
2924 Box::pin(async move {
2925 let mut conn = self.conn().await?;
2926 let rows: Vec<TokenRow> = api_tokens::table
2927 .filter(api_tokens::principal_id.eq(principal_id))
2928 .order(api_tokens::id.asc())
2929 .select((
2930 api_tokens::id,
2931 api_tokens::name,
2932 api_tokens::principal_id,
2933 api_tokens::scopes,
2934 api_tokens::created_at,
2935 api_tokens::expires_at,
2936 api_tokens::last_used_at,
2937 api_tokens::revoked_at,
2938 ))
2939 .load(&mut conn)
2940 .await
2941 .map_err(|e| AutumnError::internal_server_error_msg(e.to_string()))?;
2942 Ok(rows
2943 .into_iter()
2944 .map(|r| TokenMetadata {
2945 id: r.id.to_string(),
2946 name: r.name,
2947 principal_id: r.principal_id,
2948 scopes: scopes_from_json(&r.scopes),
2949 created_at: to_utc(r.created_at),
2950 expires_at: r.expires_at.map(to_utc),
2951 last_used_at: r.last_used_at.map(to_utc),
2952 revoked_at: r.revoked_at.map(to_utc),
2953 })
2954 .collect())
2955 })
2956 }
2957 }
2958}
2959
2960#[cfg(feature = "db")]
2961pub use db_store::DbApiTokenStore;
2962#[cfg(feature = "db")]
2968#[doc(hidden)]
2969pub use db_store::scopes_from_json;
2970
2971#[cfg(test)]
2972mod tests {
2973 use super::*;
2974
2975 fn test_app_state(auth_session_key: &str) -> crate::state::AppState {
2979 crate::state::AppState {
2980 extensions: std::sync::Arc::new(std::sync::RwLock::new(
2981 std::collections::HashMap::new(),
2982 )),
2983 #[cfg(feature = "db")]
2984 pool: None,
2985 #[cfg(feature = "db")]
2986 replica_pool: None,
2987 #[cfg(feature = "db")]
2988 shards: None,
2989 profile: None,
2990 role: crate::config::ProcessRole::Combined,
2991 started_at: std::time::Instant::now(),
2992 health_detailed: false,
2993 probes: crate::probe::ProbeState::ready_for_test(),
2994 metrics: crate::middleware::MetricsCollector::new(),
2995 log_levels: crate::actuator::LogLevels::new("info"),
2996 task_registry: crate::actuator::TaskRegistry::new(),
2997 job_registry: crate::actuator::JobRegistry::new(),
2998 config_props: crate::actuator::ConfigProperties::default(),
2999 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
3000 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
3001 #[cfg(feature = "ws")]
3002 channels: crate::channels::Channels::new(32),
3003 #[cfg(feature = "presence")]
3004 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
3005 #[cfg(feature = "ws")]
3006 shutdown: tokio_util::sync::CancellationToken::new(),
3007 policy_registry: crate::authorization::PolicyRegistry::default(),
3008 forbidden_response: crate::authorization::ForbiddenResponse::default(),
3009 auth_session_key: auth_session_key.to_owned(),
3010 shared_cache: None,
3011 clock: std::sync::Arc::new(crate::time::SystemClock),
3012 app_id: crate::state::AppState::next_app_id(),
3013 }
3014 }
3015
3016 #[tokio::test]
3017 async fn hash_and_verify_password() {
3018 let hash = hash_password("test_password").await.unwrap();
3019 assert!(hash.starts_with("$2b$"));
3020 assert!(verify_password("test_password", &hash).await.unwrap());
3021 assert!(!verify_password("wrong_password", &hash).await.unwrap());
3022 }
3023
3024 #[tokio::test]
3025 async fn verify_invalid_hash_returns_false() {
3026 let result = verify_password("test", "not-a-valid-hash").await;
3027 assert!(result.is_ok());
3028 assert!(!result.unwrap());
3029 }
3030
3031 #[tokio::test]
3032 async fn verify_password_rejects_invalid_hash_format_safely() {
3033 let result = verify_password("test", "short").await;
3035 assert!(result.is_ok());
3036 assert!(!result.unwrap());
3037
3038 let bad_prefix = "a".repeat(60);
3040 let result = verify_password("test", &bad_prefix).await;
3041 assert!(result.is_ok());
3042 assert!(!result.unwrap());
3043
3044 let bad_length = "$2b$12$short";
3046 let result = verify_password("test", bad_length).await;
3047 assert!(result.is_ok());
3048 assert!(!result.unwrap());
3049 }
3050
3051 #[test]
3052 fn auth_config_defaults() {
3053 let config = AuthConfig::default();
3054 assert_eq!(config.bcrypt_cost, 12);
3055 assert_eq!(config.session_key, "user_id");
3056 #[cfg(feature = "oauth2")]
3057 assert!(config.oauth2.providers.is_empty());
3058 }
3059
3060 #[test]
3063 fn session_tracking_config_defaults_to_revoke_on_credential_change() {
3064 let config = AuthConfig::default();
3065 assert!(config.sessions.revoke_on_credential_change);
3066 assert_eq!(config.sessions.last_seen_update_secs, 60);
3067 }
3068
3069 #[test]
3071 fn session_tracking_config_deserializes_from_toml() {
3072 let cfg: crate::config::AutumnConfig = toml::from_str(
3073 r"
3074 [auth.sessions]
3075 revoke_on_credential_change = false
3076 last_seen_update_secs = 5
3077 ",
3078 )
3079 .expect("config must parse");
3080 assert!(!cfg.auth.sessions.revoke_on_credential_change);
3081 assert_eq!(cfg.auth.sessions.last_seen_update_secs, 5);
3082 }
3083
3084 #[cfg(feature = "oauth2")]
3085 #[test]
3086 fn oauth2_config_deserializes_provider_tables() {
3087 let cfg: crate::config::AutumnConfig = toml::from_str(
3088 r#"
3089 [auth.oauth2.github]
3090 client_id = "cid"
3091 client_secret = "secret"
3092 authorize_url = "https://github.com/login/oauth/authorize"
3093 token_url = "https://github.com/login/oauth/access_token"
3094 redirect_uri = "http://localhost:3000/auth/github/callback"
3095 "#,
3096 )
3097 .unwrap();
3098 let provider = cfg.auth.oauth2.providers.get("github").unwrap();
3099 assert_eq!(provider.client_id, "cid");
3100 assert_eq!(provider.scope, "");
3101 assert!(provider.issuer.is_none());
3102 assert!(provider.jwks_url.is_none());
3103 }
3104
3105 #[cfg(feature = "oauth2")]
3106 #[tokio::test]
3107 async fn oauth2_authorize_url_sets_state_and_nonce() {
3108 let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
3109 let provider = OAuth2ProviderConfig {
3110 client_id: "cid".into(),
3111 client_secret: "secret".into(),
3112 authorize_url: "https://idp.example/authorize".into(),
3113 token_url: "https://idp.example/token".into(),
3114 userinfo_url: None,
3115 redirect_uri: "http://localhost:3000/callback".into(),
3116 scope: "openid profile".into(),
3117 issuer: None,
3118 jwks_url: None,
3119 discovery_url: None,
3120 };
3121 let url = oauth2_authorize_url(&session, "github", &provider)
3122 .await
3123 .unwrap();
3124 assert!(url.contains("response_type=code"));
3125 assert!(session.get("oauth2:github:state").await.is_some());
3126 assert!(session.get("oauth2:github:nonce").await.is_some());
3127 }
3128
3129 #[cfg(feature = "oauth2")]
3130 #[tokio::test]
3131 async fn oauth2_authorize_url_omits_scope_when_empty() {
3132 let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
3133 let provider = OAuth2ProviderConfig {
3134 client_id: "cid".into(),
3135 client_secret: "secret".into(),
3136 authorize_url: "https://idp.example/authorize".into(),
3137 token_url: "https://idp.example/token".into(),
3138 userinfo_url: None,
3139 redirect_uri: "http://localhost:3000/callback".into(),
3140 scope: String::new(),
3141 issuer: None,
3142 jwks_url: None,
3143 discovery_url: None,
3144 };
3145 let url = oauth2_authorize_url(&session, "github", &provider)
3146 .await
3147 .unwrap();
3148 assert!(!url.contains("scope="));
3149 }
3150
3151 #[cfg(feature = "oauth2")]
3152 #[tokio::test]
3153 async fn validate_id_token_requires_oidc_metadata() {
3154 let provider = OAuth2ProviderConfig {
3155 client_id: "cid".into(),
3156 client_secret: "secret".into(),
3157 authorize_url: "https://idp.example/authorize".into(),
3158 token_url: "https://idp.example/token".into(),
3159 userinfo_url: None,
3160 redirect_uri: "http://localhost:3000/callback".into(),
3161 scope: "openid profile".into(),
3162 issuer: None,
3163 jwks_url: None,
3164 discovery_url: None,
3165 };
3166 let err = validate_and_decode_id_token("bad.token.value", &provider)
3167 .await
3168 .unwrap_err();
3169 assert_eq!(err.to_string(), "provider.issuer required for oidc");
3170 }
3171
3172 #[cfg(feature = "oauth2")]
3175 fn rsa_test_jwk_json(key_algorithm: Option<&str>) -> serde_json::Value {
3176 let mut jwk = serde_json::json!({
3177 "kty": "RSA",
3178 "kid": "test-kid",
3179 "n": "ofgWCuLjybRlzo0tZWJjNiuSfb4p4fAkd_wWJcyQoTbji9k0l8W26mPddxHmfHQp\
3180 -Vaw-4qPCJrcS2mJPMEzP1Pt0Bm4d4QlL-yRT-SFd2lZS-pCgNMsD1W_YpRPEwOW\
3181 vG6b32690r2jZ47soMZo9wGzjb_7OMg0LOL-bSf63kpaSHSXndS5z5rexMdbBYUs\
3182 LA9e-KXBdQOS-UTo7WTBEMa2R2CapHg665xsmtdVMTBQY4uDZlxvb3qCo5ZwKh9k\
3183 G4LT6_I5IhlJH7aGhyxXFvUK-DWNmoudF8NAco9_h9iaGNj8q2ethFkMLs91kzk2\
3184 PAcDTW9gb54h4FRWyuXpoQ",
3185 "e": "AQAB"
3186 });
3187 if let Some(alg) = key_algorithm {
3188 jwk["alg"] = serde_json::json!(alg);
3189 }
3190 jwk
3191 }
3192
3193 #[cfg(feature = "oauth2")]
3194 fn rsa_test_jwk(key_algorithm: Option<&str>) -> jsonwebtoken::jwk::Jwk {
3195 serde_json::from_value(rsa_test_jwk_json(key_algorithm)).unwrap()
3196 }
3197
3198 #[cfg(feature = "oauth2")]
3199 #[test]
3200 fn jwk_allowed_algorithms_pins_declared_algorithm() {
3201 let algs = jwk_allowed_algorithms(&rsa_test_jwk(Some("RS256"))).unwrap();
3202 assert_eq!(algs, vec![jsonwebtoken::Algorithm::RS256]);
3203 }
3204
3205 #[cfg(feature = "oauth2")]
3206 #[test]
3207 fn jwk_allowed_algorithms_rejects_symmetric_declared_algorithm() {
3208 let err = jwk_allowed_algorithms(&rsa_test_jwk(Some("HS256"))).unwrap_err();
3209 assert!(
3210 err.to_string().contains("not allowed"),
3211 "expected symmetric alg rejection, got: {err}"
3212 );
3213 }
3214
3215 #[cfg(feature = "oauth2")]
3216 #[test]
3217 fn jwk_allowed_algorithms_derives_asymmetric_set_from_key_type() {
3218 let algs = jwk_allowed_algorithms(&rsa_test_jwk(None)).unwrap();
3219 assert!(algs.contains(&jsonwebtoken::Algorithm::RS256));
3220 assert!(algs.contains(&jsonwebtoken::Algorithm::PS256));
3221 assert!(!algs.contains(&jsonwebtoken::Algorithm::HS256));
3222 assert!(!algs.contains(&jsonwebtoken::Algorithm::HS384));
3223 assert!(!algs.contains(&jsonwebtoken::Algorithm::HS512));
3224 }
3225
3226 #[cfg(feature = "oauth2")]
3227 #[test]
3228 fn jwk_allowed_algorithms_rejects_symmetric_octet_key() {
3229 let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(serde_json::json!({
3230 "kty": "oct",
3231 "kid": "sym-kid",
3232 "k": "c2VjcmV0"
3233 }))
3234 .unwrap();
3235 let err = jwk_allowed_algorithms(&jwk).unwrap_err();
3236 assert!(
3237 err.to_string().contains("symmetric jwk not allowed"),
3238 "expected symmetric jwk rejection, got: {err}"
3239 );
3240 }
3241
3242 #[cfg(feature = "oauth2")]
3245 async fn spawn_jwks_stub(body: String) -> String {
3246 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
3247 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3248 let addr = listener.local_addr().unwrap();
3249 tokio::spawn(async move {
3250 while let Ok((mut stream, _)) = listener.accept().await {
3251 let mut buf = [0u8; 4096];
3252 let _ = stream.read(&mut buf).await;
3253 let response = format!(
3254 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
3255 content-length: {}\r\nconnection: close\r\n\r\n{body}",
3256 body.len()
3257 );
3258 let _ = stream.write_all(response.as_bytes()).await;
3259 let _ = stream.shutdown().await;
3260 }
3261 });
3262 format!("http://{addr}/jwks")
3263 }
3264
3265 #[cfg(feature = "oauth2")]
3266 fn oidc_test_provider(jwks_url: String) -> OAuth2ProviderConfig {
3267 OAuth2ProviderConfig {
3268 client_id: "cid".into(),
3269 client_secret: "secret".into(),
3270 authorize_url: "https://idp.example/authorize".into(),
3271 token_url: "https://idp.example/token".into(),
3272 userinfo_url: None,
3273 redirect_uri: "http://localhost:3000/callback".into(),
3274 scope: "openid".into(),
3275 issuer: Some("https://idp.example".into()),
3276 jwks_url: Some(jwks_url),
3277 discovery_url: None,
3278 }
3279 }
3280
3281 #[cfg(feature = "oauth2")]
3282 fn unix_now_secs() -> u64 {
3283 std::time::SystemTime::now()
3284 .duration_since(std::time::UNIX_EPOCH)
3285 .unwrap()
3286 .as_secs()
3287 }
3288
3289 #[cfg(feature = "oauth2")]
3295 #[tokio::test]
3296 async fn validate_id_token_rejects_hs256_against_rsa_jwks_key() {
3297 let jwks_body = serde_json::json!({ "keys": [rsa_test_jwk_json(None)] }).to_string();
3298 let provider = oidc_test_provider(spawn_jwks_stub(jwks_body).await);
3299
3300 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
3301 header.kid = Some("test-kid".into());
3302 let claims = serde_json::json!({
3303 "sub": "attacker-controlled",
3304 "iss": "https://idp.example",
3305 "aud": "cid",
3306 "exp": unix_now_secs() + 3600,
3307 });
3308 let token = jsonwebtoken::encode(
3309 &header,
3310 &claims,
3311 &jsonwebtoken::EncodingKey::from_secret(b"guessed-public-key-material"),
3312 )
3313 .unwrap();
3314
3315 let err = validate_and_decode_id_token(&token, &provider)
3316 .await
3317 .unwrap_err();
3318 assert!(
3319 err.to_string().contains("not permitted by matching jwk"),
3320 "expected algorithm pinning rejection, got: {err}"
3321 );
3322 }
3323
3324 #[cfg(feature = "oauth2")]
3326 #[tokio::test]
3327 async fn validate_id_token_rejects_alg_none() {
3328 use base64::Engine as _;
3329 let b64 = |v: &serde_json::Value| {
3330 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string())
3331 };
3332 let header = b64(&serde_json::json!({"alg": "none", "kid": "test-kid", "typ": "JWT"}));
3333 let payload = b64(&serde_json::json!({
3334 "sub": "attacker-controlled",
3335 "iss": "https://idp.example",
3336 "aud": "cid",
3337 "exp": unix_now_secs() + 3600,
3338 }));
3339 let token = format!("{header}.{payload}.");
3340
3341 let provider = oidc_test_provider("http://127.0.0.1:9/jwks".into());
3343 let err = validate_and_decode_id_token(&token, &provider)
3344 .await
3345 .unwrap_err();
3346 assert!(
3347 err.to_string().contains("invalid id_token header"),
3348 "expected header rejection for alg=none, got: {err}"
3349 );
3350 }
3351
3352 #[cfg(feature = "oauth2")]
3353 #[test]
3354 fn parse_oauth2_token_response_supports_form_encoded_payload() {
3355 let token = parse_oauth2_token_response(
3356 Some("application/x-www-form-urlencoded"),
3357 "access_token=abc123&token_type=bearer&id_token=xyz789&extra_field=ignored",
3358 )
3359 .unwrap();
3360 assert_eq!(token.access_token, "abc123");
3361 assert_eq!(token.token_type.as_deref(), Some("bearer"));
3362 assert_eq!(token.id_token.as_deref(), Some("xyz789"));
3363 }
3364
3365 #[cfg(feature = "oauth2")]
3366 #[test]
3367 fn parse_oauth2_token_response_fails_without_access_token() {
3368 let err = parse_oauth2_token_response(
3369 Some("application/x-www-form-urlencoded"),
3370 "token_type=bearer&id_token=xyz789",
3371 )
3372 .unwrap_err();
3373 assert_eq!(err.to_string(), "token response missing access_token");
3374 }
3375
3376 #[cfg(feature = "oauth2")]
3377 #[test]
3378 fn extract_subject_allows_userinfo_id_fallback() {
3379 let claims = serde_json::json!({ "id": 42 });
3380 let subject = extract_subject(&claims, IdentitySource::UserInfo).unwrap();
3381 assert_eq!(subject, "42");
3382 }
3383
3384 #[cfg(feature = "oauth2")]
3385 #[tokio::test]
3386 async fn validate_callback_state_preserves_state_on_mismatch() {
3387 let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
3391 session
3392 .insert("oauth2:github:state".to_owned(), "real-state".to_owned())
3393 .await;
3394 let bad_callback = OAuth2Callback {
3395 code: "c".into(),
3396 state: "wrong-state".into(),
3397 };
3398 let err = validate_callback_state(&session, "github", &bad_callback)
3399 .await
3400 .unwrap_err();
3401 assert!(err.to_string().contains("state mismatch"));
3402 assert_eq!(
3404 session.get("oauth2:github:state").await.as_deref(),
3405 Some("real-state")
3406 );
3407 }
3408
3409 #[cfg(feature = "oauth2")]
3410 #[tokio::test]
3411 async fn validate_oidc_nonce_rejects_missing_nonce_for_id_token() {
3412 let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
3415 let claims = serde_json::json!({ "nonce": "any" });
3417 let err = validate_oidc_nonce(&session, "github", &claims, IdentitySource::IdToken)
3418 .await
3419 .unwrap_err();
3420 assert!(err.to_string().contains("nonce missing from session"));
3421 }
3422
3423 #[cfg(feature = "oauth2")]
3424 #[test]
3425 fn extract_subject_requires_sub_for_id_token() {
3426 let claims = serde_json::json!({ "id": "abc" });
3427 let err = extract_subject(&claims, IdentitySource::IdToken).unwrap_err();
3428 assert_eq!(err.to_string(), "missing sub claim");
3429 }
3430
3431 #[test]
3432 fn auth_rejection_is_401() {
3433 let rejection = AuthRejection;
3434 let response = rejection.into_response();
3435 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3436 }
3437
3438 #[test]
3439 fn auth_rejection_display() {
3440 assert_eq!(AuthRejection.to_string(), "authentication required");
3441 }
3442
3443 #[tokio::test]
3444 async fn auth_extractor_returns_401_when_no_user() {
3445 use axum::Router;
3446 use axum::body::Body;
3447 use axum::routing::get;
3448 use tower::ServiceExt;
3449
3450 #[derive(Clone)]
3451 struct TestUser {
3452 name: String,
3453 }
3454
3455 async fn handler(Auth(user): Auth<TestUser>) -> String {
3456 user.name
3457 }
3458
3459 let state = test_app_state("user_id");
3460
3461 let app = Router::new().route("/", get(handler)).with_state(state);
3462
3463 let response = app
3464 .oneshot(
3465 http::Request::builder()
3466 .uri("/")
3467 .body(Body::empty())
3468 .unwrap(),
3469 )
3470 .await
3471 .unwrap();
3472
3473 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3474 }
3475
3476 #[tokio::test]
3477 async fn auth_extractor_returns_user_when_present() {
3478 use axum::Router;
3479 use axum::body::Body;
3480 use axum::routing::get;
3481 use tower::ServiceExt;
3482
3483 #[derive(Clone)]
3484 struct TestUser {
3485 name: String,
3486 }
3487
3488 async fn handler(Auth(user): Auth<TestUser>) -> String {
3489 user.name
3490 }
3491
3492 let state = test_app_state("user_id");
3493
3494 let app = Router::new()
3496 .route("/", get(handler))
3497 .layer(axum::middleware::from_fn(
3498 |mut req: axum::extract::Request, next: axum::middleware::Next| async move {
3499 req.extensions_mut().insert(TestUser {
3500 name: "alice".into(),
3501 });
3502 next.run(req).await
3503 },
3504 ))
3505 .with_state(state);
3506
3507 let response = app
3508 .oneshot(
3509 http::Request::builder()
3510 .uri("/")
3511 .body(Body::empty())
3512 .unwrap(),
3513 )
3514 .await
3515 .unwrap();
3516
3517 assert_eq!(response.status(), StatusCode::OK);
3518 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3519 .await
3520 .unwrap();
3521 assert_eq!(std::str::from_utf8(&body).unwrap(), "alice");
3522 }
3523
3524 #[tokio::test]
3525 async fn require_auth_rejects_unauthenticated() {
3526 use axum::Router;
3527 use axum::body::Body;
3528 use axum::routing::get;
3529 use tower::ServiceExt;
3530
3531 use crate::session::{MemoryStore, SessionConfig, SessionLayer};
3532
3533 let state = test_app_state("user_id");
3534
3535 let app = Router::new()
3536 .route("/protected", get(|| async { "secret" }))
3537 .layer(RequireAuth::new("user_id"))
3538 .layer(SessionLayer::new(
3539 MemoryStore::new(),
3540 SessionConfig::default(),
3541 ))
3542 .with_state(state);
3543
3544 let response = app
3545 .oneshot(
3546 http::Request::builder()
3547 .uri("/protected")
3548 .body(Body::empty())
3549 .unwrap(),
3550 )
3551 .await
3552 .unwrap();
3553
3554 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3555 }
3556
3557 #[tokio::test]
3560 async fn check_secured_rejects_unauthenticated() {
3561 let session =
3562 crate::session::Session::new_for_test(String::new(), std::collections::HashMap::new());
3563 let result = __check_secured(&session, &[]).await;
3564 assert!(result.is_err());
3565 let err = result.unwrap_err();
3566 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
3567 assert_eq!(err.to_string(), "authentication required");
3568 }
3569
3570 #[tokio::test]
3571 async fn check_secured_allows_authenticated() {
3572 let data = std::collections::HashMap::from([("user_id".into(), "42".into())]);
3573 let session = crate::session::Session::new_for_test("sess".into(), data);
3574 let result = __check_secured(&session, &[]).await;
3575 assert!(result.is_ok());
3576 }
3577
3578 #[tokio::test]
3579 async fn check_secured_rejects_wrong_role() {
3580 let data = std::collections::HashMap::from([
3581 ("user_id".into(), "42".into()),
3582 ("role".into(), "viewer".into()),
3583 ]);
3584 let session = crate::session::Session::new_for_test("sess".into(), data);
3585 let result = __check_secured(&session, &["admin"]).await;
3586 assert!(result.is_err());
3587 let err = result.unwrap_err();
3588 assert_eq!(err.status(), StatusCode::FORBIDDEN);
3589 assert_eq!(err.to_string(), "insufficient permissions");
3590 }
3591
3592 #[tokio::test]
3593 async fn check_secured_allows_matching_role() {
3594 let data = std::collections::HashMap::from([
3595 ("user_id".into(), "42".into()),
3596 ("role".into(), "admin".into()),
3597 ]);
3598 let session = crate::session::Session::new_for_test("sess".into(), data);
3599 let result = __check_secured(&session, &["admin"]).await;
3600 assert!(result.is_ok());
3601 }
3602
3603 #[tokio::test]
3604 async fn check_secured_allows_any_of_multiple_roles() {
3605 let data = std::collections::HashMap::from([
3606 ("user_id".into(), "42".into()),
3607 ("role".into(), "editor".into()),
3608 ]);
3609 let session = crate::session::Session::new_for_test("sess".into(), data);
3610 let result = __check_secured(&session, &["admin", "editor"]).await;
3611 assert!(result.is_ok());
3612 }
3613
3614 #[tokio::test]
3615 async fn check_secured_seeds_actor_when_none_established() {
3616 crate::current::scope_request(async {
3621 assert_eq!(crate::current::Current::actor(), None);
3622 let data = std::collections::HashMap::from([("user_id".into(), "42".into())]);
3623 let session = crate::session::Session::new_for_test("sess".into(), data);
3624 let result = __check_secured_with_key(&session, "user_id", &[]).await;
3625 assert!(result.is_ok());
3626 assert_eq!(crate::current::Current::actor(), Some("42".to_owned()));
3627 })
3628 .await;
3629 }
3630
3631 #[tokio::test]
3632 async fn check_secured_preserves_already_established_actor() {
3633 crate::current::scope_request(async {
3640 crate::current::Current::set_actor("token-principal".to_owned());
3641 let data = std::collections::HashMap::from([("user_id".into(), "42".into())]);
3642 let session = crate::session::Session::new_for_test("sess".into(), data);
3643 let result = __check_secured_with_key(&session, "user_id", &[]).await;
3644 assert!(result.is_ok());
3646 assert_eq!(
3648 crate::current::Current::actor(),
3649 Some("token-principal".to_owned())
3650 );
3651 })
3652 .await;
3653 }
3654
3655 #[tokio::test]
3658 async fn secured_macro_rejects_unauthenticated() {
3659 use axum::Router;
3660 use axum::body::Body;
3661 use axum::routing::get;
3662 use tower::ServiceExt;
3663
3664 use crate::session::{MemoryStore, SessionConfig, SessionLayer};
3665
3666 #[autumn_macros::secured]
3667 async fn protected_handler() -> crate::AutumnResult<&'static str> {
3668 Ok("secret")
3669 }
3670
3671 let state = test_app_state("user_id");
3672
3673 let app = Router::new()
3674 .route("/", get(protected_handler))
3675 .layer(SessionLayer::new(
3676 MemoryStore::new(),
3677 SessionConfig::default(),
3678 ))
3679 .with_state(state);
3680
3681 let response = app
3682 .oneshot(
3683 http::Request::builder()
3684 .uri("/")
3685 .body(Body::empty())
3686 .unwrap(),
3687 )
3688 .await
3689 .unwrap();
3690
3691 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
3692 }
3693
3694 #[tokio::test]
3695 async fn secured_macro_allows_authenticated() {
3696 use axum::Router;
3697 use axum::body::Body;
3698 use axum::routing::get;
3699 use http::header::COOKIE;
3700 use tower::ServiceExt;
3701
3702 use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3703
3704 #[autumn_macros::secured]
3705 async fn protected_handler() -> crate::AutumnResult<&'static str> {
3706 Ok("secret")
3707 }
3708
3709 let store = MemoryStore::new();
3710 store
3711 .save(
3712 "sess1",
3713 std::collections::HashMap::from([("user_id".into(), "42".into())]),
3714 )
3715 .await
3716 .unwrap();
3717
3718 let state = test_app_state("user_id");
3719
3720 let app = Router::new()
3721 .route("/", get(protected_handler))
3722 .layer(SessionLayer::new(store, SessionConfig::default()))
3723 .with_state(state);
3724
3725 let response = app
3726 .oneshot(
3727 http::Request::builder()
3728 .uri("/")
3729 .header(COOKIE, "autumn.sid=sess1")
3730 .body(Body::empty())
3731 .unwrap(),
3732 )
3733 .await
3734 .unwrap();
3735
3736 assert_eq!(response.status(), StatusCode::OK);
3737 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3738 .await
3739 .unwrap();
3740 assert_eq!(std::str::from_utf8(&body).unwrap(), "secret");
3741 }
3742
3743 #[tokio::test]
3744 async fn secured_macro_honors_configured_auth_session_key() {
3745 use axum::Router;
3746 use axum::body::Body;
3747 use axum::routing::get;
3748 use http::header::COOKIE;
3749 use tower::ServiceExt;
3750
3751 use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3752
3753 #[autumn_macros::secured]
3754 async fn account_handler() -> crate::AutumnResult<&'static str> {
3755 Ok("account")
3756 }
3757
3758 let store = MemoryStore::new();
3759 store
3760 .save(
3761 "sess1",
3762 std::collections::HashMap::from([
3763 ("uid".into(), "42".into()),
3764 ("account_id".into(), "42".into()),
3765 ]),
3766 )
3767 .await
3768 .unwrap();
3769
3770 let state = test_app_state("uid");
3771
3772 let app = Router::new()
3773 .route("/account", get(account_handler))
3774 .layer(SessionLayer::new(store, SessionConfig::default()))
3775 .with_state(state);
3776
3777 let response = app
3778 .oneshot(
3779 http::Request::builder()
3780 .uri("/account")
3781 .header(COOKIE, "autumn.sid=sess1")
3782 .body(Body::empty())
3783 .unwrap(),
3784 )
3785 .await
3786 .unwrap();
3787
3788 assert_eq!(response.status(), StatusCode::OK);
3789 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3790 .await
3791 .unwrap();
3792 assert_eq!(std::str::from_utf8(&body).unwrap(), "account");
3793 }
3794
3795 #[tokio::test]
3796 async fn secured_macro_with_role_rejects_wrong_role() {
3797 use axum::Router;
3798 use axum::body::Body;
3799 use axum::routing::get;
3800 use http::header::COOKIE;
3801 use tower::ServiceExt;
3802
3803 use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3804
3805 #[autumn_macros::secured("admin")]
3806 async fn admin_only() -> crate::AutumnResult<&'static str> {
3807 Ok("admin area")
3808 }
3809
3810 let store = MemoryStore::new();
3811 store
3812 .save(
3813 "sess1",
3814 std::collections::HashMap::from([
3815 ("user_id".into(), "42".into()),
3816 ("role".into(), "viewer".into()),
3817 ]),
3818 )
3819 .await
3820 .unwrap();
3821
3822 let state = test_app_state("user_id");
3823
3824 let app = Router::new()
3825 .route("/", get(admin_only))
3826 .layer(SessionLayer::new(store, SessionConfig::default()))
3827 .with_state(state);
3828
3829 let response = app
3830 .oneshot(
3831 http::Request::builder()
3832 .uri("/")
3833 .header(COOKIE, "autumn.sid=sess1")
3834 .body(Body::empty())
3835 .unwrap(),
3836 )
3837 .await
3838 .unwrap();
3839
3840 assert_eq!(response.status(), StatusCode::FORBIDDEN);
3841 }
3842
3843 #[tokio::test]
3844 async fn secured_macro_with_multiple_roles_allows_match() {
3845 use axum::Router;
3846 use axum::body::Body;
3847 use axum::routing::get;
3848 use http::header::COOKIE;
3849 use tower::ServiceExt;
3850
3851 use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3852
3853 #[autumn_macros::secured("admin", "editor")]
3854 async fn content_handler() -> crate::AutumnResult<&'static str> {
3855 Ok("content")
3856 }
3857
3858 let store = MemoryStore::new();
3859 store
3860 .save(
3861 "sess1",
3862 std::collections::HashMap::from([
3863 ("user_id".into(), "42".into()),
3864 ("role".into(), "editor".into()),
3865 ]),
3866 )
3867 .await
3868 .unwrap();
3869
3870 let state = test_app_state("user_id");
3871
3872 let app = Router::new()
3873 .route("/", get(content_handler))
3874 .layer(SessionLayer::new(store, SessionConfig::default()))
3875 .with_state(state);
3876
3877 let response = app
3878 .oneshot(
3879 http::Request::builder()
3880 .uri("/")
3881 .header(COOKIE, "autumn.sid=sess1")
3882 .body(Body::empty())
3883 .unwrap(),
3884 )
3885 .await
3886 .unwrap();
3887
3888 assert_eq!(response.status(), StatusCode::OK);
3889 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3890 .await
3891 .unwrap();
3892 assert_eq!(std::str::from_utf8(&body).unwrap(), "content");
3893 }
3894
3895 #[tokio::test]
3896 async fn require_auth_allows_authenticated() {
3897 use axum::Router;
3898 use axum::body::Body;
3899 use axum::routing::get;
3900 use http::header::COOKIE;
3901 use tower::ServiceExt;
3902
3903 use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3904
3905 let store = MemoryStore::new();
3906 let mut session_data = std::collections::HashMap::new();
3908 session_data.insert("user_id".into(), "42".into());
3909 store.save("valid-session", session_data).await.unwrap();
3910
3911 let state = test_app_state("user_id");
3912
3913 let app = Router::new()
3914 .route("/protected", get(|| async { "secret" }))
3915 .layer(RequireAuth::new("user_id"))
3916 .layer(SessionLayer::new(store, SessionConfig::default()))
3917 .with_state(state);
3918
3919 let response = app
3920 .oneshot(
3921 http::Request::builder()
3922 .uri("/protected")
3923 .header(COOKIE, "autumn.sid=valid-session")
3924 .body(Body::empty())
3925 .unwrap(),
3926 )
3927 .await
3928 .unwrap();
3929
3930 assert_eq!(response.status(), StatusCode::OK);
3931 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3932 .await
3933 .unwrap();
3934 assert_eq!(std::str::from_utf8(&body).unwrap(), "secret");
3935 }
3936
3937 #[tokio::test]
3938 async fn require_auth_sets_rate_limit_principal() {
3939 use axum::Router;
3940 use axum::body::Body;
3941 use axum::routing::get;
3942 use http::header::COOKIE;
3943 use tower::ServiceExt;
3944
3945 use crate::security::RateLimitPrincipal;
3946 use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3947
3948 async fn handler(
3949 axum::Extension(principal): axum::Extension<RateLimitPrincipal>,
3950 ) -> String {
3951 principal.0
3952 }
3953
3954 let store = MemoryStore::new();
3955 let mut session_data = std::collections::HashMap::new();
3956 session_data.insert("user_id".into(), "42".into());
3957 store.save("valid-session", session_data).await.unwrap();
3958
3959 let state = test_app_state("user_id");
3960
3961 let app = Router::new()
3962 .route("/protected", get(handler))
3963 .layer(RequireAuth::new("user_id"))
3964 .layer(SessionLayer::new(store, SessionConfig::default()))
3965 .with_state(state);
3966
3967 let response = app
3968 .oneshot(
3969 http::Request::builder()
3970 .uri("/protected")
3971 .header(COOKIE, "autumn.sid=valid-session")
3972 .body(Body::empty())
3973 .unwrap(),
3974 )
3975 .await
3976 .unwrap();
3977
3978 assert_eq!(response.status(), StatusCode::OK);
3979 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3980 .await
3981 .unwrap();
3982 assert_eq!(std::str::from_utf8(&body).unwrap(), "42");
3983 }
3984
3985 #[tokio::test]
3986 async fn require_auth_rate_limits_by_session_principal() {
3987 use axum::Router;
3991 use axum::body::Body;
3992 use axum::routing::get;
3993 use http::header::COOKIE;
3994 use tower::ServiceExt;
3995
3996 use crate::security::{KeyStrategy, RateLimitConfig, RateLimitLayer};
3997 use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
3998
3999 let session_store = MemoryStore::new();
4000 let mut data_a = std::collections::HashMap::new();
4001 data_a.insert("user_id".into(), "user-1".into());
4002 session_store.save("sess-a", data_a).await.unwrap();
4003 let mut data_b = std::collections::HashMap::new();
4004 data_b.insert("user_id".into(), "user-2".into());
4005 session_store.save("sess-b", data_b).await.unwrap();
4006
4007 let rl_config = RateLimitConfig {
4008 enabled: true,
4009 requests_per_second: 0.1,
4010 burst: 1,
4011 key_strategy: KeyStrategy::AuthenticatedPrincipal,
4012 ..Default::default()
4013 };
4014
4015 let state = test_app_state("user_id");
4016
4017 let app = Router::new()
4018 .route("/protected", get(|| async { "ok" }))
4019 .layer(RateLimitLayer::from_config(&rl_config)) .layer(RequireAuth::new("user_id")) .layer(SessionLayer::new(session_store, SessionConfig::default()))
4022 .with_state(state);
4023
4024 let r = app
4026 .clone()
4027 .oneshot(
4028 http::Request::builder()
4029 .uri("/protected")
4030 .header(COOKIE, "autumn.sid=sess-a")
4031 .body(Body::empty())
4032 .unwrap(),
4033 )
4034 .await
4035 .unwrap();
4036 assert_eq!(r.status(), StatusCode::OK);
4037
4038 let r = app
4040 .clone()
4041 .oneshot(
4042 http::Request::builder()
4043 .uri("/protected")
4044 .header(COOKIE, "autumn.sid=sess-a")
4045 .body(Body::empty())
4046 .unwrap(),
4047 )
4048 .await
4049 .unwrap();
4050 assert_eq!(r.status(), StatusCode::TOO_MANY_REQUESTS);
4051
4052 let r = app
4054 .clone()
4055 .oneshot(
4056 http::Request::builder()
4057 .uri("/protected")
4058 .header(COOKIE, "autumn.sid=sess-b")
4059 .body(Body::empty())
4060 .unwrap(),
4061 )
4062 .await
4063 .unwrap();
4064 assert_eq!(r.status(), StatusCode::OK);
4065 }
4066
4067 #[tokio::test]
4068 async fn require_auth_poll_ready_propagates() {
4069 use std::task::{Context, Poll};
4070 use tower::{Layer, Service};
4071
4072 #[derive(Clone)]
4073 struct MockService {
4074 ready: bool,
4075 poll_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
4076 }
4077
4078 impl Service<axum::extract::Request> for MockService {
4079 type Response = axum::response::Response;
4080 type Error = std::convert::Infallible;
4081 type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
4082
4083 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
4084 self.poll_count
4085 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4086 if self.ready {
4087 Poll::Ready(Ok(()))
4088 } else {
4089 Poll::Pending
4090 }
4091 }
4092
4093 fn call(&mut self, _req: axum::extract::Request) -> Self::Future {
4094 std::future::ready(Ok(axum::response::Response::new(axum::body::Body::empty())))
4095 }
4096 }
4097
4098 let layer = RequireAuth::new("user_id");
4099 let poll_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
4100 let mock_service = MockService {
4101 ready: false,
4102 poll_count: poll_count.clone(),
4103 };
4104 let mut service = layer.layer(mock_service);
4105
4106 let waker = futures::task::noop_waker();
4107 let mut cx = Context::from_waker(&waker);
4108
4109 let poll = service.poll_ready(&mut cx);
4111 assert!(poll.is_pending());
4112 assert_eq!(poll_count.load(std::sync::atomic::Ordering::SeqCst), 1);
4113
4114 let mock_service_ready = MockService {
4116 ready: true,
4117 poll_count: poll_count.clone(),
4118 };
4119 let mut service_ready = layer.layer(mock_service_ready);
4120 let poll_ready = service_ready.poll_ready(&mut cx);
4121 assert!(poll_ready.is_ready());
4122 assert_eq!(poll_count.load(std::sync::atomic::Ordering::SeqCst), 2);
4123 }
4124
4125 #[tokio::test]
4126 async fn auth_rejection_into_response() {
4127 let rejection = AuthRejection;
4128 let response = rejection.into_response();
4129 assert_eq!(response.status(), axum::http::StatusCode::UNAUTHORIZED);
4130 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4131 .await
4132 .unwrap();
4133 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4134 assert_eq!(json["status"], 401);
4135 assert_eq!(json["detail"], "authentication required");
4136 assert_eq!(json["code"], "autumn.unauthorized");
4137 }
4138
4139 #[test]
4140 fn test_auth_config_defaults() {
4141 let config = AuthConfig::default();
4142 assert_eq!(config.bcrypt_cost, DEFAULT_BCRYPT_COST);
4143 assert_eq!(config.session_key, "user_id");
4144 }
4145
4146 #[tokio::test]
4147 async fn test_hash_password() {
4148 let test_input = uuid::Uuid::new_v4().to_string();
4149
4150 let hash = super::hash_password(&test_input)
4152 .await
4153 .expect("Failed to hash password");
4154 assert!(hash.starts_with("$2b$"));
4155
4156 let is_valid = super::verify_password(&test_input, &hash)
4158 .await
4159 .expect("Failed to verify password");
4160 assert!(is_valid, "Password should be verified successfully");
4161
4162 let is_invalid = super::verify_password(&uuid::Uuid::new_v4().to_string(), &hash)
4164 .await
4165 .expect("Failed to verify wrong password");
4166 assert!(!is_invalid, "Wrong password should not be verified");
4167 }
4168
4169 #[tokio::test]
4170 async fn test_hash_password_empty() {
4171 let test_input = String::new();
4172 let hash = super::hash_password(&test_input)
4173 .await
4174 .expect("Failed to hash empty password");
4175 assert!(hash.starts_with("$2b$"));
4176
4177 let is_valid = super::verify_password(&test_input, &hash)
4178 .await
4179 .expect("Failed to verify empty password");
4180 assert!(is_valid, "Empty password should be verified successfully");
4181 }
4182
4183 #[tokio::test]
4184 async fn test_hash_password_long() {
4185 let test_input = "a".repeat(100);
4187 let hash = super::hash_password(&test_input)
4188 .await
4189 .expect("Failed to hash long password");
4190 assert!(hash.starts_with("$2b$"));
4191
4192 let is_valid = super::verify_password(&test_input, &hash)
4193 .await
4194 .expect("Failed to verify long password");
4195 assert!(is_valid, "Long password should be verified successfully");
4196 }
4197
4198 #[tokio::test]
4199 async fn test_hash_password_unicode() {
4200 let test_input = format!("{}🚀my_secrët_passwörd🔑", uuid::Uuid::new_v4());
4202 let hash = super::hash_password(&test_input)
4203 .await
4204 .expect("Failed to hash unicode password");
4205 assert!(hash.starts_with("$2b$"));
4206
4207 let is_valid = super::verify_password(&test_input, &hash)
4208 .await
4209 .expect("Failed to verify unicode password");
4210 assert!(is_valid, "Unicode password should be verified successfully");
4211 }
4212
4213 #[tokio::test]
4214 async fn test_verify_password_invalid_hash() {
4215 let test_input = uuid::Uuid::new_v4().to_string();
4217
4218 let result = super::verify_password(&test_input, "invalid_hash_string").await;
4220 assert!(result.is_err() || !result.unwrap());
4221
4222 let result2 = super::verify_password(&test_input, "$2b$04$").await;
4224 assert!(result2.is_err() || !result2.unwrap());
4225 }
4226}
4227
4228#[cfg(feature = "oauth2")]
4231#[cfg(test)]
4232mod http_interceptor_task_local_tests {
4233 use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor, HttpInterceptorFuture};
4234 use std::sync::{
4235 Arc,
4236 atomic::{AtomicBool, Ordering},
4237 };
4238
4239 struct FlagInterceptor {
4240 fired: Arc<AtomicBool>,
4241 }
4242
4243 impl HttpInterceptor for FlagInterceptor {
4244 fn intercept<'a>(
4245 &'a self,
4246 req: reqwest::Request,
4247 next: &'a dyn Fn(reqwest::Request) -> HttpInterceptorFuture<'a>,
4248 ) -> HttpInterceptorFuture<'a> {
4249 self.fired.store(true, Ordering::SeqCst);
4250 next(req)
4253 }
4254 }
4255
4256 #[tokio::test]
4260 async fn http_request_builder_send_fires_interceptor_inside_scope() {
4261 let fired = Arc::new(AtomicBool::new(false));
4262 let interceptor: Arc<dyn HttpInterceptor> = Arc::new(FlagInterceptor {
4263 fired: Arc::clone(&fired),
4264 });
4265
4266 let client = reqwest::Client::new();
4267 let http_client = super::HttpClient::new(client);
4268
4269 ACTIVE_HTTP_INTERCEPTORS
4270 .scope(vec![interceptor], async {
4271 let _ = http_client
4272 .get("http://127.0.0.1:54321/noreply")
4273 .send()
4274 .await;
4275 })
4276 .await;
4277
4278 assert!(
4279 fired.load(Ordering::SeqCst),
4280 "interceptor must fire when ACTIVE_HTTP_INTERCEPTORS scope is established"
4281 );
4282 }
4283
4284 #[tokio::test]
4289 async fn http_request_builder_send_skips_interceptor_outside_scope() {
4290 let fired = Arc::new(AtomicBool::new(false));
4291 let _interceptor: Arc<dyn HttpInterceptor> = Arc::new(FlagInterceptor {
4292 fired: Arc::clone(&fired),
4293 });
4294
4295 let client = reqwest::Client::new();
4297 let http_client = super::HttpClient::new(client);
4298 let _ = http_client
4299 .get("http://127.0.0.1:54321/noreply")
4300 .send()
4301 .await;
4302
4303 assert!(
4304 !fired.load(Ordering::SeqCst),
4305 "interceptor must NOT fire when ACTIVE_HTTP_INTERCEPTORS scope is absent"
4306 );
4307 }
4308}
4309
4310#[cfg(test)]
4311mod api_token_tests {
4312 use std::sync::Arc;
4313
4314 use http::StatusCode;
4315
4316 use super::{
4317 ApiToken, ApiTokenStore, InMemoryApiTokenStore, RequireApiToken, hash_api_token,
4318 issue_api_token, revoke_api_token,
4319 };
4320
4321 struct FailingApiTokenStore;
4322
4323 impl ApiTokenStore for FailingApiTokenStore {
4324 fn issue<'a>(
4325 &'a self,
4326 _principal_id: &'a str,
4327 ) -> std::pin::Pin<
4328 Box<dyn std::future::Future<Output = crate::AutumnResult<String>> + Send + 'a>,
4329 > {
4330 Box::pin(async {
4331 Err(crate::AutumnError::service_unavailable_msg(
4332 "api token store unavailable",
4333 ))
4334 })
4335 }
4336
4337 fn verify<'a>(
4338 &'a self,
4339 _raw_token: &'a str,
4340 ) -> std::pin::Pin<
4341 Box<dyn std::future::Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>,
4342 > {
4343 Box::pin(async {
4344 Err(crate::AutumnError::service_unavailable_msg(
4345 "api token store unavailable",
4346 ))
4347 })
4348 }
4349
4350 fn revoke<'a>(
4351 &'a self,
4352 _raw_token: &'a str,
4353 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
4354 {
4355 Box::pin(async {
4356 Err(crate::AutumnError::service_unavailable_msg(
4357 "api token store unavailable",
4358 ))
4359 })
4360 }
4361 }
4362
4363 #[test]
4366 fn hash_api_token_is_deterministic() {
4367 let h1 = hash_api_token("abc123");
4368 let h2 = hash_api_token("abc123");
4369 assert_eq!(h1, h2);
4370 }
4371
4372 #[test]
4373 fn hash_api_token_produces_64_char_hex() {
4374 let hash = hash_api_token("any_raw_token");
4375 assert_eq!(hash.len(), 64, "SHA-256 hex must be 64 chars");
4376 assert!(
4377 hash.chars().all(|c| c.is_ascii_hexdigit()),
4378 "hash must be lowercase hex digits"
4379 );
4380 }
4381
4382 #[test]
4383 fn hash_api_token_differs_from_input() {
4384 let raw = "my_raw_token";
4385 assert_ne!(hash_api_token(raw), raw);
4386 }
4387
4388 #[test]
4389 fn hash_api_token_different_inputs_produce_different_hashes() {
4390 assert_ne!(hash_api_token("token_a"), hash_api_token("token_b"));
4391 }
4392
4393 #[tokio::test]
4396 async fn in_memory_store_issue_returns_unique_tokens() {
4397 let store = InMemoryApiTokenStore::default();
4398 let t1 = store.issue("user:1").await.unwrap();
4399 let t2 = store.issue("user:1").await.unwrap();
4400 assert_ne!(t1, t2, "each issued token must be unique");
4401 assert!(t1.len() >= 32, "token must have sufficient entropy");
4402 }
4403
4404 #[tokio::test]
4405 async fn in_memory_store_verify_returns_principal_for_valid_token() {
4406 let store = InMemoryApiTokenStore::default();
4407 let raw = store.issue("user:42").await.unwrap();
4408 let principal = store.verify(&raw).await.unwrap();
4409 assert_eq!(principal, Some("user:42".to_owned()));
4410 }
4411
4412 #[tokio::test]
4413 async fn in_memory_store_verify_returns_none_for_unknown_token() {
4414 let store = InMemoryApiTokenStore::default();
4415 let result = store.verify("not_a_real_token").await.unwrap();
4416 assert_eq!(result, None);
4417 }
4418
4419 #[tokio::test]
4420 async fn in_memory_store_revoke_invalidates_token() {
4421 let store = InMemoryApiTokenStore::default();
4422 let raw = store.issue("user:7").await.unwrap();
4423 assert_eq!(
4424 store.verify(&raw).await.unwrap(),
4425 Some("user:7".to_owned()),
4426 "token must be valid before revoking"
4427 );
4428 store.revoke(&raw).await.unwrap();
4429 assert_eq!(store.verify(&raw).await.unwrap(), None);
4430 }
4431
4432 #[tokio::test]
4433 async fn in_memory_store_raw_token_not_stored_verbatim() {
4434 let store = InMemoryApiTokenStore::default();
4435 let raw = store.issue("user:1").await.unwrap();
4436 let tampered = format!("{raw}x");
4438 assert_eq!(store.verify(&tampered).await.unwrap(), None);
4439 }
4440
4441 #[tokio::test]
4442 async fn issue_api_token_helper_issues_verifiable_token() {
4443 let store = InMemoryApiTokenStore::default();
4444 let raw = issue_api_token(&store, "user:5").await.unwrap();
4445 assert_eq!(store.verify(&raw).await.unwrap(), Some("user:5".to_owned()));
4446 }
4447
4448 #[tokio::test]
4449 async fn revoke_api_token_helper_revokes_token() {
4450 let store = InMemoryApiTokenStore::default();
4451 let raw = store.issue("user:6").await.unwrap();
4452 revoke_api_token(&store, &raw).await.unwrap();
4453 assert_eq!(store.verify(&raw).await.unwrap(), None);
4454 }
4455
4456 #[tokio::test]
4459 async fn with_token_seeds_token_resolvable_through_same_path() {
4460 let store = InMemoryApiTokenStore::default().with_token("known-dev-token", "user:dev");
4463 assert_eq!(
4464 store.verify("known-dev-token").await.unwrap(),
4465 Some("user:dev".to_owned()),
4466 );
4467 assert_eq!(store.verify("known-dev-tokenx").await.unwrap(), None);
4470 let verified = store
4472 .verify_scoped("known-dev-token")
4473 .await
4474 .unwrap()
4475 .unwrap();
4476 assert_eq!(verified.principal_id, "user:dev");
4477 assert!(verified.scopes.is_empty());
4478 store.revoke("known-dev-token").await.unwrap();
4480 assert_eq!(store.verify("known-dev-token").await.unwrap(), None);
4481 }
4482
4483 #[tokio::test]
4484 async fn with_scoped_token_seeds_scopes_via_verify_scoped() {
4485 let granted = scopes(&["reports:read", "reports:write"]);
4486 let store = InMemoryApiTokenStore::default().with_scoped_token(
4487 "scoped-dev-token",
4488 "svc:reports",
4489 &granted,
4490 );
4491 let verified = store
4492 .verify_scoped("scoped-dev-token")
4493 .await
4494 .unwrap()
4495 .unwrap();
4496 assert_eq!(verified.principal_id, "svc:reports");
4497 assert_eq!(verified.scopes, granted);
4498 }
4499
4500 #[tokio::test]
4501 async fn blank_seed_token_stores_no_credential() {
4502 for blank in ["", " ", "\t\n"] {
4507 let store = InMemoryApiTokenStore::default().with_token(blank, "user:oops");
4508 assert_eq!(store.verify(blank).await.unwrap(), None);
4510 assert_eq!(store.verify("").await.unwrap(), None);
4511 assert!(store.verify_scoped(blank).await.unwrap().is_none());
4512 assert!(store.verify_scoped("").await.unwrap().is_none());
4513
4514 let scoped = InMemoryApiTokenStore::default().with_scoped_token(
4516 blank,
4517 "svc:oops",
4518 &scopes(&["reports:read"]),
4519 );
4520 assert_eq!(scoped.verify(blank).await.unwrap(), None);
4521 assert!(scoped.verify_scoped("").await.unwrap().is_none());
4522 }
4523
4524 let store = InMemoryApiTokenStore::default().with_token("real-token", "user:ok");
4527 assert_eq!(
4528 store.verify("real-token").await.unwrap(),
4529 Some("user:ok".to_owned()),
4530 );
4531 }
4532
4533 #[test]
4534 fn from_env_errors_when_variable_unset() {
4535 const VAR: &str = "AUTUMN_TEST_MCP_TOKEN_1970_UNSET";
4543 assert!(std::env::var(VAR).is_err(), "test var must be unset");
4544 assert!(InMemoryApiTokenStore::from_env(VAR, "user:mcp").is_err());
4545 }
4546
4547 use super::{
4550 __check_secured_scopes, ApiTokenScopes, IssueTokenSpec, issue_scoped_api_token,
4551 list_api_tokens, rotate_api_token,
4552 };
4553 use crate::time::{FixedClock, TickingClock};
4554 use chrono::{Duration as ChronoDuration, TimeZone as _, Utc};
4555
4556 fn scopes(s: &[&str]) -> Vec<String> {
4557 s.iter().map(|x| (*x).to_owned()).collect()
4558 }
4559
4560 #[derive(Default)]
4564 struct LegacyOnlyStore(InMemoryApiTokenStore);
4565
4566 impl ApiTokenStore for LegacyOnlyStore {
4567 fn issue<'a>(
4568 &'a self,
4569 principal_id: &'a str,
4570 ) -> std::pin::Pin<
4571 Box<dyn std::future::Future<Output = crate::AutumnResult<String>> + Send + 'a>,
4572 > {
4573 self.0.issue(principal_id)
4574 }
4575 fn verify<'a>(
4576 &'a self,
4577 raw_token: &'a str,
4578 ) -> std::pin::Pin<
4579 Box<dyn std::future::Future<Output = crate::AutumnResult<Option<String>>> + Send + 'a>,
4580 > {
4581 self.0.verify(raw_token)
4582 }
4583 fn revoke<'a>(
4584 &'a self,
4585 raw_token: &'a str,
4586 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::AutumnResult<()>> + Send + 'a>>
4587 {
4588 self.0.revoke(raw_token)
4589 }
4590 }
4591
4592 #[tokio::test]
4593 async fn legacy_store_default_verify_scoped_yields_empty_scopes() {
4594 let store = LegacyOnlyStore::default();
4595 let raw = store.issue("user:1").await.unwrap();
4596 let verified = store.verify_scoped(&raw).await.unwrap().unwrap();
4597 assert_eq!(verified.principal_id, "user:1");
4598 assert!(verified.scopes.is_empty());
4599 }
4600
4601 #[tokio::test]
4602 async fn issue_scoped_round_trips_name_and_scopes() {
4603 let store = InMemoryApiTokenStore::default();
4604 let granted = scopes(&["posts:read", "posts:write"]);
4605 let raw = issue_scoped_api_token(
4606 &store,
4607 IssueTokenSpec {
4608 principal_id: "service:ci",
4609 name: "ci",
4610 scopes: &granted,
4611 expires_at: None,
4612 },
4613 )
4614 .await
4615 .unwrap();
4616
4617 let verified = store.verify_scoped(&raw).await.unwrap().unwrap();
4618 assert_eq!(verified.principal_id, "service:ci");
4619 assert_eq!(verified.scopes, granted);
4620
4621 let listed = list_api_tokens(&store, "service:ci").await.unwrap();
4622 assert_eq!(listed.len(), 1);
4623 assert_eq!(listed[0].name, "ci");
4624 assert_eq!(listed[0].scopes, granted);
4625 }
4626
4627 #[tokio::test]
4628 async fn expired_token_verifies_as_none() {
4629 let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
4630 let store = InMemoryApiTokenStore::default().with_clock(Arc::new(FixedClock::at(now)));
4631 let granted = scopes(&["posts:read"]);
4632 let raw = store
4633 .issue_scoped(IssueTokenSpec {
4634 principal_id: "service:ci",
4635 name: "ci",
4636 scopes: &granted,
4637 expires_at: Some(now - ChronoDuration::seconds(1)),
4638 })
4639 .await
4640 .unwrap();
4641
4642 assert_eq!(store.verify(&raw).await.unwrap(), None);
4643 assert!(store.verify_scoped(&raw).await.unwrap().is_none());
4644 }
4645
4646 #[tokio::test]
4647 async fn unexpired_token_verifies_then_records_last_used_at() {
4648 let start = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
4649 let clock = TickingClock::starting_at(start);
4650 let store = InMemoryApiTokenStore::default().with_clock(Arc::new(clock));
4651 let granted = scopes(&["posts:read"]);
4652 let raw = store
4653 .issue_scoped(IssueTokenSpec {
4654 principal_id: "service:ci",
4655 name: "ci",
4656 scopes: &granted,
4657 expires_at: Some(start + ChronoDuration::days(30)),
4658 })
4659 .await
4660 .unwrap();
4661
4662 assert!(
4664 list_api_tokens(&store, "service:ci").await.unwrap()[0]
4665 .last_used_at
4666 .is_none()
4667 );
4668
4669 assert!(store.verify_scoped(&raw).await.unwrap().is_some());
4670
4671 assert!(
4672 list_api_tokens(&store, "service:ci").await.unwrap()[0]
4673 .last_used_at
4674 .is_some()
4675 );
4676 }
4677
4678 #[tokio::test]
4679 async fn list_metadata_carries_no_secret_and_reflects_revocation() {
4680 let store = InMemoryApiTokenStore::default();
4681 let granted = scopes(&["posts:read"]);
4682 let raw = store
4683 .issue_scoped(IssueTokenSpec {
4684 principal_id: "service:ci",
4685 name: "ci",
4686 scopes: &granted,
4687 expires_at: None,
4688 })
4689 .await
4690 .unwrap();
4691
4692 let listed = list_api_tokens(&store, "service:ci").await.unwrap();
4693 assert_eq!(listed.len(), 1);
4694 assert!(listed[0].revoked_at.is_none());
4697
4698 store.revoke(&raw).await.unwrap();
4699 assert_eq!(store.verify(&raw).await.unwrap(), None);
4700 let listed = list_api_tokens(&store, "service:ci").await.unwrap();
4701 assert!(listed[0].revoked_at.is_some());
4702 }
4703
4704 #[tokio::test]
4705 async fn rotate_revokes_old_and_preserves_scopes() {
4706 let store = InMemoryApiTokenStore::default();
4707 let granted = scopes(&["posts:read", "posts:write"]);
4708 let old = store
4709 .issue_scoped(IssueTokenSpec {
4710 principal_id: "service:ci",
4711 name: "ci",
4712 scopes: &granted,
4713 expires_at: None,
4714 })
4715 .await
4716 .unwrap();
4717
4718 let new = rotate_api_token(&store, &old).await.unwrap().unwrap();
4719 assert_ne!(new, old);
4720 assert!(store.verify_scoped(&old).await.unwrap().is_none());
4722 let verified = store.verify_scoped(&new).await.unwrap().unwrap();
4724 assert_eq!(verified.scopes, granted);
4725 assert_eq!(verified.principal_id, "service:ci");
4726
4727 assert!(rotate_api_token(&store, "nope").await.unwrap().is_none());
4729 }
4730
4731 #[tokio::test]
4732 async fn check_secured_scopes_is_default_deny_and_all_must_match() {
4733 assert!(__check_secured_scopes(None, &[]).await.is_ok());
4735 let err = __check_secured_scopes(None, &["posts:write"])
4737 .await
4738 .unwrap_err();
4739 assert_eq!(err.status(), StatusCode::FORBIDDEN);
4740 let granted = ApiTokenScopes(scopes(&["posts:read", "posts:write"]));
4742 assert!(
4743 __check_secured_scopes(Some(&granted), &["posts:write"])
4744 .await
4745 .is_ok()
4746 );
4747 assert!(
4749 __check_secured_scopes(Some(&granted), &["posts:write", "posts:delete"])
4750 .await
4751 .is_err()
4752 );
4753 }
4754
4755 #[tokio::test]
4758 async fn require_api_token_rejects_missing_authorization_header() {
4759 use axum::body::Body;
4760 use tower::ServiceExt;
4761
4762 let store = Arc::new(InMemoryApiTokenStore::default());
4763 let app = axum::Router::new()
4764 .route("/", axum::routing::get(|| async { "ok" }))
4765 .layer(RequireApiToken::new(store));
4766
4767 let response = app
4768 .oneshot(
4769 http::Request::builder()
4770 .uri("/")
4771 .body(Body::empty())
4772 .unwrap(),
4773 )
4774 .await
4775 .unwrap();
4776
4777 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4778 }
4779
4780 #[tokio::test]
4781 async fn require_api_token_rejects_non_bearer_scheme() {
4782 use axum::body::Body;
4783 use tower::ServiceExt;
4784
4785 let store = Arc::new(InMemoryApiTokenStore::default());
4786 let app = axum::Router::new()
4787 .route("/", axum::routing::get(|| async { "ok" }))
4788 .layer(RequireApiToken::new(store));
4789
4790 let response = app
4791 .oneshot(
4792 http::Request::builder()
4793 .uri("/")
4794 .header(http::header::AUTHORIZATION, "Basic dXNlcjpwYXNz")
4795 .body(Body::empty())
4796 .unwrap(),
4797 )
4798 .await
4799 .unwrap();
4800
4801 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4802 }
4803
4804 #[tokio::test]
4805 async fn require_api_token_rejects_unknown_bearer_token() {
4806 use axum::body::Body;
4807 use tower::ServiceExt;
4808
4809 let store = Arc::new(InMemoryApiTokenStore::default());
4810 let app = axum::Router::new()
4811 .route("/", axum::routing::get(|| async { "ok" }))
4812 .layer(RequireApiToken::new(store));
4813
4814 let response = app
4815 .oneshot(
4816 http::Request::builder()
4817 .uri("/")
4818 .header(http::header::AUTHORIZATION, "Bearer unknown_token_xyz")
4819 .body(Body::empty())
4820 .unwrap(),
4821 )
4822 .await
4823 .unwrap();
4824
4825 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4826 }
4827
4828 #[tokio::test]
4829 async fn require_api_token_propagates_store_verify_errors() {
4830 use axum::body::Body;
4831 use tower::ServiceExt;
4832
4833 let store = Arc::new(FailingApiTokenStore);
4834 let app = axum::Router::new()
4835 .route("/", axum::routing::get(|| async { "ok" }))
4836 .layer(RequireApiToken::new(store));
4837
4838 let response = app
4839 .oneshot(
4840 http::Request::builder()
4841 .uri("/")
4842 .header(http::header::AUTHORIZATION, "Bearer valid_client_token")
4843 .body(Body::empty())
4844 .unwrap(),
4845 )
4846 .await
4847 .unwrap();
4848
4849 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
4850 assert_eq!(
4851 response
4852 .headers()
4853 .get(http::header::CONTENT_TYPE)
4854 .map(|value| value.to_str().unwrap_or_default()),
4855 Some("application/problem+json")
4856 );
4857
4858 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4859 .await
4860 .unwrap();
4861 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4862 assert_eq!(json["status"], 503);
4863 assert_eq!(json["code"], "autumn.service_unavailable");
4864 assert_eq!(json["detail"], "api token store unavailable");
4865 }
4866
4867 #[tokio::test]
4868 async fn require_api_token_allows_valid_bearer_token() {
4869 use axum::body::Body;
4870 use tower::ServiceExt;
4871
4872 let store = Arc::new(InMemoryApiTokenStore::default());
4873 let raw = store.issue("user:1").await.unwrap();
4874 let app = axum::Router::new()
4875 .route("/", axum::routing::get(|| async { "ok" }))
4876 .layer(RequireApiToken::new(Arc::clone(&store)));
4877
4878 let response = app
4879 .oneshot(
4880 http::Request::builder()
4881 .uri("/")
4882 .header(http::header::AUTHORIZATION, format!("Bearer {raw}"))
4883 .body(Body::empty())
4884 .unwrap(),
4885 )
4886 .await
4887 .unwrap();
4888
4889 assert_eq!(response.status(), StatusCode::OK);
4890 }
4891
4892 #[tokio::test]
4893 async fn require_api_token_accepts_case_insensitive_bearer_scheme() {
4894 use axum::body::Body;
4895 use tower::ServiceExt;
4896
4897 let store = Arc::new(InMemoryApiTokenStore::default());
4898 let raw = store.issue("user:1").await.unwrap();
4899
4900 for scheme in ["bearer", "bEaReR"] {
4901 let app = axum::Router::new()
4902 .route("/", axum::routing::get(|| async { "ok" }))
4903 .layer(RequireApiToken::new(Arc::clone(&store)));
4904
4905 let response = app
4906 .oneshot(
4907 http::Request::builder()
4908 .uri("/")
4909 .header(http::header::AUTHORIZATION, format!("{scheme} {raw}"))
4910 .body(Body::empty())
4911 .unwrap(),
4912 )
4913 .await
4914 .unwrap();
4915
4916 assert_eq!(response.status(), StatusCode::OK, "scheme {scheme}");
4917 }
4918 }
4919
4920 #[tokio::test]
4921 async fn require_api_token_rejects_revoked_token() {
4922 use axum::body::Body;
4923 use tower::ServiceExt;
4924
4925 let store = Arc::new(InMemoryApiTokenStore::default());
4926 let raw = store.issue("user:1").await.unwrap();
4927 store.revoke(&raw).await.unwrap();
4928 let app = axum::Router::new()
4929 .route("/", axum::routing::get(|| async { "ok" }))
4930 .layer(RequireApiToken::new(Arc::clone(&store)));
4931
4932 let response = app
4933 .oneshot(
4934 http::Request::builder()
4935 .uri("/")
4936 .header(http::header::AUTHORIZATION, format!("Bearer {raw}"))
4937 .body(Body::empty())
4938 .unwrap(),
4939 )
4940 .await
4941 .unwrap();
4942
4943 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4944 }
4945
4946 #[tokio::test]
4947 async fn require_api_token_401_response_has_problem_details() {
4948 use axum::body::Body;
4949 use tower::ServiceExt;
4950
4951 let store = Arc::new(InMemoryApiTokenStore::default());
4952 let app = axum::Router::new()
4953 .route("/", axum::routing::get(|| async { "ok" }))
4954 .layer(RequireApiToken::new(store));
4955
4956 let response = app
4957 .oneshot(
4958 http::Request::builder()
4959 .uri("/")
4960 .body(Body::empty())
4961 .unwrap(),
4962 )
4963 .await
4964 .unwrap();
4965
4966 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4967 assert_eq!(
4968 response
4969 .headers()
4970 .get(http::header::CONTENT_TYPE)
4971 .map(|v| v.to_str().unwrap_or_default()),
4972 Some("application/problem+json")
4973 );
4974 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4975 .await
4976 .unwrap();
4977 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4978 assert_eq!(json["status"], 401);
4979 assert_eq!(json["code"], "autumn.unauthorized");
4980 assert!(json["detail"].as_str().is_some());
4981 }
4982
4983 #[tokio::test]
4984 async fn require_api_token_401_problem_details_include_request_context() {
4985 use crate::middleware::RequestIdLayer;
4986 use axum::body::Body;
4987 use tower::ServiceExt;
4988
4989 let store = Arc::new(InMemoryApiTokenStore::default());
4990 let app = axum::Router::new()
4991 .route("/api/private", axum::routing::get(|| async { "ok" }))
4992 .layer(RequireApiToken::new(store))
4993 .layer(RequestIdLayer);
4994
4995 let response = app
4996 .oneshot(
4997 http::Request::builder()
4998 .uri("/api/private")
4999 .body(Body::empty())
5000 .unwrap(),
5001 )
5002 .await
5003 .unwrap();
5004
5005 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
5006 let request_id = response
5007 .headers()
5008 .get("x-request-id")
5009 .and_then(|value| value.to_str().ok())
5010 .expect("request id header should be present")
5011 .to_owned();
5012 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5013 .await
5014 .unwrap();
5015 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5016 assert_eq!(json["request_id"], request_id);
5017 assert_eq!(json["instance"], "/api/private");
5018 }
5019
5020 #[tokio::test]
5023 async fn api_token_extractor_yields_principal_id_to_handler() {
5024 use axum::body::Body;
5025 use tower::ServiceExt;
5026
5027 async fn handler(ApiToken(principal): ApiToken) -> String {
5028 principal
5029 }
5030
5031 let store = Arc::new(InMemoryApiTokenStore::default());
5032 let raw = store.issue("user:99").await.unwrap();
5033 let app = axum::Router::new()
5034 .route("/", axum::routing::get(handler))
5035 .layer(RequireApiToken::new(Arc::clone(&store)));
5036
5037 let response = app
5038 .oneshot(
5039 http::Request::builder()
5040 .uri("/")
5041 .header(http::header::AUTHORIZATION, format!("Bearer {raw}"))
5042 .body(Body::empty())
5043 .unwrap(),
5044 )
5045 .await
5046 .unwrap();
5047
5048 assert_eq!(response.status(), StatusCode::OK);
5049 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5050 .await
5051 .unwrap();
5052 assert_eq!(std::str::from_utf8(&body).unwrap(), "user:99");
5053 }
5054
5055 #[tokio::test]
5056 async fn api_token_extractor_rejects_when_no_principal_in_extensions() {
5057 use axum::body::Body;
5058 use tower::ServiceExt;
5059
5060 async fn handler(ApiToken(principal): ApiToken) -> String {
5061 principal
5062 }
5063
5064 let app = axum::Router::new().route("/", axum::routing::get(handler));
5065
5066 let response = app
5067 .oneshot(
5068 http::Request::builder()
5069 .uri("/")
5070 .body(Body::empty())
5071 .unwrap(),
5072 )
5073 .await
5074 .unwrap();
5075
5076 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
5077 }
5078
5079 #[tokio::test]
5082 async fn api_token_and_session_auth_compose_without_conflict() {
5083 use axum::body::Body;
5084 use tower::ServiceExt;
5085
5086 use crate::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
5087
5088 async fn api_handler(ApiToken(principal): ApiToken) -> String {
5089 principal
5090 }
5091
5092 let store = Arc::new(InMemoryApiTokenStore::default());
5093 let raw = store.issue("api_user").await.unwrap();
5094
5095 let session_store = MemoryStore::new();
5096 session_store
5097 .save(
5098 "sess1",
5099 std::collections::HashMap::from([("user_id".into(), "session_user".into())]),
5100 )
5101 .await
5102 .unwrap();
5103
5104 let app = axum::Router::new()
5105 .route(
5106 "/api",
5107 axum::routing::get(api_handler).layer(RequireApiToken::new(Arc::clone(&store))),
5108 )
5109 .layer(SessionLayer::new(session_store, SessionConfig::default()));
5110
5111 let response = app
5112 .oneshot(
5113 http::Request::builder()
5114 .uri("/api")
5115 .header(http::header::AUTHORIZATION, format!("Bearer {raw}"))
5116 .body(Body::empty())
5117 .unwrap(),
5118 )
5119 .await
5120 .unwrap();
5121
5122 assert_eq!(response.status(), StatusCode::OK);
5123 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5124 .await
5125 .unwrap();
5126 assert_eq!(std::str::from_utf8(&body).unwrap(), "api_user");
5127 }
5128
5129 #[tokio::test]
5132 async fn require_api_token_poll_ready_propagates_to_inner() {
5133 use std::task::{Context, Poll};
5134 use tower::{Layer, Service};
5135
5136 #[derive(Clone)]
5137 struct MockService {
5138 ready: bool,
5139 }
5140
5141 impl tower::Service<axum::extract::Request> for MockService {
5142 type Response = axum::response::Response;
5143 type Error = std::convert::Infallible;
5144 type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
5145
5146 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
5147 if self.ready {
5148 Poll::Ready(Ok(()))
5149 } else {
5150 Poll::Pending
5151 }
5152 }
5153
5154 fn call(&mut self, _req: axum::extract::Request) -> Self::Future {
5155 std::future::ready(Ok(axum::response::Response::new(axum::body::Body::empty())))
5156 }
5157 }
5158
5159 let waker = futures::task::noop_waker();
5160 let mut cx = Context::from_waker(&waker);
5161
5162 let store = Arc::new(InMemoryApiTokenStore::default());
5163 let layer = RequireApiToken::new(store);
5164 let mut svc = layer.layer(MockService { ready: false });
5165 assert!(svc.poll_ready(&mut cx).is_pending());
5166
5167 let store2 = Arc::new(InMemoryApiTokenStore::default());
5168 let layer2 = RequireApiToken::new(store2);
5169 let mut svc2 = layer2.layer(MockService { ready: true });
5170 assert!(svc2.poll_ready(&mut cx).is_ready());
5171 }
5172
5173 #[tokio::test]
5174 async fn require_api_token_rate_limits_by_principal() {
5175 use axum::body::Body;
5180 use tower::ServiceExt;
5181
5182 use crate::security::{KeyStrategy, RateLimitConfig, RateLimitLayer};
5183
5184 let rl_config = RateLimitConfig {
5185 enabled: true,
5186 requests_per_second: 0.1,
5187 burst: 1,
5188 key_strategy: KeyStrategy::AuthenticatedPrincipal,
5189 ..Default::default()
5190 };
5191
5192 let store = Arc::new(InMemoryApiTokenStore::default());
5193 let token_a1 = issue_api_token(&*store, "principal-1").await.unwrap();
5194 let token_a2 = issue_api_token(&*store, "principal-1").await.unwrap(); let token_b = issue_api_token(&*store, "principal-2").await.unwrap();
5196
5197 let app = axum::Router::new()
5198 .route("/", axum::routing::get(|| async { "ok" }))
5199 .layer(RateLimitLayer::from_config(&rl_config)) .layer(RequireApiToken::new(Arc::clone(&store))); let r = app
5204 .clone()
5205 .oneshot(
5206 http::Request::builder()
5207 .uri("/")
5208 .header("authorization", format!("Bearer {token_a1}"))
5209 .body(Body::empty())
5210 .unwrap(),
5211 )
5212 .await
5213 .unwrap();
5214 assert_eq!(r.status(), StatusCode::OK);
5215
5216 let r = app
5218 .clone()
5219 .oneshot(
5220 http::Request::builder()
5221 .uri("/")
5222 .header("authorization", format!("Bearer {token_a2}"))
5223 .body(Body::empty())
5224 .unwrap(),
5225 )
5226 .await
5227 .unwrap();
5228 assert_eq!(
5229 r.status(),
5230 StatusCode::TOO_MANY_REQUESTS,
5231 "second token for the same principal must share the rate-limit bucket"
5232 );
5233
5234 let r = app
5236 .clone()
5237 .oneshot(
5238 http::Request::builder()
5239 .uri("/")
5240 .header("authorization", format!("Bearer {token_b}"))
5241 .body(Body::empty())
5242 .unwrap(),
5243 )
5244 .await
5245 .unwrap();
5246 assert_eq!(r.status(), StatusCode::OK);
5247 }
5248
5249 #[tokio::test]
5250 async fn require_api_token_sets_rate_limit_principal() {
5251 use axum::body::Body;
5252 use tower::ServiceExt;
5253
5254 use crate::security::RateLimitPrincipal;
5255
5256 async fn handler(
5257 axum::Extension(principal): axum::Extension<RateLimitPrincipal>,
5258 ) -> String {
5259 principal.0
5260 }
5261
5262 let store = Arc::new(InMemoryApiTokenStore::default());
5263 let raw = issue_api_token(&*store, "agent:bot").await.unwrap();
5264
5265 let app = axum::Router::new()
5266 .route("/", axum::routing::get(handler))
5267 .layer(RequireApiToken::new(Arc::clone(&store)));
5268
5269 let response = app
5270 .oneshot(
5271 http::Request::builder()
5272 .uri("/")
5273 .header("authorization", format!("Bearer {raw}"))
5274 .body(Body::empty())
5275 .unwrap(),
5276 )
5277 .await
5278 .unwrap();
5279
5280 assert_eq!(response.status(), StatusCode::OK);
5281 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
5282 .await
5283 .unwrap();
5284 assert_eq!(std::str::from_utf8(&body).unwrap(), "agent:bot");
5285 }
5286}
5287
5288#[cfg(feature = "oauth2")]
5291#[cfg(test)]
5292mod oauth2_unit_tests {
5293 use std::collections::HashMap;
5294
5295 use super::{
5296 AuthConfig, OAuth2ProviderConfig, OAuthLinkingPolicy, oauth2_authorize_url, provider_preset,
5297 };
5298
5299 #[allow(dead_code)]
5300 fn make_provider(authorize_url: &str) -> OAuth2ProviderConfig {
5301 OAuth2ProviderConfig {
5302 client_id: "cid".into(),
5303 client_secret: "secret".into(),
5304 authorize_url: authorize_url.into(),
5305 token_url: "https://idp.example/token".into(),
5306 userinfo_url: None,
5307 redirect_uri: "http://localhost:3000/callback".into(),
5308 scope: "openid profile".into(),
5309 issuer: None,
5310 jwks_url: None,
5311 discovery_url: None,
5312 }
5313 }
5314
5315 #[test]
5316 fn provider_preset_google_returns_oidc_config() {
5317 let preset = provider_preset("google").expect("google preset must exist");
5318 assert!(
5319 !preset.authorize_url.is_empty(),
5320 "google authorize_url must not be empty"
5321 );
5322 assert!(
5323 !preset.token_url.is_empty(),
5324 "google token_url must not be empty"
5325 );
5326 assert!(
5327 preset.discovery_url.is_some(),
5328 "google must have discovery_url for OIDC"
5329 );
5330 assert!(
5331 preset.scope.contains("openid"),
5332 "google preset scope must include openid: {}",
5333 preset.scope
5334 );
5335 assert!(
5336 preset.scope.contains("email"),
5337 "google preset scope must include email: {}",
5338 preset.scope
5339 );
5340 assert_eq!(
5341 preset.client_id, "",
5342 "client_id must be empty in preset (user fills in)"
5343 );
5344 assert_eq!(
5345 preset.client_secret, "",
5346 "client_secret must be empty in preset"
5347 );
5348 assert_eq!(
5349 preset.redirect_uri, "",
5350 "redirect_uri must be empty in preset"
5351 );
5352 }
5353
5354 #[cfg(feature = "oauth2")]
5355 #[test]
5356 fn provider_preset_github_returns_pure_oauth2_config() {
5357 let preset = provider_preset("github").expect("github preset must exist");
5358 assert!(
5359 !preset.authorize_url.is_empty(),
5360 "github authorize_url must not be empty"
5361 );
5362 assert!(
5363 !preset.token_url.is_empty(),
5364 "github token_url must not be empty"
5365 );
5366 assert!(
5367 preset.userinfo_url.is_some(),
5368 "github must have userinfo_url (it is not OIDC)"
5369 );
5370 assert!(
5371 preset.discovery_url.is_none(),
5372 "github must NOT have discovery_url (pure OAuth2)"
5373 );
5374 }
5375
5376 #[cfg(feature = "oauth2")]
5377 #[test]
5378 fn provider_preset_microsoft_returns_oidc_config() {
5379 let preset = provider_preset("microsoft").expect("microsoft preset must exist");
5380 assert!(
5381 !preset.authorize_url.is_empty(),
5382 "microsoft authorize_url must not be empty"
5383 );
5384 assert!(
5385 preset.discovery_url.is_some(),
5386 "microsoft must have discovery_url for OIDC"
5387 );
5388 assert!(
5389 preset.scope.contains("openid"),
5390 "microsoft preset scope must include openid: {}",
5391 preset.scope
5392 );
5393 }
5394
5395 #[cfg(feature = "oauth2")]
5396 #[test]
5397 fn provider_preset_unknown_returns_none() {
5398 assert!(
5399 provider_preset("nonexistent_provider_xyz").is_none(),
5400 "unknown provider must return None"
5401 );
5402 }
5403
5404 #[cfg(feature = "oauth2")]
5405 #[tokio::test]
5406 async fn oauth2_authorize_url_includes_pkce_code_challenge() {
5407 let session = crate::session::Session::new_for_test("s1".into(), HashMap::new());
5408 let provider = OAuth2ProviderConfig {
5409 client_id: "cid".into(),
5410 client_secret: "secret".into(),
5411 authorize_url: "https://idp.example/authorize".into(),
5412 token_url: "https://idp.example/token".into(),
5413 userinfo_url: None,
5414 redirect_uri: "http://localhost:3000/callback".into(),
5415 scope: "openid profile".into(),
5416 issuer: None,
5417 jwks_url: None,
5418 discovery_url: None,
5419 };
5420 let url = oauth2_authorize_url(&session, "testprovider", &provider)
5421 .await
5422 .unwrap();
5423 assert!(
5424 url.contains("code_challenge="),
5425 "PKCE code_challenge must be present in URL: {url}"
5426 );
5427 assert!(
5428 url.contains("code_challenge_method=S256"),
5429 "PKCE method must be S256: {url}"
5430 );
5431 assert!(
5432 session
5433 .get("oauth2:testprovider:code_verifier")
5434 .await
5435 .is_some(),
5436 "code_verifier must be stored in session for later exchange"
5437 );
5438 }
5439
5440 #[cfg(feature = "oauth2")]
5441 #[test]
5442 fn oauth2_provider_config_has_discovery_url_field() {
5443 let provider = OAuth2ProviderConfig {
5444 client_id: "cid".into(),
5445 client_secret: "secret".into(),
5446 authorize_url: "https://idp.example/authorize".into(),
5447 token_url: "https://idp.example/token".into(),
5448 userinfo_url: None,
5449 redirect_uri: "http://localhost:3000/callback".into(),
5450 scope: String::new(),
5451 issuer: None,
5452 jwks_url: None,
5453 discovery_url: Some("https://idp.example".into()),
5454 };
5455 assert_eq!(
5456 provider.discovery_url.as_deref(),
5457 Some("https://idp.example"),
5458 "discovery_url must be accessible as a field"
5459 );
5460 }
5461
5462 #[cfg(feature = "oauth2")]
5463 #[test]
5464 fn auth_config_has_oauth_linking_policy() {
5465 let config = AuthConfig::default();
5466 assert!(
5469 matches!(
5470 config.oauth_linking_policy,
5471 OAuthLinkingPolicy::CreateAccount
5472 ),
5473 "default linking policy must be CreateAccount"
5474 );
5475 }
5476}