`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
736 lines
28 KiB
Rust
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");
|
|
}
|
|
}
|