Files
maarcadetweet/crates/appview/tests/common/mod.rs
T
tomdeboneandClaude Opus 5 6fd046417a feat(auth): Audience der Access-Tokens prüfen
`verify_jwt` setzt `validate_aud = false` — es kann den Aufrufer nicht
kennen. Also blieb `aud` bisher ungeprüft, obwohl die PDS es setzt.

Was die Prüfung bringt: die PDS signiert Tokens für *ihre* AppView.
Ohne Audience-Check wäre ein Token, das an einen anderen Dienst mit
derselben PDS-Vertrauensbeziehung geht, hier wiederverwendbar — und
umgekehrt. Es ist der Unterschied zwischen "die PDS bürgt für diesen
Nutzer" und "die PDS bürgt für diesen Nutzer *im Gespräch mit uns*".

Dafür musste der Wert erst einmal etwas sein, das beide Seiten
berechnen können: die PDS setzte ihn hart auf
did:web:appview.maarcadetweet.local. Jetzt leiten ihn beide über
AppConfig::appview_did() aus APPVIEW_PUBLIC_URL ab — dieselbe
did:web-Regel wie schon für pds_did().

Ein Mismatch ist TokenInvalid, nicht Forbidden: das ist der Code, auf
den der Client seine Token-Erneuerung stützt. Eine Instanz, die ihre
APPVIEW_PUBLIC_URL ändert, heilt sich damit beim nächsten Refresh
selbst, statt jeden angemeldeten Nutzer auszusperren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 06:25:51 +02:00

177 lines
6.7 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 — and, since the
/// audience check landed, the value the AppView insists on: its own
/// service DID, derived from `APPVIEW_PUBLIC_URL`. A token minted with
/// anything else is rejected as `TokenInvalid`, which is exactly what
/// we want a wrong value here to look like.
///
/// Derived the same way `AppConfig::appview_did()` does it, from the
/// same environment variable, so this helper can't drift from the
/// service it's impersonating the PDS for.
fn appview_aud() -> String {
let url = std::env::var("APPVIEW_PUBLIC_URL")
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
at_shared::config::did_web_from_url(&url)
}
/// 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(),
iat: now - 1,
exp: now + ttl_secs,
jti: None,
scope: Some(scope.to_string()),
},
)
.ok()
}