diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index 6459fbee..21707793 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -56,6 +56,10 @@ const GATE_PREFIX: &str = "/__archipelago-gate/"; pub enum Authorization { /// Proxy it through. Allow, + /// Proxy it through, but the credential that allowed it was the gate's own + /// `Authorization: Bearer ` — strip that header before the + /// app sees it, exactly as the session cookie is stripped. + AllowGateToken, /// Serve the login page. Challenge, } @@ -121,7 +125,7 @@ impl AppGate { .await .is_some() { - return Authorization::Allow; + return Authorization::AllowGateToken; } } @@ -159,11 +163,18 @@ impl AppGate { // let `/manifest.json/../api/secrets` style paths ride through, and // anything user-specific must keep being challenged. if Self::is_credentialless_public_path(&path) { - return proxy_to_app(req, app).await; + // Nothing on this list needs a credential, so any Authorization + // riding along is unverified as far as the gate is concerned — + // drop it rather than hand an unexamined token to the app. + return proxy_to_app(req, app, true).await; } match self.authorize(req.headers(), &app.app_id).await { - Authorization::Allow => proxy_to_app(req, app).await, + // The credential was a cookie (or none was needed): the + // Authorization header, if any, belongs to the app. Forward it. + Authorization::Allow => proxy_to_app(req, app, false).await, + // The credential WAS the Authorization header, and it was ours. + Authorization::AllowGateToken => proxy_to_app(req, app, true).await, // 401 rather than a redirect: a redirect to a login page is // indistinguishable from the app itself redirecting, and machine // clients would follow it and parse HTML as if it were their API @@ -421,7 +432,11 @@ fn percent_decode(input: &str) -> String { } /// Forward an authorised request to the app on loopback. -async fn proxy_to_app(req: Request, app: &GatedPort) -> Response { +async fn proxy_to_app( + req: Request, + app: &GatedPort, + drop_authorization: bool, +) -> Response { let port = app.port; let path_and_query = req .uri() @@ -446,7 +461,24 @@ async fn proxy_to_app(req: Request, app: &GatedPort) -> Response { if !app.session_passthrough { strip_gate_cookies(&mut parts.headers); } - parts.headers.remove(header::AUTHORIZATION); + // Same principle, applied to the other credential the gate accepts, and + // with the same precision. The gate's own header credential is exactly + // one thing: `Authorization: Bearer `, and the + // caller has already told us whether THIS request was authorised by it. + // + // Removing the header unconditionally — as this did — deletes credentials + // the gate never issued and never inspects. IndeeHub sends its NIP-98 + // `Authorization: Nostr ` to its own `/api/auth/nostr/session`; + // the header arrived stripped, so its backend answered "Authorization + // header is missing" and a Nostr login could not complete by ANY route: + // a NIP-07 extension in a tab, the parent frame's NIP-07 bridge + // (`nostr-provider.js`), or AIUI. The signing was always fine — the proof + // of it was thrown away one hop before the app. The same blanket removal + // breaks every app doing Basic auth or presenting a bearer token it + // issued itself. + if drop_authorization { + parts.headers.remove(header::AUTHORIZATION); + } let client = hyper::Client::new(); match client.request(Request::from_parts(parts, body)).await { @@ -1099,6 +1131,95 @@ mod tests { assert!(headers.get(header::COOKIE).is_none()); } + /// The regression that killed every Nostr login on 2026-08-06. + /// + /// IndeeHub's NIP-98 credential rides in `Authorization: Nostr ` + /// while the *gate's* credential is the session cookie. The gate must + /// classify that as a plain `Allow` — not as its own token — because only + /// `AllowGateToken` strips the header. Getting this wrong deleted the + /// signed event one hop before the app, and the app answered + /// "Authorization header is missing" no matter which signer produced it. + #[tokio::test] + async fn an_apps_own_authorization_is_not_mistaken_for_the_gates() { + let gate = test_gate().await; + let session = gate.sessions.create().await; + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + format!("session={session}").parse().unwrap(), + ); + for scheme in [ + "Nostr eyJraW5kIjogMjcyMzV9", + "Basic dXNlcjpwYXNz", + "Token app-issued-abc", + ] { + headers.insert(header::AUTHORIZATION, scheme.parse().unwrap()); + assert_eq!( + gate.authorize(&headers, "indeehub").await, + Authorization::Allow, + "{scheme} was treated as the gate's own credential" + ); + } + } + + /// The end of that path: an authorised request must reach the app with the + /// app's own `Authorization` intact. Asserted against a real proxy hop — + /// the header survived every unit test of the classifier while being + /// dropped in `proxy_to_app`, which is precisely how this shipped. + #[tokio::test] + async fn proxy_forwards_an_apps_own_authorization_and_withholds_the_gates() { + // A stand-in app that reports the Authorization header it received. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let _ = hyper::server::conn::Http::new() + .serve_connection( + stream, + hyper::service::service_fn(|req: Request| async move { + let seen = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + Ok::<_, hyper::Error>(Response::new(Body::from(seen))) + }), + ) + .await; + } + }); + + let mut app = app(); + app.port = port; + + async fn seen_by_app(app: &GatedPort, header_value: &str, drop: bool) -> String { + let req = Request::builder() + .method(Method::POST) + .uri("/api/auth/nostr/session") + .header(header::AUTHORIZATION, header_value) + .body(Body::empty()) + .unwrap(); + let resp = proxy_to_app(req, app, drop).await; + let body = hyper::body::to_bytes(resp.into_body()).await.unwrap(); + String::from_utf8_lossy(&body).to_string() + } + + // The app's own credential reaches the app. + assert_eq!( + seen_by_app(&app, "Nostr eyJraW5kIjogMjcyMzV9", false).await, + "Nostr eyJraW5kIjogMjcyMzV9" + ); + // The gate's own device token never does. + assert_eq!( + seen_by_app(&app, "Bearer gate-device-token", true).await, + "" + ); + } + #[test] fn strip_gate_cookies_no_header_is_a_noop() { let mut headers = HeaderMap::new();