A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
20 kB · 678 lines
Rust
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679use std::sync::Arc;
use atrium_oauth::{AuthorizeOptions, Scope};use axum::extract::{Query, State};use axum::response::{IntoResponse, Redirect, Response};use base64::Engine;use rand::Rng;use serde::Deserialize;use sha2::{Digest, Sha256};
use crate::AppState;use crate::HappyViewOAuthClient;use crate::auth::client_registry::is_loopback_url;use crate::db::{adapt_sql, now_rfc3339};use crate::error::AppError;
use super::db;use super::types::LinkedRepo;
/// How long an invite link stays valid.const INVITE_TTL_SECS: i64 = 60 * 60 * 24 * 7;/// How long an in-flight OAuth state row stays valid.const STATE_TTL_SECS: i64 = 60 * 15;
fn hash_token(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes()))}
fn random_token() -> String { let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)}
fn expires_at(secs: i64) -> String { (chrono::Utc::now() + chrono::Duration::seconds(secs)).to_rfc3339()}
pub use crate::identity::ResolvedIdentifier;
pub async fn resolve_identifier( _state: &AppState, input: &str,) -> Result<ResolvedIdentifier, AppError> { crate::identity::resolve_identifier(input).await}
pub async fn client_for_grant( state: &AppState, grant: &LinkedRepo,) -> Result<Arc<HappyViewOAuthClient>, AppError> { resolve_grant_client(state, grant, true) .await .map(|(client, _)| client)}
pub async fn client_and_kid_for_grant( state: &AppState, grant: &LinkedRepo,) -> Result<(Arc<HappyViewOAuthClient>, Option<String>), AppError> { resolve_grant_client(state, grant, true).await}
/// The client to use when starting a *new* authorization.pub async fn client_for_new_authorization( state: &AppState, grant: &LinkedRepo,) -> Result<Arc<HappyViewOAuthClient>, AppError> { resolve_grant_client(state, grant, false) .await .map(|(client, _)| client)}
async fn resolve_grant_client( state: &AppState, grant: &LinkedRepo, use_pinned_session_key: bool,) -> Result<(Arc<HappyViewOAuthClient>, Option<String>), AppError> { if !is_loopback_url(&state.config.public_url) { let (default_client, default_kid) = state.oauth.linked_repos_primary().unwrap_or_else(|| { ( Arc::clone(&state.linked_repos_client), state.linked_repos_client_kid.clone(), ) });
if let Some(did) = &grant.did && use_pinned_session_key { let signing_kid = crate::auth::oauth_store::lookup_signing_kid( &state.db, state.db_backend, super::client::LINKED_SESSIONS_TABLE, did, ) .await .map_err(|e| { AppError::Internal(format!( "failed to look up linked-repo session signing key: {e}" )) })?;
if let Some(kid) = signing_kid { let client = state.oauth.get_linked_repos_for_kid(&kid).ok_or_else(|| { AppError::Auth( "the key this session was established with is no longer available; the linked repo must be reauthorized" .into(), ) })?; return Ok((client, Some(kid))); }
if let Some(kid) = &default_kid && let Err(e) = crate::auth::oauth_store::stamp_signing_kid_if_unset( &state.db, state.db_backend, super::client::LINKED_SESSIONS_TABLE, "did", did, kid, ) .await { tracing::warn!(error = %e, "failed to lazily stamp linked-repo signing_kid"); } } return Ok((default_client, default_kid)); }
let scopes: Vec<Scope> = crate::auth::parse_scope_string(&grant.scopes); let public_url = state.config.effective_public_url(); let base = public_url.trim_end_matches('/');
super::client::build( &state.config.plc_url, &format!("{base}/oauth-client-metadata.json"), &public_url, format!("{base}/auth/callback"), true, scopes, state.oauth_state_store.clone(), state.db.clone(), state.db_backend, None, ) .map(|client| (Arc::new(client), None)) .map_err(AppError::Internal)}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum AuthOrigin { Admin, Invite,}
impl AuthOrigin { pub fn as_str(&self) -> &'static str { match self { Self::Admin => "admin", Self::Invite => "invite", } }
pub fn parse(raw: Option<&str>) -> Self { match raw { Some("invite") => Self::Invite, _ => Self::Admin, } }}
pub async fn start_authorization( state: &AppState, grant: &LinkedRepo, identifier: &str, origin: AuthOrigin,) -> Result<String, AppError> { let scopes = crate::auth::parse_scope_string(&grant.scopes); let client = client_for_new_authorization(state, grant).await?;
let guard = state.oauth_state_store.authorize_lock.lock().await;
let url = client .authorize( identifier, AuthorizeOptions { scopes, ..Default::default() }, ) .await .map_err(|e| { let msg = format!("{e}"); if msg.contains("invalid_scope") { AppError::BadRequest(format!( "the authorization server rejected the requested scopes ({msg}). \ Either it does not accept one of this grant's scopes — in which \ case the grant must be recreated with scopes it does accept — or \ it is serving a cached copy of our client metadata, which a retry \ in a minute will clear." )) } else { AppError::Internal(format!("authorize failed: {msg}")) } })?;
let oauth_state = state .oauth_state_store .take_last_state_key() .ok_or_else(|| AppError::Internal("no OAuth state captured for linked repo".into()))?;
drop(guard);
let sql = adapt_sql( "INSERT INTO happyview_linked_repo_auth_state (state, grant_id, expires_at, origin) \ VALUES (?, ?, ?, ?)", state.db_backend, ); crate::db::query(&sql) .bind(&oauth_state) .bind(&grant.id) .bind(expires_at(STATE_TTL_SECS)) .bind(origin.as_str()) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to store linked auth state: {e}")))?;
Ok(url.to_string())}
pub struct MintedInvite { pub token: String, pub expires_at: String,}
pub const MIN_INVITE_TTL_SECS: i64 = 60;pub const MAX_INVITE_TTL_SECS: i64 = 60 * 60 * 24 * 30;
pub async fn mint_invite_with_expiry( state: &AppState, grant_id: &str, ttl_secs: Option<i64>,) -> Result<MintedInvite, AppError> { let ttl = match ttl_secs { Some(t) if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&t) => { return Err(AppError::BadRequest(format!( "expires_in must be between {MIN_INVITE_TTL_SECS} and {MAX_INVITE_TTL_SECS} seconds" ))); } Some(t) => t, None => INVITE_TTL_SECS, };
let token = random_token(); let hash = hash_token(&token); let expires = expires_at(ttl);
let sql = adapt_sql( "INSERT INTO happyview_linked_repo_auth_state (state, grant_id, token_hash, expires_at) \ VALUES (?, ?, ?, ?)", state.db_backend, ); crate::db::query(&sql) .bind(format!("invite:{hash}")) .bind(grant_id) .bind(&hash) .bind(&expires) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to store invite: {e}")))?;
Ok(MintedInvite { token, expires_at: expires, })}
#[derive(Debug, Clone, serde::Serialize)]pub struct InviteSummary { pub invite_id: String, pub expires_at: String,}
pub async fn list_invites( state: &AppState, grant_id: &str,) -> Result<Vec<InviteSummary>, AppError> { let sql = adapt_sql( "SELECT token_hash, expires_at FROM happyview_linked_repo_auth_state \ WHERE grant_id = ? AND token_hash IS NOT NULL AND expires_at > ? \ ORDER BY expires_at", state.db_backend, ); let rows: Vec<(String, String)> = crate::db::query_as(&sql) .bind(grant_id) .bind(now_rfc3339()) .fetch_all(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to list invites: {e}")))?;
Ok(rows .into_iter() .map(|(invite_id, expires_at)| InviteSummary { invite_id, expires_at, }) .collect())}
pub async fn revoke_invite( state: &AppState, grant_id: &str, invite_id: &str,) -> Result<bool, AppError> { let sql = adapt_sql( "DELETE FROM happyview_linked_repo_auth_state \ WHERE grant_id = ? AND token_hash = ? AND token_hash IS NOT NULL", state.db_backend, ); let result = crate::db::query(&sql) .bind(grant_id) .bind(invite_id) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to revoke invite: {e}")))?;
Ok(result.rows_affected() > 0)}
pub async fn invite_exists(state: &AppState, token: &str) -> Result<bool, AppError> { let hash = hash_token(token); let sql = adapt_sql( "SELECT 1 FROM happyview_linked_repo_auth_state \ WHERE token_hash = ? AND expires_at > ?", state.db_backend, ); let row: Option<(i32,)> = crate::db::query_as(&sql) .bind(&hash) .bind(now_rfc3339()) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to look up invite: {e}")))?; Ok(row.is_some())}
pub async fn invite_grant_id(state: &AppState, token: &str) -> Result<Option<String>, AppError> { let hash = hash_token(token); let sql = adapt_sql( "SELECT grant_id FROM happyview_linked_repo_auth_state \ WHERE token_hash = ? AND expires_at > ?", state.db_backend, ); let row: Option<(String,)> = crate::db::query_as(&sql) .bind(&hash) .bind(now_rfc3339()) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to look up invite: {e}")))?; Ok(row.map(|(grant_id,)| grant_id))}
pub async fn invalidate_invites_for_grant( state: &AppState, grant_id: &str,) -> Result<u64, AppError> { let sql = adapt_sql( "DELETE FROM happyview_linked_repo_auth_state \ WHERE grant_id = ? AND token_hash IS NOT NULL", state.db_backend, ); let result = crate::db::query(&sql) .bind(grant_id) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to invalidate invites: {e}")))?; Ok(result.rows_affected())}
#[derive(Debug, PartialEq, Eq)]pub enum PendingGrant { NotLinked, Expired, Grant { grant_id: String, origin: AuthOrigin, },}
pub async fn take_pending_grant( state: &AppState, oauth_state: &str,) -> Result<PendingGrant, AppError> { let sql = adapt_sql( "SELECT grant_id, expires_at, origin FROM happyview_linked_repo_auth_state \ WHERE state = ? AND token_hash IS NULL", state.db_backend, ); let row: Option<(String, String, Option<String>)> = crate::db::query_as(&sql) .bind(oauth_state) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to look up linked auth state: {e}")))?;
let Some((grant_id, expires, origin)) = row else { return Ok(PendingGrant::NotLinked); }; let origin = AuthOrigin::parse(origin.as_deref());
let del = adapt_sql( "DELETE FROM happyview_linked_repo_auth_state WHERE state = ? AND token_hash IS NULL", state.db_backend, ); let result = crate::db::query(&del) .bind(oauth_state) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to clear linked auth state: {e}")))?;
if result.rows_affected() == 0 { return Ok(PendingGrant::Expired); }
if expires.as_str() <= now_rfc3339().as_str() { return Ok(PendingGrant::Expired); }
Ok(PendingGrant::Grant { grant_id, origin })}
pub async fn complete(state: &AppState, grant_id: &str, did: &str) -> Result<(), AppError> { let grant = db::get(state, grant_id) .await? .ok_or_else(|| AppError::NotFound("linked repo not found".into()))?;
if let Some(ref expected) = grant.did && expected != did { tracing::warn!( grant_id = %grant.id, expected = %expected, authorized_by = %did, "refusing linked-repo completion: pinned grant authorized by a different account" ); flag_session_lost( state, did, "another account's authorization overwrote this session", ) .await; return Err(AppError::BadRequest( "this link was issued for a different account".into(), )); }
if grant.did.is_none() && let Some(existing) = db::get_by_did(state, did).await? && existing.id != grant.id { tracing::warn!( grant_id = %grant.id, existing_grant_id = %existing.id, %did, "refusing linked-repo completion: DID already linked to another grant" ); flag_session_lost( state, did, "another authorization for this account overwrote this session", ) .await; return Err(AppError::Conflict( "this account is already linked to this instance".into(), )); }
db::bind_did(state, grant_id, did, None).await}
async fn flag_session_lost(state: &AppState, did: &str, reason: &str) { let existing = match db::get_by_did(state, did).await { Ok(Some(grant)) => grant, Ok(None) => return, Err(e) => { tracing::error!(%did, error = %e, "failed to look up grant for a refused authorization"); return; } }; if let Err(e) = db::mark_needs_reauth(state, &existing.id, reason).await { tracing::error!(grant_id = %existing.id, error = %e, "failed to flag grant as needing reauth"); }}
pub async fn discard_session(state: &AppState, did: &str) -> Result<(), AppError> { let sql = adapt_sql( "DELETE FROM happyview_linked_repo_sessions WHERE did = ?", state.db_backend, ); crate::db::query(&sql) .bind(did) .execute(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to discard linked session: {e}")))?; Ok(())}
#[derive(Deserialize)]pub struct StartQuery { pub token: String, pub handle: Option<String>,}
#[derive(serde::Serialize)]pub struct InviteInfo { pub valid: bool, pub app_name: String, pub logo_url: Option<String>, pub scopes: Vec<String>, pub reason: Option<String>, pub pinned_identifier: Option<String>, pub expires_at: Option<String>,}
fn invalid_invite_info() -> InviteInfo { InviteInfo { valid: false, app_name: String::new(), logo_url: None, scopes: Vec::new(), reason: None, pinned_identifier: None, expires_at: None, }}
pub async fn invite_info_handler( State(state): State<AppState>, Query(query): Query<InviteInfoQuery>,) -> Result<axum::Json<InviteInfo>, AppError> { let hash = hash_token(&query.token); let sql = adapt_sql( "SELECT grant_id, expires_at FROM happyview_linked_repo_auth_state \ WHERE token_hash = ? AND expires_at > ?", state.db_backend, ); let row: Option<(String, String)> = crate::db::query_as(&sql) .bind(&hash) .bind(now_rfc3339()) .fetch_optional(&state.db) .await .map_err(|e| AppError::Internal(format!("failed to look up invite: {e}")))?;
let Some((grant_id, expires_at)) = row else { return Ok(axum::Json(invalid_invite_info())); };
let Some(grant) = db::get(&state, &grant_id).await? else { // The grant was deleted from a live invite return Ok(axum::Json(invalid_invite_info())); };
let app_name = crate::admin::settings::get_setting(&state.db, "app_name", state.db_backend) .await .unwrap_or_else(|| "HappyView".to_string());
let logo_url = crate::admin::settings::get_setting(&state.db, "logo_data", state.db_backend) .await .map(|_| { format!( "{}/settings/logo", state.config.effective_public_url().trim_end_matches('/') ) }) .or(crate::admin::settings::get_setting(&state.db, "logo_uri", state.db_backend).await);
Ok(axum::Json(InviteInfo { valid: true, app_name, logo_url, scopes: super::scope::parse(&grant.scopes), reason: grant.reason.clone(), pinned_identifier: grant.handle.clone().or_else(|| grant.did.clone()), expires_at: Some(expires_at), }))}
#[derive(Deserialize)]pub struct InviteInfoQuery { pub token: String,}
fn link_page(state: &AppState, path: &str) -> String { format!( "{}/link/{path}", state.config.effective_public_url().trim_end_matches('/') )}
pub fn link_result_redirect(state: &AppState, status: &str, handle: Option<&str>) -> Response { let mut url = format!( "{}?status={}", link_page(state, "result"), urlencoding::encode(status) ); if let Some(handle) = handle { url.push_str(&format!("&handle={}", urlencoding::encode(handle))); } Redirect::to(&url).into_response()}
pub async fn start_handler( State(state): State<AppState>, Query(query): Query<StartQuery>,) -> Result<Response, AppError> { let Some(handle) = query .handle .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) else { return Ok(Redirect::to(&format!( "{}?token={}", link_page(&state, "start"), urlencoding::encode(&query.token) )) .into_response()); };
if !invite_exists(&state, &query.token).await? { return Err(AppError::BadRequest( "this link is invalid, expired, or has already been used".into(), )); }
let resolved = resolve_identifier(&state, handle).await?; let identifier = resolved.handle.as_deref().unwrap_or(&resolved.did);
let Some(grant_id) = invite_grant_id(&state, &query.token).await? else { return Err(AppError::BadRequest( "this link is invalid, expired, or has already been used".into(), )); };
let grant = match db::get(&state, &grant_id).await { Ok(Some(grant)) => grant, Ok(None) => return Err(AppError::NotFound("linked repo not found".into())), Err(e) => return Err(e), };
let url = start_authorization(&state, &grant, identifier, AuthOrigin::Invite).await?; Ok(Redirect::to(&url).into_response())}
#[cfg(test)]mod tests { use super::*;
#[test] fn hash_token_is_stable_and_hex() { let a = hash_token("hello"); assert_eq!(a, hash_token("hello")); assert_eq!(a.len(), 64); assert!(a.chars().all(|c| c.is_ascii_hexdigit())); assert_ne!(a, hash_token("hellp")); }
#[test] fn random_token_is_url_safe_and_unique() { let a = random_token(); let b = random_token(); assert_ne!(a, b); assert!( a.chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), "token must be URL-safe, got {a}" ); }}