feat(appview): Bearer-Auth für Timeline und Notifications

Die AppView hatte keinerlei Authentifizierung: jeder konnte
/api/notifications?did=<beliebig> lesen und per /seen als gelesen
markieren. Mit Phase 8 sind das die ersten privaten Daten im System.

Das Access-JWT der PDS trug von Anfang an sub, scope
"com.atproto.access" und aud "did:web:appview…" — es war für die
AppView ausgestellt, nur hat sie es nie geprüft. Neu ist deshalb vor
allem die Schlüsselbeschaffung: auth.rs holt das DID-Dokument der PDS
(PDS_INTERNAL_URL, sonst PDS_PUBLIC_URL), cached den Schlüssel und lädt
ihn bei einem Verifikationsfehler nach — höchstens einmal pro Minute,
damit Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein
Schlüsselwechsel braucht damit keinen Neustart.

Ist die PDS beim Start weg, warnt die AppView nur und startet trotzdem
(sie indiziert den Firehose, der von der lokalen PDS unabhängig ist).
Ist der Schlüssel beim Prüfen eines Tokens nicht zu beschaffen, gibt es
503 — fail closed.

Geschützt: /api/timeline/home und die drei Notification-Endpoints, jeweils
mit sub == did. Öffentlich bleiben Profile, Suche, Posts, Threads und die
Follower-Listen; das sind in AT Proto öffentliche Records.

401 AuthMissing / 401 TokenInvalid / 403 Forbidden / 503 AuthUnavailable.
TokenInvalid ist ein Vertrag mit dem Client: daran erkennt er, dass er
sein Token erneuern und einmal wiederholen muss.

Dazu CORS: statt Any für alles jetzt eine Allowlist über
APPVIEW_CORS_ORIGINS (unset = altes Verhalten plus Warnung), und
/internal/ingest-commit liegt außerhalb der CORS-Schicht — die Route
wird server-zu-server aufgerufen, ein Allow-Origin darauf würde nur
einer Webseite helfen, in den Index zu schreiben.

