1mod jwks;
5
6use std::sync::Arc;
7
8use base64ct::{Base64UrlUnpadded, Encoding};
9use chrono::{DateTime, Utc};
10use dashmap::DashMap;
11use jsonwebtoken::{Algorithm, DecodingKey, Validation};
12use rand::TryRngCore;
13use rand::rngs::OsRng;
14use serde::Deserialize;
15use sha2::{Digest, Sha256};
16use uuid::Uuid;
17
18use allowthem_core::{
19 AuthClient, AuthError, AuthFuture, Email, PermissionName, RoleName, SessionToken, User, UserId,
20 Username,
21};
22
23use crate::jwks::JwksManager;
24
25#[derive(Debug, Deserialize)]
30struct ExternalAccessTokenClaims {
31 sub: String,
32 #[allow(dead_code)]
33 iss: String,
34 #[allow(dead_code)]
35 aud: String,
36 exp: i64,
37 iat: i64,
38 #[allow(dead_code)]
39 scope: String,
40 email: String,
41 email_verified: bool,
42 #[serde(default)]
43 username: Option<String>,
44 #[serde(default)]
45 roles: Vec<String>,
46 #[serde(default)]
47 permissions: Vec<String>,
48}
49
50#[derive(Debug, Deserialize)]
51struct MinimalClaims {
52 sub: String,
53}
54
55struct CachedClaims {
60 roles: Vec<String>,
61 permissions: Vec<String>,
62 expires_at: i64,
63}
64
65pub struct AuthorizeRequest {
74 pub code_verifier: String,
75 pub state: String,
76}
77
78#[derive(Debug, Deserialize)]
80pub struct TokenExchangeResponse {
81 pub access_token: String,
82 pub token_type: String,
83 pub expires_in: i64,
84 pub refresh_token: String,
85 pub id_token: String,
86}
87
88struct ExternalAuthClientInner {
93 base_url: String,
94 client_id: String,
95 client_secret: String,
96 redirect_uri: String,
97 expected_issuer: String,
98 session_cookie_name: &'static str,
99 http: reqwest::Client,
100 jwks: JwksManager,
101 claims_cache: DashMap<UserId, CachedClaims>,
102 login_url: String,
103 scopes: String,
104}
105
106#[derive(Clone)]
111pub struct ExternalAuthClient {
112 inner: Arc<ExternalAuthClientInner>,
113}
114
115pub struct ExternalAuthClientBuilder {
117 base_url: String,
118 client_id: String,
119 client_secret: String,
120 redirect_uri: String,
121 login_url: String,
122 session_cookie_name: Option<&'static str>,
123 scopes: Option<String>,
124}
125
126impl ExternalAuthClientBuilder {
127 pub fn new(
135 base_url: impl Into<String>,
136 client_id: impl Into<String>,
137 client_secret: impl Into<String>,
138 redirect_uri: impl Into<String>,
139 login_url: impl Into<String>,
140 ) -> Self {
141 Self {
142 base_url: base_url.into(),
143 client_id: client_id.into(),
144 client_secret: client_secret.into(),
145 redirect_uri: redirect_uri.into(),
146 login_url: login_url.into(),
147 session_cookie_name: None,
148 scopes: None,
149 }
150 }
151
152 pub fn cookie_name(mut self, name: &'static str) -> Self {
154 self.session_cookie_name = Some(name);
155 self
156 }
157
158 pub fn scopes(mut self, scopes: impl Into<String>) -> Self {
160 self.scopes = Some(scopes.into());
161 self
162 }
163
164 pub fn build(self) -> ExternalAuthClient {
166 let http = reqwest::Client::new();
167 let jwks = JwksManager::new(&self.base_url, http.clone());
168 let expected_issuer = self.base_url.clone();
169
170 ExternalAuthClient {
171 inner: Arc::new(ExternalAuthClientInner {
172 base_url: self.base_url,
173 client_id: self.client_id,
174 client_secret: self.client_secret,
175 redirect_uri: self.redirect_uri,
176 expected_issuer,
177 session_cookie_name: self.session_cookie_name.unwrap_or("allowthem_session"),
178 http,
179 jwks,
180 claims_cache: DashMap::new(),
181 login_url: self.login_url,
182 scopes: self.scopes.unwrap_or_else(|| "openid profile email".into()),
183 }),
184 }
185 }
186}
187
188impl ExternalAuthClient {
189 pub fn builder(
191 base_url: impl Into<String>,
192 client_id: impl Into<String>,
193 client_secret: impl Into<String>,
194 redirect_uri: impl Into<String>,
195 login_url: impl Into<String>,
196 ) -> ExternalAuthClientBuilder {
197 ExternalAuthClientBuilder::new(base_url, client_id, client_secret, redirect_uri, login_url)
198 }
199
200 pub fn authorize_url(&self) -> (String, AuthorizeRequest) {
205 let mut verifier_bytes = [0u8; 32];
207 OsRng
208 .try_fill_bytes(&mut verifier_bytes)
209 .expect("OS RNG unavailable");
210 let code_verifier = Base64UrlUnpadded::encode_string(&verifier_bytes);
211
212 let digest = Sha256::digest(code_verifier.as_bytes());
214 let code_challenge = Base64UrlUnpadded::encode_string(&digest);
215
216 let mut state_bytes = [0u8; 32];
218 OsRng
219 .try_fill_bytes(&mut state_bytes)
220 .expect("OS RNG unavailable");
221 let state = Base64UrlUnpadded::encode_string(&state_bytes);
222
223 let url = format!(
224 "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}",
225 self.inner.base_url,
226 urlencoded(&self.inner.client_id),
227 urlencoded(&self.inner.redirect_uri),
228 urlencoded(&self.inner.scopes),
229 urlencoded(&code_challenge),
230 urlencoded(&state),
231 );
232
233 (
234 url,
235 AuthorizeRequest {
236 code_verifier,
237 state,
238 },
239 )
240 }
241
242 pub async fn exchange_code(
244 &self,
245 code: &str,
246 code_verifier: &str,
247 redirect_uri: &str,
248 ) -> Result<TokenExchangeResponse, AuthError> {
249 let url = format!("{}/oauth/token", self.inner.base_url);
250
251 let params = [
252 ("grant_type", "authorization_code"),
253 ("code", code),
254 ("redirect_uri", redirect_uri),
255 ("code_verifier", code_verifier),
256 ("client_id", &self.inner.client_id),
257 ("client_secret", &self.inner.client_secret),
258 ];
259
260 let resp = self
261 .inner
262 .http
263 .post(&url)
264 .form(¶ms)
265 .send()
266 .await
267 .map_err(|e| AuthError::OAuthHttp(e.to_string()))?;
268
269 if !resp.status().is_success() {
270 let status = resp.status();
271 let body = resp.text().await.unwrap_or_default();
272 return Err(AuthError::OAuthTokenExchange(format!("{status}: {body}")));
273 }
274
275 resp.json::<TokenExchangeResponse>()
276 .await
277 .map_err(|e| AuthError::OAuthHttp(e.to_string()))
278 }
279}
280
281impl AuthClient for ExternalAuthClient {
286 fn validate_session<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, Option<User>> {
287 Box::pin(async move {
288 let jwt = token.as_str();
289
290 let header = match jsonwebtoken::decode_header(jwt) {
292 Ok(h) => h,
293 Err(_) => return Ok(None),
294 };
295 let kid = match header.kid {
296 Some(k) => k,
297 None => return Ok(None),
298 };
299
300 let decoding_key = match self.inner.jwks.get_decoding_key(&kid).await {
302 Ok(Some(key)) => key,
303 Ok(None) => return Ok(None),
304 Err(e) => return Err(e),
305 };
306
307 let mut validation = Validation::new(Algorithm::RS256);
309 validation.set_issuer(&[&self.inner.expected_issuer]);
310 validation.set_audience(&[&self.inner.client_id]);
311 validation.leeway = 0;
312
313 let token_data = match jsonwebtoken::decode::<ExternalAccessTokenClaims>(
314 jwt,
315 &decoding_key,
316 &validation,
317 ) {
318 Ok(td) => td,
319 Err(_) => return Ok(None),
320 };
321
322 let claims = token_data.claims;
323
324 let sub_uuid = match Uuid::parse_str(&claims.sub) {
326 Ok(u) => u,
327 Err(_) => return Ok(None),
328 };
329 let user_id = UserId::from_uuid(sub_uuid);
330
331 let email = match Email::new(claims.email) {
333 Ok(e) => e,
334 Err(_) => return Ok(None),
335 };
336 let ts = DateTime::from_timestamp(claims.iat, 0).unwrap_or_else(Utc::now);
337 let user = User {
338 id: user_id,
339 email,
340 username: claims.username.map(Username::new),
341 password_hash: None,
342 email_verified: claims.email_verified,
343 is_active: true,
344 created_at: ts,
345 updated_at: ts,
346 custom_data: None,
347 };
348
349 self.inner.claims_cache.insert(
351 user_id,
352 CachedClaims {
353 roles: claims.roles,
354 permissions: claims.permissions,
355 expires_at: claims.exp,
356 },
357 );
358
359 Ok(Some(user))
360 })
361 }
362
363 fn check_role<'a>(&'a self, user_id: &'a UserId, role: &'a RoleName) -> AuthFuture<'a, bool> {
364 Box::pin(async move {
365 let now = Utc::now().timestamp();
366 match self.inner.claims_cache.get(user_id) {
367 Some(cached) if cached.expires_at > now => {
368 Ok(cached.roles.iter().any(|r| r == role.as_str()))
369 }
370 _ => Ok(false),
371 }
372 })
373 }
374
375 fn check_permission<'a>(
376 &'a self,
377 user_id: &'a UserId,
378 permission: &'a PermissionName,
379 ) -> AuthFuture<'a, bool> {
380 Box::pin(async move {
381 let now = Utc::now().timestamp();
382 match self.inner.claims_cache.get(user_id) {
383 Some(cached) if cached.expires_at > now => {
384 Ok(cached.permissions.iter().any(|p| p == permission.as_str()))
385 }
386 _ => Ok(false),
387 }
388 })
389 }
390
391 fn resolve_highest_role<'a>(
392 &'a self,
393 user_id: &'a UserId,
394 hierarchy: &'a [&str],
395 ) -> AuthFuture<'a, Option<String>> {
396 Box::pin(async move {
397 let now = Utc::now().timestamp();
398 match self.inner.claims_cache.get(user_id) {
399 Some(cached) if cached.expires_at > now => {
400 for &name in hierarchy {
401 if cached.roles.iter().any(|r| r == name) {
402 return Ok(Some(name.to_owned()));
403 }
404 }
405 Ok(None)
406 }
407 _ => Ok(None),
408 }
409 })
410 }
411
412 fn logout<'a>(&'a self, token: &'a SessionToken) -> AuthFuture<'a, ()> {
413 Box::pin(async move {
414 let jwt = token.as_str();
415 let mut insecure = Validation::new(Algorithm::RS256);
417 insecure.insecure_disable_signature_validation();
418 insecure.validate_aud = false;
419 insecure.validate_exp = false;
420 if let Ok(data) = jsonwebtoken::decode::<MinimalClaims>(
421 jwt,
422 &DecodingKey::from_secret(&[]),
423 &insecure,
424 ) && let Ok(uuid) = Uuid::parse_str(&data.claims.sub)
425 {
426 self.inner.claims_cache.remove(&UserId::from_uuid(uuid));
427 }
428 Ok(())
429 })
430 }
431
432 fn login_url(&self) -> &str {
433 &self.inner.login_url
434 }
435
436 fn session_cookie_name(&self) -> &str {
437 self.inner.session_cookie_name
438 }
439}
440
441fn urlencoded(s: &str) -> String {
447 s.replace('%', "%25")
448 .replace(' ', "%20")
449 .replace('&', "%26")
450 .replace('=', "%3D")
451 .replace('+', "%2B")
452 .replace('/', "%2F")
453 .replace(':', "%3A")
454}
455
456#[cfg(test)]
461mod tests {
462 use super::*;
463
464 fn test_builder() -> ExternalAuthClient {
465 ExternalAuthClient::builder(
466 "https://auth.example.com",
467 "ath_test_client",
468 "test_secret",
469 "https://myapp.example.com/callback",
470 "/auth/login",
471 )
472 .build()
473 }
474
475 #[test]
476 fn builder_defaults() {
477 let client = test_builder();
478 assert_eq!(client.session_cookie_name(), "allowthem_session");
479 assert_eq!(client.login_url(), "/auth/login");
480 }
481
482 #[test]
483 fn builder_overrides() {
484 let client = ExternalAuthClient::builder(
485 "https://auth.example.com",
486 "ath_test",
487 "secret",
488 "https://app.example.com/cb",
489 "/login",
490 )
491 .cookie_name("my_session")
492 .scopes("openid")
493 .build();
494
495 assert_eq!(client.session_cookie_name(), "my_session");
496 assert_eq!(client.inner.scopes, "openid");
497 }
498
499 #[test]
500 fn authorize_url_contains_pkce() {
501 let client = test_builder();
502 let (url, req) = client.authorize_url();
503 assert!(url.contains("code_challenge="));
504 assert!(url.contains("code_challenge_method=S256"));
505 assert!(url.contains("state="));
506 assert!(!req.code_verifier.is_empty());
507 assert!(!req.state.is_empty());
508 }
509
510 #[test]
511 fn authorize_url_unique_per_call() {
512 let client = test_builder();
513 let (_, req1) = client.authorize_url();
514 let (_, req2) = client.authorize_url();
515 assert_ne!(req1.code_verifier, req2.code_verifier);
516 assert_ne!(req1.state, req2.state);
517 }
518
519 #[test]
520 fn clone_is_cheap() {
521 let client = test_builder();
522 let _clone = client.clone();
523 }
524}