The smokesignal.events web application

refactor: updated to atproto-* 0.9.5 and updated traits and function calls to better support blind oauth

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

+158 -158
+6 -6
Cargo.toml
··· 23 23 minijinja-embed = {version = "2.7"} 24 24 25 25 [dependencies] 26 - atproto-identity = { version = "0.9.4", features = ["lru", "axum", "zeroize"] } 27 - atproto-oauth = { version = "0.9.4", features = ["lru", "axum", "zeroize"] } 28 - atproto-oauth-axum = { version = "0.9.4", features = ["zeroize"] } 29 - atproto-oauth-aip = { version = "0.9.4", features = ["zeroize"] } 30 - atproto-client = { version = "0.9.4" } 31 - atproto-record = { version = "0.9.4" } 26 + atproto-identity = { version = "0.9.5", features = ["lru", "axum", "zeroize"] } 27 + atproto-oauth = { version = "0.9.5", features = ["lru", "axum", "zeroize"] } 28 + atproto-oauth-axum = { version = "0.9.5", features = ["zeroize"] } 29 + atproto-oauth-aip = { version = "0.9.5", features = ["zeroize"] } 30 + atproto-client = { version = "0.9.5" } 31 + atproto-record = { version = "0.9.5" } 32 32 33 33 anyhow = "1.0" 34 34 async-trait = "0.1"
+15
migrations/20250709190627_oauth_requests_authorization_server.sql
··· 1 + -- Add authorization_server column and remove did column from atproto_oauth_requests table 2 + 3 + -- Add authorization_server column 4 + ALTER TABLE atproto_oauth_requests 5 + ADD COLUMN authorization_server VARCHAR(512) NOT NULL DEFAULT ''; 6 + 7 + -- Drop the DID index first 8 + DROP INDEX idx_atproto_oauth_requests_did; 9 + 10 + -- Remove the did column 11 + ALTER TABLE atproto_oauth_requests 12 + DROP COLUMN did; 13 + 14 + -- Add index for authorization_server lookups 15 + CREATE INDEX idx_atproto_oauth_requests_authorization_server ON atproto_oauth_requests(authorization_server);
+5 -2
src/atproto/auth.rs
··· 7 7 use atproto_oauth_aip::{resources::oauth_protected_resource, workflow::session_exchange}; 8 8 9 9 /// Create DPoPAuth directly from OAuthSession 10 - pub(crate) fn create_dpop_auth_from_oauth_session(oauth_session: &OAuthSession) -> Result<DPoPAuth> { 10 + pub(crate) fn create_dpop_auth_from_oauth_session( 11 + oauth_session: &OAuthSession, 12 + ) -> Result<DPoPAuth> { 11 13 let dpop_private_key_data = identify_key(&oauth_session.dpop_jwk)?; 12 14 13 15 Ok(DPoPAuth { ··· 23 25 ) -> Result<DPoPAuth> { 24 26 let protected_resource = oauth_protected_resource(http_client, aip_server).await?; 25 27 26 - let session_response = session_exchange(http_client, &protected_resource, access_token).await?; 28 + let session_response = 29 + session_exchange(http_client, &protected_resource.resource, access_token).await?; 27 30 28 31 let dpop_private_key_data = identify_key(&session_response.dpop_key)?; 29 32
+3 -1
src/http/handle_admin_import_event.rs
··· 135 135 } 136 136 137 137 // Insert the handle if it doesn't exist 138 - if let Err(err) = handle_warm_up(&admin_ctx.web_context.pool, &document.id, handle, pds).await { 138 + if let Err(err) = 139 + handle_warm_up(&admin_ctx.web_context.pool, &document.id, handle, pds, None).await 140 + { 139 141 return contextual_error!( 140 142 admin_ctx.web_context, 141 143 admin_ctx.language,
+3 -1
src/http/handle_admin_import_rsvp.rs
··· 141 141 } 142 142 143 143 // Insert the handle if it doesn't exist 144 - if let Err(err) = handle_warm_up(&admin_ctx.web_context.pool, &document.id, handle, pds).await { 144 + if let Err(err) = 145 + handle_warm_up(&admin_ctx.web_context.pool, &document.id, handle, pds, None).await 146 + { 145 147 return contextual_error!( 146 148 admin_ctx.web_context, 147 149 admin_ctx.language,
+81 -30
src/http/handle_oauth_aip_callback.rs
··· 1 1 use std::collections::HashMap; 2 2 3 3 use crate::{ 4 - config::OAuthBackendConfig, 5 - contextual_error, select_template, 6 - storage::identity_profile::{handle_for_did, identity_profile_set_email}, 4 + config::OAuthBackendConfig, contextual_error, select_template, 5 + storage::identity_profile::handle_warm_up, 7 6 }; 8 - use anyhow::{Context, Result, anyhow}; 7 + use anyhow::{Context, Result, anyhow, bail}; 8 + use atproto_client::errors::SimpleError; 9 + use atproto_identity::resolve::IdentityResolver; 9 10 use axum::{ 10 11 extract::State, 11 12 response::{IntoResponse, Redirect}, ··· 33 34 pub(crate) async fn handle_oauth_callback( 34 35 State(web_context): State<WebContext>, 35 36 Language(language): Language, 37 + identity_resolver: IdentityResolver, 36 38 jar: PrivateCookieJar, 37 39 Form(callback_form): Form<OAuthCallbackForm>, 38 40 ) -> Result<impl IntoResponse, WebError> { ··· 112 114 let token_response = atproto_oauth_aip::workflow::oauth_complete( 113 115 &web_context.http_client, 114 116 &oauth_client, 115 - &authorization_server, 117 + &authorization_server.token_endpoint, 116 118 &callback_code, 117 119 &oauth_request, 118 120 ) ··· 128 130 129 131 let token_response = token_response.unwrap(); 130 132 131 - let identity_profile = handle_for_did(&web_context.pool, &oauth_request.did).await?; 132 - 133 - let maybe_email = get_email_from_userinfo( 133 + let (did, maybe_email) = match get_email_from_userinfo( 134 134 &web_context.http_client, 135 135 hostname, 136 - &identity_profile.did, 137 136 &token_response.access_token, 138 137 ) 139 - .await; 140 - let maybe_email = match maybe_email { 138 + .await 139 + { 141 140 Ok(value) => value, 142 141 Err(err) => { 143 142 tracing::error!(error = ?err, "error getting AIP userinfo"); 144 - None 143 + return contextual_error!(web_context, language, error_template, default_context, err); 145 144 } 146 145 }; 147 - if let Some(email) = maybe_email { 148 - // Write the email address to the database if it already isn't in the database. 149 - // Only set if the identity_profile's email field is None (not even an empty string) 150 - if identity_profile.email.is_none() { 151 - if let Err(err) = 152 - identity_profile_set_email(&web_context.pool, &oauth_request.did, Some(&email)) 153 - .await 154 - { 155 - tracing::error!(error = ?err, "Failed to set email from OAuth userinfo"); 156 - } 146 + 147 + let document = match identity_resolver.resolve(&did).await { 148 + Ok(value) => value, 149 + Err(err) => { 150 + return contextual_error!(web_context, language, error_template, default_context, err); 151 + } 152 + }; 153 + 154 + let handle = match document 155 + .handles() 156 + .ok_or(WebError::Login(LoginError::NoHandle)) 157 + { 158 + Ok(value) => value, 159 + Err(err) => { 160 + tracing::error!(?err, "handles"); 161 + return contextual_error!(web_context, language, error_template, default_context, err); 162 + } 163 + }; 164 + 165 + let pds = match document 166 + .pds_endpoints() 167 + .first() 168 + .cloned() 169 + .ok_or(WebError::Login(LoginError::NoPDS)) 170 + { 171 + Ok(value) => value, 172 + Err(err) => { 173 + tracing::error!(?err, "pds_endpoints first"); 174 + return contextual_error!(web_context, language, error_template, default_context, err); 157 175 } 176 + }; 177 + 178 + if let Err(err) = web_context 179 + .document_storage 180 + .store_document(document.clone()) 181 + .await 182 + { 183 + tracing::error!(?err, "store_document"); 184 + return contextual_error!(web_context, language, error_template, default_context, err); 185 + } 186 + 187 + // Insert the handle if it doesn't exist 188 + if let Err(err) = handle_warm_up( 189 + &web_context.pool, 190 + &document.id, 191 + handle, 192 + pds, 193 + maybe_email.as_deref(), 194 + ) 195 + .await 196 + { 197 + tracing::error!(?err, "handle_warm_up"); 198 + return contextual_error!(web_context, language, error_template, default_context, err); 158 199 } 159 200 160 201 let cookie_value: String = WebSession::Aip { 161 - did: oauth_request.did.clone(), 202 + did, 162 203 access_token: token_response.access_token.clone(), 163 204 } 164 205 .try_into()?; ··· 202 243 #[derive(Clone, Deserialize)] 203 244 pub struct OpenIDClaims { 204 245 pub sub: String, 246 + pub profile: String, 247 + pub did: String, 248 + pub name: String, 249 + pub pds_endpoint: String, 205 250 206 251 #[serde(skip_serializing_if = "Option::is_none")] 207 252 pub email: Option<String>, ··· 210 255 pub additional_claims: HashMap<String, serde_json::Value>, 211 256 } 212 257 258 + #[derive(Clone, Deserialize)] 259 + #[serde(untagged)] 260 + pub enum OpenIDClaimsResponse { 261 + OpenIDClaims(OpenIDClaims), 262 + 263 + SimpleError(SimpleError), 264 + } 265 + 213 266 async fn get_email_from_userinfo( 214 267 http_client: &reqwest::Client, 215 268 aip_server: &str, 216 - did: &str, 217 269 aip_access_token: &str, 218 - ) -> Result<Option<String>> { 270 + ) -> Result<(String, Option<String>)> { 219 271 let userinfo_endpoint = format!("{}/oauth/userinfo", aip_server); 220 272 221 - let response: OpenIDClaims = http_client 273 + let response: OpenIDClaimsResponse = http_client 222 274 .get(userinfo_endpoint) 223 275 .bearer_auth(aip_access_token) 224 276 .send() ··· 228 280 .await 229 281 .context(anyhow!("Parsing HTTP response for userinfo failed"))?; 230 282 231 - if response.sub != did { 232 - return Err(anyhow!("DID does not match userinfo subject")); 283 + match response { 284 + OpenIDClaimsResponse::OpenIDClaims(claims) => Ok((claims.did, claims.email)), 285 + OpenIDClaimsResponse::SimpleError(simple_error) => bail!(simple_error.error_message()), 233 286 } 234 - 235 - Ok(response.email) 236 287 }
+4 -89
src/http/handle_oauth_aip_login.rs
··· 1 - use anyhow::{Result, anyhow}; 2 - use atproto_identity::resolve::IdentityResolver; 1 + use anyhow::Result; 3 2 use atproto_oauth::pkce::generate; 4 3 use atproto_oauth::workflow::OAuthRequestState as AipOAuthRequestState; 5 4 use atproto_oauth_aip::{ ··· 20 19 config::OAuthBackendConfig, 21 20 contextual_error, 22 21 http::{ 23 - context::WebContext, 24 - errors::{LoginError, WebError}, 25 - middleware_auth::Auth, 26 - middleware_i18n::Language, 22 + context::WebContext, errors::WebError, middleware_auth::Auth, middleware_i18n::Language, 27 23 utils::stringify, 28 24 }, 29 25 select_template, 30 - storage::{denylist::denylist_exists, identity_profile::handle_warm_up}, 31 26 }; 32 27 33 28 #[derive(Deserialize)] ··· 46 41 State(web_context): State<WebContext>, 47 42 Language(language): Language, 48 43 Cached(auth): Cached<Auth>, 49 - identity_resolver: IdentityResolver, 50 44 HxRequest(hx_request): HxRequest, 51 45 HxBoosted(hx_boosted): HxBoosted, 52 46 Query(destination): Query<Destination>, ··· 63 57 let error_template = select_template!(hx_boosted, hx_request, language); 64 58 65 59 if let Some(subject) = login_form.handle { 66 - // let handle_denied = denylist_exists(&web_context.pool, &[subject.as_str()]) 67 - // .await 68 - // .unwrap_or(true); 69 - // if handle_denied { 70 - // return contextual_error!( 71 - // web_context, 72 - // language, 73 - // error_template, 74 - // default_context, 75 - // anyhow!("access-denied") 76 - // ); 77 - // } 78 - 79 - // let document = match identity_resolver.resolve(&subject).await { 80 - // Ok(value) => value, 81 - // Err(err) => { 82 - // return contextual_error!( 83 - // web_context, 84 - // language, 85 - // error_template, 86 - // default_context, 87 - // err 88 - // ); 89 - // } 90 - // }; 91 - 92 - // let handle = match document 93 - // .handles() 94 - // .ok_or(WebError::Login(LoginError::NoHandle)) 95 - // { 96 - // Ok(value) => value, 97 - // Err(err) => { 98 - // tracing::error!(?err, "handles"); 99 - // return contextual_error!( 100 - // web_context, 101 - // language, 102 - // error_template, 103 - // default_context, 104 - // err 105 - // ); 106 - // } 107 - // }; 108 - 109 - // let pds = match document 110 - // .pds_endpoints() 111 - // .first() 112 - // .cloned() 113 - // .ok_or(WebError::Login(LoginError::NoPDS)) 114 - // { 115 - // Ok(value) => value, 116 - // Err(err) => { 117 - // tracing::error!(?err, "pds_endpoints first"); 118 - // return contextual_error!( 119 - // web_context, 120 - // language, 121 - // error_template, 122 - // default_context, 123 - // err 124 - // ); 125 - // } 126 - // }; 127 - 128 - // if let Err(err) = web_context 129 - // .document_storage 130 - // .store_document(document.clone()) 131 - // .await 132 - // { 133 - // tracing::error!(?err, "store_document"); 134 - // return contextual_error!(web_context, language, error_template, default_context, err); 135 - // } 136 - 137 - // // Insert the handle if it doesn't exist 138 - // if let Err(err) = handle_warm_up(&web_context.pool, &document.id, handle, pds).await { 139 - // tracing::error!(?err, "handle_warm_up"); 140 - // return contextual_error!(web_context, language, error_template, default_context, err); 141 - // } 142 - 143 60 // Generate OAuth parameters 144 61 let state: String = rand::thread_rng() 145 62 .sample_iter(&Alphanumeric) ··· 199 116 client_id: client_id.clone(), 200 117 client_secret: client_secret.clone(), 201 118 }; 202 - tracing::info!(oauth_client.redirect_uri, "oauth_client"); 203 119 204 120 // Initialize AIP OAuth flow 205 121 let par_response = oauth_init( 206 122 &web_context.http_client, 207 123 &oauth_client, 208 124 Some(&subject), 209 - &authorization_server, 125 + &authorization_server.pushed_authorization_request_endpoint, 210 126 &aip_oauth_request_state, 211 127 ) 212 128 .await; ··· 227 143 pkce_verifier: pkce_verifier.clone(), 228 144 229 145 issuer: authorization_server.issuer.clone(), 230 - // did: document.id, 231 - did: "unknown".to_string(), 146 + authorization_server: authorization_server.issuer.clone(), 232 147 233 148 signing_public_key: "".to_string(), 234 149 dpop_private_key: "".to_string(),
+11 -16
src/http/handle_oauth_callback.rs
··· 1 1 use anyhow::{Result, anyhow}; 2 2 use atproto_identity::{axum::state::KeyProviderExtractor, key::identify_key}; 3 - use atproto_oauth::workflow::{OAuthClient, oauth_complete}; 3 + use atproto_oauth::{ 4 + resources::oauth_authorization_server, 5 + workflow::{OAuthClient, oauth_complete}, 6 + }; 4 7 use axum::{ 5 8 extract::State, 6 9 response::{IntoResponse, Redirect}, ··· 88 91 ); 89 92 } 90 93 91 - let document = match web_context 92 - .document_storage 93 - .get_document_by_did(&oauth_request.did) 94 - .await 94 + let authorization_server = match oauth_authorization_server( 95 + &web_context.http_client, 96 + &oauth_request.authorization_server, 97 + ) 98 + .await 95 99 { 100 + Ok(value) => value, 96 101 Err(err) => { 97 102 return contextual_error!(web_context, language, error_template, default_context, err); 98 103 } 99 - Ok(None) => { 100 - return contextual_error!( 101 - web_context, 102 - language, 103 - error_template, 104 - default_context, 105 - anyhow!("identity did document not found in storage") 106 - ); 107 - } 108 - Ok(Some(value)) => value, 109 104 }; 110 105 111 106 let secret_signing_key = key_provider ··· 154 149 &dpop_key_data, 155 150 &callback_code, 156 151 &oauth_request, 157 - &document, 152 + &authorization_server, 158 153 ) 159 154 .await; 160 155 if let Err(err) = token_response {
+9 -3
src/http/handle_oauth_login.rs
··· 138 138 } 139 139 }; 140 140 141 - if let Err(err) = 142 - handle_warm_up(&web_context.pool, &did_document.id, primary_handle, pds).await 141 + if let Err(err) = handle_warm_up( 142 + &web_context.pool, 143 + &did_document.id, 144 + primary_handle, 145 + pds, 146 + None, 147 + ) 148 + .await 143 149 { 144 150 return contextual_error!(web_context, language, error_template, default_context, err); 145 151 } ··· 255 261 let oauth_request = OAuthRequest { 256 262 oauth_state: oauth_request_state.state.clone(), 257 263 issuer: authorization_server.issuer.clone(), 258 - did: did_document.id.clone(), 264 + authorization_server: authorization_server.issuer.clone(), 259 265 nonce: oauth_request_state.nonce.clone(), 260 266 pkce_verifier: pkce_verifier.clone(), 261 267 signing_public_key: public_signing_key,
+1 -4
src/http/middleware_auth.rs
··· 71 71 /// This creates a redirect URL with a signed token containing the destination, 72 72 /// which the login handler can verify and redirect back to after successful authentication. 73 73 #[instrument(level = "debug", skip(self), err)] 74 - pub(crate) fn require( 75 - &self, 76 - location: &str, 77 - ) -> Result<IdentityProfile, MiddlewareAuthError> { 74 + pub(crate) fn require(&self, location: &str) -> Result<IdentityProfile, MiddlewareAuthError> { 78 75 match self { 79 76 Auth::Pds { profile, .. } | Auth::Aip { profile, .. } => { 80 77 trace!(did = %profile.did, "User authenticated");
+5 -5
src/storage/atproto.rs
··· 13 13 struct OAuthRequestRow { 14 14 pub oauth_state: String, 15 15 pub issuer: String, 16 - pub did: String, 16 + pub authorization_server: String, 17 17 pub nonce: String, 18 18 pub pkce_verifier: String, 19 19 pub signing_public_key: String, ··· 201 201 202 202 sqlx::query( 203 203 "INSERT INTO atproto_oauth_requests ( 204 - oauth_state, issuer, did, nonce, pkce_verifier, 204 + oauth_state, issuer, authorization_server, nonce, pkce_verifier, 205 205 signing_public_key, dpop_private_key, created_at, expires_at 206 206 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", 207 207 ) 208 208 .bind(&request.oauth_state) 209 209 .bind(&request.issuer) 210 - .bind(&request.did) 210 + .bind(&request.authorization_server) 211 211 .bind(&request.nonce) 212 212 .bind(&request.pkce_verifier) 213 213 .bind(&request.signing_public_key) ··· 232 232 let mut tx = self.pool.begin().await?; 233 233 234 234 let result = sqlx::query_as::<_, OAuthRequestRow>( 235 - "SELECT oauth_state, issuer, did, nonce, pkce_verifier, 235 + "SELECT oauth_state, issuer, authorization_server, nonce, pkce_verifier, 236 236 signing_public_key, dpop_private_key, created_at, expires_at, destination 237 237 FROM atproto_oauth_requests 238 238 WHERE oauth_state = $1 AND expires_at > NOW()", ··· 247 247 let oauth_request = OAuthRequest { 248 248 oauth_state: row.oauth_state, 249 249 issuer: row.issuer, 250 - did: row.did, 250 + authorization_server: row.authorization_server, 251 251 nonce: row.nonce, 252 252 pkce_verifier: row.pkce_verifier, 253 253 signing_public_key: row.signing_public_key,
+15 -1
src/storage/identity_profile.rs
··· 35 35 did: &str, 36 36 handle: &str, 37 37 pds: &str, 38 + email: Option<&str>, 38 39 ) -> Result<(), StorageError> { 39 40 // Validate inputs aren't empty 40 41 if did.trim().is_empty() { ··· 61 62 .map_err(StorageError::CannotBeginDatabaseTransaction)?; 62 63 63 64 let now = Utc::now(); 64 - let insert_result = sqlx::query("INSERT INTO identity_profiles (did, handle, pds, created_at, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING") 65 + let insert_result = sqlx::query("INSERT INTO identity_profiles (did, handle, pds, email, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING") 65 66 .bind(did) 66 67 .bind(handle) 67 68 .bind(pds) 69 + .bind(email) 68 70 .bind(now) 69 71 .bind(now) 70 72 .execute(tx.as_mut()) ··· 78 80 .bind(now) 79 81 .bind(handle) 80 82 .bind(pds) 83 + .bind(did) 84 + .execute(tx.as_mut()) 85 + .await 86 + .map_err(StorageError::UnableToExecuteQuery)?; 87 + } 88 + 89 + if insert_result.rows_affected() == 0 && email.is_some() { 90 + sqlx::query( 91 + "UPDATE identity_profiles SET updated_at = $1, email = $2 WHERE did = $3 AND email is NULL", 92 + ) 93 + .bind(now) 94 + .bind(email) 81 95 .bind(did) 82 96 .execute(tx.as_mut()) 83 97 .await