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
168 lines
6.3 KiB
Rust
168 lines
6.3 KiB
Rust
//! Shared test support for the AppView integration suites.
|
|
//!
|
|
//! ## Why the suites need this
|
|
//!
|
|
//! `/api/notifications*` and `/api/timeline/home` require a
|
|
//! PDS-issued access token whose `sub` equals the `did` in the request.
|
|
//! The suites, however, seed synthetic DIDs (`did:plc:ntf_…`) through
|
|
//! `/internal/ingest-commit` — accounts the PDS has never heard of, so
|
|
//! there is no `createSession` that would hand out a token for them.
|
|
//!
|
|
//! The way out is that a token is just an ES256 JWT signed with the
|
|
//! server key derived from `PDS_JWT_SECRET`. A test that can read that
|
|
//! secret (from the process environment, or from the repo `.env` the
|
|
//! dev stack itself was started with) can mint a token for any DID it
|
|
//! likes — the same thing `pds-server/src/jwt_issuer.rs` does.
|
|
//!
|
|
//! ## Fail-open, like the rest of the suites
|
|
//!
|
|
//! [`TestAuth::probe`] asks the running AppView whether it enforces
|
|
//! auth at all:
|
|
//!
|
|
//! - not enforcing (`APPVIEW_AUTH_REQUIRED=false`) → no header needed;
|
|
//! - enforcing and we have the secret → mint per-DID tokens;
|
|
//! - enforcing and we don't → `None`, and the caller skips, exactly as
|
|
//! it already skips when the service or the database is down.
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use at_crypto::ecdsa::P256Keypair;
|
|
use at_crypto::jwt::{issue_jwt, JwtClaims};
|
|
|
|
/// Audience the PDS stamps into access tokens. Not validated by
|
|
/// `verify_jwt` today (`validate_aud = false`), but minting a token
|
|
/// that differs from the real thing would make this helper a poor
|
|
/// stand-in for the client.
|
|
const APPVIEW_AUD: &str = "did:web:appview.maarcadetweet.local";
|
|
|
|
/// The scope the AppView insists on. A token with any other scope —
|
|
/// `com.atproto.refresh`, say — is rejected with `TokenInvalid`.
|
|
pub const ACCESS_SCOPE: &str = "com.atproto.access";
|
|
|
|
/// How the suite should authenticate against the AppView under test.
|
|
///
|
|
/// `Clone` because a test that pages through results in a closure has
|
|
/// to hand each iteration its own copy, exactly like the client and the
|
|
/// base URL next to it.
|
|
#[derive(Clone)]
|
|
pub enum TestAuth {
|
|
/// `APPVIEW_AUTH_REQUIRED=false`: send no `Authorization` header.
|
|
Disabled,
|
|
/// Auth is enforced; mint tokens with this hex secret.
|
|
Secret(String),
|
|
}
|
|
|
|
impl TestAuth {
|
|
/// Decide how (or whether) this suite can talk to the AppView.
|
|
///
|
|
/// Returns `None` when the AppView enforces auth but no
|
|
/// `PDS_JWT_SECRET` is reachable — the caller should print a notice
|
|
/// and return, keeping `cargo test --workspace` green on a machine
|
|
/// without the dev stack's environment.
|
|
pub async fn probe(c: &reqwest::Client, base_url: &str) -> Option<Self> {
|
|
// An unauthenticated probe against a private endpoint. We only
|
|
// look at the status: 401 means the extractor is active. A DID
|
|
// that doesn't exist is fine — the auth check runs first.
|
|
let status = c
|
|
.get(format!("{base_url}/api/notifications/count"))
|
|
.query(&[("did", "did:plc:auth_probe")])
|
|
.send()
|
|
.await
|
|
.ok()?
|
|
.status()
|
|
.as_u16();
|
|
if status != 401 && status != 503 {
|
|
return Some(TestAuth::Disabled);
|
|
}
|
|
match pds_jwt_secret() {
|
|
Some(secret) => Some(TestAuth::Secret(secret)),
|
|
None => {
|
|
eprintln!(
|
|
"appview enforces auth (probe returned {status}) but PDS_JWT_SECRET \
|
|
is not set and no .env was found — skipping"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Attach an `Authorization: Bearer` header for `did`, if needed.
|
|
pub fn apply(&self, rb: reqwest::RequestBuilder, did: &str) -> reqwest::RequestBuilder {
|
|
match self {
|
|
TestAuth::Disabled => rb,
|
|
TestAuth::Secret(secret) => match mint_access_jwt(secret, did, ACCESS_SCOPE, 3600) {
|
|
Some(token) => rb.bearer_auth(token),
|
|
None => rb,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// A token for `did` — for tests that want to send a *wrong* one on
|
|
/// purpose. `None` when auth is disabled, in which case the test
|
|
/// that needs it should skip.
|
|
pub fn token_for(&self, did: &str) -> Option<String> {
|
|
match self {
|
|
TestAuth::Disabled => None,
|
|
TestAuth::Secret(secret) => mint_access_jwt(secret, did, ACCESS_SCOPE, 3600),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `PDS_JWT_SECRET` from the environment, falling back to the repo
|
|
/// `.env` — the same file the running dev stack loaded at startup, so
|
|
/// the minted tokens verify against the key the PDS actually publishes.
|
|
pub fn pds_jwt_secret() -> Option<String> {
|
|
if let Ok(v) = std::env::var("PDS_JWT_SECRET") {
|
|
if !v.trim().is_empty() {
|
|
return Some(v);
|
|
}
|
|
}
|
|
// `dotenvy::dotenv` walks up from the current directory, which for
|
|
// a test binary is the crate root — so this finds the workspace
|
|
// `.env` two levels up. It never overrides a real env var.
|
|
let _ = dotenvy::dotenv();
|
|
std::env::var("PDS_JWT_SECRET")
|
|
.ok()
|
|
.filter(|v| !v.trim().is_empty())
|
|
}
|
|
|
|
/// Mint an access JWT for `did`, signed with the PDS's server key.
|
|
///
|
|
/// Mirrors `pds-server/src/jwt_issuer.rs`: the P-256 secret scalar is
|
|
/// the **first 32 bytes** of `PDS_JWT_SECRET` (the config allows a
|
|
/// longer secret), i.e. the first 64 hex characters.
|
|
///
|
|
/// `ttl_secs` may be negative to build a deliberately expired token.
|
|
pub fn mint_access_jwt(
|
|
secret_hex: &str,
|
|
did: &str,
|
|
scope: &str,
|
|
ttl_secs: i64,
|
|
) -> Option<String> {
|
|
let hex = secret_hex.trim().trim_start_matches("0x");
|
|
if hex.len() < 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
eprintln!("PDS_JWT_SECRET is not ≥32 bytes of hex; cannot mint a test token");
|
|
return None;
|
|
}
|
|
let kp = P256Keypair {
|
|
secret_hex: hex[..64].to_string(),
|
|
// Only the signing half is used by `issue_jwt`; the verifier
|
|
// fetches the public key from the PDS's DID document.
|
|
public_multibase: String::new(),
|
|
};
|
|
let now = chrono::Utc::now().timestamp();
|
|
issue_jwt(
|
|
&kp,
|
|
&JwtClaims {
|
|
iss: "did:web:test".into(),
|
|
sub: did.to_string(),
|
|
aud: APPVIEW_AUD.into(),
|
|
iat: now - 1,
|
|
exp: now + ttl_secs,
|
|
jti: None,
|
|
scope: Some(scope.to_string()),
|
|
},
|
|
)
|
|
.ok()
|
|
}
|