Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
6.1 kB · 175 lines
Rust
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176//! 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<SharedState>) -> 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<SharedState>, Form(form): Form<LoginForm>,) -> 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<SharedState>, jar: CookieJar, Query(params): Query<CallbackParams>,) -> 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<String> = { 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<SharedState>, 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<UserAgent> { 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<Viewer> { 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 })}