//! 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 = `, `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 `. 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) { 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 for (StatusCode, Json) { fn from(e: AuthError) -> Self { e.into_response_parts() } } // -- key cache --------------------------------------------------------------- #[derive(Default)] struct CachedKey { multibase: Option, /// When we last *attempted* a fetch — successful or not. Throttles /// the on-failure refresh path; see [`KEY_REFETCH_MIN_INTERVAL`]. last_attempt: Option, } /// 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, } 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) -> 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 { 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 { // 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 { 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 { { 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 { 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 { 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 { 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("") ))) } } // 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 { 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); 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)> { 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 for AuthedDid { type Rejection = (StatusCode, Json); async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { 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"); } }