learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: DPoP nonce retry for OAuth token exchange

* sign PDS requests with DPoP keys

* add a client-side login success page.

Changed files
+417 -72
crates
server
src
api
middleware
oauth
repository
web
+1 -1
.env.example
··· 2 2 DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable" 3 3 4 4 # OAuth Client Configuration (Optional - defaults shown) 5 - APP_URL=http://localhost:3000 5 + APP_URL=http://127.0.0.1:3000 6 6 APP_NAME=Malfestio 7 7 8 8 # Server Configuration (Optional - defaults shown)
+37 -12
crates/server/src/api/oauth.rs
··· 59 59 Err(crate::repository::oauth::OAuthRepoError::NotFound(did.to_string())) 60 60 } 61 61 62 + async fn get_token_by_access_token( 63 + &self, _access_token: &str, 64 + ) -> Result<crate::repository::oauth::StoredToken, crate::repository::oauth::OAuthRepoError> { 65 + Err(crate::repository::oauth::OAuthRepoError::NotFound( 66 + "Mock impl".to_string(), 67 + )) 68 + } 69 + 62 70 async fn update_tokens( 63 71 &self, _did: &str, _access_token: &str, _refresh_token: Option<&str>, 64 72 _expires_at: Option<chrono::DateTime<Utc>>, ··· 90 98 /// Query parameters from OAuth callback. 91 99 #[derive(Deserialize)] 92 100 pub struct CallbackQuery { 93 - pub code: String, 101 + pub code: Option<String>, 94 102 pub state: String, 95 103 #[serde(default)] 96 104 pub error: Option<String>, ··· 150 158 .into_response(); 151 159 } 152 160 161 + let code = match params.code { 162 + Some(c) => c, 163 + None => { 164 + tracing::error!("OAuth callback missing authorization code"); 165 + return Redirect::to("/login?error=missing_code").into_response(); 166 + } 167 + }; 168 + 153 169 tracing::debug!("Retrieving session for state: {}", params.state); 154 170 let session = { 155 171 let sessions = oauth.sessions.read().unwrap(); ··· 167 183 } 168 184 }; 169 185 170 - match oauth 171 - .flow 172 - .exchange_code(&params.code, &params.state, &oauth.sessions) 173 - .await 174 - { 186 + match oauth.flow.exchange_code(&code, &params.state, &oauth.sessions).await { 175 187 Ok(tokens) => { 176 188 let did = session.did.clone().unwrap_or_default(); 177 189 let pds_url = session.pds_url.unwrap_or_default(); ··· 180 192 .map(|secs| Utc::now() + Duration::seconds(secs as i64)); 181 193 182 194 tracing::info!("Storing tokens for DID: {}", did); 195 + 183 196 if let Err(e) = oauth 184 197 .repo 185 198 .store_tokens(StoreTokensRequest { ··· 199 212 } 200 213 201 214 tracing::info!("OAuth flow completed successfully for DID: {}", did); 202 - Redirect::to(&format!("/login/success?did={}", urlencoding::encode(&did))).into_response() 215 + 216 + let handle = match oauth.flow.resolve_did(&did).await { 217 + Ok(identity) => identity.handle.unwrap_or(did.clone()), 218 + Err(e) => { 219 + tracing::warn!("Failed to resolve handle for DID {}: {}", did, e); 220 + did.clone() 221 + } 222 + }; 223 + 224 + let fragment = format!( 225 + "accessJwt={}&refreshJwt={}&did={}&handle={}", 226 + urlencoding::encode(&tokens.access_token), 227 + urlencoding::encode(tokens.refresh_token.as_deref().unwrap_or("")), 228 + urlencoding::encode(&did), 229 + urlencoding::encode(&handle) 230 + ); 231 + Redirect::to(&format!("/login/success#{}", fragment)).into_response() 203 232 } 204 233 Err(e) => { 205 234 tracing::error!("Token exchange failed: {}", e); ··· 228 257 pub async fn refresh(State(oauth): State<Arc<OAuthState>>, Json(payload): Json<RefreshRequest>) -> impl IntoResponse { 229 258 tracing::info!("Token refresh request for DID: {}", payload.did); 230 259 231 - // Get stored tokens from database 232 260 tracing::debug!("Retrieving stored tokens from database for DID: {}", payload.did); 233 261 let stored = match oauth.repo.get_tokens(&payload.did).await { 234 262 Ok(t) => { ··· 241 269 } 242 270 }; 243 271 244 - // Reconstruct DPoP keypair 245 272 tracing::debug!("Reconstructing DPoP keypair from stored data"); 246 273 let dpop_keypair = match stored.dpop_keypair() { 247 274 Some(kp) => kp, ··· 255 282 } 256 283 }; 257 284 258 - // Get refresh token 259 285 let refresh_token = match &stored.refresh_token { 260 286 Some(rt) => rt.clone(), 261 287 None => { ··· 268 294 } 269 295 }; 270 296 271 - // Refresh tokens via OAuth flow 272 297 match oauth 273 298 .flow 274 299 .refresh_token(&refresh_token, &stored.pds_url, &dpop_keypair) ··· 347 372 fn test_callback_query_deserialization() { 348 373 let query = "code=abc123&state=xyz789"; 349 374 let parsed: CallbackQuery = serde_qs::from_str(query).unwrap(); 350 - assert_eq!(parsed.code, "abc123"); 375 + assert_eq!(parsed.code, Some("abc123".to_string())); 351 376 assert_eq!(parsed.state, "xyz789"); 352 377 assert!(parsed.error.is_none()); 353 378 }
+3 -2
crates/server/src/lib.rs
··· 47 47 let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 48 48 let config = state::AppConfig { pds_url }; 49 49 let repos = state::Repositories::from(&pool); 50 - let state = state::AppState::new(pool, repos, config); 51 - let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::new()); 50 + let state = state::AppState::new(pool.clone(), repos, config); 51 + let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::with_pool(pool)); 52 52 53 53 let auth_routes = Router::new() 54 54 .route("/me", get(api::auth::me)) ··· 94 94 let oauth_routes = Router::new() 95 95 .route("/authorize", post(api::oauth::authorize)) 96 96 .route("/callback", get(api::oauth::callback)) 97 + .route("/client-metadata.json", get(oauth::client_metadata_handler)) 97 98 .route("/refresh", post(api::oauth::refresh)) 98 99 .with_state(oauth_state.clone()); 99 100
+102 -21
crates/server/src/middleware/auth.rs
··· 124 124 let client = reqwest::Client::new(); 125 125 let pds_url = &state.config.pds_url; 126 126 127 - let resp = client 128 - .get(format!("{}/xrpc/com.atproto.server.getSession", pds_url)) 129 - .header("Authorization", format!("Bearer {}", token)) 130 - .send() 131 - .await; 127 + let lookup_result = state.oauth_repo.get_token_by_access_token(&token).await; 128 + 129 + if let Err(ref e) = lookup_result { 130 + tracing::debug!("Token lookup failed: {}", e); 131 + } 132 + 133 + let stored_token = lookup_result.ok(); 134 + 135 + let target_pds_url = stored_token 136 + .as_ref() 137 + .map(|t| t.pds_url.as_str()) 138 + .unwrap_or(pds_url.as_str()); 139 + 140 + let endpoint_url = format!("{}/xrpc/com.atproto.server.getSession", target_pds_url); 141 + let mut nonce: Option<String> = None; 142 + let mut attempt = 0; 143 + 144 + loop { 145 + attempt += 1; 146 + if attempt > 3 { 147 + tracing::error!("Failed to verify token with PDS after multiple attempts"); 148 + return ( 149 + axum::http::StatusCode::UNAUTHORIZED, 150 + axum::Json(json!({ "error": "Invalid session" })), 151 + ) 152 + .into_response(); 153 + } 154 + 155 + let mut request_builder = client.get(&endpoint_url); 156 + 157 + if let Some(ref stored) = stored_token { 158 + if attempt == 1 { 159 + tracing::debug!("Found stored DPoP token for validation"); 160 + } 161 + if let Some(dpop_keypair) = stored.dpop_keypair() { 162 + if attempt == 1 { 163 + tracing::debug!("Signing PDS request with DPoP Key"); 164 + } 165 + 166 + let method = "GET"; 167 + let proof = if let Some(ref n) = nonce { 168 + dpop_keypair.generate_proof_with_nonce(method, &endpoint_url, Some(&token), Some(n)) 169 + } else { 170 + dpop_keypair.generate_proof(method, &endpoint_url, Some(&token)) 171 + }; 172 + 173 + request_builder = request_builder 174 + .header("Authorization", format!("DPoP {}", token)) 175 + .header("DPoP", proof); 176 + } else { 177 + request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); 178 + } 179 + } else { 180 + if attempt == 1 { 181 + tracing::debug!("No stored DPoP token found, using standard Bearer auth"); 182 + } 183 + request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); 184 + } 185 + 186 + let resp = request_builder.send().await; 187 + 188 + match resp { 189 + Ok(response) if response.status().is_success() => { 190 + let body: serde_json::Value = response.json().await.unwrap_or_default(); 191 + let did = body["did"].as_str().unwrap_or("").to_string(); 192 + let handle = body["handle"].as_str().unwrap_or("").to_string(); 193 + let user_ctx = UserContext { did: did.clone(), handle }; 132 194 133 - match resp { 134 - Ok(response) if response.status().is_success() => { 135 - let body: serde_json::Value = response.json().await.unwrap_or_default(); 136 - let did = body["did"].as_str().unwrap_or("").to_string(); 137 - let handle = body["handle"].as_str().unwrap_or("").to_string(); 138 - let user_ctx = UserContext { did, handle }; 195 + tracing::debug!("PDS verification successful for DID: {}", did); 196 + 197 + { 198 + let mut cache = state.auth_cache.write().await; 199 + cache.insert(token.to_string(), (user_ctx.clone(), Instant::now())); 200 + } 139 201 140 - { 141 - let mut cache = state.auth_cache.write().await; 142 - cache.insert(token.to_string(), (user_ctx.clone(), Instant::now())); 202 + req.extensions_mut().insert(user_ctx); 203 + return next.run(req).await; 143 204 } 205 + Ok(response) => { 206 + let status = response.status(); 144 207 145 - req.extensions_mut().insert(user_ctx); 146 - next.run(req).await 208 + if status == axum::http::StatusCode::UNAUTHORIZED 209 + && let Some(new_nonce) = response.headers().get("DPoP-Nonce") 210 + && let Ok(nonce_str) = new_nonce.to_str() 211 + { 212 + tracing::info!("Received DPoP nonce challenge from PDS, retrying verification..."); 213 + nonce = Some(nonce_str.to_string()); 214 + continue; 215 + } 216 + 217 + let body = response.text().await.unwrap_or_default(); 218 + tracing::error!("PDS Verification failed. Status: {}, Body: {}", status, body); 219 + return ( 220 + axum::http::StatusCode::UNAUTHORIZED, 221 + axum::Json(json!({ "error": "Invalid session", "pds_error": body })), 222 + ) 223 + .into_response(); 224 + } 225 + Err(e) => { 226 + tracing::error!("PDS Request failed: {}", e); 227 + return ( 228 + axum::http::StatusCode::UNAUTHORIZED, 229 + axum::Json(json!({ "error": "Invalid session" })), 230 + ) 231 + .into_response(); 232 + } 147 233 } 148 - _ => ( 149 - axum::http::StatusCode::UNAUTHORIZED, 150 - axum::Json(json!({ "error": "Invalid session" })), 151 - ) 152 - .into_response(), 153 234 } 154 235 } 155 236
+4 -3
crates/server/src/oauth/client_metadata.rs
··· 30 30 /// Create client metadata from environment variables. 31 31 pub fn from_env() -> Self { 32 32 let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); 33 + let app_url = app_url.trim_end_matches('/'); 33 34 let app_name = std::env::var("APP_NAME").unwrap_or_else(|_| "Malfestio".to_string()); 34 35 35 36 Self { 36 - client_id: format!("{}/oauth/client-metadata.json", app_url), 37 + client_id: format!("{}/api/oauth/client-metadata.json", app_url), 37 38 application_type: "web".to_string(), 38 39 grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()], 39 40 scope: "atproto transition:generic".to_string(), 40 41 response_types: vec!["code".to_string()], 41 - redirect_uris: vec![format!("{}/oauth/callback", app_url)], 42 + redirect_uris: vec![format!("{}/api/oauth/callback", app_url)], 42 43 client_name: app_name, 43 - client_uri: app_url, 44 + client_uri: app_url.to_string(), 44 45 token_endpoint_auth_method: "none".to_string(), 45 46 dpop_bound_access_tokens: true, 46 47 }
+43 -3
crates/server/src/oauth/flow.rs
··· 58 58 /// Create a new OAuth flow manager. 59 59 pub fn new() -> Self { 60 60 let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); 61 + let app_url = app_url.trim_end_matches('/'); 61 62 62 63 Self { 63 64 resolver: IdentityResolver::new(), 64 65 client: reqwest::Client::new(), 65 - client_id: format!("{}/oauth/client-metadata.json", app_url), 66 - redirect_uri: format!("{}/oauth/callback", app_url), 66 + client_id: format!("{}/api/oauth/client-metadata.json", app_url), 67 + redirect_uri: format!("{}/api/oauth/callback", app_url), 67 68 } 69 + } 70 + 71 + /// Resolve a DID to an identity (including handle). 72 + pub async fn resolve_did( 73 + &self, did: &str, 74 + ) -> Result<super::resolver::ResolvedIdentity, super::resolver::ResolveError> { 75 + self.resolver.resolve_did(did).await 68 76 } 69 77 70 78 /// Start the OAuth flow for a user handle or DID. ··· 164 172 .generate_proof("POST", &auth_server.token_endpoint, None); 165 173 166 174 tracing::info!("Sending token exchange request to: {}", auth_server.token_endpoint); 167 - let response = self 175 + let mut response = self 168 176 .client 169 177 .post(&auth_server.token_endpoint) 170 178 .header("DPoP", dpop_proof) ··· 181 189 tracing::error!("Network error during token exchange: {}", e); 182 190 OAuthFlowError::NetworkError(e.to_string()) 183 191 })?; 192 + 193 + if (response.status().as_u16() == 400 || response.status().as_u16() == 401) 194 + && let Some(nonce) = response 195 + .headers() 196 + .get("DPoP-Nonce") 197 + .and_then(|h| h.to_str().ok().map(|s| s.to_string())) 198 + { 199 + tracing::info!("Received DPoP nonce, retrying token exchange"); 200 + 201 + let dpop_proof = 202 + session 203 + .dpop_keypair 204 + .generate_proof_with_nonce("POST", &auth_server.token_endpoint, None, Some(&nonce)); 205 + 206 + response = self 207 + .client 208 + .post(&auth_server.token_endpoint) 209 + .header("DPoP", dpop_proof) 210 + .form(&[ 211 + ("grant_type", "authorization_code"), 212 + ("code", code), 213 + ("redirect_uri", &self.redirect_uri), 214 + ("client_id", &self.client_id), 215 + ("code_verifier", &session.code_verifier), 216 + ]) 217 + .send() 218 + .await 219 + .map_err(|e| { 220 + tracing::error!("Network error during retry token exchange: {}", e); 221 + OAuthFlowError::NetworkError(e.to_string()) 222 + })?; 223 + } 184 224 185 225 let status = response.status(); 186 226 if !status.is_success() {
+2
crates/server/src/oauth/mod.rs
··· 13 13 pub mod flow; 14 14 pub mod pkce; 15 15 pub mod resolver; 16 + 17 + pub use client_metadata::client_metadata_handler;
+46
crates/server/src/repository/oauth.rs
··· 76 76 /// Get stored tokens for a user. 77 77 async fn get_tokens(&self, did: &str) -> Result<StoredToken, OAuthRepoError>; 78 78 79 + /// Get stored tokens by access token. 80 + async fn get_token_by_access_token(&self, access_token: &str) -> Result<StoredToken, OAuthRepoError>; 81 + 79 82 /// Update tokens after refresh. 80 83 async fn update_tokens( 81 84 &self, did: &str, access_token: &str, refresh_token: Option<&str>, expires_at: Option<DateTime<Utc>>, ··· 143 146 .await 144 147 .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))? 145 148 .ok_or_else(|| OAuthRepoError::NotFound(format!("No tokens for DID: {}", did)))?; 149 + 150 + Ok(StoredToken { 151 + did: row.get("did"), 152 + pds_url: row.get("pds_url"), 153 + access_token: row.get("access_token"), 154 + refresh_token: row.get("refresh_token"), 155 + token_type: row.get("token_type"), 156 + expires_at: row.get("expires_at"), 157 + dpop_private_key: row.get("dpop_private_key"), 158 + created_at: row.get("created_at"), 159 + updated_at: row.get("updated_at"), 160 + }) 161 + } 162 + 163 + async fn get_token_by_access_token(&self, access_token: &str) -> Result<StoredToken, OAuthRepoError> { 164 + let client = self 165 + .pool 166 + .get() 167 + .await 168 + .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?; 169 + 170 + let row = client 171 + .query_opt( 172 + "SELECT did, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key, created_at, updated_at 173 + FROM oauth_tokens WHERE access_token = $1", 174 + &[&access_token], 175 + ) 176 + .await 177 + .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))? 178 + .ok_or_else(|| OAuthRepoError::NotFound("Token not found".to_string()))?; 146 179 147 180 Ok(StoredToken { 148 181 did: row.get("did"), ··· 277 310 .find(|t| t.did == did) 278 311 .cloned() 279 312 .ok_or_else(|| OAuthRepoError::NotFound(format!("No tokens for DID: {}", did))) 313 + } 314 + 315 + async fn get_token_by_access_token(&self, access_token: &str) -> Result<StoredToken, OAuthRepoError> { 316 + if *self.should_fail.lock().unwrap() { 317 + return Err(OAuthRepoError::DatabaseError("Mock failure".to_string())); 318 + } 319 + 320 + let tokens = self.tokens.lock().unwrap(); 321 + tokens 322 + .iter() 323 + .find(|t| t.access_token == access_token) 324 + .cloned() 325 + .ok_or_else(|| OAuthRepoError::NotFound("Token not found".to_string())) 280 326 } 281 327 282 328 async fn update_tokens(
+3
justfile
··· 72 72 clean: 73 73 cargo clean 74 74 cd web && rm -rf dist node_modules/.vite 75 + 76 + push: 77 + git push origin main && git push alpha main
+2
web/src/App.tsx
··· 15 15 import LectureImport from "$pages/LectureImport"; 16 16 import Library from "$pages/Library"; 17 17 import Login from "$pages/Login"; 18 + import LoginSuccess from "$pages/LoginSuccess"; 18 19 import NoteNew from "$pages/NoteNew"; 19 20 import Notes from "$pages/Notes"; 20 21 import NoteView from "$pages/NoteView"; ··· 58 59 return ( 59 60 <Router> 60 61 <Route path="/login" component={Login} /> 62 + <Route path="/login/success" component={LoginSuccess} /> 61 63 <Route path="/about" component={About} /> 62 64 <Route path="/help" component={Help} /> 63 65 <Route path="/" component={ProtectedLayout}>
+11 -9
web/src/components/layout/Header.tsx
··· 11 11 12 12 export const Header: Component = () => { 13 13 return ( 14 - <header class="h-16 border-b border-gray-800 bg-gray-900 flex items-center justify-between px-6 sticky top-0 z-50"> 14 + <header class="h-16 border-b border-gray-800 bg-black flex items-center justify-between px-6 sticky top-0 z-50"> 15 15 <div class="flex items-center gap-6"> 16 16 <A href="/" class="text-xl font-bold text-white tracking-tight">Malfestio</A> 17 - <nav class="hidden md:flex items-center gap-4 text-sm font-medium text-gray-400"> 18 - <A href="/home" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A> 19 - <A href="/study" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A> 20 - <A href="/discovery" activeClass="text-blue-500" class="hover:text-white transition-colors">Discovery</A> 21 - <A href="/library" activeClass="text-blue-500" class="hover:text-white transition-colors">Library</A> 22 - <A href="/feed" activeClass="text-blue-500" class="hover:text-white transition-colors">Feed</A> 23 - <A href="/settings" activeClass="text-blue-500" class="hover:text-white transition-colors">Settings</A> 24 - </nav> 17 + <Show when={authStore.isAuthenticated()}> 18 + <nav class="hidden md:flex items-center gap-4 text-sm font-medium text-gray-400"> 19 + <A href="/home" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A> 20 + <A href="/study" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A> 21 + <A href="/discovery" activeClass="text-blue-500" class="hover:text-white transition-colors">Discovery</A> 22 + <A href="/library" activeClass="text-blue-500" class="hover:text-white transition-colors">Library</A> 23 + <A href="/feed" activeClass="text-blue-500" class="hover:text-white transition-colors">Feed</A> 24 + <A href="/settings" activeClass="text-blue-500" class="hover:text-white transition-colors">Settings</A> 25 + </nav> 26 + </Show> 25 27 </div> 26 28 <div class="flex items-center gap-4"> 27 29 <Show when={authStore.user()} fallback={<Login />}>
+7 -1
web/src/index.css
··· 15 15 --font-body: "Figtree Variable", sans-serif; 16 16 17 17 /* Spacing Tokens - 16px grid */ 18 + /* 19 + FIXME: These conflict with, and break existing usage of classes like max-w-4xl 18 20 --spacing-xs: 4px; 19 21 --spacing-sm: 8px; 20 22 --spacing-md: 12px; ··· 23 25 --spacing-xl: 32px; 24 26 --spacing-2xl: 48px; 25 27 --spacing-3xl: 64px; 26 - --spacing-4xl: 96px; 28 + --spacing-4xl: 96px; */ 27 29 28 30 /* Elevation Layers */ 29 31 --layer-00: #161616; ··· 181 183 transform: scale(0.97); 182 184 transition: transform var(--duration-instant) var(--easing-sharp); 183 185 } 186 + 187 + button { 188 + @apply cursor-pointer disabled:cursor-not-allowed disabled:opacity-50; 189 + }
+11 -3
web/src/pages/Login.tsx
··· 1 1 import { AppLayout } from "$components/layout/AppLayout"; 2 2 import { api } from "$lib/api"; 3 3 import { authStore } from "$lib/store"; 4 - import { useNavigate } from "@solidjs/router"; 4 + import { useNavigate, useSearchParams } from "@solidjs/router"; 5 5 import type { Component } from "solid-js"; 6 - import { createSignal } from "solid-js"; 6 + import { createEffect, createSignal } from "solid-js"; 7 7 8 8 const Login: Component = () => { 9 9 const [identifier, setIdentifier] = createSignal(""); ··· 11 11 const [error, setError] = createSignal(""); 12 12 const [isLoading, setIsLoading] = createSignal(false); 13 13 const navigate = useNavigate(); 14 + const [searchParams] = useSearchParams(); 15 + 16 + createEffect(() => { 17 + if (searchParams.error) { 18 + const desc = searchParams.description ? `: ${searchParams.description}` : ""; 19 + setError(searchParams.error + desc); 20 + } 21 + }); 14 22 15 23 const handleLogin = async (e: Event) => { 16 24 e.preventDefault(); ··· 52 60 53 61 return ( 54 62 <AppLayout> 55 - <div class="min-h-[calc(100vh-8rem)] flex items-center justify-center p-4"> 63 + <div class="py-12 flex justify-center p-4"> 56 64 <div class="w-full max-w-md bg-[#262626] border border-[#393939] p-8 shadow-lg section-entry"> 57 65 <h1 class="text-3xl font-light text-[#F4F4F4] mb-2 tracking-tight">Log in</h1> 58 66 <p class="text-[#C6C6C6] text-sm mb-8 font-light">Continue to Malfestio</p>
+37
web/src/pages/LoginSuccess.tsx
··· 1 + import { authStore } from "$lib/store"; 2 + import { useNavigate } from "@solidjs/router"; 3 + import { type Component, onMount } from "solid-js"; 4 + 5 + const LoginSuccess: Component = () => { 6 + const navigate = useNavigate(); 7 + 8 + onMount(() => { 9 + const hash = window.location.hash.substring(1); 10 + const params = new URLSearchParams(hash); 11 + 12 + const accessJwt = params.get("accessJwt"); 13 + const refreshJwt = params.get("refreshJwt"); 14 + const did = params.get("did"); 15 + const handle = params.get("handle"); 16 + 17 + if (accessJwt && did) { 18 + authStore.login({ accessJwt, refreshJwt: refreshJwt || "", did, handle: handle || did }); 19 + window.history.replaceState(null, "", "/"); 20 + navigate("/"); 21 + } else { 22 + console.error("Missing tokens in login success"); 23 + navigate("/login?error=missing_tokens"); 24 + } 25 + }); 26 + 27 + return ( 28 + <div class="flex items-center justify-center h-screen bg-[#161616] text-[#F4F4F4]"> 29 + <div class="flex flex-col items-center gap-4"> 30 + <div class="w-8 h-8 border-t-2 border-[#0F62FE] rounded-full animate-spin" /> 31 + <p class="text-sm text-[#8D8D8D]">Finalizing login...</p> 32 + </div> 33 + </div> 34 + ); 35 + }; 36 + 37 + export default LoginSuccess;
+1 -1
web/src/pages/tests/Login.test.tsx
··· 11 11 12 12 vi.mock("$lib/store", () => ({ authStore: { login: vi.fn() } })); 13 13 14 - vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate })); 14 + vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate, useSearchParams: () => [{}, vi.fn()] })); 15 15 16 16 vi.mock( 17 17 "$components/layout/AppLayout",
+73
web/src/pages/tests/LoginSuccess.test.tsx
··· 1 + import { authStore } from "$lib/store"; 2 + import { cleanup, render, waitFor } from "@solidjs/testing-library"; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 + import LoginSuccess from "../LoginSuccess"; 5 + 6 + const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); 7 + 8 + vi.mock("$lib/store", () => ({ authStore: { login: vi.fn() } })); 9 + vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate })); 10 + 11 + describe("LoginSuccess Page", () => { 12 + const originalLocation = window.location; 13 + 14 + beforeEach(() => { 15 + vi.stubGlobal("location", { 16 + configurable: true, 17 + enumerable: true, 18 + value: { hash: "", href: "http://localhost/login/success", assign: vi.fn(), replace: vi.fn() }, 19 + }); 20 + vi.spyOn(window.history, "replaceState"); 21 + }); 22 + 23 + afterEach(() => { 24 + cleanup(); 25 + vi.clearAllMocks(); 26 + vi.stubGlobal("location", originalLocation); 27 + }); 28 + 29 + it("logs in and redirects on valid tokens", async () => { 30 + window.location.hash = "#accessJwt=access123&refreshJwt=refresh123&did=did:plc:123&handle=alice.bsky.social"; 31 + 32 + render(() => <LoginSuccess />); 33 + 34 + await waitFor(() => { 35 + expect(authStore.login).toHaveBeenCalledWith({ 36 + accessJwt: "access123", 37 + refreshJwt: "refresh123", 38 + did: "did:plc:123", 39 + handle: "alice.bsky.social", 40 + }); 41 + 42 + expect(window.history.replaceState).toHaveBeenCalledWith(null, "", "/"); 43 + expect(mockNavigate).toHaveBeenCalledWith("/"); 44 + }); 45 + }); 46 + 47 + it("handles missing optional parameters (handle fallback)", async () => { 48 + window.location.hash = "#accessJwt=access123&refreshJwt=&did=did:plc:123"; 49 + 50 + render(() => <LoginSuccess />); 51 + 52 + await waitFor(() => { 53 + expect(authStore.login).toHaveBeenCalledWith({ 54 + accessJwt: "access123", 55 + refreshJwt: "", 56 + did: "did:plc:123", 57 + handle: "did:plc:123", 58 + }); 59 + expect(mockNavigate).toHaveBeenCalledWith("/"); 60 + }); 61 + }); 62 + 63 + it("redirects to error on missing required tokens", async () => { 64 + window.location.hash = "#did=did:plc:123"; 65 + 66 + render(() => <LoginSuccess />); 67 + 68 + await waitFor(() => { 69 + expect(authStore.login).not.toHaveBeenCalled(); 70 + expect(mockNavigate).toHaveBeenCalledWith("/login?error=missing_tokens"); 71 + }); 72 + }); 73 + });
+34 -16
web/vite.config.ts
··· 1 1 import tailwindcss from "@tailwindcss/vite"; 2 2 import path from "path"; 3 + import { loadEnv } from "vite"; 3 4 import solid from "vite-plugin-solid"; 4 5 import { defineConfig } from "vitest/config"; 5 6 6 - export default defineConfig({ 7 - plugins: [solid(), tailwindcss()], 8 - resolve: { 9 - alias: { 10 - $lib: path.resolve(__dirname, "src/lib"), 11 - $pages: path.resolve(__dirname, "src/pages"), 12 - $components: path.resolve(__dirname, "src/components"), 13 - $ui: path.resolve(__dirname, "src/components/ui"), 7 + export default defineConfig(({ mode }) => { 8 + const env = loadEnv(mode, process.cwd(), ""); 9 + const appUrl = env.APP_URL || "http://localhost:3000"; 10 + let host = "localhost"; 11 + try { 12 + const url = new URL(appUrl); 13 + host = url.hostname; 14 + } catch { 15 + console.warn("Invalid APP_URL in .env, defaulting to localhost"); 16 + } 17 + 18 + return { 19 + plugins: [solid(), tailwindcss()], 20 + resolve: { 21 + alias: { 22 + $lib: path.resolve(__dirname, "src/lib"), 23 + $pages: path.resolve(__dirname, "src/pages"), 24 + $components: path.resolve(__dirname, "src/components"), 25 + $ui: path.resolve(__dirname, "src/components/ui"), 26 + }, 14 27 }, 15 - }, 16 - server: { proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } } }, 17 - test: { 18 - environment: "jsdom", 19 - ui: false, 20 - watch: false, 21 - server: { deps: { inline: [/@solidjs/, /solid-js/, /solid-motionone/, /motion/] } }, 22 - }, 28 + server: { 29 + host: "0.0.0.0", 30 + allowedHosts: [host, "localhost", "127.0.0.1", ".ts.net", ".ngrok-free.app"], 31 + proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } }, 32 + port: 3000, 33 + }, 34 + test: { 35 + environment: "jsdom", 36 + ui: false, 37 + watch: false, 38 + server: { deps: { inline: [/@solidjs/, /solid-js/, /solid-motionone/, /motion/] } }, 39 + }, 40 + }; 23 41 });