A better Rust ATProto crate
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}