Skip to main content

allowthem_client/
client.rs

1//! allowthem-client: External-mode AuthClient that validates RS256 JWTs
2//! locally via JWKS and talks to the allowthem server for code exchange.
3
4mod 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// ---------------------------------------------------------------------------
26// Internal claims types for JWT deserialization
27// ---------------------------------------------------------------------------
28
29#[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
55// ---------------------------------------------------------------------------
56// Claims cache
57// ---------------------------------------------------------------------------
58
59struct CachedClaims {
60    roles: Vec<String>,
61    permissions: Vec<String>,
62    expires_at: i64,
63}
64
65// ---------------------------------------------------------------------------
66// Public types
67// ---------------------------------------------------------------------------
68
69/// PKCE and state for an authorization request.
70///
71/// The `code_verifier` must be stored (e.g., in a cookie or session)
72/// until the callback handler calls `exchange_code`.
73pub struct AuthorizeRequest {
74    pub code_verifier: String,
75    pub state: String,
76}
77
78/// Response from the token endpoint after authorization code exchange.
79#[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
88// ---------------------------------------------------------------------------
89// ExternalAuthClient
90// ---------------------------------------------------------------------------
91
92struct 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/// External-mode AuthClient that validates RS256 JWTs locally
107/// and talks to the allowthem server for code exchange.
108///
109/// Cheap to clone (Arc internals).
110#[derive(Clone)]
111pub struct ExternalAuthClient {
112    inner: Arc<ExternalAuthClientInner>,
113}
114
115/// Builder for constructing an `ExternalAuthClient`.
116pub 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    /// Create a builder for an external auth client.
128    ///
129    /// - `base_url`: allowthem server URL (e.g., `"https://auth.wavefunk.io"`)
130    /// - `client_id`: OAuth client ID from the application registry
131    /// - `client_secret`: OAuth client secret
132    /// - `redirect_uri`: the consuming project's OIDC callback URL
133    /// - `login_url`: local path to redirect unauthenticated users (e.g., `"/auth/login"`)
134    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    /// Override session cookie name. Default: `"allowthem_session"`.
153    pub fn cookie_name(mut self, name: &'static str) -> Self {
154        self.session_cookie_name = Some(name);
155        self
156    }
157
158    /// Override requested scopes. Default: `"openid profile email"`.
159    pub fn scopes(mut self, scopes: impl Into<String>) -> Self {
160        self.scopes = Some(scopes.into());
161        self
162    }
163
164    /// Build the client. Fetches JWKS on first use, not at build time.
165    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    /// Shorthand for `ExternalAuthClientBuilder::new(...)`.
190    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    /// Generate PKCE challenge and state for an authorization request.
201    ///
202    /// Returns the full authorize URL and the `AuthorizeRequest` containing
203    /// the `code_verifier` and `state` for later verification.
204    pub fn authorize_url(&self) -> (String, AuthorizeRequest) {
205        // Generate code_verifier: 32 random bytes, base64url-encoded
206        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        // code_challenge = BASE64URL(SHA256(code_verifier))
213        let digest = Sha256::digest(code_verifier.as_bytes());
214        let code_challenge = Base64UrlUnpadded::encode_string(&digest);
215
216        // Generate state: 32 random bytes, base64url-encoded
217        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    /// Exchange an authorization code for tokens.
243    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(&params)
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
281// ---------------------------------------------------------------------------
282// AuthClient trait implementation
283// ---------------------------------------------------------------------------
284
285impl 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            // 1. Decode header for kid
291            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            // 2. Get decoding key from JWKS cache
301            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            // 3. Validate JWT — check iss, aud, exp
308            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            // 4. Parse sub as UserId
325            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            // 5. Build User from claims
332            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            // 6. Cache claims for check_role/check_permission
350            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            // Decode without signature verification to extract sub
416            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
441// ---------------------------------------------------------------------------
442// Helpers
443// ---------------------------------------------------------------------------
444
445/// Minimal URL encoding for query parameters.
446fn 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// ---------------------------------------------------------------------------
457// Tests
458// ---------------------------------------------------------------------------
459
460#[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}