Skip to main content

synd_api/serve/
auth.rs

1use std::time::Duration;
2
3use futures_util::future::BoxFuture;
4use moka::future::Cache;
5use synd_auth::jwt::google::JwtService as GoogleJwtService;
6use tracing::warn;
7use tracing::{debug, instrument};
8
9use crate::{
10    Error, Result as ApiResult,
11    client::github::GithubClient,
12    principal::{Principal, User},
13    serve::layer::authenticate::Authenticate,
14};
15
16#[derive(Clone)]
17pub struct Authenticator {
18    kind: AuthenticatorKind,
19}
20
21#[derive(Clone)]
22enum AuthenticatorKind {
23    Remote {
24        github: GithubClient,
25        google: Box<GoogleJwtService>,
26        cache: Cache<String, Principal>,
27    },
28    Local {
29        token: String,
30    },
31    TrustedLocal,
32}
33
34impl Authenticator {
35    pub fn new() -> ApiResult<Self> {
36        let cache = Cache::builder()
37            .max_capacity(1024 * 1024)
38            .time_to_live(Duration::from_hours(1))
39            .build();
40
41        Ok(Self {
42            kind: AuthenticatorKind::Remote {
43                github: GithubClient::new()?,
44                google: Box::default(),
45                cache,
46            },
47        })
48    }
49
50    pub fn local(token: impl Into<String>) -> ApiResult<Self> {
51        let token = token.into();
52        if token.is_empty() {
53            return Err(Error::EmptyLocalToken);
54        }
55
56        Ok(Self {
57            kind: AuthenticatorKind::Local { token },
58        })
59    }
60
61    pub fn trusted_local() -> Self {
62        Self {
63            kind: AuthenticatorKind::TrustedLocal,
64        }
65    }
66
67    #[must_use]
68    pub fn with_github_client(self, github: GithubClient) -> Self {
69        match self.kind {
70            AuthenticatorKind::Remote { google, cache, .. } => Self {
71                kind: AuthenticatorKind::Remote {
72                    github,
73                    google,
74                    cache,
75                },
76            },
77            AuthenticatorKind::Local { token } => Self {
78                kind: AuthenticatorKind::Local { token },
79            },
80            AuthenticatorKind::TrustedLocal => Self {
81                kind: AuthenticatorKind::TrustedLocal,
82            },
83        }
84    }
85
86    #[must_use]
87    pub fn with_google_jwt(self, google: GoogleJwtService) -> Self {
88        match self.kind {
89            AuthenticatorKind::Remote { github, cache, .. } => Self {
90                kind: AuthenticatorKind::Remote {
91                    github,
92                    google: Box::new(google),
93                    cache,
94                },
95            },
96            AuthenticatorKind::Local { token } => Self {
97                kind: AuthenticatorKind::Local { token },
98            },
99            AuthenticatorKind::TrustedLocal => Self {
100                kind: AuthenticatorKind::TrustedLocal,
101            },
102        }
103    }
104
105    /// Authenticate from given token
106    #[instrument(skip_all)]
107    pub async fn authenticate<S>(&self, token: S) -> Result<Principal, ()>
108    where
109        S: AsRef<str>,
110    {
111        let token = token.as_ref();
112        match &self.kind {
113            AuthenticatorKind::Remote {
114                github,
115                google,
116                cache,
117            } => Self::authenticate_remote(github, google, cache, token).await,
118            AuthenticatorKind::Local {
119                token: expected_token,
120            } => Self::authenticate_local(expected_token, token),
121            AuthenticatorKind::TrustedLocal => Ok(Principal::User(User::local())),
122        }
123    }
124
125    async fn authenticate_optional(&self, token: Option<String>) -> Result<Principal, ()> {
126        match token {
127            Some(token) => self.authenticate(token).await,
128            None if matches!(self.kind, AuthenticatorKind::TrustedLocal) => {
129                Ok(Principal::User(User::local()))
130            }
131            None => Err(()),
132        }
133    }
134
135    async fn authenticate_remote(
136        github: &GithubClient,
137        google: &GoogleJwtService,
138        cache: &Cache<String, Principal>,
139        token: &str,
140    ) -> Result<Principal, ()> {
141        let mut split = token.splitn(2, ' ');
142        match (split.next(), split.next()) {
143            (Some("github"), Some(access_token)) => {
144                if let Some(principal) = cache.get(token).await {
145                    debug!("Principal cache hit");
146                    return Ok(principal);
147                }
148
149                match github.authenticate(access_token).await {
150                    Ok(email) => {
151                        let principal = Principal::User(User::from_email(email));
152
153                        cache.insert(token.to_owned(), principal.clone()).await;
154
155                        Ok(principal)
156                    }
157                    Err(err) => {
158                        warn!("Failed to authenticate github: {err}");
159                        Err(())
160                    }
161                }
162            }
163            (Some("google"), Some(id_token)) => {
164                if let Some(principal) = cache.get(id_token).await {
165                    debug!("Principal cache hit");
166                    return Ok(principal);
167                }
168
169                match google.decode_id_token(id_token).await {
170                    Ok(claims) => {
171                        if !claims.email_verified {
172                            warn!("Google jwt claims email is not verified");
173                            return Err(());
174                        }
175                        let principal = Principal::User(User::from_email(claims.email));
176
177                        cache.insert(id_token.to_owned(), principal.clone()).await;
178
179                        Ok(principal)
180                    }
181                    Err(err) => {
182                        // If a lot of intentional invalid id tokens are sent
183                        // google's api limit will be exceeded.
184                        // To prevent this, it is necessary to cache the currently valid kids
185                        // and discard jwt headers with other kids.
186                        warn!("Failed to authenticate google: {err}");
187                        Err(())
188                    }
189                }
190            }
191            _ => Err(()),
192        }
193    }
194
195    fn authenticate_local(expected_token: &str, token: &str) -> Result<Principal, ()> {
196        let mut split = token.splitn(2, ' ');
197        match (split.next(), split.next()) {
198            (Some(scheme), Some(actual_token))
199                if scheme.eq_ignore_ascii_case("Bearer") && actual_token == expected_token =>
200            {
201                Ok(Principal::User(User::local()))
202            }
203            _ => Err(()),
204        }
205    }
206}
207
208impl Authenticate for Authenticator {
209    type Output = BoxFuture<'static, Result<Principal, ()>>;
210
211    fn authenticate(&self, token: Option<String>) -> Self::Output {
212        let this = self.clone();
213        Box::pin(async move { this.authenticate_optional(token).await })
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[tokio::test]
222    async fn local_auth_accepts_matching_bearer_token() {
223        let authenticator = Authenticator::local("secret").unwrap();
224        let principal = authenticator.authenticate("Bearer secret").await.unwrap();
225
226        assert_eq!(principal.principal_id(), "local");
227    }
228
229    #[tokio::test]
230    async fn local_auth_rejects_missing_or_wrong_token() {
231        let authenticator = Authenticator::local("secret").unwrap();
232
233        assert!(authenticator.authenticate("Bearer wrong").await.is_err());
234        assert!(authenticator.authenticate("github secret").await.is_err());
235        assert!(authenticator.authenticate("").await.is_err());
236    }
237
238    #[test]
239    fn local_auth_requires_non_empty_token() {
240        assert!(Authenticator::local("").is_err());
241    }
242
243    #[tokio::test]
244    async fn trusted_local_auth_accepts_missing_token() {
245        let authenticator = Authenticator::trusted_local();
246        let principal = authenticator.authenticate_optional(None).await.unwrap();
247
248        assert_eq!(principal.principal_id(), "local");
249    }
250}