diff --git a/src/aip.rs b/src/aip.rs new file mode 100644 index 0000000..4c4a4e8 --- /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 index 6f5f8a1..1ed05c1 100644 --- 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 index 08d679c..f4b5141 100644 --- 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 @@ pub fn router(state: AppState) -> Router { ) // 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()) @@ -38,6 +41,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( State(state): State, claims: Claims, diff --git a/web/next.config.ts b/web/next.config.ts index 6e1a109..e5dc909 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -16,6 +16,7 @@ if (process.env.NODE_ENV === "production") { { 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 index efd7349..b07bc36 100644 --- 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 @@ export default function RootLayout({ - - {children} - + + + {children} + + ) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d80510a..a040874 100644 --- 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 @@ async function apiFetch( // 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 index 96e16a4..00a2030 100644 --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -9,6 +9,7 @@ import { } from "react" import { clearDpopKeypair, createDpopProof, ensureDpopKeypair, setDpopNonce } from "./dpop" +import { useConfig } from "./config-context" interface AuthContextType { did: string | null @@ -28,9 +29,6 @@ const AuthContext = createContext({ error: null, }) -// AIP URL for browser redirects (authorization endpoint) -const AIP_URL = process.env.NEXT_PUBLIC_AIP_URL || "" - // PKCE helpers function base64urlEncode(buffer: ArrayBuffer): string { @@ -54,8 +52,8 @@ async function generateCodeChallenge(verifier: string): Promise { // 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 @@ async function getOrRegisterClient(redirectUri: string): Promise { } 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 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } async function handleOAuthCallback( + aipUrl: string, code: string, state: string, cancelled: boolean, @@ -269,7 +269,7 @@ async function handleOAuthCallback( // 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 @@ async function handleOAuthCallback( // 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 index 0000000..ed48864 --- /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) +}