From c7fb89d23fdc57d72e244d0cc725f6575aed4e0d Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 16 Feb 2026 21:19:41 +0000 Subject: [PATCH] feat: add AIP reverse proxy and runtime config endpoint --- src/aip.rs | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/server.rs | 7 +++++++ web/next.config.ts | 1 + web/src/app/layout.tsx | 9 ++++++--- web/src/lib/api.ts | 7 ++++--- web/src/lib/auth-context.tsx | 28 ++++++++++++++-------------- web/src/lib/config-context.tsx | 42 ++++++++++++++++++++++++++++++++++++++++++ 8 file(s) changed, 316 insertion(s)(+), 20 deletion(s)(-) diff --git a/src/aip.rs b/src/aip.rs new file mode 100644 --- /dev/null +++ b/src/aip.rs @@ -0,0 +1,241 @@ +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, Method, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; + +use crate::AppState; + +/// Reverse-proxy requests from `/aip/*` to the configured AIP server. +pub async fn aip_proxy( + State(state): State, + method: Method, + Path(path): Path, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> impl IntoResponse { + let query = uri.query().map(|q| format!("?{q}")).unwrap_or_default(); + let upstream_url = format!("{}/{path}{query}", state.config.aip_url); + + let mut req = state.http.request(method.clone(), &upstream_url); + + // Copy relevant request headers + for name in ["content-type", "authorization", "dpop", "accept"] { + if let Some(val) = headers.get(name) { + req = req.header(name, val); + } + } + + // Attach body for non-GET requests + if method != Method::GET { + let bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await { + Ok(b) => b, + Err(_) => { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from("request body too large")) + .unwrap(); + } + }; + req = req.body(bytes); + } + + let upstream_resp = match req.send().await { + Ok(r) => r, + Err(e) => { + tracing::error!("AIP proxy error: {e}"); + return Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Body::from("upstream request failed")) + .unwrap(); + } + }; + + let status = upstream_resp.status(); + let mut resp_headers = HeaderMap::new(); + + // Copy relevant response headers + for name in [ + "content-type", + "dpop-nonce", + "www-authenticate", + "cache-control", + ] { + if let Some(val) = upstream_resp.headers().get(name) { + resp_headers.insert( + name.parse::().unwrap(), + val.clone(), + ); + } + } + + let bytes = match upstream_resp.bytes().await { + Ok(b) => b, + Err(e) => { + tracing::error!("AIP proxy read error: {e}"); + return Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Body::from("failed to read upstream response")) + .unwrap(); + } + }; + + let mut response = Response::new(Body::from(bytes)); + *response.status_mut() = status; + *response.headers_mut() = resp_headers; + response +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::Router; + use axum::body::to_bytes; + use axum::extract::Request; + use axum::routing::{get, post}; + use tokio::sync::watch; + use tower::ServiceExt; + + fn test_state(aip_url: &str) -> AppState { + let config = crate::config::Config { + host: "127.0.0.1".into(), + port: 3000, + database_url: String::new(), + aip_url: aip_url.into(), + tap_url: String::new(), + tap_admin_password: None, + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + }; + let (tx, _) = watch::channel(vec![]); + AppState { + config, + http: reqwest::Client::new(), + db: sqlx::PgPool::connect_lazy("postgres://localhost/fake").unwrap(), + lexicons: crate::lexicon::LexiconRegistry::new(), + collections_tx: tx, + } + } + + #[tokio::test] + async fn proxy_forwards_get_request() { + let mock = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/oauth/authorize")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_string("ok") + .insert_header("content-type", "text/plain") + .insert_header("dpop-nonce", "test-nonce"), + ) + .mount(&mock) + .await; + + let state = test_state(&mock.uri()); + let app = Router::new() + .route("/aip/{*path}", get(aip_proxy)) + .with_state(state); + + let req = Request::builder() + .method("GET") + .uri("/aip/oauth/authorize") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers().get("dpop-nonce").unwrap(), "test-nonce"); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&body[..], b"ok"); + } + + #[tokio::test] + async fn proxy_forwards_post_with_body() { + let mock = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/oauth/token")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"access_token": "tok"})) + .insert_header("content-type", "application/json"), + ) + .mount(&mock) + .await; + + let state = test_state(&mock.uri()); + let app = Router::new() + .route("/aip/{*path}", post(aip_proxy)) + .with_state(state); + + let req = Request::builder() + .method("POST") + .uri("/aip/oauth/token") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("grant_type=authorization_code")) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); + } + + #[tokio::test] + async fn proxy_forwards_query_string() { + let mock = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/oauth/authorize")) + .and(wiremock::matchers::query_param("client_id", "abc")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("found")) + .mount(&mock) + .await; + + let state = test_state(&mock.uri()); + let app = Router::new() + .route("/aip/{*path}", get(aip_proxy)) + .with_state(state); + + let req = Request::builder() + .method("GET") + .uri("/aip/oauth/authorize?client_id=abc") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&body[..], b"found"); + } + + #[tokio::test] + async fn proxy_preserves_error_status() { + let mock = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/oauth/token")) + .respond_with( + wiremock::ResponseTemplate::new(400) + .set_body_string("bad request") + .insert_header("www-authenticate", "DPoP error=\"use_dpop_nonce\""), + ) + .mount(&mock) + .await; + + let state = test_state(&mock.uri()); + let app = Router::new() + .route("/aip/{*path}", post(aip_proxy)) + .with_state(state); + + let req = Request::builder() + .method("POST") + .uri("/aip/oauth/token") + .body(Body::from("grant_type=authorization_code")) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert!(resp.headers().get("www-authenticate").is_some()); + } +} diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod aip; pub mod auth; pub mod config; pub mod error; diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -7,6 +7,7 @@ use tower_http::trace::TraceLayer; use crate::AppState; use crate::admin; +use crate::aip; use crate::auth::Claims; use crate::error::AppError; use crate::profile; @@ -28,6 +29,8 @@ post(repo::upload_blob).layer(DefaultBodyLimit::max(50 * 1024 * 1024)), ) // Catch-all for dynamically registered lexicons .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) + .route("/config", get(config_endpoint)) + .route("/aip/{*path}", get(aip::aip_proxy).post(aip::aip_proxy)) .fallback_service(serve_dir) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) @@ -36,6 +39,10 @@ } async fn health() -> &'static str { "ok" +} + +async fn config_endpoint(State(state): State) -> Json { + Json(serde_json::json!({ "aip_url": state.config.aip_url })) } async fn get_profile( diff --git a/web/next.config.ts b/web/next.config.ts --- a/web/next.config.ts +++ b/web/next.config.ts @@ -16,6 +16,7 @@ { source: "/admin/:path*", destination: `${apiBase}/admin/:path*` }, { source: "/xrpc/:path*", destination: `${apiBase}/xrpc/:path*` }, { source: "/health", destination: `${apiBase}/health` }, { source: "/aip/:path*", destination: `${aipBase}/:path*` }, + { source: "/config", destination: `${apiBase}/config` }, ]; } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next" import { Geist, Geist_Mono } from "next/font/google" import "./globals.css" +import { ConfigProvider } from "@/lib/config-context" import { AuthProvider } from "@/lib/auth-context" import { TooltipProvider } from "@/components/ui/tooltip" @@ -29,9 +30,11 @@ - - {children} - + + + {children} + + ) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2,8 +2,9 @@ import { createDpopProof, setDpopNonce } from "./dpop" // The DPoP proof for admin API calls must target AIP's userinfo URL, // because the backend forwards the proof to AIP for token validation. -const AIP_URL = process.env.NEXT_PUBLIC_AIP_URL || "" -const AIP_USERINFO_URL = `${AIP_URL}/oauth/userinfo` +// Set at runtime via ConfigProvider. +let aipUrl = "" +export function setAipUrl(url: string) { aipUrl = url } export class ApiError extends Error { status: number @@ -24,7 +25,7 @@ if (!token) throw new ApiError(401, "Not authenticated") // Proof targets AIP's userinfo endpoint (GET) since the backend // forwards it there for token validation. - const dpopProof = await createDpopProof("GET", AIP_USERINFO_URL, token, dpopNonce) + const dpopProof = await createDpopProof("GET", `${aipUrl}/oauth/userinfo`, token, dpopNonce) const headers: Record = { Authorization: `DPoP ${token}`, diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -9,6 +9,7 @@ useState, } from "react" import { clearDpopKeypair, createDpopProof, ensureDpopKeypair, setDpopNonce } from "./dpop" +import { useConfig } from "./config-context" interface AuthContextType { did: string | null @@ -27,9 +28,6 @@ logout: async () => {}, loading: true, error: null, }) - -// AIP URL for browser redirects (authorization endpoint) -const AIP_URL = process.env.NEXT_PUBLIC_AIP_URL || "" // PKCE helpers @@ -54,8 +52,8 @@ } // Dynamic client registration with AIP. // Caches the client_id in localStorage so we only register once. -async function getOrRegisterClient(redirectUri: string): Promise { - const cacheKey = `oauth_client_id:${AIP_URL}:${redirectUri}` +async function getOrRegisterClient(aipUrl: string, redirectUri: string): Promise { + const cacheKey = `oauth_client_id:${aipUrl}:${redirectUri}` const cached = localStorage.getItem(cacheKey) if (cached) return cached @@ -84,6 +82,7 @@ return clientId } export function AuthProvider({ children }: { children: React.ReactNode }) { + const { aip_url } = useConfig() const [accessToken, setAccessToken] = useState(null) const [did, setDid] = useState(null) const [loading, setLoading] = useState(true) @@ -106,7 +105,7 @@ const state = params.get("state") if (code && state) { console.log("[auth] OAuth callback detected, exchanging code") - await handleOAuthCallback(code, state, cancelled, { + await handleOAuthCallback(aip_url, code, state, cancelled, { setAccessToken, setDid, }) @@ -149,15 +148,15 @@ init() return () => { cancelled = true } - }, []) + }, [aip_url]) const getToken = useCallback(async (): Promise => { return accessToken }, [accessToken]) const login = useCallback(async (handle: string) => { - if (!AIP_URL) { - throw new Error("AIP URL not configured (set NEXT_PUBLIC_AIP_URL)") + if (!aip_url) { + throw new Error("AIP URL not configured") } setError(null) @@ -165,7 +164,7 @@ await ensureDpopKeypair() const redirectUri = `${window.location.origin}/` - const clientId = await getOrRegisterClient(redirectUri) + const clientId = await getOrRegisterClient(aip_url, redirectUri) const codeVerifier = generateRandomString(32) const codeChallenge = await generateCodeChallenge(codeVerifier) @@ -186,8 +185,8 @@ scope: "atproto", login_hint: handle, }) - window.location.href = `${AIP_URL}/oauth/authorize?${params.toString()}` - }, []) + window.location.href = `${aip_url}/oauth/authorize?${params.toString()}` + }, [aip_url]) const logout = useCallback(async () => { const clientId = sessionStorage.getItem("oauth_client_id") @@ -232,6 +231,7 @@ ) } async function handleOAuthCallback( + aipUrl: string, code: string, state: string, cancelled: boolean, @@ -269,7 +269,7 @@ const redirectUri = `${window.location.origin}/` // Token exchange via proxied path (avoids CORS). // AIP may require a DPoP nonce — retry once if we get one back. - const tokenUrl = `${AIP_URL}/oauth/token` + const tokenUrl = `${aipUrl}/oauth/token` const tokenBody = new URLSearchParams({ grant_type: "authorization_code", code, @@ -331,7 +331,7 @@ // Get DID from token response or userinfo let userDid: string | undefined = tokens.sub if (!userDid) { - const userinfoUrl = `${AIP_URL}/oauth/userinfo` + const userinfoUrl = `${aipUrl}/oauth/userinfo` // Use the nonce from the token response if available let currentNonce = dpopNonce let userinfoDpopProof = await createDpopProof("GET", userinfoUrl, accessToken, currentNonce ?? undefined) diff --git a/web/src/lib/config-context.tsx b/web/src/lib/config-context.tsx new file mode 100644 --- /dev/null +++ b/web/src/lib/config-context.tsx @@ -0,0 +1,42 @@ +"use client" + +import { createContext, useContext, useEffect, useState } from "react" +import { setAipUrl } from "./api" + +interface ConfigContextType { + aip_url: string +} + +const ConfigContext = createContext({ aip_url: "" }) + +export function ConfigProvider({ children }: { children: React.ReactNode }) { + const [config, setConfig] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + fetch("/config") + .then((res) => { + if (!res.ok) throw new Error(`Config fetch failed: ${res.status}`) + return res.json() + }) + .then((data) => { + setAipUrl(data.aip_url) + setConfig({ aip_url: data.aip_url }) + }) + .catch((e) => setError(e.message)) + }, []) + + if (error) { + return
Failed to load config: {error}
+ } + + if (!config) return null + + return ( + {children} + ) +} + +export function useConfig() { + return useContext(ConfigContext) +} -- tangled.sh