//! OAuth routes (docs/07): handle input → PAR → PDS redirect → callback → //! cookie session. Cookies are opaque server-side IDs mapping to jacquard OAuth //! sessions (per-DID) in the SQLite auth store; jacquard handles DPoP nonces //! and token refresh. use axum::extract::{Form, Query, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use jacquard::oauth::client::OAuthSession; use jacquard::oauth::types::{AuthorizeOptions, CallbackParams}; use rand::RngCore; use serde::Deserialize; use crate::atproto::{self, UserAgent}; use crate::state::SharedState; pub const COOKIE: &str = "ideonomics_session"; pub async fn client_metadata_json(State(state): State) -> Response { let meta = atproto::client_metadata(&state.config); match jacquard::oauth::atproto::atproto_client_metadata(&meta, &None) { Ok(wire) => ( [(header::CONTENT_TYPE, "application/json")], serde_json::to_string_pretty(&wire).unwrap_or_default(), ) .into_response(), Err(e) => { (StatusCode::INTERNAL_SERVER_ERROR, format!("metadata error: {e}")).into_response() } } } #[derive(Deserialize)] pub struct LoginForm { handle: String, } pub async fn login( State(state): State, Form(form): Form, ) -> Response { let handle = form.handle.trim().trim_start_matches('@').to_string(); let opts = AuthorizeOptions::default() .with_scope_str("atproto transition:generic") .expect("valid scopes"); match state.oauth.start_auth(&handle, opts).await { Ok(url) => Redirect::to(&url).into_response(), Err(e) => ( StatusCode::BAD_REQUEST, crate::web::pages::shell( "login failed", false, maud::html! { p class="err" { "Could not begin login for “" (handle) "”: " (e.to_string()) } p { a href="/" { "try again" } } }, ), ) .into_response(), } } pub async fn callback( State(state): State, jar: CookieJar, Query(params): Query, ) -> Response { let session = match state.oauth.callback(params).await { Ok(s) => s, Err(e) => { return ( StatusCode::BAD_REQUEST, crate::web::pages::shell( "login failed", false, maud::html! { p class="err" { "OAuth callback failed: " (e.to_string()) } p { a href="/" { "try again" } } }, ), ) .into_response(); } }; let (did, session_id) = session.session_info().await; // Best-effort handle for display names (atlas, comparisons). let handle: Option = { use jacquard::identity::resolver::IdentityResolver; match state.oauth.resolve_did_doc(&did).await { Ok(doc) => doc .parse() .ok() .and_then(|d| d.handles().first().map(|h| h.as_str().to_string())), Err(_) => None, } }; let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); let cookie_id: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); if let Err(e) = sqlx::query( "INSERT INTO web_sessions (cookie, did, oauth_session_id, handle, created_at) VALUES (?,?,?,?,?)", ) .bind(&cookie_id) .bind(did.as_str()) .bind(session_id.as_str()) .bind(&handle) .bind(crate::db::now()) .execute(&state.pool) .await { return (StatusCode::INTERNAL_SERVER_ERROR, format!("session store failed: {e}")) .into_response(); } let cookie = Cookie::build((COOKIE, cookie_id)) .path("/") .http_only(true) .same_site(SameSite::Lax) .permanent() .build(); (jar.add(cookie), Redirect::to("/session")).into_response() } pub async fn logout(State(state): State, jar: CookieJar) -> Response { if let Some(cookie) = jar.get(COOKIE) { let _ = sqlx::query("DELETE FROM web_sessions WHERE cookie = ?") .bind(cookie.value()) .execute(&state.pool) .await; } (jar.remove(Cookie::from(COOKIE)), Redirect::to("/")).into_response() } /// The authenticated caller: DID plus a restorable jacquard OAuth session. pub struct Viewer { pub did: String, pub cookie: String, } impl Viewer { /// Restore the jacquard session (refreshing tokens if needed) and wrap it /// in an Agent for typed record I/O against the user's PDS. pub async fn agent(&self, state: &crate::state::AppState) -> anyhow::Result { let row: Option<(String, String)> = sqlx::query_as( "SELECT did, oauth_session_id FROM web_sessions WHERE cookie = ?", ) .bind(&self.cookie) .fetch_optional(&state.pool) .await?; let (did, session_id) = row.ok_or_else(|| anyhow::anyhow!("session vanished"))?; let did = jacquard::types::string::Did::new(smol_str::SmolStr::new(did)) .map_err(|e| anyhow::anyhow!("bad did: {e}"))?; let session: OAuthSession<_, _> = state .oauth .restore(&did, &session_id) .await .map_err(|e| anyhow::anyhow!("session restore failed: {e}"))?; Ok(jacquard::client::Agent::from(session)) } } pub async fn viewer(state: &crate::state::AppState, jar: &CookieJar) -> Option { let cookie = jar.get(COOKIE)?.value().to_string(); let row: Option<(String,)> = sqlx::query_as("SELECT did FROM web_sessions WHERE cookie = ?") .bind(&cookie) .fetch_optional(&state.pool) .await .ok()?; row.map(|(did,)| Viewer { did, cookie }) }