Something went wrong. Try again.
Paper Mario mod hosting site - AT Protocol appview starhaven.dev
appview
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246//! OAuth callback: exchange the authorization code for DPoP-bound tokens and//! set the encrypted session + readable identity cookies.
use atproto_identity::key::{identify_key, to_public};use atproto_oauth::resources::pds_resources;use atproto_oauth::workflow::{oauth_complete, OAuthClient, OAuthRequest};use axum::extract::{Query, State};use axum::http::{header, HeaderMap};use axum::response::{IntoResponse, Redirect, Response};use axum::routing::get;use axum::Router;use chrono::{Duration, Utc};use serde::Deserialize;
use crate::atproto::id::Did;use crate::config::OAUTH_SCOPE;use crate::error::AppError;use crate::oauth::login::LoginTarget;use crate::oauth::session::{ build_identity_cookie_header, build_session_cookie_header, encode_identity_cookie, encode_session_cookie, IdentityCookie, SessionCookie,};use crate::state::AppState;
/// Query parameters for `GET /callback`.#[derive(Debug, Deserialize)]pub struct CallbackQuery { /// The opaque state value echoed back. pub state: String, /// The issuer (RFC 9207), if present. pub iss: Option<String>, /// The authorization code. pub code: Option<String>, /// An error code, if the authorization failed. pub error: Option<String>,}
/// `GET /callback` -- complete the OAuth flow and set session cookies.pub async fn handle_get_callback( State(state): State<AppState>, Query(query): Query<CallbackQuery>,) -> Result<Response, AppError> { if let Some(error) = query.error { return Err(AppError::BadRequest(format!( "authorization failed: {error}" ))); }
let code = query .code .ok_or_else(|| AppError::BadRequest("missing authorization code".to_string()))?;
// Pop + delete: single-use, and a missing entry means it can't be trusted. let persisted = { let mut requests = state .oauth_requests .lock() .expect("oauth_requests lock poisoned"); requests.remove(&query.state) } .ok_or_else(|| AppError::BadRequest("unknown or expired oauth state".to_string()))?;
if persisted.expires_at < Utc::now() { return Err(AppError::BadRequest("oauth state expired".to_string())); }
// RFC 9207: `iss` must match the authorization server we sent the user to. if let Some(iss) = &query.iss { if iss != &persisted.issuer { return Err(AppError::BadRequest("issuer mismatch".to_string())); } }
// Re-discover the authorization server for the token exchange: the // account's PDS may have moved to another since the login began. A URL // may be an authorization server itself, like bsky.social, which has no // protected-resource metadata of its own. let (pds_endpoint, authorization_server) = match &persisted.target { LoginTarget::Account(did) => { let document = crate::atproto::actor::resolve_document(&state, did) .await .map_err(|e| AppError::BadRequest(format!("identity resolution failed: {e}")))?; let pds_endpoint = document .pds_endpoints() .first() .map(|s| s.to_string()) .ok_or_else(|| AppError::BadRequest("no PDS endpoint".to_string()))?; let authorization_server = pds_resources(&state.http_client, &pds_endpoint) .await .map_err(|e| AppError::Internal(anyhow::anyhow!("resource discovery failed: {e}")))? .1; (pds_endpoint, authorization_server) } LoginTarget::Server(url) => ( url.clone(), crate::oauth::login::resolve_as_server_url(&state, url).await?, ), };
if authorization_server.issuer != persisted.issuer { return Err(AppError::BadRequest( "authorization server issuer changed".to_string(), )); }
// Rebuild the DPoP key + OAuth client used during the login/PAR step. let dpop_key = identify_key(&persisted.dpop_private_key) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid stored DPoP key: {e}")))?;
let signing_key = state.secrets.oauth_private_key.clone();
let client_id = state.config.oauth_client_id_for_login(); let redirect_uri = state.config.oauth_redirect_uri();
// Loopback dev uses the public-client path -- see `oauth::public_client`. let token_response = if state.config.is_loopback() { crate::oauth::public_client::exchange_code( &state.http_client, &client_id, &redirect_uri, &dpop_key, &code, &persisted.pkce_verifier, &authorization_server, ) .await .map_err(|e| AppError::Internal(anyhow::anyhow!("token exchange failed: {e}")))? } else { let signing_public_key = to_public(&signing_key) .map(|k| k.to_string()) .unwrap_or_default();
let oauth_client = OAuthClient { redirect_uri, client_id, private_signing_key_data: signing_key, };
let oauth_request = OAuthRequest { oauth_state: persisted.state.clone(), issuer: persisted.issuer.clone(), authorization_server: authorization_server.issuer.clone(), nonce: persisted.nonce.clone(), pkce_verifier: persisted.pkce_verifier.clone(), signing_public_key, dpop_private_key: persisted.dpop_private_key.clone(), created_at: persisted.created_at, expires_at: persisted.expires_at, };
oauth_complete( &state.http_client, &oauth_client, &dpop_key, &code, &oauth_request, &authorization_server, ) .await .map_err(|e| AppError::Internal(anyhow::anyhow!("token exchange failed: {e}")))? };
// Subject-binding check. let did = match (&persisted.target, &token_response.sub) { (LoginTarget::Account(expected), Some(sub)) if expected == sub => sub.clone(), (LoginTarget::Account(expected), Some(sub)) => { return Err(AppError::BadRequest(format!( "token subject mismatch: expected {expected}, got {sub}" ))); } (LoginTarget::Account(expected), None) => expected.clone(), (LoginTarget::Server(_), Some(sub)) => sub.clone(), (LoginTarget::Server(_), None) => { return Err(AppError::BadRequest( "could not determine account DID".to_string(), )); } }; let did = Did::new(did);
// A URL names a server, not the account's own PDS, which is whatever its // identity says. let pds_endpoint = match &persisted.target { LoginTarget::Account(_) => pds_endpoint, LoginTarget::Server(_) => crate::atproto::actor::resolve_document(&state, did.as_str()) .await .ok() .and_then(|document| document.pds_endpoints().first().map(|s| s.to_string())) .unwrap_or(pds_endpoint), };
let expires_at = Utc::now() + Duration::seconds(i64::from(token_response.expires_in));
let session = SessionCookie { did: did.clone(), access_token: token_response.access_token.clone(), refresh_token: token_response.refresh_token.clone(), expires_at, dpop_private_key: persisted.dpop_private_key.clone(), pds_endpoint: pds_endpoint.clone(), scope: OAUTH_SCOPE.clone(), };
let handle = crate::atproto::actor::verified_handle(&state, &did).await?;
// Associate this handle with the DID so we can look it up later. if let Some(handle) = &handle { crate::atproto::actor::upsert(&state.db, &did, handle).await?; } // Whatever they star from here on reaches this server. crate::atproto::tap::follow(&state, &did);
let identity = IdentityCookie { did: did.clone(), handle, pds_url: Some(pds_endpoint), scope: OAUTH_SCOPE.clone(), };
let session_value = encode_session_cookie(&state.secrets.cookie_secret, &session) .map_err(|e| AppError::Internal(anyhow::anyhow!("session encode failed: {e}")))?; let identity_value = encode_identity_cookie(&identity) .map_err(|e| AppError::Internal(anyhow::anyhow!("identity encode failed: {e}")))?;
let max_age = Duration::days(30).num_seconds(); let mut headers = HeaderMap::new(); if let Ok(h) = build_session_cookie_header(state.config.external_base(), &session_value, max_age) { headers.append(header::SET_COOKIE, h); } if let Ok(h) = build_identity_cookie_header(state.config.external_base(), &identity_value, max_age) { headers.append(header::SET_COOKIE, h); }
let return_to = persisted.return_to.as_deref().unwrap_or("/"); Ok((headers, Redirect::to(return_to)).into_response())}
/// Routes for the callback step.pub fn router() -> Router<AppState> { Router::new().route("/callback", get(handle_get_callback))}