The smokesignal.events web application

bug: login destination redirect was not persisted in aip auth flow

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

+146 -31
+3
migrations/20250706165000_add_destination_to_oauth_requests.sql
··· 1 + -- Add destination column to atproto_oauth_requests table 2 + ALTER TABLE atproto_oauth_requests 3 + ADD COLUMN destination VARCHAR(255) DEFAULT NULL;
+22 -5
src/http/handle_oauth_aip_callback.rs
··· 162 162 163 163 let updated_jar = jar.add(cookie); 164 164 165 - // let destination = match oauth_request.destination { 166 - // Some(destination) => destination, 167 - // None => "/".to_string(), 168 - // }; 165 + // Retrieve destination from OAuth request before deleting it 166 + let postgres_storage = crate::storage::atproto::PostgresOAuthRequestStorage::new( 167 + web_context.pool.clone() 168 + ); 169 + let destination = match postgres_storage.get_destination(&callback_state).await { 170 + Ok(Some(dest)) => dest, 171 + Ok(None) => "/".to_string(), 172 + Err(err) => { 173 + tracing::error!(?err, "Failed to get destination"); 174 + "/".to_string() 175 + } 176 + }; 177 + 178 + // Delete the OAuth request now that we're done with it 179 + if let Err(err) = web_context 180 + .oauth_storage 181 + .delete_oauth_request_by_state(&callback_state) 182 + .await 183 + { 184 + tracing::error!(error = ?err, "Unable to remove oauth_request"); 185 + } 169 186 170 - Ok((updated_jar, Redirect::to("/")).into_response()) 187 + Ok((updated_jar, Redirect::to(destination.as_str())).into_response()) 171 188 } 172 189 173 190 #[derive(Clone, Deserialize)]
+15 -1
src/http/handle_oauth_aip_login.rs
··· 33 33 #[derive(Deserialize)] 34 34 pub(crate) struct OAuthLoginForm { 35 35 handle: Option<String>, 36 + destination: Option<String>, 36 37 } 37 38 38 - #[derive(Deserialize)] 39 + #[derive(Debug, Deserialize)] 39 40 pub(crate) struct Destination { 40 41 destination: Option<String>, 41 42 } ··· 241 242 { 242 243 tracing::error!(?err, "insert_oauth_request"); 243 244 return contextual_error!(web_context, language, error_template, default_context, err); 245 + } 246 + 247 + if let Some(ref dest) = login_form.destination { 248 + if dest != "/" { 249 + // Create a direct instance to access the set_destination method 250 + let postgres_storage = crate::storage::atproto::PostgresOAuthRequestStorage::new( 251 + web_context.pool.clone() 252 + ); 253 + if let Err(err) = postgres_storage.set_destination(&state, dest).await { 254 + tracing::error!(?err, "set_destination"); 255 + // Don't fail the login flow if we can't store the destination 256 + } 257 + } 244 258 } 245 259 246 260 let oauth_args = [
+13 -2
src/http/handle_oauth_callback.rs
··· 163 163 164 164 let token_response = token_response.unwrap(); 165 165 166 + // Retrieve destination from OAuth request before deleting it 167 + let postgres_storage = crate::storage::atproto::PostgresOAuthRequestStorage::new( 168 + web_context.pool.clone() 169 + ); 170 + let destination = match postgres_storage.get_destination(&callback_state).await { 171 + Ok(Some(dest)) => dest, 172 + Ok(None) => "/".to_string(), 173 + Err(err) => { 174 + tracing::error!(?err, "Failed to get destination"); 175 + "/".to_string() 176 + } 177 + }; 178 + 166 179 if let Err(err) = web_context 167 180 .oauth_storage 168 181 .delete_oauth_request_by_state(&callback_state) ··· 187 200 cookie.set_same_site(Some(SameSite::Lax)); 188 201 189 202 let updated_jar = jar.add(cookie); 190 - 191 - let destination = "/".to_string(); // Simplified for initial pass 192 203 193 204 Ok((updated_jar, Redirect::to(&destination)).into_response()) 194 205 }
+14
src/http/handle_oauth_login.rs
··· 272 272 return contextual_error!(web_context, language, error_template, default_context, err); 273 273 } 274 274 275 + // Store destination if provided and not "/" 276 + if let Some(ref dest) = destination.destination { 277 + if dest != "/" { 278 + // Create a direct instance to access the set_destination method 279 + let postgres_storage = crate::storage::atproto::PostgresOAuthRequestStorage::new( 280 + web_context.pool.clone() 281 + ); 282 + if let Err(err) = postgres_storage.set_destination(&oauth_request_state.state, dest).await { 283 + tracing::error!(?err, "set_destination"); 284 + // Don't fail the login flow if we can't store the destination 285 + } 286 + } 287 + } 288 + 275 289 let oauth_args = [ 276 290 ( 277 291 "request_uri".to_string(),
+4
src/http/handle_view_event.rs
··· 283 283 284 284 let event_url = url_from_aturi(&ctx.web_context.config.external_base, &event.aturi)?; 285 285 286 + // Create login URL with destination parameter for this event 287 + let login_url = format!("/oauth/login?destination={}", urlencoding::encode(&format!("/{}/{}", handle_slug, event_rkey))); 288 + 286 289 // Add Edit button link if the user is the event creator 287 290 let can_edit = ctx 288 291 .current_handle ··· 464 467 language => ctx.language.to_string(), 465 468 identity_has_email, 466 469 canonical_url => event_url, 470 + login_url => login_url, 467 471 event => event_with_counts, 468 472 is_self, 469 473 can_edit,
+1 -20
src/http/middleware_auth.rs
··· 87 87 Auth::Unauthenticated => {} 88 88 } 89 89 90 - debug!( 91 - location, 92 - "Authentication required, creating signed redirect" 93 - ); 94 - 95 - let header: Header = config 96 - .destination_key_data 97 - .clone() 98 - .try_into() 99 - .map_err(AuthMiddlewareError::SigningFailed)?; 100 - let claims = Claims::new(JoseClaims { 101 - http_uri: Some(location.to_string()), 102 - nonce: Some(ulid::Ulid::new().to_string()), 103 - ..Default::default() 104 - }); 105 - 106 - let destination_token = mint(&config.destination_key_data, &header, &claims) 107 - .map_err(AuthMiddlewareError::SigningFailed)?; 108 - 109 - Err(MiddlewareAuthError::AccessDenied(destination_token)) 90 + Err(MiddlewareAuthError::AccessDenied(location.to_string())) 110 91 } 111 92 112 93 /// Simpler authentication check that just redirects to root path
+72 -1
src/storage/atproto.rs
··· 20 20 pub dpop_private_key: String, 21 21 pub created_at: DateTime<Utc>, 22 22 pub expires_at: DateTime<Utc>, 23 + pub destination: Option<String>, 23 24 } 24 25 25 26 /// Postgres implementation of DidDocumentStorage trait ··· 122 123 pub fn new_arc(pool: StoragePool) -> Arc<dyn OAuthRequestStorage> { 123 124 Arc::new(Self::new(pool)) 124 125 } 126 + 127 + /// Set the destination for an OAuth request 128 + pub async fn set_destination( 129 + &self, 130 + oauth_state: &str, 131 + destination: &str, 132 + ) -> Result<(), StorageError> { 133 + if oauth_state.trim().is_empty() { 134 + return Err(StorageError::UnableToExecuteQuery( 135 + sqlx::Error::Protocol("OAuth state cannot be empty".to_string()), 136 + )); 137 + } 138 + 139 + let mut tx = self 140 + .pool 141 + .begin() 142 + .await 143 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 144 + 145 + sqlx::query( 146 + "UPDATE atproto_oauth_requests 147 + SET destination = $1 148 + WHERE oauth_state = $2", 149 + ) 150 + .bind(destination) 151 + .bind(oauth_state) 152 + .execute(tx.as_mut()) 153 + .await 154 + .map_err(StorageError::UnableToExecuteQuery)?; 155 + 156 + tx.commit() 157 + .await 158 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 159 + 160 + Ok(()) 161 + } 162 + 163 + /// Get the destination for an OAuth request 164 + pub async fn get_destination( 165 + &self, 166 + oauth_state: &str, 167 + ) -> Result<Option<String>, StorageError> { 168 + if oauth_state.trim().is_empty() { 169 + return Err(StorageError::UnableToExecuteQuery( 170 + sqlx::Error::Protocol("OAuth state cannot be empty".to_string()), 171 + )); 172 + } 173 + 174 + let mut tx = self 175 + .pool 176 + .begin() 177 + .await 178 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 179 + 180 + let result = sqlx::query_scalar::<_, Option<String>>( 181 + "SELECT destination 182 + FROM atproto_oauth_requests 183 + WHERE oauth_state = $1 AND expires_at > NOW()", 184 + ) 185 + .bind(oauth_state) 186 + .fetch_optional(tx.as_mut()) 187 + .await 188 + .map_err(StorageError::UnableToExecuteQuery)?; 189 + 190 + tx.commit() 191 + .await 192 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 193 + 194 + Ok(result.flatten()) 195 + } 125 196 } 126 197 127 198 #[async_trait] ··· 163 234 164 235 let result = sqlx::query_as::<_, OAuthRequestRow>( 165 236 "SELECT oauth_state, issuer, did, nonce, pkce_verifier, 166 - signing_public_key, dpop_private_key, created_at, expires_at 237 + signing_public_key, dpop_private_key, created_at, expires_at, destination 167 238 FROM atproto_oauth_requests 168 239 WHERE oauth_state = $1 AND expires_at > NOW()", 169 240 )
+1 -1
templates/nav.en-us.html
··· 49 49 <a class="button is-danger is-light" 50 50 href="/logout" hx-boost="false">Log out</a> 51 51 {% else %} 52 - <a class="button is-primary" href="/oauth/login" hx-boost="true">Log in</a> 52 + <a class="button is-primary" href="{% if login_url %}{{ login_url }}{% else %}/oauth/login{% endif %}" hx-boost="true">Log in</a> 53 53 {% endif %} 54 54 </div> 55 55 </div>
+1 -1
templates/view_event.en-us.common.html
··· 243 243 {% elif not current_handle %} 244 244 <article class="message"> 245 245 <div class="message-body"> 246 - <a href="{{ base }}/oauth/login">Log in</a> to RSVP to this 246 + <a href="{{ login_url }}">Log in</a> to RSVP to this 247 247 event. 248 248 </div> 249 249 </article>