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:
tomdebone
2026-09-09 23:02:27 +02:00
co-authored by Claude Opus 5
parent 786a892658
commit a2a371b7d9
14 changed files with 1520 additions and 147 deletions
+659
View File
@@ -0,0 +1,659 @@
//! Bearer-token authentication for the AppView's private endpoints.
//!
//! ## What is being proven
//!
//! The PDS already issues an ES256 access JWT on
//! `com.atproto.server.createSession` / `createAccount`. That token
//! carries `sub = <did>`, `scope = "com.atproto.access"` and
//! `aud = "did:web:appview.maarcadetweet.local"` — it was always meant
//! to be presented *here*. All the AppView has to do is verify the
//! signature and read `sub`.
//!
//! ## Where the key comes from
//!
//! Verifying an ES256 signature needs only the public half of the PDS's
//! P-256 key. The PDS publishes it in its DID document at
//! `GET /.well-known/did.json` (see `pds-server/src/main.rs`), so
//! `PDS_JWT_SECRET` never leaves the PDS process. We fetch that
//! document once, cache the `publicKeyMultibase` behind an `RwLock`,
//! and re-fetch on a verification failure so an operator can rotate
//! `PDS_JWT_SECRET` without restarting the AppView.
//!
//! Two failure modes are deliberately handled differently:
//!
//! - **PDS unreachable at startup.** The AppView has always booted
//! independently of the PDS (it indexes the firehose, which has
//! nothing to do with the local PDS), and a crash-loop on a
//! colocated service that happens to boot second would be a
//! self-inflicted outage. So startup only *warns*; the first
//! authenticated request retries the fetch.
//! - **PDS unreachable when a token must be checked.** There is no
//! safe way to guess, so the request gets a `503`. Fail closed —
//! never fail open.
//!
//! ## Error contract
//!
//! The body shape matches every other AppView error
//! (`{"error": …, "message": …}`). The `error` codes are load-bearing:
//!
//! | case | status | `error` |
//! |----------------------------------------|--------|-------------------|
//! | no / malformed `Authorization` header | 401 | `AuthMissing` |
//! | bad signature, expired, wrong `scope` | 401 | `TokenInvalid` |
//! | valid token, but `sub` ≠ requested did | 403 | `Forbidden` |
//! | PDS key not obtainable | 503 | `AuthUnavailable` |
//!
//! **`TokenInvalid` is a contract with the desktop client**: seeing it,
//! the client refreshes its access JWT (`com.atproto.server.refreshSession`)
//! and retries the request once. Renaming it silently logs every user
//! out an hour after login.
use at_crypto::jwt::JwtClaims;
use axum::async_trait;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, warn};
use crate::state::AppState;
/// The scope an *access* token carries. Refresh tokens are minted with
/// `com.atproto.refresh` by the same key, so without this check a
/// refresh token — which lives for 90 days instead of an hour — would
/// be accepted as a session credential everywhere.
const ACCESS_SCOPE: &str = "com.atproto.access";
/// Minimum spacing between two key re-fetches triggered by a failed
/// verification.
///
/// Re-fetching on failure is what makes key rotation work without a
/// restart. Doing it on *every* failure would also hand anyone who can
/// reach the AppView a free amplifier: a stream of garbage tokens
/// becomes a stream of requests to the PDS. One re-fetch per minute is
/// far quicker than any plausible rotation cadence needs and costs the
/// PDS nothing.
const KEY_REFETCH_MIN_INTERVAL: Duration = Duration::from_secs(60);
/// HTTP timeout for the DID-document fetch. The PDS is colocated; if it
/// doesn't answer in two seconds it isn't answering, and a request
/// blocked on auth is a request the user is staring at.
const DID_DOC_TIMEOUT: Duration = Duration::from_secs(2);
// -- error type --------------------------------------------------------------
/// Everything that can go wrong between "a request arrived" and "we know
/// which DID it belongs to". Converted into the AppView's standard error
/// body by [`AuthError::into_response_parts`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthError {
/// No `Authorization` header, or one that isn't `Bearer <token>`.
Missing,
/// Signature, expiry or scope check failed. The client's cue to
/// refresh its access token and retry.
Invalid(String),
/// Authenticated fine, but the token belongs to somebody else.
Forbidden,
/// We could not obtain the PDS's public key, so we cannot decide.
Unavailable(String),
}
impl AuthError {
pub fn into_response_parts(self) -> (StatusCode, Json<Value>) {
let (status, code, message) = match self {
AuthError::Missing => (
StatusCode::UNAUTHORIZED,
"AuthMissing",
"missing Authorization: Bearer header".to_string(),
),
AuthError::Invalid(detail) => {
(StatusCode::UNAUTHORIZED, "TokenInvalid", detail)
}
AuthError::Forbidden => (
StatusCode::FORBIDDEN,
"Forbidden",
"token sub does not match the requested did".to_string(),
),
AuthError::Unavailable(detail) => (
StatusCode::SERVICE_UNAVAILABLE,
"AuthUnavailable",
format!("cannot verify tokens: {detail}"),
),
};
(
status,
Json(json!({
"error": code,
"message": message,
})),
)
}
}
impl From<AuthError> for (StatusCode, Json<Value>) {
fn from(e: AuthError) -> Self {
e.into_response_parts()
}
}
// -- key cache ---------------------------------------------------------------
#[derive(Default)]
struct CachedKey {
multibase: Option<String>,
/// When we last *attempted* a fetch — successful or not. Throttles
/// the on-failure refresh path; see [`KEY_REFETCH_MIN_INTERVAL`].
last_attempt: Option<Instant>,
}
/// The PDS signing key, lazily fetched and cached.
///
/// Lives in [`AppState`] behind an `Arc`, so all handlers share one
/// cache and one HTTP client.
pub struct PdsKeys {
http: reqwest::Client,
/// Fully-qualified URL of the PDS's DID document.
did_doc_url: String,
inner: RwLock<CachedKey>,
}
impl PdsKeys {
/// Build a cache pointed at `base_url` (no trailing slash required).
pub fn new(base_url: &str) -> Self {
let http = reqwest::Client::builder()
.timeout(DID_DOC_TIMEOUT)
.build()
.expect("reqwest client build should never fail");
Self {
http,
did_doc_url: format!(
"{}/.well-known/did.json",
base_url.trim_end_matches('/')
),
inner: RwLock::new(CachedKey::default()),
}
}
/// Same PDS the handle-sync worker talks to: `PDS_INTERNAL_URL`
/// when set, else `PDS_PUBLIC_URL`.
pub fn from_config(cfg: &at_shared::config::AppConfig) -> Self {
Self::new(&cfg.pds_base_url())
}
pub fn did_doc_url(&self) -> &str {
&self.did_doc_url
}
/// The cached key, if we have ever fetched one.
pub async fn cached(&self) -> Option<String> {
self.inner.read().await.multibase.clone()
}
/// Fetch the DID document and replace the cached key.
///
/// Called once at startup (best effort), on the first authenticated
/// request if startup failed, and — throttled — after a failed
/// verification.
pub async fn refresh(&self) -> anyhow::Result<String> {
// Record the attempt before the await so two concurrent
// failures can't both decide they're the first one.
self.inner.write().await.last_attempt = Some(Instant::now());
let resp = self.http.get(&self.did_doc_url).send().await?;
if !resp.status().is_success() {
anyhow::bail!(
"{} returned {}",
self.did_doc_url,
resp.status().as_u16()
);
}
let doc: Value = resp.json().await?;
let key = extract_public_key_multibase(&doc)?;
{
let mut guard = self.inner.write().await;
guard.multibase = Some(key.clone());
}
debug!(url = %self.did_doc_url, "loaded PDS signing key");
Ok(key)
}
/// The key to verify with, fetching it if we don't have one yet.
async fn key_or_fetch(&self) -> Result<String, AuthError> {
if let Some(k) = self.cached().await {
return Ok(k);
}
self.refresh().await.map_err(|e| {
warn!(url = %self.did_doc_url, error = %e, "PDS signing key unavailable");
AuthError::Unavailable(format!(
"PDS did document at {} not reachable: {e}",
self.did_doc_url
))
})
}
/// Re-fetch after a verification failure, at most once per
/// [`KEY_REFETCH_MIN_INTERVAL`]. Returns the new key only when it
/// actually differs from `stale` — re-running the same failing
/// verification against an unchanged key proves nothing.
async fn refetch_if_stale(&self, stale: &str) -> Option<String> {
{
let guard = self.inner.read().await;
if let Some(last) = guard.last_attempt {
if last.elapsed() < KEY_REFETCH_MIN_INTERVAL {
return None;
}
}
}
match self.refresh().await {
Ok(fresh) if fresh != stale => {
warn!("PDS signing key changed; re-verifying with the rotated key");
Some(fresh)
}
Ok(_) => None,
Err(e) => {
warn!(error = %e, "PDS key re-fetch after verification failure failed");
None
}
}
}
/// Verify an access token and return its claims.
///
/// Retries exactly once against a freshly fetched key, so a rotated
/// `PDS_JWT_SECRET` costs one extra HTTP round trip rather than a
/// restart.
pub async fn verify_access_token(&self, token: &str) -> Result<JwtClaims, AuthError> {
let key = self.key_or_fetch().await?;
match verify_with_key(token, &key) {
Ok(claims) => Ok(claims),
Err(first) => {
let Some(fresh) = self.refetch_if_stale(&key).await else {
return Err(first);
};
verify_with_key(token, &fresh).map_err(|_| first)
}
}
}
}
/// Pull `verificationMethod[].publicKeyMultibase` out of a DID document.
///
/// We take the first entry that carries a `publicKeyMultibase` rather
/// than insisting on a fragment name: the PDS writes `#atproto`, but a
/// document served by a proxy or a future PDS version may order or name
/// its methods differently, and any key in the document is a key the
/// controller published for itself. A document with none is an error,
/// not an empty key — silently caching `""` would turn every later
/// verification into a confusing signature failure.
fn extract_public_key_multibase(doc: &Value) -> anyhow::Result<String> {
let methods = doc
.get("verificationMethod")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("did document has no verificationMethod array"))?;
for m in methods {
if let Some(k) = m.get("publicKeyMultibase").and_then(|v| v.as_str()) {
if !k.is_empty() {
return Ok(k.to_string());
}
}
}
anyhow::bail!("did document has no verificationMethod with publicKeyMultibase")
}
/// Signature + expiry + scope check against one specific key.
///
/// `verify_jwt` handles the ES256 signature and `exp` (with a 30 s
/// leeway for clock skew); the scope check is ours, and it is the line
/// that keeps a 90-day refresh token from working as a session
/// credential.
fn verify_with_key(token: &str, pubkey_multibase: &str) -> Result<JwtClaims, AuthError> {
let claims = at_crypto::jwt::verify_jwt(token, pubkey_multibase)
.map_err(|e| AuthError::Invalid(format!("invalid token: {e}")))?;
match claims.scope.as_deref() {
Some(ACCESS_SCOPE) => Ok(claims),
other => Err(AuthError::Invalid(format!(
"token scope {:?} is not {ACCESS_SCOPE}",
other.unwrap_or("<none>")
))),
}
}
/// Extract the bearer token from an `Authorization` header.
///
/// The scheme match is case-insensitive (RFC 7235 says it is) — some
/// HTTP clients send `bearer`.
fn bearer_token(headers: &HeaderMap) -> Result<String, AuthError> {
let raw = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AuthError::Missing)?;
let (scheme, token) = raw.split_once(' ').ok_or(AuthError::Missing)?;
if !scheme.eq_ignore_ascii_case("bearer") {
return Err(AuthError::Missing);
}
let token = token.trim();
if token.is_empty() {
return Err(AuthError::Missing);
}
Ok(token.to_string())
}
// -- extractor ---------------------------------------------------------------
/// The authenticated DID of the caller.
///
/// `None` means auth is switched off for this instance
/// (`APPVIEW_AUTH_REQUIRED=false`) — *not* "anonymous but allowed
/// through". When auth is on, this is always `Some` by construction:
/// the extractor rejects the request otherwise.
///
/// Handlers must still call [`AuthedDid::ensure_matches`] with the DID
/// the request asks about. Proving *who you are* is not the same as
/// proving *whose notifications you may read*.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthedDid(pub Option<String>);
impl AuthedDid {
/// 403 unless the token's `sub` is the DID the request targets.
///
/// With auth disabled this is a no-op, which is exactly what
/// `APPVIEW_AUTH_REQUIRED=false` means.
pub fn ensure_matches(&self, did: &str) -> Result<(), (StatusCode, Json<Value>)> {
match self.0.as_deref() {
None => Ok(()),
Some(sub) if sub == did => Ok(()),
Some(_) => Err(AuthError::Forbidden.into_response_parts()),
}
}
/// The caller's DID, if authenticated.
///
/// No handler needs it yet — every private endpoint takes the DID
/// as a parameter and compares it — but an endpoint that acts
/// purely on "whoever is calling" would read it here instead of
/// trusting a query parameter.
#[allow(dead_code)]
pub fn did(&self) -> Option<&str> {
self.0.as_deref()
}
}
#[async_trait]
impl FromRequestParts<AppState> for AuthedDid {
type Rejection = (StatusCode, Json<Value>);
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
if !state.cfg.appview_auth_required {
// Opt-out instance. The startup log says so in capitals.
return Ok(AuthedDid(None));
}
let token = bearer_token(&parts.headers).map_err(AuthError::into_response_parts)?;
let claims = state
.pds_keys
.verify_access_token(&token)
.await
.map_err(AuthError::into_response_parts)?;
Ok(AuthedDid(Some(claims.sub)))
}
}
// -- startup posture ---------------------------------------------------------
/// Log, once at boot, every way this instance is configured to be less
/// strict than the defaults.
///
/// All three of these are legitimate configurations — a VPN-only
/// deployment, a dev box, a single-machine setup — and all three are
/// also exactly what an accidentally-public instance looks like. The
/// only defence that survives a hurried deployment is a log line the
/// operator cannot miss, so each one is a `warn!` naming the variable
/// that turns it back on.
pub fn log_startup_posture(cfg: &at_shared::config::AppConfig) {
if !cfg.appview_auth_required {
warn!(
"APPVIEW_AUTH_REQUIRED=false — /api/notifications* and \
/api/timeline/home are served to ANY caller for ANY did. \
Only safe when this instance is unreachable from untrusted \
networks."
);
}
if cfg.appview_cors_origins.is_empty() {
warn!(
"APPVIEW_CORS_ORIGINS unset — sending Access-Control-Allow-Origin: * \
so any web page can call this AppView from a browser. Set it to the \
origins your client actually uses, e.g. \
'tauri://localhost,http://127.0.0.1:1430'."
);
}
if cfg.appview_ingest_secret.is_none() {
warn!(
"APPVIEW_INGEST_SECRET unset — POST /internal/ingest-commit accepts \
unauthenticated writes into the index (anyone who can reach this port \
can forge posts, follows and notifications). Set the same value here \
and on the PDS."
);
}
}
// -- tests -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use at_crypto::ecdsa::P256Keypair;
use at_crypto::jwt::{issue_jwt, JwtClaims};
use axum::http::HeaderValue;
/// A throwaway server key plus its `publicKeyMultibase`, in the
/// exact encoding the PDS publishes: `0x8012` (P-256) followed by
/// the *uncompressed* affine coordinates. `P256Keypair::generate`
/// stores a compressed point, which `verify_jwt` cannot decode — so
/// this mirrors `pds-server`'s `server_p256_keypair`.
fn test_key() -> (P256Keypair, String) {
let kp = P256Keypair::generate().unwrap();
let vk = kp.verifying_key().unwrap();
let pt = vk.to_encoded_point(false);
let mut raw = vec![0x80u8, 0x12u8];
raw.extend_from_slice(pt.x().unwrap());
raw.extend_from_slice(pt.y().unwrap());
let multibase = at_crypto::multibase_util::encode_b58btc(&raw);
(kp, multibase)
}
fn mint(kp: &P256Keypair, did: &str, scope: &str, ttl_secs: i64) -> String {
let now = chrono::Utc::now().timestamp();
issue_jwt(
kp,
&JwtClaims {
iss: "did:web:127.0.0.1%3A2583".into(),
sub: did.into(),
aud: "did:web:appview.maarcadetweet.local".into(),
iat: now - 1,
exp: now + ttl_secs,
jti: None,
scope: Some(scope.into()),
},
)
.unwrap()
}
fn header_map(value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("authorization", HeaderValue::from_str(value).unwrap());
h
}
#[test]
fn missing_header_is_auth_missing() {
assert_eq!(bearer_token(&HeaderMap::new()), Err(AuthError::Missing));
}
#[test]
fn malformed_header_is_auth_missing() {
// No scheme, wrong scheme, and an empty token all read as
// "the client never presented a credential" — 401 AuthMissing,
// not TokenInvalid, so the client re-authenticates instead of
// burning a refresh round trip.
assert_eq!(bearer_token(&header_map("abc.def.ghi")), Err(AuthError::Missing));
assert_eq!(bearer_token(&header_map("Basic dXNlcjpwdw==")), Err(AuthError::Missing));
assert_eq!(bearer_token(&header_map("Bearer ")), Err(AuthError::Missing));
}
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(bearer_token(&header_map("bearer tok")).unwrap(), "tok");
assert_eq!(bearer_token(&header_map("Bearer tok")).unwrap(), "tok");
}
#[test]
fn error_bodies_carry_the_documented_codes() {
// These strings are a contract: the desktop client keys its
// "refresh and retry" behaviour off `TokenInvalid`.
let (s, b) = AuthError::Missing.into_response_parts();
assert_eq!(s, StatusCode::UNAUTHORIZED);
assert_eq!(b.0["error"], "AuthMissing");
let (s, b) = AuthError::Invalid("x".into()).into_response_parts();
assert_eq!(s, StatusCode::UNAUTHORIZED);
assert_eq!(b.0["error"], "TokenInvalid");
let (s, b) = AuthError::Forbidden.into_response_parts();
assert_eq!(s, StatusCode::FORBIDDEN);
assert_eq!(b.0["error"], "Forbidden");
let (s, b) = AuthError::Unavailable("pds down".into()).into_response_parts();
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(b.0["error"], "AuthUnavailable");
// Every body carries both fields the client parses.
assert!(b.0["message"].as_str().unwrap().contains("pds down"));
}
#[test]
fn valid_access_token_verifies() {
let (kp, mb) = test_key();
let token = mint(&kp, "did:plc:alice", ACCESS_SCOPE, 3600);
let claims = verify_with_key(&token, &mb).unwrap();
assert_eq!(claims.sub, "did:plc:alice");
}
#[test]
fn garbage_token_is_invalid() {
let (_, mb) = test_key();
let err = verify_with_key("not-a-jwt", &mb).unwrap_err();
assert!(matches!(err, AuthError::Invalid(_)));
}
#[test]
fn token_signed_by_another_key_is_invalid() {
let (kp, _) = test_key();
let (_, other_mb) = test_key();
let token = mint(&kp, "did:plc:alice", ACCESS_SCOPE, 3600);
assert!(matches!(
verify_with_key(&token, &other_mb).unwrap_err(),
AuthError::Invalid(_)
));
}
#[test]
fn refresh_scope_is_rejected() {
// The refresh token is signed by the same key and lives 90
// days. Without the scope check it would be a session token.
let (kp, mb) = test_key();
let token = mint(&kp, "did:plc:alice", "com.atproto.refresh", 3600);
let err = verify_with_key(&token, &mb).unwrap_err();
match err {
AuthError::Invalid(msg) => assert!(msg.contains("com.atproto.refresh")),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn expired_token_is_rejected() {
// Beyond `verify_jwt`'s 30 s leeway.
let (kp, mb) = test_key();
let token = mint(&kp, "did:plc:alice", ACCESS_SCOPE, -120);
assert!(matches!(
verify_with_key(&token, &mb).unwrap_err(),
AuthError::Invalid(_)
));
}
#[test]
fn ensure_matches_enforces_sub_equals_did() {
let me = AuthedDid(Some("did:plc:alice".into()));
assert!(me.ensure_matches("did:plc:alice").is_ok());
let (status, body) = me.ensure_matches("did:plc:bob").unwrap_err();
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body.0["error"], "Forbidden");
// A prefix of the real DID must not pass.
assert!(me.ensure_matches("did:plc:ali").is_err());
}
#[test]
fn ensure_matches_is_a_noop_when_auth_disabled() {
let off = AuthedDid(None);
assert!(off.ensure_matches("did:plc:anyone").is_ok());
assert_eq!(off.did(), None);
}
#[test]
fn did_doc_key_extraction() {
let doc = json!({
"id": "did:web:127.0.0.1%3A2583",
"verificationMethod": [{
"id": "did:web:127.0.0.1%3A2583#atproto",
"type": "Multikey",
"controller": "did:web:127.0.0.1%3A2583",
"publicKeyMultibase": "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme"
}]
});
assert_eq!(
extract_public_key_multibase(&doc).unwrap(),
"zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme"
);
// A document without a usable key must error rather than cache
// an empty string.
assert!(extract_public_key_multibase(&json!({})).is_err());
assert!(extract_public_key_multibase(&json!({
"verificationMethod": [{"id": "#x", "type": "Multikey"}]
}))
.is_err());
assert!(extract_public_key_multibase(&json!({
"verificationMethod": [{"publicKeyMultibase": ""}]
}))
.is_err());
}
#[test]
fn did_doc_url_is_built_from_the_base_url() {
assert_eq!(
PdsKeys::new("http://127.0.0.1:2583").did_doc_url(),
"http://127.0.0.1:2583/.well-known/did.json"
);
// A trailing slash must not produce a double slash — some
// servers 404 on it.
assert_eq!(
PdsKeys::new("http://pds:3000/").did_doc_url(),
"http://pds:3000/.well-known/did.json"
);
}
#[tokio::test]
async fn verification_fails_closed_when_the_pds_is_unreachable() {
// Port 1 on loopback: nothing listens there, so the fetch fails
// fast. The result must be a 503, never a pass-through.
let keys = PdsKeys::new("http://127.0.0.1:1");
let err = keys.verify_access_token("whatever").await.unwrap_err();
assert!(
matches!(err, AuthError::Unavailable(_)),
"expected Unavailable, got {err:?}"
);
let (status, body) = err.into_response_parts();
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body.0["error"], "AuthUnavailable");
}
}