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:
co-authored by
Claude Opus 5
parent
786a892658
commit
a2a371b7d9
@@ -14,6 +14,9 @@
|
||||
//! API. That's deliberate: it's the only way to catch a mismatch
|
||||
//! between what the write path stores and what the read path joins.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::TestAuth;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -55,8 +58,12 @@ async fn db_pool() -> Option<sqlx::PgPool> {
|
||||
}
|
||||
|
||||
/// Guard used at the top of every test. Returns `None` (→ skip) unless
|
||||
/// both the HTTP service and the database are up.
|
||||
async fn ready() -> Option<(reqwest::Client, sqlx::PgPool)> {
|
||||
/// the HTTP service is up, the database is reachable, **and** we know
|
||||
/// how to authenticate: `/api/notifications*` now requires a token
|
||||
/// whose `sub` is the requested DID, and the DIDs seeded here are
|
||||
/// synthetic, so the token has to be minted from the PDS's signing
|
||||
/// secret. See `tests/common/mod.rs`.
|
||||
async fn ready() -> Option<(reqwest::Client, sqlx::PgPool, TestAuth)> {
|
||||
if !wait_for_appview_db().await {
|
||||
eprintln!("appview not running, skipping");
|
||||
return None;
|
||||
@@ -65,7 +72,32 @@ async fn ready() -> Option<(reqwest::Client, sqlx::PgPool)> {
|
||||
eprintln!("appview DB unreachable, skipping");
|
||||
return None;
|
||||
};
|
||||
Some((client().await, pool))
|
||||
let c = client().await;
|
||||
let auth = TestAuth::probe(&c, &appview_url()).await?;
|
||||
Some((c, pool, auth))
|
||||
}
|
||||
|
||||
/// `GET <url>` against a private endpoint, carrying the token for
|
||||
/// `did` when the instance enforces auth.
|
||||
fn authed_get(
|
||||
c: &reqwest::Client,
|
||||
auth: &TestAuth,
|
||||
url: String,
|
||||
did: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
auth.apply(c.get(url), did)
|
||||
}
|
||||
|
||||
/// `POST <url>` against a private endpoint. Same rule as
|
||||
/// [`authed_get`] — `/api/notifications/seen` is a write into one
|
||||
/// user's read state.
|
||||
fn authed_post(
|
||||
c: &reqwest::Client,
|
||||
auth: &TestAuth,
|
||||
url: String,
|
||||
did: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
auth.apply(c.post(url), did)
|
||||
}
|
||||
|
||||
async fn post_ingest(c: &reqwest::Client, body: Value) -> reqwest::Response {
|
||||
@@ -181,7 +213,7 @@ async fn seed_follow(c: &reqwest::Client, follower: &str, subject: &str) {
|
||||
#[tokio::test]
|
||||
async fn notifications_list_count_and_seen() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let alice = did_for_test("alice");
|
||||
@@ -196,8 +228,7 @@ async fn notifications_list_count_and_seen() {
|
||||
let reply_uri = seed_reply(&c, &carol, &post_uri, &post_uri, "carol's reply").await;
|
||||
seed_follow(&c, &bob, &alice).await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str()), ("limit", "50")])
|
||||
.send()
|
||||
.await
|
||||
@@ -253,8 +284,7 @@ async fn notifications_list_count_and_seen() {
|
||||
}
|
||||
|
||||
// The unread count agrees with the list.
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications/count"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -264,8 +294,7 @@ async fn notifications_list_count_and_seen() {
|
||||
assert_eq!(body["count"], json!(3));
|
||||
|
||||
// Mark everything seen.
|
||||
let resp = c
|
||||
.post(format!("{base}/api/notifications/seen"))
|
||||
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||
.json(&json!({ "did": alice }))
|
||||
.send()
|
||||
.await
|
||||
@@ -276,8 +305,7 @@ async fn notifications_list_count_and_seen() {
|
||||
assert_eq!(body["updated"], json!(3));
|
||||
|
||||
// Idempotent: a second call updates nothing and still succeeds.
|
||||
let resp = c
|
||||
.post(format!("{base}/api/notifications/seen"))
|
||||
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||
.json(&json!({ "did": alice }))
|
||||
.send()
|
||||
.await
|
||||
@@ -286,8 +314,7 @@ async fn notifications_list_count_and_seen() {
|
||||
assert_eq!(body["updated"], json!(0));
|
||||
|
||||
// Count is now zero and the rows carry a read_at.
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications/count"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -295,8 +322,7 @@ async fn notifications_list_count_and_seen() {
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["count"], json!(0));
|
||||
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -311,8 +337,7 @@ async fn notifications_list_count_and_seen() {
|
||||
"/api/notifications",
|
||||
"/api/notifications/count",
|
||||
] {
|
||||
let resp = c
|
||||
.get(format!("{base}{path}"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}{path}"), &alice)
|
||||
.query(&[("did", "")])
|
||||
.send()
|
||||
.await
|
||||
@@ -326,7 +351,7 @@ async fn notifications_list_count_and_seen() {
|
||||
#[tokio::test]
|
||||
async fn notifications_skip_self_interactions() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let alice = did_for_test("solo");
|
||||
@@ -335,8 +360,7 @@ async fn notifications_skip_self_interactions() {
|
||||
seed_reply(&c, &alice, &post_uri, &post_uri, "and replying too").await;
|
||||
seed_follow(&c, &alice, &alice).await;
|
||||
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -355,7 +379,7 @@ async fn notifications_skip_self_interactions() {
|
||||
#[tokio::test]
|
||||
async fn notifications_paginate_with_cursor() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let alice = did_for_test("popular");
|
||||
@@ -372,9 +396,9 @@ async fn notifications_paginate_with_cursor() {
|
||||
let c = c.clone();
|
||||
let alice = alice.clone();
|
||||
let base = base.clone();
|
||||
let auth = auth.clone();
|
||||
async move {
|
||||
let mut req = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let mut req = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str()), ("limit", "5")]);
|
||||
if let Some(cur) = cursor {
|
||||
req = req.query(&[("cursor", cur.as_str())]);
|
||||
@@ -417,8 +441,7 @@ async fn notifications_paginate_with_cursor() {
|
||||
assert_eq!(all.len(), 12);
|
||||
|
||||
// A mangled cursor is a 400, not a silent restart at page 1.
|
||||
let resp = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let resp = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str()), ("cursor", "!!!garbage!!!")])
|
||||
.send()
|
||||
.await
|
||||
@@ -431,7 +454,7 @@ async fn notifications_paginate_with_cursor() {
|
||||
#[tokio::test]
|
||||
async fn notifications_seen_respects_watermark() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let alice = did_for_test("watermark");
|
||||
@@ -442,8 +465,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
|
||||
// Read back the first notification's indexed_at — that's the
|
||||
// watermark a client would echo after rendering page 1.
|
||||
let body: Value = c
|
||||
.get(format!("{base}/api/notifications"))
|
||||
let body: Value = authed_get(&c, &auth, format!("{base}/api/notifications"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -461,8 +483,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
let second = did_for_test("late");
|
||||
seed_like(&c, &second, &post_uri).await;
|
||||
|
||||
let resp = c
|
||||
.post(format!("{base}/api/notifications/seen"))
|
||||
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||
.json(&json!({ "did": alice, "seenAt": watermark }))
|
||||
.send()
|
||||
.await
|
||||
@@ -475,8 +496,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
);
|
||||
|
||||
// The later one is still unread.
|
||||
let body: Value = c
|
||||
.get(format!("{base}/api/notifications/count"))
|
||||
let body: Value = authed_get(&c, &auth, format!("{base}/api/notifications/count"), &alice)
|
||||
.query(&[("did", alice.as_str())])
|
||||
.send()
|
||||
.await
|
||||
@@ -487,8 +507,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
assert_eq!(body["count"], json!(1));
|
||||
|
||||
// snake_case spelling must work identically.
|
||||
let resp = c
|
||||
.post(format!("{base}/api/notifications/seen"))
|
||||
let resp = authed_post(&c, &auth, format!("{base}/api/notifications/seen"), &alice)
|
||||
.json(&json!({ "did": alice, "seen_at": null }))
|
||||
.send()
|
||||
.await
|
||||
@@ -502,7 +521,7 @@ async fn notifications_seen_respects_watermark() {
|
||||
#[tokio::test]
|
||||
async fn followers_and_following_lists() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, _auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let hub = did_for_test("hub");
|
||||
@@ -591,7 +610,8 @@ async fn followers_and_following_lists() {
|
||||
assert_eq!(unique.len(), seen.len(), "paged followers repeat: {seen:?}");
|
||||
assert_eq!(seen.len(), 3, "paging lost a follower: {seen:?}");
|
||||
|
||||
// `did` is mandatory.
|
||||
// `did` is mandatory. These two stay public — a follow edge is a
|
||||
// public record — so no token is involved.
|
||||
for path in ["/api/followers", "/api/following"] {
|
||||
let resp = c
|
||||
.get(format!("{base}{path}"))
|
||||
@@ -610,7 +630,7 @@ async fn followers_and_following_lists() {
|
||||
#[tokio::test]
|
||||
async fn thread_returns_parents_and_replies() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, _auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let a = did_for_test("root");
|
||||
@@ -689,7 +709,7 @@ async fn thread_returns_parents_and_replies() {
|
||||
#[tokio::test]
|
||||
async fn post_by_uri_stays_backwards_compatible() {
|
||||
let base = appview_url();
|
||||
let Some((c, _pool)) = ready().await else {
|
||||
let Some((c, _pool, _auth)) = ready().await else {
|
||||
return;
|
||||
};
|
||||
let a = did_for_test("compat_a");
|
||||
|
||||
Reference in New Issue
Block a user