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 #[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 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}