Skip to main content

what_core/auth/
mod.rs

1//! Authentication module for the What framework
2//!
3//! Provides JWT-based authentication with:
4//! - Cookie-based JWT storage (HttpOnly, Secure, SameSite)
5//! - Configurable protected routes
6//! - Session integration for user data
7//! - Backend API integration for login/logout
8
9use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use std::collections::HashMap;
13use std::sync::OnceLock;
14
15use crate::Result;
16use crate::config::AuthConfig;
17
18/// Auto-generated JWT secret used when no secret is configured.
19/// Generated once per process lifetime using cryptographically secure random bytes.
20static AUTO_JWT_SECRET: OnceLock<String> = OnceLock::new();
21
22/// Get or generate a fallback JWT secret (32 random bytes, hex-encoded).
23fn get_or_generate_jwt_secret() -> &'static str {
24    AUTO_JWT_SECRET.get_or_init(|| {
25        use rand::RngCore;
26        let mut bytes = [0u8; 32];
27        rand::thread_rng().fill_bytes(&mut bytes);
28        let secret: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
29        tracing::warn!(
30            "No jwt_secret configured — generated a random secret. \
31             JWTs signed by external services will fail validation. \
32             Set [auth] jwt_secret in what.toml or WHAT_AUTH_JWT_SECRET env var."
33        );
34        secret
35    })
36}
37
38/// JWT claims structure
39/// Uses a flexible HashMap to support any claims from the backend
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct JwtClaims {
42    /// Standard JWT claims
43    #[serde(default)]
44    pub exp: Option<u64>,
45    #[serde(default)]
46    pub iat: Option<u64>,
47    #[serde(default)]
48    pub sub: Option<String>,
49
50    /// Custom claims (user_id, email, full_name, etc.)
51    #[serde(flatten)]
52    pub custom: HashMap<String, Value>,
53}
54
55impl JwtClaims {
56    /// Extract specified claims into a context map for templates
57    pub fn to_context(&self, claim_names: &[String]) -> HashMap<String, Value> {
58        let mut context = HashMap::new();
59
60        // Add standard claims if present
61        if let Some(sub) = &self.sub {
62            context.insert("sub".to_string(), json!(sub));
63        }
64        if let Some(exp) = self.exp {
65            context.insert("exp".to_string(), json!(exp));
66        }
67
68        // Add requested custom claims
69        for name in claim_names {
70            if let Some(value) = self.custom.get(name) {
71                context.insert(name.clone(), value.clone());
72            }
73        }
74
75        context
76    }
77
78    /// Check if the token is expired
79    pub fn is_expired(&self) -> bool {
80        if let Some(exp) = self.exp {
81            let now = std::time::SystemTime::now()
82                .duration_since(std::time::UNIX_EPOCH)
83                .map(|d| d.as_secs())
84                .unwrap_or(0);
85            exp < now
86        } else {
87            false // No expiration = not expired
88        }
89    }
90}
91
92/// Authentication handler
93#[derive(Clone)]
94pub struct AuthHandler {
95    config: AuthConfig,
96}
97
98impl AuthHandler {
99    /// Create a new auth handler with the given configuration
100    pub fn new(config: AuthConfig) -> Self {
101        Self { config }
102    }
103
104    /// Load configuration with environment variable overrides
105    pub fn from_config_with_env(mut config: AuthConfig) -> Self {
106        // Override with environment variables
107        if let Ok(val) = std::env::var("WHAT_AUTH_ENABLED") {
108            config.enabled = val.parse().unwrap_or(config.enabled);
109        }
110        if let Ok(val) = std::env::var("WHAT_AUTH_LOGIN_ENDPOINT") {
111            config.login_endpoint = Some(val);
112        }
113        if let Ok(val) = std::env::var("WHAT_AUTH_LOGOUT_ENDPOINT") {
114            config.logout_endpoint = Some(val);
115        }
116        if let Ok(val) = std::env::var("WHAT_AUTH_JWT_SECRET") {
117            config.jwt_secret = Some(val);
118        }
119        if let Ok(val) = std::env::var("WHAT_AUTH_JWT_COOKIE_NAME") {
120            config.jwt_cookie_name = val;
121        }
122        if let Ok(val) = std::env::var("WHAT_AUTH_LOGIN_PATH") {
123            config.login_path = val;
124        }
125        if let Ok(val) = std::env::var("WHAT_AUTH_AFTER_LOGIN") {
126            config.after_login = val;
127        }
128
129        Self { config }
130    }
131
132    /// Check if authentication is enabled
133    pub fn is_enabled(&self) -> bool {
134        self.config.enabled
135    }
136
137    /// Check if a path is protected
138    pub fn is_protected(&self, path: &str) -> bool {
139        if !self.config.enabled {
140            return false;
141        }
142
143        // Match against the NORMALIZED path so the guard sees the same route the
144        // file resolver serves. Without this, `//admin/users` or `/./admin/users`
145        // slips past `starts_with("/admin/")` while the resolver still serves
146        // admin/users.html (CWE-22/-436 auth bypass).
147        let normalized = normalize_request_path(path);
148        for pattern in &self.config.protected_paths {
149            if pattern_matches(pattern, &normalized) {
150                return true;
151            }
152        }
153        false
154    }
155
156    /// Get the login path
157    pub fn login_path(&self) -> &str {
158        &self.config.login_path
159    }
160
161    /// Get the after-login redirect path
162    pub fn after_login_path(&self) -> &str {
163        &self.config.after_login
164    }
165
166    /// Get the login endpoint URL
167    pub fn login_endpoint(&self) -> Option<&str> {
168        self.config.login_endpoint.as_deref()
169    }
170
171    /// Get the logout endpoint URL
172    pub fn logout_endpoint(&self) -> Option<&str> {
173        self.config.logout_endpoint.as_deref()
174    }
175
176    /// Get the JWT cookie name
177    pub fn jwt_cookie_name(&self) -> &str {
178        &self.config.jwt_cookie_name
179    }
180
181    /// Get the list of claims to extract
182    pub fn jwt_claims(&self) -> &[String] {
183        &self.config.jwt_claims
184    }
185
186    /// Parse JWT from cookie header
187    pub fn parse_jwt_cookie(&self, cookie_header: Option<&str>) -> Option<String> {
188        cookie_header.and_then(|header| {
189            header
190                .split(';')
191                .map(|s| s.trim())
192                .find(|s| s.starts_with(&format!("{}=", self.config.jwt_cookie_name)))
193                .map(|s| s[self.config.jwt_cookie_name.len() + 1..].to_string())
194        })
195    }
196
197    /// Decode and validate a JWT token.
198    ///
199    /// Always validates the signature. If no jwt_secret is configured, a random
200    /// secret is generated at startup (logged as WARN). This means externally-signed
201    /// tokens will be rejected unless the correct secret is provided.
202    pub fn decode_jwt(&self, token: &str) -> Result<JwtClaims> {
203        let secret = match self.config.jwt_secret {
204            Some(ref s) => s.as_str(),
205            None => get_or_generate_jwt_secret(),
206        };
207        let key = DecodingKey::from_secret(secret.as_bytes());
208        let validation = Validation::new(Algorithm::HS256);
209        let token_data = decode::<JwtClaims>(token, &key, &validation)?;
210        Ok(token_data.claims)
211    }
212
213    /// Build Set-Cookie header for JWT token
214    pub fn build_jwt_cookie(&self, token: &str, max_age: i64, secure: bool) -> String {
215        let mut cookie = format!(
216            "{}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}",
217            self.config.jwt_cookie_name, token, max_age
218        );
219
220        if secure {
221            cookie.push_str("; Secure");
222        }
223
224        cookie
225    }
226
227    /// Build Set-Cookie header to clear the JWT cookie
228    pub fn build_clear_cookie(&self) -> String {
229        format!(
230            "{}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
231            self.config.jwt_cookie_name
232        )
233    }
234}
235
236/// Normalize a request path to the canonical form the file resolver serves:
237/// collapse empty segments (`//`, leading slashes), drop `.`, and pop on `..`.
238/// This keeps the auth guard and the page resolver looking at the same route.
239pub(crate) fn normalize_request_path(path: &str) -> String {
240    let mut out: Vec<&str> = Vec::new();
241    for seg in path.split('/') {
242        match seg {
243            "" | "." => {}
244            ".." => {
245                out.pop();
246            }
247            s => out.push(s),
248        }
249    }
250    let base = format!("/{}", out.join("/"));
251    // Preserve a single trailing slash — it distinguishes a directory index
252    // request (/dashboard/ → dashboard/index.html) which `/dashboard/*` matches.
253    if base != "/" && path.ends_with('/') {
254        format!("{base}/")
255    } else {
256        base
257    }
258}
259
260/// Simple glob-style pattern matching
261/// Supports:
262/// - Exact match: "/admin" matches "/admin"
263/// - Wildcard suffix: "/admin/*" matches "/admin/users", "/admin/settings"
264/// - Double wildcard: "/api/**" matches "/api/v1/users", "/api/v1/users/123"
265fn pattern_matches(pattern: &str, path: &str) -> bool {
266    if pattern.ends_with("/**") {
267        let prefix = &pattern[..pattern.len() - 3];
268        path.starts_with(prefix)
269    } else if pattern.ends_with("/*") {
270        // Include the trailing slash in prefix for proper matching
271        // "/admin/*" → prefix "/admin/" should match "/admin/users" but not "/admin/users/edit"
272        let prefix = &pattern[..pattern.len() - 1];
273        path.starts_with(prefix) && !path[prefix.len()..].contains('/')
274    } else {
275        pattern == path
276    }
277}
278
279/// User context for templates
280/// Contains authenticated user information extracted from JWT
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct UserContext {
283    /// Whether the user is authenticated
284    pub authenticated: bool,
285    /// User claims from JWT
286    #[serde(flatten)]
287    pub claims: HashMap<String, Value>,
288}
289
290impl UserContext {
291    /// Create an unauthenticated context
292    pub fn unauthenticated() -> Self {
293        Self {
294            authenticated: false,
295            claims: HashMap::new(),
296        }
297    }
298
299    /// Create an authenticated context from JWT claims
300    pub fn from_claims(claims: HashMap<String, Value>) -> Self {
301        Self {
302            authenticated: true,
303            claims,
304        }
305    }
306
307    /// Convert to JSON Value for template context
308    pub fn to_context(&self) -> Value {
309        let mut map = serde_json::Map::new();
310        map.insert("authenticated".to_string(), json!(self.authenticated));
311
312        // Add all claims
313        for (key, value) in &self.claims {
314            map.insert(key.clone(), value.clone());
315        }
316
317        Value::Object(map)
318    }
319
320    /// Extract the user's roles from the JWT claims. Accepts a `roles` claim
321    /// (JSON array or comma-separated string) and falls back to a `role` claim.
322    pub fn roles(&self) -> Vec<String> {
323        self.claims
324            .get("roles")
325            .or_else(|| self.claims.get("role"))
326            .map(|v| match v {
327                Value::Array(arr) => arr
328                    .iter()
329                    .filter_map(|v| v.as_str().map(String::from))
330                    .collect(),
331                Value::String(s) => s.split(',').map(|r| r.trim().to_string()).collect(),
332                _ => Vec::new(),
333            })
334            .unwrap_or_default()
335    }
336
337    /// The `sub` (subject) claim, if present — the stable user identifier.
338    pub fn sub(&self) -> Option<String> {
339        self.claims.get("sub").and_then(|v| v.as_str().map(String::from))
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_pattern_matches() {
349        // Exact match
350        assert!(pattern_matches("/admin", "/admin"));
351        assert!(!pattern_matches("/admin", "/admin/users"));
352
353        // Single wildcard
354        assert!(pattern_matches("/admin/*", "/admin/users"));
355        assert!(pattern_matches("/admin/*", "/admin/settings"));
356        assert!(!pattern_matches("/admin/*", "/admin/users/123"));
357        assert!(!pattern_matches("/admin/*", "/admin"));
358
359        // Double wildcard
360        assert!(pattern_matches("/api/**", "/api/v1"));
361        assert!(pattern_matches("/api/**", "/api/v1/users"));
362        assert!(pattern_matches("/api/**", "/api/v1/users/123"));
363    }
364
365    #[test]
366    fn test_normalize_request_path() {
367        assert_eq!(normalize_request_path("/admin/users"), "/admin/users");
368        // Collapsed slashes and dot-segments must canonicalize to the served route
369        assert_eq!(normalize_request_path("//admin/users"), "/admin/users");
370        assert_eq!(normalize_request_path("/./admin/users"), "/admin/users");
371        assert_eq!(normalize_request_path("/admin//users"), "/admin/users");
372        assert_eq!(normalize_request_path("/public/../admin/x"), "/admin/x");
373        assert_eq!(normalize_request_path("/admin/../public"), "/public");
374        assert_eq!(normalize_request_path("/"), "/");
375        // A single trailing slash (directory index) is preserved
376        assert_eq!(normalize_request_path("/dashboard/"), "/dashboard/");
377        assert_eq!(normalize_request_path("//dashboard//"), "/dashboard/");
378    }
379
380    #[test]
381    fn test_is_protected_resists_path_normalization_bypass() {
382        let config = AuthConfig {
383            enabled: true,
384            protected_paths: vec!["/admin/*".to_string()],
385            ..Default::default()
386        };
387        let handler = AuthHandler::new(config);
388        assert!(handler.is_protected("/admin/users"));
389        // These previously bypassed the guard while still resolving admin/users.html
390        assert!(handler.is_protected("//admin/users"));
391        assert!(handler.is_protected("/./admin/users"));
392        assert!(handler.is_protected("/foo/../admin/users"));
393    }
394
395    #[test]
396    fn test_jwt_claims_to_context() {
397        let claims = JwtClaims {
398            exp: Some(1234567890),
399            iat: Some(1234567800),
400            sub: Some("user123".to_string()),
401            custom: [
402                ("email".to_string(), json!("user@example.com")),
403                ("full_name".to_string(), json!("John Doe")),
404                ("role".to_string(), json!("admin")),
405            ]
406            .into_iter()
407            .collect(),
408        };
409
410        let context = claims.to_context(&["email".to_string(), "full_name".to_string()]);
411
412        assert_eq!(context.get("email"), Some(&json!("user@example.com")));
413        assert_eq!(context.get("full_name"), Some(&json!("John Doe")));
414        assert_eq!(context.get("sub"), Some(&json!("user123")));
415        assert!(!context.contains_key("role")); // Not requested
416    }
417
418    #[test]
419    fn test_user_context() {
420        let unauthenticated = UserContext::unauthenticated();
421        assert!(!unauthenticated.authenticated);
422
423        let authenticated = UserContext::from_claims(
424            [("email".to_string(), json!("user@example.com"))]
425                .into_iter()
426                .collect(),
427        );
428        assert!(authenticated.authenticated);
429
430        let context = authenticated.to_context();
431        assert_eq!(context.get("authenticated"), Some(&json!(true)));
432        assert_eq!(context.get("email"), Some(&json!("user@example.com")));
433    }
434
435    #[test]
436    fn test_auth_handler_parse_jwt_cookie() {
437        let config = AuthConfig {
438            enabled: true,
439            jwt_cookie_name: "w_token".to_string(),
440            ..Default::default()
441        };
442        let handler = AuthHandler::new(config);
443
444        // Test valid cookie header
445        let cookie_header = Some("w_token=abc123; other_cookie=xyz");
446        let result = handler.parse_jwt_cookie(cookie_header);
447        assert_eq!(result, Some("abc123".to_string()));
448
449        // Test cookie at end of header
450        let cookie_header = Some("other=value; w_token=def456");
451        let result = handler.parse_jwt_cookie(cookie_header);
452        assert_eq!(result, Some("def456".to_string()));
453
454        // Test missing cookie
455        let cookie_header = Some("other_cookie=xyz");
456        let result = handler.parse_jwt_cookie(cookie_header);
457        assert!(result.is_none());
458
459        // Test None header
460        let result = handler.parse_jwt_cookie(None);
461        assert!(result.is_none());
462    }
463
464    #[test]
465    fn test_auth_handler_is_protected() {
466        let config = AuthConfig {
467            enabled: true,
468            protected_paths: vec![
469                "/admin".to_string(),
470                "/admin/*".to_string(),
471                "/api/**".to_string(),
472            ],
473            ..Default::default()
474        };
475        let handler = AuthHandler::new(config);
476
477        // Exact match
478        assert!(handler.is_protected("/admin"));
479
480        // Single wildcard match
481        assert!(handler.is_protected("/admin/users"));
482        assert!(handler.is_protected("/admin/settings"));
483
484        // Single wildcard should NOT match deeper paths
485        assert!(!handler.is_protected("/admin/users/123"));
486
487        // Double wildcard matches all depths
488        assert!(handler.is_protected("/api/v1"));
489        assert!(handler.is_protected("/api/v1/users"));
490        assert!(handler.is_protected("/api/v1/users/123"));
491
492        // Non-matching paths
493        assert!(!handler.is_protected("/"));
494        assert!(!handler.is_protected("/public"));
495        assert!(!handler.is_protected("/login"));
496    }
497
498    #[test]
499    fn test_auth_handler_disabled() {
500        let config = AuthConfig {
501            enabled: false,
502            protected_paths: vec!["/admin/**".to_string()],
503            ..Default::default()
504        };
505        let handler = AuthHandler::new(config);
506
507        // When auth is disabled, nothing should be protected
508        assert!(!handler.is_protected("/admin"));
509        assert!(!handler.is_protected("/admin/users"));
510        assert!(!handler.is_enabled());
511    }
512
513    #[test]
514    fn test_build_jwt_cookie() {
515        let config = AuthConfig {
516            enabled: true,
517            jwt_cookie_name: "w_token".to_string(),
518            ..Default::default()
519        };
520        let handler = AuthHandler::new(config);
521
522        // Build cookie without Secure flag
523        let cookie = handler.build_jwt_cookie("test_token_123", 3600, false);
524        assert!(cookie.contains("w_token=test_token_123"));
525        assert!(cookie.contains("HttpOnly"));
526        assert!(cookie.contains("SameSite=Strict"));
527        assert!(cookie.contains("Path=/"));
528        assert!(cookie.contains("Max-Age=3600"));
529        assert!(!cookie.contains("Secure"));
530
531        // Build cookie with Secure flag
532        let cookie = handler.build_jwt_cookie("test_token_123", 3600, true);
533        assert!(cookie.contains("Secure"));
534    }
535
536    #[test]
537    fn test_build_clear_cookie() {
538        let config = AuthConfig {
539            enabled: true,
540            jwt_cookie_name: "w_token".to_string(),
541            ..Default::default()
542        };
543        let handler = AuthHandler::new(config);
544
545        let cookie = handler.build_clear_cookie();
546        assert!(cookie.contains("w_token="));
547        assert!(cookie.contains("Max-Age=0"));
548        assert!(cookie.contains("HttpOnly"));
549        assert!(cookie.contains("SameSite=Strict"));
550        assert!(cookie.contains("Path=/"));
551    }
552
553    #[test]
554    fn test_jwt_claims_is_expired() {
555        // Not expired (future timestamp)
556        let future_exp = std::time::SystemTime::now()
557            .duration_since(std::time::UNIX_EPOCH)
558            .unwrap()
559            .as_secs()
560            + 3600; // 1 hour in the future
561
562        let claims = JwtClaims {
563            exp: Some(future_exp),
564            iat: None,
565            sub: None,
566            custom: HashMap::new(),
567        };
568        assert!(!claims.is_expired());
569
570        // Expired (past timestamp)
571        let past_exp = std::time::SystemTime::now()
572            .duration_since(std::time::UNIX_EPOCH)
573            .unwrap()
574            .as_secs()
575            - 3600; // 1 hour in the past
576
577        let expired_claims = JwtClaims {
578            exp: Some(past_exp),
579            iat: None,
580            sub: None,
581            custom: HashMap::new(),
582        };
583        assert!(expired_claims.is_expired());
584
585        // No expiration = not expired
586        let no_exp_claims = JwtClaims {
587            exp: None,
588            iat: None,
589            sub: None,
590            custom: HashMap::new(),
591        };
592        assert!(!no_exp_claims.is_expired());
593    }
594
595    #[test]
596    fn test_decode_jwt_with_configured_secret() {
597        use jsonwebtoken::{EncodingKey, Header, encode};
598
599        let secret = "test_secret_123";
600        let config = AuthConfig {
601            enabled: true,
602            jwt_secret: Some(secret.to_string()),
603            ..Default::default()
604        };
605        let handler = AuthHandler::new(config);
606
607        // Create a valid token with the same secret
608        let exp = std::time::SystemTime::now()
609            .duration_since(std::time::UNIX_EPOCH)
610            .unwrap()
611            .as_secs()
612            + 3600;
613
614        let claims = JwtClaims {
615            exp: Some(exp),
616            iat: None,
617            sub: Some("user1".to_string()),
618            custom: [("email".to_string(), json!("a@b.com"))]
619                .into_iter()
620                .collect(),
621        };
622
623        let token = encode(
624            &Header::default(),
625            &claims,
626            &EncodingKey::from_secret(secret.as_bytes()),
627        )
628        .unwrap();
629        let decoded = handler.decode_jwt(&token).unwrap();
630        assert_eq!(decoded.sub, Some("user1".to_string()));
631        assert_eq!(decoded.custom.get("email"), Some(&json!("a@b.com")));
632    }
633
634    #[test]
635    fn test_decode_jwt_rejects_wrong_secret() {
636        use jsonwebtoken::{EncodingKey, Header, encode};
637
638        let config = AuthConfig {
639            enabled: true,
640            jwt_secret: Some("correct_secret".to_string()),
641            ..Default::default()
642        };
643        let handler = AuthHandler::new(config);
644
645        let exp = std::time::SystemTime::now()
646            .duration_since(std::time::UNIX_EPOCH)
647            .unwrap()
648            .as_secs()
649            + 3600;
650
651        let claims = JwtClaims {
652            exp: Some(exp),
653            iat: None,
654            sub: None,
655            custom: HashMap::new(),
656        };
657
658        // Sign with a different secret
659        let token = encode(
660            &Header::default(),
661            &claims,
662            &EncodingKey::from_secret(b"wrong_secret"),
663        )
664        .unwrap();
665        let result = handler.decode_jwt(&token);
666        assert!(
667            result.is_err(),
668            "Should reject JWT signed with wrong secret"
669        );
670    }
671
672    #[test]
673    fn test_decode_jwt_no_secret_uses_auto_generated() {
674        // When no secret is configured, decode_jwt should use the auto-generated secret.
675        // A token signed with an arbitrary secret should be rejected.
676        use jsonwebtoken::{EncodingKey, Header, encode};
677
678        let config = AuthConfig {
679            enabled: true,
680            jwt_secret: None, // No secret configured
681            ..Default::default()
682        };
683        let handler = AuthHandler::new(config);
684
685        let exp = std::time::SystemTime::now()
686            .duration_since(std::time::UNIX_EPOCH)
687            .unwrap()
688            .as_secs()
689            + 3600;
690
691        let claims = JwtClaims {
692            exp: Some(exp),
693            iat: None,
694            sub: None,
695            custom: HashMap::new(),
696        };
697
698        // Sign with an arbitrary secret — this should NOT match the auto-generated one
699        let token = encode(
700            &Header::default(),
701            &claims,
702            &EncodingKey::from_secret(b"attacker_secret"),
703        )
704        .unwrap();
705        let result = handler.decode_jwt(&token);
706        assert!(
707            result.is_err(),
708            "Should reject JWT when no secret is configured (auto-generated secret won't match)"
709        );
710    }
711
712    #[test]
713    fn test_auth_handler_getters() {
714        let config = AuthConfig {
715            enabled: true,
716            login_path: "/login".to_string(),
717            after_login: "/dashboard".to_string(),
718            login_endpoint: Some("https://api.example.com/login".to_string()),
719            logout_endpoint: Some("https://api.example.com/logout".to_string()),
720            jwt_cookie_name: "auth_token".to_string(),
721            jwt_claims: vec!["email".to_string(), "name".to_string()],
722            ..Default::default()
723        };
724        let handler = AuthHandler::new(config);
725
726        assert!(handler.is_enabled());
727        assert_eq!(handler.login_path(), "/login");
728        assert_eq!(handler.after_login_path(), "/dashboard");
729        assert_eq!(
730            handler.login_endpoint(),
731            Some("https://api.example.com/login")
732        );
733        assert_eq!(
734            handler.logout_endpoint(),
735            Some("https://api.example.com/logout")
736        );
737        assert_eq!(handler.jwt_cookie_name(), "auth_token");
738        assert_eq!(
739            handler.jwt_claims(),
740            &["email".to_string(), "name".to_string()]
741        );
742    }
743}