APPVIEW_AUTH_REQUIRED=false stellt das alte Verhalten her (VPN-Instanz,
fail-open-Tests) und warnt beim Start in Großbuchstaben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-09 23:02:27 +02:00
co-authored by Claude Opus 5
parent 786a892658
commit a2a371b7d9
14 changed files with 1520 additions and 147 deletions
+45 -50
View File
@@ -7,6 +7,9 @@
//! rather than panicking — so `cargo test --workspace` stays green in
//! environments where the appview hasn't been started.
mod common;
use common::TestAuth;
use serde_json::{json, Value};
use std::time::Duration;
@@ -46,6 +49,36 @@ async fn db_reachable() -> bool {
)
}
/// How this suite authenticates against `/api/timeline/home`, which is
/// no longer public. `None` means the AppView enforces auth but the
/// test process cannot mint a token (no `PDS_JWT_SECRET`), in which
/// case the test skips like it does for a missing service.
async fn auth_or_skip() -> Option<TestAuth> {
TestAuth::probe(&client().await, APPVIEW_URL).await
}
/// `GET /api/timeline/home` as `did`, with the bearer token attached
/// when the instance requires one. The seeded DIDs are synthetic, so
/// the token is minted from the PDS's own signing secret — see
/// `tests/common/mod.rs`.
async fn get_timeline(
c: &reqwest::Client,
auth: &TestAuth,
did: &str,
extra: &[(&str, &str)],
) -> reqwest::Response {
let mut params: Vec<(&str, &str)> = vec![("did", did)];
params.extend_from_slice(extra);
auth.apply(
c.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&params),
did,
)
.send()
.await
.unwrap()
}
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
c.post(format!("{APPVIEW_URL}/internal/ingest-commit"))
.json(&body)
@@ -120,6 +153,7 @@ async fn timeline_returns_seeded_posts() {
return;
}
let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("tl");
// Seed 3 posts with distinct rkeys.
@@ -157,12 +191,7 @@ async fn timeline_returns_seeded_posts() {
// machine that has run this suite twice) the three rows we just
// seeded fall outside a 10-row window and the assertions below
// fail for reasons that have nothing to do with the timeline.
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
let resp = get_timeline(&c, &auth, &did, &[("limit", "100")]).await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array");
@@ -222,6 +251,7 @@ async fn timeline_paginates_with_cursor() {
return;
}
let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let did = did_for_test("pg");
// Seed 50 posts.
@@ -246,28 +276,14 @@ async fn timeline_paginates_with_cursor() {
tokio::time::sleep(Duration::from_millis(100)).await;
// Page 1: limit=20.
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", did.as_str()), ("limit", "20")])
.send()
.await
.unwrap();
let resp = get_timeline(&c, &auth, &did, &[("limit", "20")]).await;
let body: Value = resp.json().await.unwrap();
let page1 = body["posts"].as_array().unwrap().clone();
let cursor1 = body["cursor"].as_str().expect("page1 cursor");
assert_eq!(page1.len(), 20, "page1 should be exactly 20");
// Page 2: with cursor.
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[
("did", did.as_str()),
("limit", "20"),
("cursor", cursor1),
])
.send()
.await
.unwrap();
let resp = get_timeline(&c, &auth, &did, &[("limit", "20"), ("cursor", cursor1)]).await;
let body: Value = resp.json().await.unwrap();
let page2 = body["posts"].as_array().unwrap().clone();
assert_eq!(page2.len(), 20, "page2 should be exactly 20");
@@ -285,16 +301,7 @@ async fn timeline_paginates_with_cursor() {
// Page 3: tail — fewer than 20 expected, cursor=null.
let cursor2 = body["cursor"].as_str().expect("page2 cursor");
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[
("did", did.as_str()),
("limit", "20"),
("cursor", cursor2),
])
.send()
.await
.unwrap();
let resp = get_timeline(&c, &auth, &did, &[("limit", "20"), ("cursor", cursor2)]).await;
let body: Value = resp.json().await.unwrap();
let page3 = body["posts"].as_array().unwrap().clone();
assert!(page3.len() <= 20, "page3 should be <= 20");
@@ -484,6 +491,7 @@ async fn timeline_filters_to_followees() {
return;
}
let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let url = std::env::var("DATABASE_URL_APPVIEW").unwrap();
let pool = sqlx::PgPool::connect(&url).await.unwrap();
@@ -503,12 +511,7 @@ async fn timeline_filters_to_followees() {
tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
let resp = get_timeline(&c, &auth, &alice, &[("limit", "100")]).await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array");
@@ -555,18 +558,14 @@ async fn timeline_includes_own_posts() {
return;
}
let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let alice = did_for_test("alone");
// Alice posts without seeding any follows.
seed_posts(&c, &alice, &["alice's first post", "alice's second post"]).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "100")])
.send()
.await
.unwrap();
let resp = get_timeline(&c, &auth, &alice, &[("limit", "100")]).await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array");
@@ -605,6 +604,7 @@ async fn timeline_caps_followee_list() {
return;
}
let c = client().await;
let Some(auth) = auth_or_skip().await else { return };
let url = std::env::var("DATABASE_URL_APPVIEW").unwrap();
let pool = sqlx::PgPool::connect(&url).await.unwrap();
@@ -624,12 +624,7 @@ async fn timeline_caps_followee_list() {
seed_posts(&c, &alice, &["poweruser post"]).await;
tokio::time::sleep(Duration::from_millis(100)).await;
let resp = c
.get(format!("{APPVIEW_URL}/api/timeline/home"))
.query(&[("did", alice.as_str()), ("limit", "50")])
.send()
.await
.unwrap();
let resp = get_timeline(&c, &auth, &alice, &[("limit", "50")]).await;
assert_eq!(resp.status().as_u16(), 200);
let body: Value = resp.json().await.unwrap();
let posts = body["posts"].as_array().expect("posts is array");