Files
maarcadetweet/crates/appview/tests/auth_integration.rs
T
tomdeboneandClaude Opus 5 a2a371b7d9 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
2026-09-09 23:02:27 +02:00

312 lines
10 KiB
Rust

//! End-to-end tests for AppView authentication.
//!
//! Unlike the other suites in this directory these use a **real**
//! token: the test creates an account on the running PDS
//! (`com.atproto.server.createAccount`, the same call the desktop
//! client makes at signup) and presents the access JWT it gets back to
//! the AppView. That is the whole point — it exercises the actual key
//! distribution path (PDS signs → publishes its public key at
//! `/.well-known/did.json` → AppView fetches and verifies), which a
//! self-minted token would bypass.
//!
//! Fail-open like the rest of the suites: if the PDS or the AppView
//! isn't running, or the AppView runs with `APPVIEW_AUTH_REQUIRED=false`,
//! the test prints a notice and returns successfully.
use serde_json::{json, Value};
use std::time::Duration;
const PDS_URL: &str = "http://127.0.0.1:2583";
fn appview_url() -> String {
std::env::var("APPVIEW_TEST_URL").unwrap_or_else(|_| "http://127.0.0.1:2584".to_string())
}
/// 30 s, not the 5 s the sibling suites use.
///
/// The authenticated happy path for `/api/timeline/home` runs the
/// cold-start query (a brand-new account follows nobody, so the handler
/// falls back to the global recent feed). On a developer machine whose
/// AppView has been indexing the public firehose for a while that scan
/// takes seconds — a timeout there would look like an auth failure and
/// is nothing of the sort.
fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap()
}
async fn up(c: &reqwest::Client, base: &str) -> bool {
for _ in 0..20 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
/// A freshly created PDS account.
struct Account {
did: String,
access_jwt: String,
refresh_jwt: String,
}
/// Guard for every test here: both services up **and** the AppView
/// actually enforcing auth. Returns the client plus a new account.
async fn ready() -> Option<(reqwest::Client, Account)> {
let c = client();
if !up(&c, &appview_url()).await {
eprintln!("appview not running, skipping");
return None;
}
if !up(&c, PDS_URL).await {
eprintln!("pds not running, skipping");
return None;
}
// Probe: does this instance enforce auth? An operator running with
// `APPVIEW_AUTH_REQUIRED=false` (the mode the other suites use)
// would otherwise see every assertion here fail for the one reason
// that isn't a bug.
let probe = c
.get(format!("{}/api/notifications/count", appview_url()))
.query(&[("did", "did:plc:auth_probe")])
.send()
.await
.ok()?;
if probe.status().as_u16() != 401 {
eprintln!(
"appview does not enforce auth (probe returned {}), skipping",
probe.status()
);
return None;
}
let acc = create_account(&c).await?;
Some((c, acc))
}
async fn create_account(c: &reqwest::Client) -> Option<Account> {
let handle = format!("auth_{}.maarcadetweet.local", uuid::Uuid::new_v4().simple());
let r: Value = c
.post(format!("{PDS_URL}/xrpc/com.atproto.server.createAccount"))
.json(&json!({ "handle": handle, "password": "hunter2hunter2" }))
.send()
.await
.ok()?
.json()
.await
.ok()?;
Some(Account {
did: r["did"].as_str()?.to_string(),
access_jwt: r["access_jwt"].as_str()?.to_string(),
refresh_jwt: r["refresh_jwt"].as_str()?.to_string(),
})
}
/// `GET /api/notifications?did=…` with an optional bearer token.
async fn get_notifications(
c: &reqwest::Client,
did: &str,
token: Option<&str>,
) -> reqwest::Response {
let mut rb = c
.get(format!("{}/api/notifications", appview_url()))
.query(&[("did", did)]);
if let Some(t) = token {
rb = rb.bearer_auth(t);
}
rb.send().await.unwrap()
}
/// Assert the AppView's standard error envelope: the status, and the
/// `error` code the desktop client branches on.
async fn assert_error(r: reqwest::Response, status: u16, code: &str) {
let got = r.status().as_u16();
let body: Value = r.json().await.unwrap();
assert_eq!(got, status, "unexpected status; body = {body}");
assert_eq!(body["error"], json!(code), "unexpected error code: {body}");
assert!(
body["message"].is_string(),
"error body must carry a message: {body}"
);
}
#[tokio::test]
async fn own_token_reads_own_notifications() {
let Some((c, acc)) = ready().await else { return };
let r = get_notifications(&c, &acc.did, Some(&acc.access_jwt)).await;
assert_eq!(r.status().as_u16(), 200);
let body: Value = r.json().await.unwrap();
// A brand-new account has no notifications, but the shape must be
// the normal list response, not an error.
assert!(body["notifications"].is_array(), "body = {body}");
}
#[tokio::test]
async fn no_token_is_401_auth_missing() {
let Some((c, acc)) = ready().await else { return };
let r = get_notifications(&c, &acc.did, None).await;
assert_error(r, 401, "AuthMissing").await;
}
#[tokio::test]
async fn foreign_did_is_403() {
let Some((c, acc)) = ready().await else { return };
// A valid token, but asking about somebody else's inbox. This is
// the case the endpoint used to answer with a 200.
let r = get_notifications(&c, "did:plc:somebodyelse", Some(&acc.access_jwt)).await;
assert_error(r, 403, "Forbidden").await;
}
#[tokio::test]
async fn garbage_token_is_401_token_invalid() {
let Some((c, acc)) = ready().await else { return };
let r = get_notifications(&c, &acc.did, Some("not.a.jwt")).await;
assert_error(r, 401, "TokenInvalid").await;
}
#[tokio::test]
async fn refresh_token_is_not_accepted() {
let Some((c, acc)) = ready().await else { return };
// Signed by the same key and valid for 90 days — only the `scope`
// claim separates it from a session credential.
let r = get_notifications(&c, &acc.did, Some(&acc.refresh_jwt)).await;
assert_error(r, 401, "TokenInvalid").await;
}
#[tokio::test]
async fn seen_write_requires_matching_token() {
let Some((c, acc)) = ready().await else { return };
let url = format!("{}/api/notifications/seen", appview_url());
// Somebody else's unread badge: 403, nothing written.
let r = c
.post(&url)
.bearer_auth(&acc.access_jwt)
.json(&json!({ "did": "did:plc:somebodyelse" }))
.send()
.await
.unwrap();
assert_error(r, 403, "Forbidden").await;
// No credential at all: 401.
let r = c
.post(&url)
.json(&json!({ "did": &acc.did }))
.send()
.await
.unwrap();
assert_error(r, 401, "AuthMissing").await;
// Own inbox: allowed (zero rows updated — the account is new).
let r = c
.post(&url)
.bearer_auth(&acc.access_jwt)
.json(&json!({ "did": &acc.did }))
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200);
let body: Value = r.json().await.unwrap();
assert_eq!(body["ok"], json!(true));
}
#[tokio::test]
async fn timeline_home_is_authenticated() {
let Some((c, acc)) = ready().await else { return };
let base = appview_url();
let r = c
.get(format!("{base}/api/timeline/home"))
.query(&[("did", acc.did.as_str())])
.send()
.await
.unwrap();
assert_error(r, 401, "AuthMissing").await;
let r = c
.get(format!("{base}/api/timeline/home"))
.query(&[("did", "did:plc:somebodyelse")])
.bearer_auth(&acc.access_jwt)
.send()
.await
.unwrap();
assert_error(r, 403, "Forbidden").await;
let r = c
.get(format!("{base}/api/timeline/home"))
.query(&[("did", acc.did.as_str())])
.bearer_auth(&acc.access_jwt)
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200);
}
#[tokio::test]
async fn public_endpoints_stay_public() {
let Some((c, acc)) = ready().await else { return };
let base = appview_url();
// Profiles, search, follower lists and the health probe are public
// records in AT Proto. Putting them behind auth would be a
// behaviour change no protocol rule asks for — assert they still
// answer without a token.
for (path, params) in [
("/api/profile", vec![("did", acc.did.as_str())]),
("/api/search", vec![("q", "hello")]),
("/api/followers", vec![("did", acc.did.as_str())]),
("/api/following", vec![("did", acc.did.as_str())]),
("/healthz", vec![]),
] {
let r = c
.get(format!("{base}{path}"))
.query(&params)
.send()
.await
.unwrap();
assert_eq!(
r.status().as_u16(),
200,
"{path} must remain public, got {}",
r.status()
);
}
}
/// The AppView can only verify anything because the PDS publishes its
/// public key. If this document ever stops carrying a usable key, every
/// authenticated request degrades to `503 AuthUnavailable` — so assert
/// the shape the AppView parses.
#[tokio::test]
async fn pds_publishes_a_usable_signing_key() {
let c = client();
if !up(&c, PDS_URL).await {
eprintln!("pds not running, skipping");
return;
}
let doc: Value = c
.get(format!("{PDS_URL}/.well-known/did.json"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let id = doc["id"].as_str().expect("did document needs an id");
assert!(id.starts_with("did:web:"), "id = {id}");
let vm = &doc["verificationMethod"][0];
assert_eq!(vm["type"], json!("Multikey"));
assert_eq!(vm["controller"], json!(id));
let key = vm["publicKeyMultibase"]
.as_str()
.expect("verificationMethod needs publicKeyMultibase");
// base58-btc multibase: the `z` prefix is what the AppView's
// decoder expects.
assert!(key.starts_with('z'), "key = {key}");
}