A better Rust ATProto crate
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 28 kB View raw
1//! OAuth web helpers for Axum applications. 2//! 3//! This module adapts [`jacquard::oauth::client::OAuthClient`] to browser and 4//! server-side Axum flows. The OAuth client remains the single source of truth: 5//! metadata, authorization starts, callbacks, session restore, and logout all 6//! use the same client exposed from application state via [`OAuthWebState`]. 7//! 8//! Two authenticated-request styles are provided: 9//! 10//! - [`ExtractOAuthSession`] is strict and intended for APIs/headless routes. 11//! Missing or unusable auth rejects the request. 12//! - [`BrowserOAuthSession`] is browser-oriented. Missing or unusable auth 13//! redirects to a configured login/start route and preserves a local 14//! `return_to` target through the OAuth round trip using short-lived private 15//! cookies keyed by OAuth state. 16//! 17//! The private browser session cookie stores only an encoded 18//! [`SessionKey`](jacquard::common::session::SessionKey), never OAuth tokens. 19 20use std::{fmt, str::FromStr, sync::Arc}; 21 22use axum::{ 23 Form, Json, Router, 24 extract::{FromRef, FromRequestParts, Query, State}, 25 http::{HeaderMap, HeaderName, StatusCode, Uri, request::Parts}, 26 response::{IntoResponse, Redirect, Response}, 27 routing::{get, post}, 28}; 29use axum_extra::extract::PrivateCookieJar; 30use axum_extra::extract::cookie::{Cookie, SameSite}; 31use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 32use jacquard::common::deps::smol_str::{SmolStr, ToSmolStr}; 33use jacquard::identity::lexicon_resolver::LexiconSchemaResolver; 34use jacquard::{ 35 BosStr, 36 common::session::SessionKey, 37 oauth::{ 38 atproto::atproto_client_metadata, 39 authstore::ClientAuthStore, 40 client::{OAuthClient, OAuthSession}, 41 dpop::DpopExt, 42 resolver::OAuthResolver, 43 types::{AuthorizeOptions, CallbackParams}, 44 }, 45}; 46use serde::{Deserialize, Serialize}; 47use serde_json::json; 48 49/// Application-state access to the one OAuth client used by all web helpers. 50pub trait OAuthWebState<T, S> 51where 52 T: OAuthResolver, 53 S: ClientAuthStore, 54{ 55 /// Returns the configured OAuth client. 56 fn oauth_client(&self) -> &OAuthClient<T, S>; 57} 58 59impl<T, S> OAuthWebState<T, S> for OAuthClient<T, S> 60where 61 T: OAuthResolver, 62 S: ClientAuthStore, 63{ 64 fn oauth_client(&self) -> &OAuthClient<T, S> { 65 self 66 } 67} 68 69impl<T, S> OAuthWebState<T, S> for Arc<OAuthClient<T, S>> 70where 71 T: OAuthResolver, 72 S: ClientAuthStore, 73{ 74 fn oauth_client(&self) -> &OAuthClient<T, S> { 75 self.as_ref() 76 } 77} 78 79/// Route and cookie behavior for OAuth browser helpers. 80#[derive(Clone, Debug)] 81pub struct OAuthWebConfig { 82 /// Private cookie containing the encoded [`SessionKey`]. 83 pub cookie_name: SmolStr, 84 /// Prefix used for state-keyed private return-target cookies. 85 pub return_cookie_prefix: SmolStr, 86 /// Route that starts OAuth from an identifier. 87 pub start_auth_path: SmolStr, 88 /// Optional local login page used when no identifier is known. 89 pub login_page_path: Option<SmolStr>, 90 /// OAuth callback route. 91 pub callback_path: SmolStr, 92 /// Logout route. 93 pub logout_path: SmolStr, 94 /// Fallback redirect after a successful callback. 95 pub after_callback_redirect: SmolStr, 96 /// Fallback redirect after logout. 97 pub after_logout_redirect: Option<SmolStr>, 98 /// Header name for passing an encoded session key in API/headless requests. 99 /// 100 /// Clients that do not use browser cookies can pass the session key as this 101 /// header value instead. The session key is a JSON-encoded 102 /// `{"did": "...", "session_id": "..."}` string. Checked by 103 /// [`ExtractOAuthSession`] and [`ExtractOptionalOAuthSession`] as a fallback 104 /// when no session cookie is present. Defaults to `x-jacquard-session`. 105 pub session_header: HeaderName, 106} 107 108impl Default for OAuthWebConfig { 109 fn default() -> Self { 110 Self { 111 cookie_name: SmolStr::new_static("jacquard_oauth_session"), 112 return_cookie_prefix: SmolStr::new_static("jacquard_oauth_return_"), 113 start_auth_path: SmolStr::new_static("/oauth/start"), 114 login_page_path: Some(SmolStr::new_static("/oauth/login")), 115 callback_path: SmolStr::new_static("/oauth/callback"), 116 logout_path: SmolStr::new_static("/oauth/logout"), 117 after_callback_redirect: SmolStr::new_static("/"), 118 after_logout_redirect: Some(SmolStr::new_static("/")), 119 session_header: HeaderName::from_static("x-jacquard-session"), 120 } 121 } 122} 123 124/// Error type returned by OAuth Axum helpers. 125#[derive(Debug, thiserror::Error)] 126pub enum OAuthAxumError { 127 /// The request did not contain a usable session binding. 128 #[error("missing OAuth session")] 129 MissingSession, 130 /// A session key was syntactically invalid. 131 #[error("malformed OAuth session key: {0}")] 132 MalformedSessionKey(String), 133 /// A return target was unsafe or invalid. 134 #[error("invalid return target")] 135 InvalidReturnTo, 136 /// OAuth protocol/client error. 137 #[error(transparent)] 138 OAuth(#[from] jacquard::oauth::error::OAuthError), 139 /// AT Protocol OAuth metadata conversion error. 140 #[error(transparent)] 141 Atproto(#[from] jacquard::oauth::atproto::Error), 142 /// JSON serialization error. 143 #[error(transparent)] 144 Json(#[from] serde_json::Error), 145 /// Form serialization error. 146 #[error(transparent)] 147 Form(#[from] serde_html_form::ser::Error), 148} 149 150impl OAuthAxumError { 151 fn status(&self) -> StatusCode { 152 match self { 153 Self::MissingSession => StatusCode::UNAUTHORIZED, 154 Self::MalformedSessionKey(_) | Self::InvalidReturnTo => StatusCode::BAD_REQUEST, 155 Self::OAuth(err) if is_unauthorized_oauth_error(err) => StatusCode::UNAUTHORIZED, 156 Self::OAuth(jacquard::oauth::error::OAuthError::Callback(_)) => StatusCode::BAD_REQUEST, 157 Self::OAuth(_) | Self::Atproto(_) | Self::Json(_) | Self::Form(_) => { 158 StatusCode::INTERNAL_SERVER_ERROR 159 } 160 } 161 } 162 163 fn code(&self) -> &'static str { 164 match self { 165 Self::MissingSession => "AuthenticationRequired", 166 Self::MalformedSessionKey(_) | Self::InvalidReturnTo => "InvalidRequest", 167 Self::OAuth(jacquard::oauth::error::OAuthError::Callback(_)) => "InvalidRequest", 168 Self::OAuth(err) if is_unauthorized_oauth_error(err) => "AuthenticationRequired", 169 Self::OAuth(_) | Self::Atproto(_) | Self::Json(_) | Self::Form(_) => { 170 "InternalServerError" 171 } 172 } 173 } 174} 175 176impl IntoResponse for OAuthAxumError { 177 fn into_response(self) -> Response { 178 let status = self.status(); 179 let code = self.code(); 180 ( 181 status, 182 Json(json!({ "error": code, "message": self.to_string() })), 183 ) 184 .into_response() 185 } 186} 187 188/// Rejection returned by [`BrowserOAuthSession`]. 189#[derive(Debug)] 190pub enum BrowserOAuthRejection { 191 /// Redirect the browser and return an updated cookie jar. 192 Redirect(PrivateCookieJar, Redirect), 193 /// Non-auth infrastructure failure. 194 Error(OAuthAxumError), 195} 196 197impl IntoResponse for BrowserOAuthRejection { 198 fn into_response(self) -> Response { 199 match self { 200 Self::Redirect(jar, redirect) => (jar, redirect).into_response(), 201 Self::Error(err) => err.into_response(), 202 } 203 } 204} 205 206/// Strict OAuth session extractor for API/headless routes. 207/// 208/// Looks up the session key from a private cookie (browser clients) or a custom 209/// header (API/headless clients). The header name is configurable via 210/// [`OAuthWebConfig::session_header`] and defaults to `x-jacquard-session`. 211/// 212/// API clients that do not use browser cookies can pass the encoded session key 213/// (a JSON `{"did": "...", "session_id": "..."}` string) in this header to 214/// authenticate without cookie infrastructure. If both a cookie and a header are 215/// present, the cookie takes precedence. 216/// 217/// Use [`ExtractOptionalOAuthSession`] for endpoints that serve both 218/// authenticated and anonymous requests. 219/// Use [`BrowserOAuthSession`] for browser routes that should redirect to login 220/// on failure. 221pub struct ExtractOAuthSession<T, S>(pub OAuthSession<T, S>) 222where 223 T: OAuthResolver, 224 S: ClientAuthStore; 225 226impl<T, S, AppState> FromRequestParts<AppState> for ExtractOAuthSession<T, S> 227where 228 T: OAuthResolver + DpopExt + Send + Sync + 'static, 229 S: ClientAuthStore + Send + Sync + 'static, 230 AppState: OAuthWebState<T, S> + Send + Sync, 231 OAuthWebConfig: FromRef<AppState>, 232 axum_extra::extract::cookie::Key: FromRef<AppState>, 233{ 234 type Rejection = OAuthAxumError; 235 236 async fn from_request_parts( 237 parts: &mut Parts, 238 state: &AppState, 239 ) -> Result<Self, Self::Rejection> { 240 let config = OAuthWebConfig::from_ref(state); 241 let jar = PrivateCookieJar::from_request_parts(parts, state) 242 .await 243 .map_err(|_| OAuthAxumError::MissingSession)?; 244 let key = read_session_key(&jar, &parts.headers, &config)?; 245 let session = restore_session(state.oauth_client(), &key).await?; 246 Ok(Self(session)) 247 } 248} 249 250/// Browser-oriented OAuth session extractor that redirects unauthenticated users. 251pub struct BrowserOAuthSession<T, S>(pub OAuthSession<T, S>) 252where 253 T: OAuthResolver, 254 S: ClientAuthStore; 255 256impl<T, S, AppState> FromRequestParts<AppState> for BrowserOAuthSession<T, S> 257where 258 T: OAuthResolver + DpopExt + Send + Sync + 'static, 259 S: ClientAuthStore + Send + Sync + 'static, 260 AppState: OAuthWebState<T, S> + Send + Sync, 261 OAuthWebConfig: FromRef<AppState>, 262 axum_extra::extract::cookie::Key: FromRef<AppState>, 263{ 264 type Rejection = BrowserOAuthRejection; 265 266 async fn from_request_parts( 267 parts: &mut Parts, 268 state: &AppState, 269 ) -> Result<Self, Self::Rejection> { 270 let config = OAuthWebConfig::from_ref(state); 271 let jar = PrivateCookieJar::from_request_parts(parts, state) 272 .await 273 .map_err(|_| BrowserOAuthRejection::Error(OAuthAxumError::MissingSession))?; 274 let return_to = return_to_from_uri(&parts.uri).unwrap_or_else(|| "/".to_smolstr()); 275 276 let Some(cookie) = jar.get(config.cookie_name.as_str()) else { 277 return Err(redirect_to_login(jar, &config, &return_to)); 278 }; 279 280 let key = match decode_session_key(cookie.value()) { 281 Ok(key) => key, 282 Err(_) => { 283 let jar = clear_session_cookie(jar, &config); 284 return Err(redirect_to_login(jar, &config, &return_to)); 285 } 286 }; 287 288 match restore_session(state.oauth_client(), &key).await { 289 Ok(session) => Ok(Self(session)), 290 Err(err) if is_unauthorized_oauth_error(&err) => { 291 let jar = clear_session_cookie(jar, &config); 292 Err(redirect_to_start_with_identifier( 293 jar, 294 &config, 295 key.did.as_str(), 296 &return_to, 297 )) 298 } 299 Err(err) => Err(BrowserOAuthRejection::Error(err.into())), 300 } 301 } 302} 303 304/// Optional OAuth session extractor for API/headless routes. 305/// 306/// Like [`ExtractOAuthSession`], but returns `None` when no session key is 307/// present (no cookie and no header). If a session key IS present but invalid, 308/// returns an error. 309/// 310/// Use this for endpoints that serve both authenticated and anonymous requests. 311pub struct ExtractOptionalOAuthSession<T, S>(pub Option<OAuthSession<T, S>>) 312where 313 T: OAuthResolver, 314 S: ClientAuthStore; 315 316impl<T, S, AppState> FromRequestParts<AppState> for ExtractOptionalOAuthSession<T, S> 317where 318 T: OAuthResolver + DpopExt + Send + Sync + 'static, 319 S: ClientAuthStore + Send + Sync + 'static, 320 AppState: OAuthWebState<T, S> + Send + Sync, 321 OAuthWebConfig: FromRef<AppState>, 322 axum_extra::extract::cookie::Key: FromRef<AppState>, 323{ 324 type Rejection = OAuthAxumError; 325 326 async fn from_request_parts( 327 parts: &mut Parts, 328 state: &AppState, 329 ) -> Result<Self, Self::Rejection> { 330 let config = OAuthWebConfig::from_ref(state); 331 let jar = PrivateCookieJar::from_request_parts(parts, state) 332 .await 333 .map_err(|_| OAuthAxumError::MissingSession)?; 334 match read_session_key(&jar, &parts.headers, &config) { 335 Ok(key) => { 336 let session = restore_session(state.oauth_client(), &key).await?; 337 Ok(Self(Some(session))) 338 } 339 Err(OAuthAxumError::MissingSession) => Ok(Self(None)), 340 Err(e) => Err(e), 341 } 342 } 343} 344 345/// Optional browser-oriented OAuth session extractor. 346/// 347/// Like [`BrowserOAuthSession`], but returns `None` when no session cookie is 348/// present instead of redirecting to login. If a cookie IS present but the 349/// session is invalid or expired, the cookie is cleared and the extractor 350/// still returns `None` rather than erroring or redirecting. 351/// 352/// Use this for browser pages that show different content for authenticated 353/// vs. anonymous users, without forcing a login redirect. 354pub struct OptionalBrowserOAuthSession<T, S>(pub Option<OAuthSession<T, S>>) 355where 356 T: OAuthResolver, 357 S: ClientAuthStore; 358 359impl<T, S, AppState> FromRequestParts<AppState> for OptionalBrowserOAuthSession<T, S> 360where 361 T: OAuthResolver + DpopExt + Send + Sync + 'static, 362 S: ClientAuthStore + Send + Sync + 'static, 363 AppState: OAuthWebState<T, S> + Send + Sync, 364 OAuthWebConfig: FromRef<AppState>, 365 axum_extra::extract::cookie::Key: FromRef<AppState>, 366{ 367 type Rejection = OAuthAxumError; 368 369 async fn from_request_parts( 370 parts: &mut Parts, 371 state: &AppState, 372 ) -> Result<Self, Self::Rejection> { 373 let config = OAuthWebConfig::from_ref(state); 374 let jar = PrivateCookieJar::from_request_parts(parts, state) 375 .await 376 .map_err(|_| OAuthAxumError::MissingSession)?; 377 378 let Some(cookie) = jar.get(config.cookie_name.as_str()) else { 379 return Ok(Self(None)); 380 }; 381 382 let key = match decode_session_key(cookie.value()) { 383 Ok(key) => key, 384 Err(_) => { 385 // Stale or malformed cookie — clear it and treat as anonymous. 386 let jar = clear_session_cookie(jar, &config); 387 // The jar is consumed by clear; we can't write it back here, so 388 // we just return None. The cookie will expire naturally. 389 drop(jar); 390 return Ok(Self(None)); 391 } 392 }; 393 394 match restore_session(state.oauth_client(), &key).await { 395 Ok(session) => Ok(Self(Some(session))), 396 Err(err) if is_unauthorized_oauth_error(&err) => { 397 // Expired/revoked session — treat as anonymous rather than erroring. 398 Ok(Self(None)) 399 } 400 Err(err) => Err(err.into()), 401 } 402 } 403} 404 405/// Query/form fields accepted by the default start-auth route adapters. 406#[derive(Debug, Clone, Deserialize)] 407pub struct StartAuthRequest { 408 /// Handle, DID, PDS URL, or entryway identifier to authenticate. 409 pub identifier: SmolStr, 410 /// Optional local path to return to after callback. 411 #[serde(default)] 412 pub return_to: Option<SmolStr>, 413} 414 415/// Conventional OAuth web routes using the state-provided OAuth client. 416/// 417/// The route paths are taken from `config` so that custom 418/// [`OAuthWebConfig`] paths are honored consistently with the redirects and 419/// cookie scopes produced by the browser helpers. Passing the same config 420/// instance that you expose via [`FromRef`] keeps the mounted routes and the 421/// helpers in agreement. 422pub fn routes<T, S, AppState>(config: &OAuthWebConfig) -> Router<AppState> 423where 424 T: OAuthResolver + DpopExt + LexiconSchemaResolver + Send + Sync + 'static, 425 S: ClientAuthStore + Send + Sync + 'static, 426 AppState: OAuthWebState<T, S> + Clone + Send + Sync + 'static, 427 OAuthWebConfig: FromRef<AppState>, 428 axum_extra::extract::cookie::Key: FromRef<AppState>, 429{ 430 Router::new() 431 .route( 432 "/oauth-client-metadata.json", 433 get(client_metadata_handler::<T, S, AppState>), 434 ) 435 .route( 436 config.start_auth_path.as_str(), 437 get(start_auth_query::<T, S, AppState>), 438 ) 439 .route( 440 config.start_auth_path.as_str(), 441 post(start_auth_form::<T, S, AppState>), 442 ) 443 .route( 444 config.callback_path.as_str(), 445 get(callback_handler::<T, S, AppState>), 446 ) 447 .route( 448 config.logout_path.as_str(), 449 post(logout_handler::<T, S, AppState>), 450 ) 451} 452 453/// Serve OAuth client metadata derived from the state OAuth client. 454pub async fn client_metadata_handler<T, S, AppState>( 455 State(state): State<AppState>, 456) -> Result<impl IntoResponse, OAuthAxumError> 457where 458 T: OAuthResolver + DpopExt + Send + Sync + 'static, 459 S: ClientAuthStore + Send + Sync + 'static, 460 AppState: OAuthWebState<T, S>, 461{ 462 let oauth = state.oauth_client(); 463 let metadata = atproto_client_metadata( 464 &oauth.registry.client_data.config, 465 &oauth.registry.client_data.keyset, 466 )?; 467 Ok(Json(metadata)) 468} 469 470/// Start OAuth from query parameters and return an authorization redirect. 471pub async fn start_auth_query<T, S, AppState>( 472 State(state): State<AppState>, 473 State(config): State<OAuthWebConfig>, 474 jar: PrivateCookieJar, 475 Query(input): Query<StartAuthRequest>, 476) -> Result<(PrivateCookieJar, Redirect), OAuthAxumError> 477where 478 T: OAuthResolver + DpopExt + Send + Sync + 'static, 479 S: ClientAuthStore + Send + Sync + 'static, 480 AppState: OAuthWebState<T, S>, 481{ 482 start_auth_with_return_cookie(state.oauth_client(), &config, jar, input).await 483} 484 485/// Start OAuth from form fields and return an authorization redirect. 486pub async fn start_auth_form<T, S, AppState>( 487 State(state): State<AppState>, 488 State(config): State<OAuthWebConfig>, 489 jar: PrivateCookieJar, 490 Form(input): Form<StartAuthRequest>, 491) -> Result<(PrivateCookieJar, Redirect), OAuthAxumError> 492where 493 T: OAuthResolver + DpopExt + Send + Sync + 'static, 494 S: ClientAuthStore + Send + Sync + 'static, 495 AppState: OAuthWebState<T, S>, 496{ 497 start_auth_with_return_cookie(state.oauth_client(), &config, jar, input).await 498} 499 500/// Complete OAuth callback, set the private session cookie, and redirect. 501pub async fn callback_handler<T, S, AppState>( 502 State(state): State<AppState>, 503 State(config): State<OAuthWebConfig>, 504 jar: PrivateCookieJar, 505 Query(params): Query<CallbackParams>, 506) -> Result<(PrivateCookieJar, Redirect), OAuthAxumError> 507where 508 T: OAuthResolver + DpopExt + Send + Sync + 'static + LexiconSchemaResolver, 509 S: ClientAuthStore + Send + Sync + 'static, 510 AppState: OAuthWebState<T, S>, 511{ 512 let state_value = params.state.clone(); 513 let session = state.oauth_client().callback(params).await?; 514 let (did, session_id) = session.session_info().await; 515 let key = SessionKey::new(did, session_id); 516 let mut jar = set_session_cookie(jar, &config, &key)?; 517 let redirect_to = if let Some(state_value) = state_value { 518 let (new_jar, return_to) = take_return_to_cookie(jar, &config, state_value.as_ref()); 519 jar = new_jar; 520 return_to.unwrap_or_else(|| config.after_callback_redirect.clone()) 521 } else { 522 config.after_callback_redirect.clone() 523 }; 524 Ok((jar, Redirect::to(redirect_to.as_str()))) 525} 526 527/// Logout the current OAuth session, clear the private session cookie, and redirect or return 204. 528pub async fn logout_handler<T, S, AppState>( 529 State(state): State<AppState>, 530 State(config): State<OAuthWebConfig>, 531 jar: PrivateCookieJar, 532 headers: HeaderMap, 533) -> Result<Response, OAuthAxumError> 534where 535 T: OAuthResolver + DpopExt + Send + Sync + 'static, 536 S: ClientAuthStore + Send + Sync + 'static, 537 AppState: OAuthWebState<T, S>, 538{ 539 let key = read_session_key(&jar, &headers, &config)?; 540 let session = restore_session(state.oauth_client(), &key).await?; 541 session.logout().await?; 542 let jar = clear_session_cookie(jar, &config); 543 Ok(match &config.after_logout_redirect { 544 Some(path) => (jar, Redirect::to(path.as_str())).into_response(), 545 None => (jar, StatusCode::NO_CONTENT).into_response(), 546 }) 547} 548 549/// Start OAuth and return a redirect to the authorization server. 550pub async fn start_auth_redirect<T, S, I, Str>( 551 oauth: &OAuthClient<T, S>, 552 identifier: I, 553 options: AuthorizeOptions<Str>, 554) -> Result<Redirect, OAuthAxumError> 555where 556 T: OAuthResolver + DpopExt + Send + Sync + 'static, 557 S: ClientAuthStore + Send + Sync + 'static, 558 I: AsRef<str>, 559 Str: BosStr + FromStr + Ord + Clone + fmt::Debug, 560 <Str as FromStr>::Err: fmt::Debug, 561{ 562 let url = oauth.start_auth(identifier.as_ref(), options).await?; 563 Ok(Redirect::temporary(&url)) 564} 565 566/// Encode a session key for cookies or headers. 567pub fn encode_session_key(key: &SessionKey) -> Result<String, OAuthAxumError> { 568 Ok(URL_SAFE_NO_PAD.encode(serde_json::to_vec(key)?)) 569} 570 571/// Decode a session key from cookies or headers. 572pub fn decode_session_key(value: &str) -> Result<SessionKey, OAuthAxumError> { 573 let bytes = URL_SAFE_NO_PAD 574 .decode(value) 575 .map_err(|err| OAuthAxumError::MalformedSessionKey(err.to_string()))?; 576 serde_json::from_slice(&bytes) 577 .map_err(|err| OAuthAxumError::MalformedSessionKey(err.to_string())) 578} 579 580/// Validate a local browser return target. 581pub fn validate_return_to(value: &str) -> Result<SmolStr, OAuthAxumError> { 582 if !value.starts_with('/') 583 || value.starts_with("//") 584 || value.contains('\\') 585 || value.chars().any(|ch| ch.is_control()) 586 { 587 return Err(OAuthAxumError::InvalidReturnTo); 588 } 589 Ok(SmolStr::from(value)) 590} 591 592/// Set the private browser session cookie. 593pub fn set_session_cookie( 594 jar: PrivateCookieJar, 595 config: &OAuthWebConfig, 596 key: &SessionKey, 597) -> Result<PrivateCookieJar, OAuthAxumError> { 598 let mut cookie = Cookie::new(config.cookie_name.to_string(), encode_session_key(key)?); 599 cookie.set_http_only(true); 600 cookie.set_same_site(SameSite::Lax); 601 cookie.set_path("/"); 602 Ok(jar.add(cookie)) 603} 604 605/// Clear the private browser session cookie. 606pub fn clear_session_cookie(jar: PrivateCookieJar, config: &OAuthWebConfig) -> PrivateCookieJar { 607 let mut cookie = Cookie::from(config.cookie_name.to_string()); 608 cookie.set_path("/"); 609 jar.remove(cookie) 610} 611 612async fn restore_session<T, S>( 613 oauth: &OAuthClient<T, S>, 614 key: &SessionKey, 615) -> Result<OAuthSession<T, S>, jacquard::oauth::error::OAuthError> 616where 617 T: OAuthResolver + DpopExt + Send + Sync + 'static, 618 S: ClientAuthStore + Send + Sync + 'static, 619{ 620 oauth.restore(&key.did, key.session_id.as_str()).await 621} 622 623fn read_session_key( 624 jar: &PrivateCookieJar, 625 headers: &HeaderMap, 626 config: &OAuthWebConfig, 627) -> Result<SessionKey, OAuthAxumError> { 628 if let Some(cookie) = jar.get(config.cookie_name.as_str()) { 629 return decode_session_key(cookie.value()); 630 } 631 if let Some(header) = headers.get(&config.session_header) { 632 let value = header 633 .to_str() 634 .map_err(|err| OAuthAxumError::MalformedSessionKey(err.to_string()))?; 635 return decode_session_key(value); 636 } 637 Err(OAuthAxumError::MissingSession) 638} 639 640async fn start_auth_with_return_cookie<T, S>( 641 oauth: &OAuthClient<T, S>, 642 config: &OAuthWebConfig, 643 jar: PrivateCookieJar, 644 input: StartAuthRequest, 645) -> Result<(PrivateCookieJar, Redirect), OAuthAxumError> 646where 647 T: OAuthResolver + DpopExt + Send + Sync + 'static, 648 S: ClientAuthStore + Send + Sync + 'static, 649{ 650 let mut options = AuthorizeOptions::<SmolStr>::default(); 651 let jar = if let Some(return_to) = input.return_to.as_deref() { 652 let return_to = validate_return_to(return_to)?; 653 let state = jacquard::oauth::utils::generate_nonce(); 654 options.state = Some(state.clone()); 655 set_return_to_cookie(jar, config, state.as_str(), return_to.as_str()) 656 } else { 657 jar 658 }; 659 let redirect = start_auth_redirect(oauth, input.identifier, options).await?; 660 Ok((jar, redirect)) 661} 662 663fn set_return_to_cookie( 664 jar: PrivateCookieJar, 665 config: &OAuthWebConfig, 666 state: &str, 667 return_to: &str, 668) -> PrivateCookieJar { 669 let mut cookie = Cookie::new(return_cookie_name(config, state), return_to.to_owned()); 670 cookie.set_http_only(true); 671 cookie.set_same_site(SameSite::Lax); 672 cookie.set_path(config.callback_path.to_string()); 673 jar.add(cookie) 674} 675 676fn take_return_to_cookie( 677 jar: PrivateCookieJar, 678 config: &OAuthWebConfig, 679 state: &str, 680) -> (PrivateCookieJar, Option<SmolStr>) { 681 let name = return_cookie_name(config, state); 682 let return_to = jar 683 .get(&name) 684 .and_then(|cookie| validate_return_to(cookie.value()).ok()); 685 let mut removal = Cookie::from(name); 686 removal.set_path(config.callback_path.to_string()); 687 (jar.remove(removal), return_to) 688} 689 690fn return_cookie_name(config: &OAuthWebConfig, state: &str) -> String { 691 format!( 692 "{}{}", 693 config.return_cookie_prefix, 694 URL_SAFE_NO_PAD.encode(state.as_bytes()) 695 ) 696} 697 698fn return_to_from_uri(uri: &Uri) -> Option<SmolStr> { 699 let mut path = uri.path().to_owned(); 700 if let Some(query) = uri.query() { 701 path.push('?'); 702 path.push_str(query); 703 } 704 validate_return_to(&path).ok() 705} 706 707fn redirect_to_login( 708 jar: PrivateCookieJar, 709 config: &OAuthWebConfig, 710 return_to: &str, 711) -> BrowserOAuthRejection { 712 let path = config 713 .login_page_path 714 .as_ref() 715 .unwrap_or(&config.start_auth_path); 716 BrowserOAuthRejection::Redirect( 717 jar, 718 Redirect::temporary(&append_query(path, &[("return_to", return_to)])), 719 ) 720} 721 722fn redirect_to_start_with_identifier( 723 jar: PrivateCookieJar, 724 config: &OAuthWebConfig, 725 identifier: &str, 726 return_to: &str, 727) -> BrowserOAuthRejection { 728 BrowserOAuthRejection::Redirect( 729 jar, 730 Redirect::temporary(&append_query( 731 &config.start_auth_path, 732 &[("identifier", identifier), ("return_to", return_to)], 733 )), 734 ) 735} 736 737fn append_query(path: &str, params: &[(&str, &str)]) -> String { 738 #[derive(Serialize)] 739 struct Pair<'a> { 740 #[serde(flatten)] 741 values: std::collections::BTreeMap<&'a str, &'a str>, 742 } 743 let values = params.iter().map(|(k, v)| (*k, *v)).collect(); 744 let query = serde_html_form::to_string(Pair { values }).unwrap_or_default(); 745 format!("{path}?{query}") 746} 747 748fn is_unauthorized_oauth_error(err: &jacquard::oauth::error::OAuthError) -> bool { 749 match err { 750 jacquard::oauth::error::OAuthError::Session(session_err) => { 751 matches!( 752 session_err, 753 jacquard::oauth::session::Error::SessionNotFound 754 ) 755 } 756 jacquard::oauth::error::OAuthError::Request(request_err) => request_err.is_permanent(), 757 _ => false, 758 } 759} 760 761#[cfg(test)] 762mod tests { 763 use super::*; 764 use jacquard::types::string::Did; 765 766 #[test] 767 fn session_key_codec_round_trips() { 768 let key = SessionKey::new(Did::new_static("did:plc:alice").unwrap(), "state"); 769 let encoded = encode_session_key(&key).unwrap(); 770 assert_eq!(decode_session_key(&encoded).unwrap(), key); 771 } 772 773 #[test] 774 fn session_key_codec_rejects_malformed_payload() { 775 assert!(matches!( 776 decode_session_key("not base64"), 777 Err(OAuthAxumError::MalformedSessionKey(_)) 778 )); 779 } 780 781 #[test] 782 fn return_to_validation_rejects_unsafe_targets() { 783 assert_eq!( 784 validate_return_to("/protected?x=1").unwrap(), 785 "/protected?x=1" 786 ); 787 assert!(validate_return_to("https://evil.example/").is_err()); 788 assert!(validate_return_to("//evil.example/").is_err()); 789 assert!(validate_return_to("/evil\\path").is_err()); 790 } 791 792 #[test] 793 fn return_cookie_names_are_keyed_by_state() { 794 let config = OAuthWebConfig::default(); 795 assert_ne!( 796 return_cookie_name(&config, "state-a"), 797 return_cookie_name(&config, "state-b") 798 ); 799 } 800}