diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 3c0e508..e23b08e 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -1,46 +1,37 @@ use axum::extract::FromRequestParts; use axum::http::request::Parts; -use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; -use crate::auth::jwks::JwksProvider; use crate::error::AppError; +use crate::AppState; -/// JWT claims from an AIP-issued access token. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Authenticated user identity extracted from an AIP-issued access token. +#[derive(Debug, Clone)] pub struct Claims { - pub iss: String, - pub sub: String, - pub aud: serde_json::Value, - pub exp: u64, - pub iat: u64, - #[serde(default)] - pub scope: Option, + did: String, } impl Claims { - /// The authenticated user's DID (the `sub` claim). + /// The authenticated user's DID. pub fn did(&self) -> &str { - &self.sub + &self.did } } -/// Axum extractor that validates the Bearer token against AIP's JWKS. -/// -/// Use in handler signatures: -/// ```ignore -/// async fn my_handler(claims: Claims) -> impl IntoResponse { ... } -/// ``` -impl FromRequestParts for Claims -where - S: Send + Sync, - JwksProvider: FromRef, -{ - type Rejection = AppError; +#[derive(Deserialize)] +struct UserinfoResponse { + sub: String, +} - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let provider = JwksProvider::from_ref(state); +/// Axum extractor that validates the Bearer token by forwarding it to AIP's +/// `/oauth/userinfo` endpoint. AIP returns the DID in the `sub` field. +impl FromRequestParts for Claims { + type Rejection = AppError; + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { let header = parts .headers .get("authorization") @@ -49,47 +40,33 @@ where let token = header .strip_prefix("Bearer ") - .or_else(|| header.strip_prefix("DPoP ")) .ok_or_else(|| AppError::Auth("invalid Authorization scheme".into()))?; - let jwks = provider - .keyset() - .await - .ok_or_else(|| AppError::Auth("JWKS not yet available".into()))?; - - // Decode the JWT header to find the `kid`. - let jwt_header = jsonwebtoken::decode_header(token) - .map_err(|e| AppError::Auth(format!("invalid token header: {e}")))?; - - let kid = jwt_header - .kid - .as_deref() - .ok_or_else(|| AppError::Auth("token missing kid".into()))?; - - let jwk = jwks - .find(kid) - .ok_or_else(|| AppError::Auth("unknown signing key".into()))?; + let userinfo_url = format!( + "{}/oauth/userinfo", + state.config.aip_url.trim_end_matches('/') + ); - let key = DecodingKey::from_jwk(jwk) - .map_err(|e| AppError::Auth(format!("bad JWK: {e}")))?; - - let mut validation = Validation::new(Algorithm::ES256); - validation.validate_aud = false; // AIP sets aud to the client_id; we skip it here + let resp = state + .http + .get(&userinfo_url) + .header("authorization", format!("Bearer {token}")) + .send() + .await + .map_err(|e| AppError::Auth(format!("userinfo request failed: {e}")))?; - let data = decode::(token, &key, &validation) - .map_err(|e| AppError::Auth(format!("token validation failed: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::Auth(format!( + "userinfo returned {}", + resp.status() + ))); + } - Ok(data.claims) - } -} - -/// Helper trait so we can pull JwksProvider out of app state. -pub trait FromRef { - fn from_ref(input: &T) -> Self; -} + let info: UserinfoResponse = resp + .json() + .await + .map_err(|e| AppError::Auth(format!("invalid userinfo response: {e}")))?; -impl FromRef for JwksProvider { - fn from_ref(state: &crate::AppState) -> Self { - state.jwks.clone() + Ok(Claims { did: info.sub }) } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 51fd62d..22797fb 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,4 +1,3 @@ -pub mod jwks; pub mod middleware; pub use middleware::Claims; diff --git a/src/config.rs b/src/config.rs index e5dd277..2e460d7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,8 +32,4 @@ impl Config { .parse() .expect("invalid HOST/PORT") } - - pub fn jwks_url(&self) -> String { - format!("{}/.well-known/jwks.json", self.aip_url.trim_end_matches('/')) - } } diff --git a/src/error.rs b/src/error.rs index 333e74e..ebf1a43 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,7 @@ use axum::response::{IntoResponse, Response}; pub enum AppError { Auth(String), Internal(String), + NotFound(String), } impl std::fmt::Display for AppError { @@ -12,6 +13,7 @@ impl std::fmt::Display for AppError { match self { AppError::Auth(msg) => write!(f, "auth error: {msg}"), AppError::Internal(msg) => write!(f, "internal error: {msg}"), + AppError::NotFound(msg) => write!(f, "not found: {msg}"), } } } @@ -27,6 +29,7 @@ impl IntoResponse for AppError { "internal server error".into(), ) } + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), }; let body = serde_json::json!({ "error": message }); diff --git a/src/main.rs b/src/main.rs index abb20f9..7b053c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,16 @@ mod auth; mod config; mod error; +mod profile; mod server; -use std::time::Duration; - use config::Config; use tracing::info; -use crate::auth::jwks::JwksProvider; - #[derive(Clone)] pub struct AppState { pub config: Config, - pub jwks: JwksProvider, + pub http: reqwest::Client, pub db: sqlx::PgPool, } @@ -37,16 +34,9 @@ async fn main() { info!("connected to database"); - // Set up JWKS provider pointed at AIP. - let jwks = JwksProvider::new(config.jwks_url()); - if let Err(e) = jwks.refresh().await { - tracing::warn!("initial JWKS fetch failed (AIP may not be running yet): {e}"); - } - jwks.clone().spawn_refresh_loop(Duration::from_secs(300)); - let state = AppState { config: config.clone(), - jwks, + http: reqwest::Client::new(), db, }; diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..f3fb526 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,146 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; + +#[derive(Serialize)] +pub struct Profile { + pub did: String, + pub handle: String, + #[serde(rename = "displayName", skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(rename = "avatarURL", skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DidDocument { + #[serde(default)] + also_known_as: Vec, + #[serde(default)] + service: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DidService { + id: String, + service_endpoint: String, +} + +#[derive(Deserialize)] +struct GetRecordResponse { + value: serde_json::Value, +} + +/// Resolve a full profile for the given DID: DID document -> handle + PDS -> profile record. +pub async fn resolve_profile(http: &reqwest::Client, did: &str) -> Result { + let did_doc = resolve_did_document(http, did).await?; + + let handle = did_doc + .also_known_as + .iter() + .find_map(|uri| uri.strip_prefix("at://")) + .map(|h| h.to_string()); + + let pds_endpoint = did_doc + .service + .iter() + .find(|s| s.id == "#atproto_pds") + .map(|s| s.service_endpoint.clone()) + .ok_or_else(|| AppError::NotFound("no PDS endpoint in DID document".into()))?; + + let (display_name, description, avatar_url) = + fetch_profile_from_pds(http, &pds_endpoint, did) + .await + .unwrap_or((None, None, None)); + + Ok(Profile { + did: did.to_string(), + handle: handle.unwrap_or_else(|| did.to_string()), + display_name, + description, + avatar_url, + }) +} + +/// Fetch a DID document from the PLC directory. +// TODO: handle did:web:* resolution (fetch https://{domain}/.well-known/did.json) +async fn resolve_did_document(http: &reqwest::Client, did: &str) -> Result { + let url = format!("https://plc.directory/{did}"); + + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("DID resolution failed: {e}")))?; + + if !resp.status().is_success() { + return Err(AppError::NotFound(format!( + "DID document not found for {did}" + ))); + } + + resp.json() + .await + .map_err(|e| AppError::Internal(format!("invalid DID document: {e}"))) +} + +/// Fetch the `app.bsky.actor.profile` record from the user's PDS and extract +/// displayName, description, and avatar URL. +async fn fetch_profile_from_pds( + http: &reqwest::Client, + pds_endpoint: &str, + did: &str, +) -> Result<(Option, Option, Option), AppError> { + let url = format!( + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.bsky.actor.profile&rkey=self", + pds_endpoint.trim_end_matches('/'), + did, + ); + + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; + + if !resp.status().is_success() { + return Ok((None, None, None)); + } + + let record: GetRecordResponse = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; + + let value = &record.value; + + let display_name = value + .get("displayName") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let description = value + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let avatar_url = value + .get("avatar") + .and_then(|avatar| avatar.get("ref")) + .and_then(|r| r.get("$link")) + .and_then(|link| link.as_str()) + .map(|cid| { + format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + pds_endpoint.trim_end_matches('/'), + did, + cid, + ) + }); + + Ok((display_name, description, avatar_url)) +} diff --git a/src/server.rs b/src/server.rs index 194ae8f..11d6478 100644 --- a/src/server.rs +++ b/src/server.rs @@ -5,12 +5,14 @@ use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use crate::auth::Claims; +use crate::error::AppError; +use crate::profile; use crate::AppState; pub fn router(state: AppState) -> Router { Router::new() .route("/health", get(health)) - .route("/xrpc/app.bsky.actor.getProfile", get(get_profile_placeholder)) + .route("/xrpc/app.bsky.actor.getProfile", get(get_profile)) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) .with_state(state) @@ -20,13 +22,10 @@ async fn health() -> &'static str { "ok" } -/// Placeholder authenticated endpoint to prove AIP integration works. -async fn get_profile_placeholder( - State(_state): State, +async fn get_profile( + State(state): State, claims: Claims, -) -> Json { - Json(serde_json::json!({ - "did": claims.did(), - "message": "HappyView is alive! Replace this with a real implementation.", - })) +) -> Result, AppError> { + let profile = profile::resolve_profile(&state.http, claims.did()).await?; + Ok(Json(profile)) }