Files
maarcadetweet/crates/appview/src/auth.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

736 lines
28 KiB
Rust

//! 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,
/// The `aud` every access token must carry: this AppView's own
/// service DID. See [`verify_with_key`] for why it's checked.
expected_aud: String,
inner: RwLock<CachedKey>,
}
impl PdsKeys {
/// Build a cache pointed at `base_url` (no trailing slash required),
/// accepting only tokens addressed to `expected_aud`.
pub fn new(base_url: &str, expected_aud: impl Into<String>) -> 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('/')
),
expected_aud: expected_aud.into(),
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(), cfg.appview_did())
}
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, &self.expected_aud) {
Ok(claims) => Ok(claims),
Err(first) => {
let Some(fresh) = self.refetch_if_stale(&key).await else {
return Err(first);
};
verify_with_key(token, &fresh, &self.expected_aud).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,
expected_aud: &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) => {}
other => {
return Err(AuthError::Invalid(format!(
"token scope {:?} is not {ACCESS_SCOPE}",
other.unwrap_or("<none>")
)))
}
}
// Audience. `at_crypto::jwt::verify_jwt` sets `validate_aud = false`
// because it has no way of knowing who the caller is, so the check
// belongs here.
//
// What it buys: the PDS signs tokens for *its* AppView. Without an
// audience check, a token handed to any other service that trusts
// the same PDS key would be replayable here — and, the other way
// round, a token this AppView issued trust in could be replayed
// there. It is the difference between "the PDS vouches for this
// user" and "the PDS vouches for this user *talking to us*".
//
// A mismatch is `TokenInvalid` rather than `Forbidden` on purpose:
// that is the code the desktop client refreshes on, so a
// deployment that changes `APPVIEW_PUBLIC_URL` heals itself on the
// next refresh instead of stranding every signed-in user.
if claims.aud != expected_aud {
return Err(AuthError::Invalid(format!(
"token audience {:?} is not {expected_aud:?}",
claims.aud
)));
}
Ok(claims)
}
/// 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)
}
/// The audience the tests' AppView identifies as — what
/// `AppConfig::appview_did()` would return for
/// `APPVIEW_PUBLIC_URL=http://127.0.0.1:2584`.
const TEST_AUD: &str = "did:web:127.0.0.1%3A2584";
fn mint(kp: &P256Keypair, did: &str, scope: &str, ttl_secs: i64) -> String {
mint_for(kp, did, scope, ttl_secs, TEST_AUD)
}
fn mint_for(
kp: &P256Keypair,
did: &str,
scope: &str,
ttl_secs: i64,
aud: &str,
) -> String {
let now = chrono::Utc::now().timestamp();
issue_jwt(
kp,
&JwtClaims {
iss: "did:web:127.0.0.1%3A2583".into(),
sub: did.into(),
aud: aud.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");
}
/// A token minted for a different AppView must not work here, and
/// must fail as `TokenInvalid` so the client refreshes rather than
/// treating it as a permanent rejection.
#[test]
fn token_for_another_audience_is_rejected() {
let (kp, mb) = test_key();
let token = mint_for(
&kp,
"did:plc:alice",
ACCESS_SCOPE,
3600,
"did:web:someone-elses-appview.example",
);
let err = verify_with_key(&token, &mb, TEST_AUD).unwrap_err();
assert!(
matches!(err, AuthError::Invalid(ref m) if m.contains("audience")),
"expected an audience rejection, got {err:?}"
);
let (status, body) = err.into_response_parts();
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body.0["error"], "TokenInvalid");
// The same token *is* fine for the AppView it was minted for.
assert!(
verify_with_key(&token, &mb, "did:web:someone-elses-appview.example").is_ok()
);
}
#[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, TEST_AUD).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, TEST_AUD).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, TEST_AUD).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, TEST_AUD).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, TEST_AUD).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", TEST_AUD).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/", TEST_AUD).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", TEST_AUD);
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");
}
}