this repo has no description

start of xrpc

+530 -10
+1
Cargo.lock
··· 2868 2868 "base64", 2869 2869 "bb8", 2870 2870 "bb8-redis", 2871 + "chrono", 2871 2872 "handlebars", 2872 2873 "hickory-resolver", 2873 2874 "log",
+1
Cargo.toml
··· 29 29 tokio = { version = "1.46.1", features = ["full"] } 30 30 markdown = "1.0.0" 31 31 rust-embed = { version = "8.7.2", features = ["include-exclude"] } 32 + base64 = "0.22"
+2
shared/Cargo.toml
··· 27 27 async-trait = "0.1.88" 28 28 atrium-xrpc-client.workspace = true 29 29 tokio.workspace = true 30 + base64.workspace = true 31 + chrono.workspace = true
+26 -1
shared/challenges_markdown/six/part_one.md
··· 1 - Day six is about XRPC. First up is we have them create an LXM jwt. We validate it's for the correct lxm and did. 1 + ## Service Authentication (serviceAuth) 2 + 3 + In the AT Protocol, services authenticate requests between each other using **serviceAuth** — a signed JWT token. When your PDS proxies a request to another service on your behalf, it creates a JWT signed with your account's signing key. 4 + 5 + For this challenge, you'll create a serviceAuth JWT yourself. Here's what you need: 6 + 7 + ### JWT Structure 8 + 9 + Your JWT must have these **header** fields: 10 + - `alg`: `ES256` (if your signing key is P-256) or `ES256K` (if secp256k1) 11 + - `typ`: `JWT` 12 + 13 + And these **payload** claims: 14 + - `iss`: Your DID (e.g. `did:plc:abc123...`) 15 + - `aud`: `{{service_did}}` 16 + - `lxm`: `{{lxm_part_one}}` 17 + - `exp`: A UNIX timestamp in the future (current time + 60 seconds works) 18 + - `iat`: Current UNIX timestamp 19 + 20 + Sign it with your account's **repo signing key** — the same key listed in your DID document's verification method. 21 + 22 + The resulting JWT is three base64url-encoded segments joined by dots: `header.payload.signature` 23 + 24 + ### Submit Your JWT 25 + 26 + Paste the raw JWT string below and we'll verify the signature against your DID document.
+18 -1
shared/challenges_markdown/six/part_two.md
··· 1 - Part 2 is them making an xrpc request and if set properly we give them the day. 1 + ## XRPC Service Proxying 2 + 3 + Now that you can create a serviceAuth JWT, let's use it in an actual XRPC request. 4 + 5 + Make an HTTP GET request to our XRPC endpoint with your JWT in the `Authorization` header: 6 + 7 + ``` 8 + GET https://{{service_did_domain}}/xrpc/codes.advent.challenge.getDay6Code 9 + Authorization: Bearer <your-jwt> 10 + ``` 11 + 12 + Your JWT for this request must have: 13 + - `iss`: Your DID 14 + - `aud`: `{{service_did}}` 15 + - `lxm`: `{{lxm_part_two}}` 16 + - `exp`: A UNIX timestamp in the future 17 + 18 + The response will contain a JSON object with your verification code. Paste that code below.
+152 -6
shared/src/advent/challenges/day_six.rs
··· 1 + use crate::HandleResolver; 1 2 use crate::OAuthAgentType; 2 3 use crate::advent::day::Day; 3 - use crate::advent::{AdventChallenge, AdventError, ChallengeCheckResponse}; 4 + use crate::advent::{AdventChallenge, AdventError, AdventPart, ChallengeCheckResponse}; 5 + use crate::atrium::service_auth::{decode_and_verify_service_auth, decode_jwt_claims, extract_signing_key_bytes}; 4 6 use async_trait::async_trait; 7 + use atrium_api::types::string::Did; 8 + use atrium_common::resolver::Resolver; 9 + use serde_json::json; 5 10 use sqlx::PgPool; 11 + 12 + pub const LXM_PART_ONE: &str = "codes.advent.challenge.verifyDay6"; 13 + pub const LXM_PART_TWO: &str = "codes.advent.challenge.getDay6Code"; 6 14 7 15 pub struct DaySix { 8 16 pub pool: PgPool, 9 17 pub oauth_client: Option<OAuthAgentType>, 18 + pub handle_resolver: HandleResolver, 19 + pub service_did: String, 10 20 } 11 21 12 22 #[async_trait] ··· 16 26 } 17 27 18 28 fn day(&self) -> Day { 19 - Day::Five 29 + Day::Six 20 30 } 21 31 22 32 fn has_part_two(&self) -> bool { 23 - false 33 + true 24 34 } 25 35 26 36 fn requires_manual_verification_part_one(&self) -> bool { 27 37 true 28 38 } 29 39 40 + fn requires_manual_verification_part_two(&self) -> bool { 41 + true 42 + } 43 + 44 + async fn build_additional_context( 45 + &self, 46 + _did: &str, 47 + _part: &AdventPart, 48 + _code: &str, 49 + ) -> Result<Option<serde_json::Value>, AdventError> { 50 + // Strip "did:web:" prefix for the domain used in URLs 51 + let domain = self.service_did.strip_prefix("did:web:").unwrap_or(&self.service_did); 52 + Ok(Some(json!({ 53 + "service_did": self.service_did, 54 + "service_did_domain": domain, 55 + "lxm_part_one": LXM_PART_ONE, 56 + "lxm_part_two": LXM_PART_TWO, 57 + }))) 58 + } 59 + 30 60 async fn check_part_one( 31 61 &self, 32 - _did: String, 33 - _verification_code: Option<String>, 62 + did: String, 63 + verification_code: Option<String>, 34 64 ) -> Result<ChallengeCheckResponse, AdventError> { 35 - todo!() 65 + let jwt = match verification_code { 66 + Some(code) if !code.trim().is_empty() => code.trim().to_string(), 67 + _ => { 68 + return Ok(ChallengeCheckResponse::Incorrect( 69 + "Please paste your serviceAuth JWT".to_string(), 70 + )); 71 + } 72 + }; 73 + 74 + // Decode claims first (without signature verification) to get the issuer 75 + let claims = match decode_jwt_claims(&jwt) { 76 + Ok(claims) => claims, 77 + Err(e) => { 78 + return Ok(ChallengeCheckResponse::Incorrect(format!( 79 + "Could not decode JWT: {e}" 80 + ))); 81 + } 82 + }; 83 + 84 + // Verify the issuer matches the logged-in user 85 + if claims.iss != did { 86 + return Ok(ChallengeCheckResponse::Incorrect(format!( 87 + "The JWT issuer (iss) '{}' does not match your DID '{}'", 88 + claims.iss, did 89 + ))); 90 + } 91 + 92 + // Check aud 93 + if claims.aud != self.service_did { 94 + return Ok(ChallengeCheckResponse::Incorrect(format!( 95 + "The JWT audience (aud) '{}' does not match the expected value '{}'", 96 + claims.aud, self.service_did 97 + ))); 98 + } 99 + 100 + // Check lxm 101 + match &claims.lxm { 102 + Some(lxm) if lxm == LXM_PART_ONE => {} 103 + Some(lxm) => { 104 + return Ok(ChallengeCheckResponse::Incorrect(format!( 105 + "The JWT lexicon method (lxm) '{}' does not match the expected value '{}'", 106 + lxm, LXM_PART_ONE 107 + ))); 108 + } 109 + None => { 110 + return Ok(ChallengeCheckResponse::Incorrect( 111 + "The JWT is missing the lexicon method (lxm) claim".to_string(), 112 + )); 113 + } 114 + } 115 + 116 + // Resolve DID document and extract signing key 117 + let iss_did: Did = claims.iss.parse().map_err(|_| { 118 + AdventError::ShouldNotHappen(format!("Invalid DID in JWT iss: {}", claims.iss)) 119 + })?; 120 + let did_doc = self.handle_resolver.resolve(&iss_did).await.map_err(|err| { 121 + log::error!("Failed to resolve DID document for {}: {}", claims.iss, err); 122 + AdventError::ShouldNotHappen(format!("Failed to resolve DID document: {err}")) 123 + })?; 124 + 125 + let (key_alg, key_bytes) = match extract_signing_key_bytes(&did_doc) { 126 + Ok(result) => result, 127 + Err(e) => { 128 + return Ok(ChallengeCheckResponse::Incorrect(format!( 129 + "Could not extract signing key from your DID document: {e}" 130 + ))); 131 + } 132 + }; 133 + 134 + // Fully verify the JWT signature 135 + match decode_and_verify_service_auth(&jwt, &key_bytes, key_alg) { 136 + Ok(_) => Ok(ChallengeCheckResponse::Correct), 137 + Err(e) => Ok(ChallengeCheckResponse::Incorrect(format!( 138 + "JWT verification failed: {e}" 139 + ))), 140 + } 141 + } 142 + 143 + async fn check_part_two( 144 + &self, 145 + did: String, 146 + verification_code: Option<String>, 147 + ) -> Result<ChallengeCheckResponse, AdventError> { 148 + let submitted_code = match verification_code { 149 + Some(code) if !code.trim().is_empty() => code.trim().to_string(), 150 + _ => { 151 + return Ok(ChallengeCheckResponse::Incorrect( 152 + "Please enter the verification code from the XRPC response".to_string(), 153 + )); 154 + } 155 + }; 156 + 157 + let challenge = self.get_days_challenge(&did).await?; 158 + match challenge { 159 + None => { 160 + log::error!("No challenge record found for day 6 for user: {did}"); 161 + Err(AdventError::ShouldNotHappen( 162 + "Could not find challenge record".to_string(), 163 + )) 164 + } 165 + Some(challenge) => { 166 + let expected = challenge 167 + .verification_code_two 168 + .ok_or(AdventError::ShouldNotHappen( 169 + "No verification code for part two".to_string(), 170 + ))?; 171 + 172 + if submitted_code == expected { 173 + Ok(ChallengeCheckResponse::Correct) 174 + } else { 175 + Ok(ChallengeCheckResponse::Incorrect(format!( 176 + "The code '{}' is incorrect. Make sure you're using the code from the XRPC response.", 177 + submitted_code 178 + ))) 179 + } 180 + } 181 + } 36 182 } 37 183 }
+1
shared/src/atrium/mod.rs
··· 2 2 use serde::de; 3 3 4 4 pub mod dns_resolver; 5 + pub mod service_auth; 5 6 pub mod stores; 6 7 7 8 /// Safely parses an unknown record into a type. If it fails, it logs the error and returns an error.
+162
shared/src/atrium/service_auth.rs
··· 1 + use atrium_api::did_doc::DidDocument; 2 + use atrium_crypto::did::parse_multikey; 3 + use atrium_crypto::verify::Verifier; 4 + use atrium_crypto::Algorithm; 5 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 6 + use base64::Engine; 7 + use serde::Deserialize; 8 + use std::fmt; 9 + 10 + #[derive(Debug)] 11 + pub enum ServiceAuthError { 12 + InvalidFormat, 13 + InvalidBase64(String), 14 + InvalidJson(String), 15 + UnsupportedAlgorithm(String), 16 + Expired, 17 + InvalidSignature(String), 18 + MissingSigningKey, 19 + ClaimMismatch(String), 20 + } 21 + 22 + impl fmt::Display for ServiceAuthError { 23 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 24 + match self { 25 + Self::InvalidFormat => write!(f, "JWT must have three dot-separated parts"), 26 + Self::InvalidBase64(msg) => write!(f, "Invalid base64url encoding: {msg}"), 27 + Self::InvalidJson(msg) => write!(f, "Invalid JSON in JWT: {msg}"), 28 + Self::UnsupportedAlgorithm(alg) => { 29 + write!(f, "Unsupported algorithm '{alg}', expected ES256 or ES256K") 30 + } 31 + Self::Expired => write!(f, "Token has expired"), 32 + Self::InvalidSignature(msg) => write!(f, "Invalid signature: {msg}"), 33 + Self::MissingSigningKey => write!(f, "No signing key found in DID document"), 34 + Self::ClaimMismatch(msg) => write!(f, "{msg}"), 35 + } 36 + } 37 + } 38 + 39 + #[derive(Debug, Deserialize)] 40 + struct JwtHeader { 41 + alg: String, 42 + #[allow(dead_code)] 43 + typ: Option<String>, 44 + } 45 + 46 + #[derive(Debug, Deserialize, Clone)] 47 + pub struct ServiceAuthClaims { 48 + pub iss: String, 49 + pub aud: String, 50 + #[serde(default)] 51 + pub exp: u64, 52 + #[serde(default)] 53 + pub iat: Option<u64>, 54 + #[serde(default)] 55 + pub lxm: Option<String>, 56 + #[serde(default)] 57 + pub jti: Option<String>, 58 + } 59 + 60 + /// Decode JWT claims without verifying the signature. 61 + /// Useful for extracting the `iss` field before resolving the DID document. 62 + pub fn decode_jwt_claims(jwt: &str) -> Result<ServiceAuthClaims, ServiceAuthError> { 63 + let parts: Vec<&str> = jwt.trim().split('.').collect(); 64 + if parts.len() != 3 { 65 + return Err(ServiceAuthError::InvalidFormat); 66 + } 67 + 68 + let payload_bytes = URL_SAFE_NO_PAD 69 + .decode(parts[1]) 70 + .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?; 71 + 72 + serde_json::from_slice(&payload_bytes) 73 + .map_err(|e| ServiceAuthError::InvalidJson(e.to_string())) 74 + } 75 + 76 + /// Fully decode and verify a serviceAuth JWT. 77 + /// 78 + /// `public_key_bytes` should be the decompressed SEC1 public key bytes 79 + /// from the issuer's DID document (as returned by `extract_signing_key_bytes`). 80 + /// `key_algorithm` is the algorithm associated with the key from the DID document. 81 + pub fn decode_and_verify_service_auth( 82 + jwt: &str, 83 + public_key_bytes: &[u8], 84 + key_algorithm: Algorithm, 85 + ) -> Result<ServiceAuthClaims, ServiceAuthError> { 86 + let jwt = jwt.trim(); 87 + let parts: Vec<&str> = jwt.split('.').collect(); 88 + if parts.len() != 3 { 89 + return Err(ServiceAuthError::InvalidFormat); 90 + } 91 + 92 + let header_bytes = URL_SAFE_NO_PAD 93 + .decode(parts[0]) 94 + .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?; 95 + let payload_bytes = URL_SAFE_NO_PAD 96 + .decode(parts[1]) 97 + .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?; 98 + let signature_bytes = URL_SAFE_NO_PAD 99 + .decode(parts[2]) 100 + .map_err(|e| ServiceAuthError::InvalidBase64(e.to_string()))?; 101 + 102 + let header: JwtHeader = serde_json::from_slice(&header_bytes) 103 + .map_err(|e| ServiceAuthError::InvalidJson(e.to_string()))?; 104 + let claims: ServiceAuthClaims = serde_json::from_slice(&payload_bytes) 105 + .map_err(|e| ServiceAuthError::InvalidJson(e.to_string()))?; 106 + 107 + // Validate algorithm 108 + let jwt_algorithm = match header.alg.as_str() { 109 + "ES256" => Algorithm::P256, 110 + "ES256K" => Algorithm::Secp256k1, 111 + other => return Err(ServiceAuthError::UnsupportedAlgorithm(other.to_string())), 112 + }; 113 + 114 + // The JWT algorithm must match the key's algorithm from the DID document 115 + if jwt_algorithm != key_algorithm { 116 + return Err(ServiceAuthError::UnsupportedAlgorithm(format!( 117 + "JWT uses {} but DID document key is {}", 118 + header.alg, 119 + match key_algorithm { 120 + Algorithm::P256 => "ES256 (P-256)", 121 + Algorithm::Secp256k1 => "ES256K (secp256k1)", 122 + } 123 + ))); 124 + } 125 + 126 + // Check expiry 127 + let now = chrono::Utc::now().timestamp() as u64; 128 + if claims.exp > 0 && claims.exp < now { 129 + return Err(ServiceAuthError::Expired); 130 + } 131 + 132 + // Verify signature: signing input is the raw "{header}.{payload}" string 133 + let signing_input = format!("{}.{}", parts[0], parts[1]); 134 + let verifier = Verifier::new(true); 135 + verifier 136 + .verify( 137 + jwt_algorithm, 138 + public_key_bytes, 139 + signing_input.as_bytes(), 140 + &signature_bytes, 141 + ) 142 + .map_err(|e| ServiceAuthError::InvalidSignature(e.to_string()))?; 143 + 144 + Ok(claims) 145 + } 146 + 147 + /// Extract the signing key bytes and algorithm from a DID document. 148 + /// Returns the decompressed SEC1 public key bytes and the associated algorithm. 149 + pub fn extract_signing_key_bytes( 150 + did_doc: &DidDocument, 151 + ) -> Result<(Algorithm, Vec<u8>), ServiceAuthError> { 152 + let method = did_doc 153 + .get_signing_key() 154 + .ok_or(ServiceAuthError::MissingSigningKey)?; 155 + let multibase = method 156 + .public_key_multibase 157 + .as_ref() 158 + .ok_or(ServiceAuthError::MissingSigningKey)?; 159 + let (alg, key_bytes) = 160 + parse_multikey(multibase).map_err(|e| ServiceAuthError::InvalidSignature(e.to_string()))?; 161 + Ok((alg, key_bytes)) 162 + }
+163 -2
web/src/handlers/day.rs
··· 3 3 use crate::{AppState, error_response}; 4 4 use atrium_api::agent::Agent; 5 5 use atrium_api::types::string::Did; 6 + use atrium_common::resolver::Resolver; 6 7 use axum::{ 8 + Json, 7 9 extract::{Form, Path, State}, 8 - http::StatusCode, 10 + http::{HeaderMap, StatusCode}, 9 11 response::{IntoResponse, Redirect, Response}, 10 12 }; 13 + use serde_json::json; 11 14 use shared::advent::challenges::day_five::DayFive; 12 15 use shared::advent::challenges::day_four::DayFour; 13 - use shared::advent::challenges::day_six::DaySix; 16 + use shared::advent::challenges::day_six::{self, DaySix}; 17 + use shared::atrium::service_auth::{ 18 + decode_and_verify_service_auth, decode_jwt_claims, extract_signing_key_bytes, 19 + }; 14 20 use shared::{ 15 21 OAuthAgentType, OAuthClientType, 16 22 advent::ChallengeCheckResponse, ··· 54 60 Day::Six => Ok(Box::new(DaySix { 55 61 pool: state.postgres_pool, 56 62 oauth_client, 63 + handle_resolver: state.handle_resolver.clone(), 64 + service_did: format!( 65 + "did:web:{}", 66 + std::env::var("OAUTH_HOST").unwrap_or_default() 67 + ), 57 68 })), 58 69 _ => Err(AdventError::InvalidDay(0)), // Day::Three => {} 59 70 // Day::Four => {} ··· 511 522 512 523 Ok(Redirect::to("/day/5")) 513 524 } 525 + 526 + /// XRPC endpoint for Day 6 Part 2: verifies a serviceAuth JWT from the Authorization header 527 + /// and returns the user's verification code. 528 + pub async fn day_six_xrpc_handler( 529 + State(state): State<AppState>, 530 + headers: HeaderMap, 531 + ) -> Result<Json<serde_json::Value>, Response> { 532 + let xrpc_error = |status: StatusCode, error: &str, message: &str| -> Response { 533 + (status, Json(json!({"error": error, "message": message}))).into_response() 534 + }; 535 + 536 + // Extract Bearer token from Authorization header 537 + let auth_header = headers 538 + .get("authorization") 539 + .and_then(|v| v.to_str().ok()) 540 + .ok_or_else(|| { 541 + xrpc_error( 542 + StatusCode::UNAUTHORIZED, 543 + "AuthMissing", 544 + "Missing Authorization header", 545 + ) 546 + })?; 547 + 548 + let jwt = auth_header.strip_prefix("Bearer ").ok_or_else(|| { 549 + xrpc_error( 550 + StatusCode::UNAUTHORIZED, 551 + "AuthMissing", 552 + "Authorization header must use Bearer scheme", 553 + ) 554 + })?; 555 + 556 + // Decode claims to get issuer 557 + let claims = decode_jwt_claims(jwt).map_err(|e| { 558 + xrpc_error( 559 + StatusCode::UNAUTHORIZED, 560 + "InvalidToken", 561 + &format!("Could not decode JWT: {e}"), 562 + ) 563 + })?; 564 + 565 + let expected_service_did = format!( 566 + "did:web:{}", 567 + std::env::var("OAUTH_HOST").unwrap_or_default() 568 + ); 569 + 570 + // Check aud 571 + if claims.aud != expected_service_did { 572 + return Err(xrpc_error( 573 + StatusCode::UNAUTHORIZED, 574 + "InvalidToken", 575 + &format!( 576 + "JWT audience '{}' does not match expected '{}'", 577 + claims.aud, expected_service_did 578 + ), 579 + )); 580 + } 581 + 582 + // Check lxm 583 + match &claims.lxm { 584 + Some(lxm) if lxm == day_six::LXM_PART_TWO => {} 585 + Some(lxm) => { 586 + return Err(xrpc_error( 587 + StatusCode::UNAUTHORIZED, 588 + "InvalidToken", 589 + &format!( 590 + "JWT lxm '{}' does not match expected '{}'", 591 + lxm, 592 + day_six::LXM_PART_TWO 593 + ), 594 + )); 595 + } 596 + None => { 597 + return Err(xrpc_error( 598 + StatusCode::UNAUTHORIZED, 599 + "InvalidToken", 600 + "JWT is missing the lxm claim", 601 + )); 602 + } 603 + } 604 + 605 + // Resolve DID document and verify signature 606 + let iss_did: Did = claims.iss.parse().map_err(|_| { 607 + xrpc_error( 608 + StatusCode::BAD_REQUEST, 609 + "InvalidToken", 610 + "Invalid DID in JWT iss claim", 611 + ) 612 + })?; 613 + let did_doc = state 614 + .handle_resolver 615 + .resolve(&iss_did) 616 + .await 617 + .map_err(|e| { 618 + log::error!("Failed to resolve DID for {}: {}", claims.iss, e); 619 + xrpc_error( 620 + StatusCode::INTERNAL_SERVER_ERROR, 621 + "InternalError", 622 + "Failed to resolve DID document", 623 + ) 624 + })?; 625 + 626 + let (key_alg, key_bytes) = extract_signing_key_bytes(&did_doc).map_err(|e| { 627 + xrpc_error( 628 + StatusCode::UNAUTHORIZED, 629 + "InvalidToken", 630 + &format!("Could not extract signing key: {e}"), 631 + ) 632 + })?; 633 + 634 + decode_and_verify_service_auth(jwt, &key_bytes, key_alg).map_err(|e| { 635 + xrpc_error( 636 + StatusCode::UNAUTHORIZED, 637 + "InvalidToken", 638 + &format!("JWT verification failed: {e}"), 639 + ) 640 + })?; 641 + 642 + // Look up the user's day 6 challenge record 643 + let challenge = sqlx::query_as::<_, shared::models::db_models::ChallengeProgress>( 644 + "SELECT * FROM challenges WHERE user_did = $1 AND day = $2", 645 + ) 646 + .bind(&claims.iss) 647 + .bind(6i16) 648 + .fetch_optional(&state.postgres_pool) 649 + .await 650 + .map_err(|e| { 651 + log::error!("DB error looking up day 6 challenge: {}", e); 652 + xrpc_error( 653 + StatusCode::INTERNAL_SERVER_ERROR, 654 + "InternalError", 655 + "Database error", 656 + ) 657 + })?; 658 + 659 + match challenge { 660 + Some(c) => match c.verification_code_two { 661 + Some(code) => Ok(Json(json!({"code": code}))), 662 + None => Err(xrpc_error( 663 + StatusCode::BAD_REQUEST, 664 + "NotReady", 665 + "Part 2 has not been started yet. Complete Part 1 and visit the Day 6 page first.", 666 + )), 667 + }, 668 + None => Err(xrpc_error( 669 + StatusCode::BAD_REQUEST, 670 + "NotReady", 671 + "You haven't started Day 6 yet. Visit the Day 6 page first.", 672 + )), 673 + } 674 + }
+4
web/src/main.rs
··· 292 292 "/day/5/{user_did}", 293 293 get(handlers::day::day_five_create_record_handler), 294 294 ) 295 + .route( 296 + "/xrpc/codes.advent.challenge.getDay6Code", 297 + get(handlers::day::day_six_xrpc_handler), 298 + ) 295 299 .route("/leaderboard", get(handlers::leaderboard::leaderboard_handler)) 296 300 .route("/login", get(handlers::auth::login_page_handler)) 297 301 .route("/logout", get(handlers::auth::logout_handler))