`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
177 lines
6.7 KiB
Rust
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()
|
|
}
|