Skip to main content

sova_passport/
lib.rs

1//! Passport-style authentication for Sova.
2//!
3//! - [`Passport`] — strategy registry, `authenticate`, session serialize/deserialize, login/logout
4//! - [`Auth`] / [`AuthMw`] — extract + verify strategies (Bearer, API key, JWT)
5//! - feature `jwt`: [`JwtAuth`] (users, refresh, migrate)
6//! - feature `oauth`: [`Oauth`] (OAuth2 code + PKCE)
7
8mod extract;
9mod events;
10mod passport;
11
12#[cfg(feature = "jwt")]
13mod jwt;
14
15#[cfg(feature = "jwt")]
16mod api_token;
17#[cfg(feature = "jwt")]
18mod entity;
19#[cfg(feature = "jwt")]
20mod jwt_auth;
21#[cfg(feature = "jwt")]
22mod migration;
23#[cfg(feature = "jwt")]
24mod password;
25#[cfg(feature = "jwt")]
26mod store;
27
28#[cfg(feature = "oauth")]
29mod oauth;
30
31pub use extract::{Credentials, Extract, Source};
32pub use events::ApiTokenRevoked;
33pub use passport::{passport_serialize, Authenticated, Passport, PassportExt};
34
35#[cfg(feature = "jwt")]
36pub use jwt::{Claims, Jwt, JwtError};
37
38#[cfg(feature = "jwt")]
39pub use jwt_auth::{JwtAuth, JwtAuthExt, JwtAuthState};
40#[cfg(feature = "jwt")]
41pub use api_token::{
42    create_api_token, list_api_tokens, revoke_api_token, token_can, user_for_api_token,
43    ApiTokenInfo, ApiTokenRow, CreateApiToken, CreatedApiToken, PAT_PREFIX,
44};
45#[cfg(feature = "jwt")]
46pub use migration::AuthMigrator;
47#[cfg(feature = "jwt")]
48pub use password::{hash_password, verify_password};
49#[cfg(feature = "jwt")]
50pub use store::{
51    hash_refresh_token, hash_token, issue_token_pair, register_user, AuthUser, TokenPair,
52};
53
54#[cfg(feature = "oauth")]
55pub use oauth::{
56    drivers as oauth_drivers, test_support as oauth_test_support, Oauth, OauthProfile,
57    OauthProvider, OauthTokens, ProfileKind,
58};
59#[cfg(feature = "oauth")]
60pub use oauth::drivers::{Apple, Custom, Driver, Github, Google};
61
62use extract::Extract as ExtractChain;
63use sova_core::extend::{named, BoxFuture, MwEntry};
64use sova_core::{with_state, App, Error, Plugin, Request, Result};
65use std::future::Future;
66use std::marker::PhantomData;
67use std::sync::Arc;
68
69type VerifyFn<U> =
70    Arc<dyn Fn(Credentials, &Request) -> BoxFuture<Result<Option<U>>> + Send + Sync>;
71
72/// Builder before [`AuthBuilder::verify`] binds the user type.
73pub struct AuthBuilder {
74    extract: ExtractChain,
75    skip: Vec<String>,
76    optional: bool,
77    name: String,
78}
79
80/// Strategy middleware: extract credential → verify → `req.set(U)`.
81pub struct AuthMw<U> {
82    extract: ExtractChain,
83    verify: VerifyFn<U>,
84    skip: Vec<String>,
85    optional: bool,
86    name: String,
87    _marker: PhantomData<U>,
88}
89
90impl<U: Send + Sync + 'static> AuthMw<U> {
91    pub fn skip(mut self, path: impl Into<String>) -> Self {
92        self.skip.push(path.into());
93        self
94    }
95
96    pub fn optional(mut self, on: bool) -> Self {
97        self.optional = on;
98        self
99    }
100
101    pub fn name(mut self, name: impl Into<String>) -> Self {
102        self.name = name.into();
103        self
104    }
105
106    /// Middleware entry for [`Passport::strategy`] / `Router::use_middleware`.
107    pub fn middleware(self) -> MwEntry {
108        let name = self.name.clone();
109        named(
110            name,
111            with_state(self, |auth, mut req, next| async move {
112                if path_skipped(&req.path, &auth.skip) {
113                    return next(req).await;
114                }
115
116                let Some(creds) = auth.extract.run(&req) else {
117                    if auth.optional {
118                        return next(req).await;
119                    }
120                    return Error::Unauthorized.into_response();
121                };
122
123                match (auth.verify)(creds, &req).await {
124                    Ok(Some(user)) => {
125                        req.set(Authenticated {
126                            id: std::any::type_name::<U>().into(),
127                        });
128                        req.set(user);
129                        next(req).await
130                    }
131                    Ok(None) => {
132                        if auth.optional {
133                            next(req).await
134                        } else {
135                            Error::Unauthorized.into_response()
136                        }
137                    }
138                    Err(err) => err.into_response(),
139                }
140            }),
141        )
142    }
143}
144
145impl AuthBuilder {
146    pub fn new() -> Self {
147        Self {
148            extract: ExtractChain::bearer(),
149            skip: Vec::new(),
150            optional: false,
151            name: "passport-strategy".into(),
152        }
153    }
154
155    pub fn extract(mut self, extract: ExtractChain) -> Self {
156        self.extract = extract;
157        self
158    }
159
160    pub fn skip(mut self, path: impl Into<String>) -> Self {
161        self.skip.push(path.into());
162        self
163    }
164
165    pub fn optional(mut self, on: bool) -> Self {
166        self.optional = on;
167        self
168    }
169
170    pub fn name(mut self, name: impl Into<String>) -> Self {
171        self.name = name.into();
172        self
173    }
174
175    pub fn verify<U, F, Fut>(self, f: F) -> AuthMw<U>
176    where
177        U: Send + Sync + 'static,
178        F: Fn(Credentials, &Request) -> Fut + Send + Sync + 'static,
179        Fut: Future<Output = Result<Option<U>>> + Send + 'static,
180    {
181        self.verify_boxed(move |creds, req| Box::pin(f(creds, req)))
182    }
183
184    pub fn verify_boxed<U, F>(self, f: F) -> AuthMw<U>
185    where
186        U: Send + Sync + 'static,
187        F: Fn(Credentials, &Request) -> BoxFuture<Result<Option<U>>> + Send + Sync + 'static,
188    {
189        AuthMw {
190            extract: self.extract,
191            verify: Arc::new(f),
192            skip: self.skip,
193            optional: self.optional,
194            name: self.name,
195            _marker: PhantomData,
196        }
197    }
198}
199
200impl Default for AuthBuilder {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206/// Strategy constructors: [`Auth::bearer`], [`Auth::api_key`], [`Auth::jwt_hs256`], …
207pub struct Auth;
208
209impl Auth {
210    #[allow(clippy::new_ret_no_self)]
211    pub fn new() -> AuthBuilder {
212        AuthBuilder::new()
213    }
214
215    pub fn bearer<U, F, Fut>(f: F) -> AuthMw<U>
216    where
217        U: Send + Sync + 'static,
218        F: Fn(String, &Request) -> Fut + Send + Sync + 'static,
219        Fut: Future<Output = Result<Option<U>>> + Send + 'static,
220    {
221        Auth::new()
222            .name("bearer")
223            .extract(ExtractChain::bearer())
224            .verify(move |creds, req| f(creds.value().to_string(), req))
225    }
226
227    pub fn api_key<U, F, Fut>(header: impl Into<String>, f: F) -> AuthMw<U>
228    where
229        U: Send + Sync + 'static,
230        F: Fn(String, &Request) -> Fut + Send + Sync + 'static,
231        Fut: Future<Output = Result<Option<U>>> + Send + 'static,
232    {
233        Auth::new()
234            .name("api-key")
235            .extract(ExtractChain::header(header))
236            .verify(move |creds, req| f(creds.value().to_string(), req))
237    }
238
239    #[cfg(feature = "jwt")]
240    pub fn jwt_hs256<U, C, F, Fut>(secret: impl Into<String>, f: F) -> AuthMw<U>
241    where
242        U: Send + Sync + 'static,
243        C: serde::de::DeserializeOwned + Send + 'static,
244        F: Fn(C, &Request) -> Fut + Send + Sync + 'static,
245        Fut: Future<Output = Result<Option<U>>> + Send + 'static,
246    {
247        let jwt = Jwt::hs256(secret);
248        Auth::new()
249            .name("jwt")
250            .extract(ExtractChain::bearer())
251            .verify_boxed(move |creds, req| match jwt.decode::<C>(creds.value()) {
252                Ok(claims) => Box::pin(f(claims, req)),
253                Err(_) => Box::pin(async { Ok(None) }),
254            })
255    }
256}
257
258/// Proper local strategy (JSON email/password) — body consumed once.
259pub fn local_strategy<U, F, Fut>(f: F) -> MwEntry
260where
261    U: Send + Sync + 'static,
262    F: Fn(String, String, Request) -> Fut + Send + Sync + 'static,
263    Fut: Future<Output = Result<(Option<U>, Request)>> + Send + 'static,
264{
265    let f = Arc::new(f);
266    named(
267        "local",
268        with_state(f, |f, mut req, next| async move {
269            #[derive(serde::Deserialize)]
270            struct Body {
271                email: String,
272                password: String,
273            }
274            let body: Body = match req.json().await {
275                Ok(b) => b,
276                Err(e) => return e.into_response(),
277            };
278            match f(body.email, body.password, req).await {
279                Ok((Some(user), mut req)) => {
280                    req.set(Authenticated {
281                        id: std::any::type_name::<U>().into(),
282                    });
283                    req.set(user);
284                    next(req).await
285                }
286                Ok((None, _)) => Error::Unauthorized.into_response(),
287                Err(e) => e.into_response(),
288            }
289        }),
290    )
291}
292
293impl<U: Send + Sync + 'static> Plugin for AuthMw<U> {
294    fn id(&self) -> &'static str {
295        "passport-auth-mw"
296    }
297
298    fn meta(&self) -> sova_core::PluginMeta {
299        sova_core::PluginMeta::new("Passport strategy")
300            .description("Extract + verify authentication strategy")
301            .version(env!("CARGO_PKG_VERSION"))
302    }
303
304    fn install(self, app: &mut App) {
305        app.use_middleware(self.middleware());
306    }
307}
308
309fn path_skipped(path: &str, skips: &[String]) -> bool {
310    skips.iter().any(|p| {
311        if let Some(prefix) = p.strip_suffix('*') {
312            path.starts_with(prefix)
313        } else {
314            path == p
315        }
316    })
317}
318
319/// Prefer [`PassportExt`] — kept as a deprecated alias name for docs only.
320#[doc(inline)]
321pub use PassportExt as AuthExt;