feat(pds): DID-Dokument unter /.well-known/did.json ausliefern
Die AppView soll die Access-Tokens der PDS prüfen können, ohne dass PDS_JWT_SECRET den PDS-Prozess verlässt. Verifiziert wird ES256 mit dem *öffentlichen* Teil des P-256-Schlüssels — den veröffentlicht die PDS jetzt als verificationMethod (Multikey) in ihrem DID-Dokument. Damit fällt auch die hartkodierte Service-DID: describeServer gab stur did:web:pds.maarcadetweet.local zurück, unabhängig von PDS_PUBLIC_URL. Beide Endpoints leiten sie jetzt aus einer Quelle ab (AppConfig::pds_did(), did:web-Regel mit %3A-kodiertem Port). Der `iss` des Access-Tokens baute die DID zuvor ohne Port-Kodierung zusammen — also in einer Form, der kein did:web-Resolver folgen könnte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
ec8fe187fe
commit
786a892658
@@ -7,6 +7,17 @@ fn default_handle_sync_interval() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
/// Default for `APPVIEW_AUTH_REQUIRED`.
|
||||
///
|
||||
/// `true` — the AppView's private endpoints (notifications, home
|
||||
/// timeline) reject unauthenticated requests. Fail closed: an operator
|
||||
/// who forgets the variable gets the safe behaviour, and the only way
|
||||
/// to serve another user's notifications to an anonymous caller is to
|
||||
/// opt out explicitly.
|
||||
fn default_auth_required() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub pds_host: String,
|
||||
@@ -46,6 +57,27 @@ pub struct AppConfig {
|
||||
/// directory. Default: 300s (5 minutes).
|
||||
#[serde(default = "default_handle_sync_interval")]
|
||||
pub appview_handle_sync_interval_secs: u64,
|
||||
/// Whether the AppView enforces bearer-token auth on the endpoints
|
||||
/// that serve a single user's private data (`/api/notifications*`,
|
||||
/// `/api/timeline/home`). Default `true`.
|
||||
///
|
||||
/// Set `APPVIEW_AUTH_REQUIRED=false` to get the pre-auth behaviour
|
||||
/// (every endpoint public). That mode exists for two callers:
|
||||
/// the fail-open integration suites, which seed synthetic DIDs the
|
||||
/// PDS has never issued a token for, and an instance that is
|
||||
/// already isolated at the network layer (VPN / private subnet).
|
||||
/// The AppView warns loudly at startup when it is off.
|
||||
#[serde(default = "default_auth_required")]
|
||||
pub appview_auth_required: bool,
|
||||
/// Browser origins allowed to call the AppView's `/api/*` routes,
|
||||
/// from the comma-separated `APPVIEW_CORS_ORIGINS`. Empty means
|
||||
/// "no allowlist configured" — the AppView then keeps the historic
|
||||
/// `Access-Control-Allow-Origin: *` behaviour and warns at startup.
|
||||
///
|
||||
/// Example (Tauri webview origins differ per platform):
|
||||
/// `APPVIEW_CORS_ORIGINS=tauri://localhost,http://127.0.0.1:1430`
|
||||
#[serde(default)]
|
||||
pub appview_cors_origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
@@ -82,6 +114,183 @@ impl AppConfig {
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or_else(default_handle_sync_interval),
|
||||
appview_auth_required: std::env::var("APPVIEW_AUTH_REQUIRED")
|
||||
.ok()
|
||||
.map(|s| parse_bool_env(&s))
|
||||
.unwrap_or_else(default_auth_required),
|
||||
appview_cors_origins: std::env::var("APPVIEW_CORS_ORIGINS")
|
||||
.ok()
|
||||
.map(|s| parse_csv_env(&s))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `did:web:` DID of *this* PDS, derived from `PDS_PUBLIC_URL`.
|
||||
///
|
||||
/// One derivation, two consumers: `com.atproto.server.describeServer`
|
||||
/// (which used to return a hardcoded `did:web:pds.maarcadetweet.local`
|
||||
/// no matter what the operator configured) and
|
||||
/// `GET /.well-known/did.json`, which publishes the server's signing
|
||||
/// key under exactly this id. If those two ever disagreed, a client
|
||||
/// that trusts `describeServer` would fetch the key document of a
|
||||
/// different identity.
|
||||
pub fn pds_did(&self) -> String {
|
||||
did_web_from_url(&self.pds_public_url)
|
||||
}
|
||||
|
||||
/// Base URL the AppView uses to reach the PDS.
|
||||
///
|
||||
/// `PDS_INTERNAL_URL` when set (the cluster-internal hostname),
|
||||
/// otherwise `PDS_PUBLIC_URL`. Both the handle-sync resolver and the
|
||||
/// signing-key fetch go through here, so the two can't end up
|
||||
/// talking to different PDS instances.
|
||||
pub fn pds_base_url(&self) -> String {
|
||||
self.pds_internal_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.pds_public_url.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret an environment variable as a boolean.
|
||||
///
|
||||
/// Accepts the spellings people actually type in a `.env` file. Anything
|
||||
/// unrecognised counts as `false` for an explicitly-set variable — the
|
||||
/// caller decides what an *absent* variable means (see
|
||||
/// [`default_auth_required`]), and a typo like `APPVIEW_AUTH_REQUIRED=ture`
|
||||
/// must never silently read as "on" when the operator's intent was to
|
||||
/// switch something off... nor as "off" for a security switch. Since
|
||||
/// this is only reached when the variable *is* set, and the only
|
||||
/// security-relevant user of it defaults to `true` when unset, we treat
|
||||
/// unknown values as `false` and rely on the startup warning to make a
|
||||
/// disabled auth switch impossible to miss in the logs.
|
||||
fn parse_bool_env(raw: &str) -> bool {
|
||||
matches!(
|
||||
raw.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
/// Split a comma-separated environment variable into trimmed,
|
||||
/// non-empty entries. `"a, b,,c "` → `["a", "b", "c"]`.
|
||||
fn parse_csv_env(raw: &str) -> Vec<String> {
|
||||
raw.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Turn an `http(s)://host[:port][/path]` URL into a `did:web:` DID.
|
||||
///
|
||||
/// The did:web method spec maps the authority to the method-specific
|
||||
/// id, with two wrinkles that matter here:
|
||||
///
|
||||
/// - a port is **percent-encoded** (`:` → `%3A`), because a bare colon
|
||||
/// already separates the DID's own segments. `http://127.0.0.1:2583`
|
||||
/// is therefore `did:web:127.0.0.1%3A2583`, *not*
|
||||
/// `did:web:127.0.0.1:2583` (which would parse as host `127.0.0.1`
|
||||
/// plus a path segment `2583`).
|
||||
/// - path segments, if any, are appended separated by `:`.
|
||||
///
|
||||
/// The default ports (80/443) are kept rather than stripped: the
|
||||
/// resolution rule is a textual one, and a client that reverses this
|
||||
/// mapping has to end up at the same URL we serve the document from.
|
||||
pub fn did_web_from_url(url: &str) -> String {
|
||||
// Strip the scheme. We accept a bare `host:port` too, which is what
|
||||
// a misconfigured `PDS_PUBLIC_URL` often contains.
|
||||
let rest = url
|
||||
.trim()
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_end_matches('/');
|
||||
// Drop any userinfo (`user@host`) and query/fragment — neither has
|
||||
// a place in a did:web identifier.
|
||||
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
|
||||
let rest = rest.rsplit('@').next().unwrap_or(rest);
|
||||
|
||||
let mut parts = rest.split('/');
|
||||
let authority = parts.next().unwrap_or("");
|
||||
let host = authority.replacen(':', "%3A", 1);
|
||||
let mut did = format!("did:web:{host}");
|
||||
for segment in parts.filter(|s| !s.is_empty()) {
|
||||
did.push(':');
|
||||
did.push_str(segment);
|
||||
}
|
||||
did
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn did_web_encodes_port_as_percent_3a() {
|
||||
// The dev default. A literal colon here would be read as a
|
||||
// did:web path segment, so it has to be percent-encoded.
|
||||
assert_eq!(
|
||||
did_web_from_url("http://127.0.0.1:2583"),
|
||||
"did:web:127.0.0.1%3A2583"
|
||||
);
|
||||
assert_eq!(
|
||||
did_web_from_url("https://pds.example.com:8443"),
|
||||
"did:web:pds.example.com%3A8443"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_web_without_port_is_plain_host() {
|
||||
assert_eq!(
|
||||
did_web_from_url("https://pds.maarcadetweet.local"),
|
||||
"did:web:pds.maarcadetweet.local"
|
||||
);
|
||||
// Trailing slash must not produce an empty path segment.
|
||||
assert_eq!(
|
||||
did_web_from_url("https://pds.example.com/"),
|
||||
"did:web:pds.example.com"
|
||||
);
|
||||
// Scheme-less input is tolerated.
|
||||
assert_eq!(did_web_from_url("pds.example.com"), "did:web:pds.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_web_appends_path_segments_with_colons() {
|
||||
assert_eq!(
|
||||
did_web_from_url("https://example.com/user/alice"),
|
||||
"did:web:example.com:user:alice"
|
||||
);
|
||||
// Port + path together: only the port gets percent-encoded.
|
||||
assert_eq!(
|
||||
did_web_from_url("http://example.com:2583/pds"),
|
||||
"did:web:example.com%3A2583:pds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn did_web_ignores_userinfo_query_and_fragment() {
|
||||
assert_eq!(
|
||||
did_web_from_url("https://user@example.com?x=1#frag"),
|
||||
"did:web:example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_env_accepts_common_spellings() {
|
||||
for on in ["1", "true", "TRUE", " yes ", "on"] {
|
||||
assert!(parse_bool_env(on), "{on} should parse as true");
|
||||
}
|
||||
for off in ["0", "false", "no", "off", "", "nonsense"] {
|
||||
assert!(!parse_bool_env(off), "{off} should parse as false");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_env_trims_and_drops_empties() {
|
||||
assert_eq!(
|
||||
parse_csv_env("tauri://localhost, http://127.0.0.1:1430 ,,"),
|
||||
vec![
|
||||
"tauri://localhost".to_string(),
|
||||
"http://127.0.0.1:1430".to_string()
|
||||
]
|
||||
);
|
||||
assert!(parse_csv_env(" ").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ pub fn issue_access_jwt(
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let exp = now + 3600;
|
||||
let claims = JwtClaims {
|
||||
iss: format!("did:web:{}", cfg.pds_public_url.trim_start_matches("http://").trim_start_matches("https://")),
|
||||
// Same derivation as `describeServer` and `/.well-known/did.json`
|
||||
// (`AppConfig::pds_did`), so a verifier can take `iss`, resolve
|
||||
// the did:web document and arrive at the key this token is
|
||||
// signed with. The previous inline version dropped the
|
||||
// percent-encoding of the port, producing an `iss` that no
|
||||
// did:web resolver could follow.
|
||||
iss: cfg.pds_did(),
|
||||
sub: did.to_string(),
|
||||
aud: "did:web:appview.maarcadetweet.local".into(),
|
||||
iat: now,
|
||||
|
||||
@@ -73,6 +73,7 @@ pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/.well-known/did.json", get(did_document))
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.describeServer",
|
||||
get(describe_server),
|
||||
@@ -163,9 +164,69 @@ async fn healthz() -> Json<serde_json::Value> {
|
||||
Json(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// `GET /.well-known/did.json` — the PDS's own DID document.
|
||||
///
|
||||
/// This is how the AppView (and any other relying party) learns the
|
||||
/// P-256 public key that the access JWTs in
|
||||
/// `Authorization: Bearer …` are signed with. Without it the AppView
|
||||
/// could not verify a token at all, and the only alternative would be
|
||||
/// shipping `PDS_JWT_SECRET` to a second service — a private signing
|
||||
/// key crossing a service boundary, for a check that needs nothing but
|
||||
/// the public half.
|
||||
///
|
||||
/// Nothing in this response is secret. `publicKeyMultibase` is the
|
||||
/// uncompressed P-256 point derived from `PDS_JWT_SECRET` by
|
||||
/// [`jwt_issuer::server_p256_public_multibase`]; the secret itself
|
||||
/// never leaves this process.
|
||||
///
|
||||
/// The document id is [`AppConfig::pds_did`], i.e. it follows
|
||||
/// `PDS_PUBLIC_URL` — so a `did:web:` resolver that starts from the DID,
|
||||
/// rebuilds the URL and fetches this path lands back here rather than at
|
||||
/// some other host's document.
|
||||
async fn did_document(State(state): State<AppState>) -> Result<Json<serde_json::Value>, (axum::http::StatusCode, Json<serde_json::Value>)> {
|
||||
let did = state.cfg.pds_did();
|
||||
let public_multibase = jwt_issuer::server_p256_public_multibase(&state.cfg).map_err(|e| {
|
||||
// A malformed `PDS_JWT_SECRET` is the one way this fails, and
|
||||
// it is exactly the failure that also breaks every token this
|
||||
// server issues — surface it instead of publishing a document
|
||||
// with a missing key.
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "InternalServerError",
|
||||
"message": format!("server key unavailable: {e}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
],
|
||||
"id": did,
|
||||
"verificationMethod": [{
|
||||
// `#atproto` is the fragment AT Proto uses for a repo's
|
||||
// signing key; we reuse it for the server key so a generic
|
||||
// did:web consumer finds it in the usual place.
|
||||
"id": format!("{did}#atproto"),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": public_multibase,
|
||||
}],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": state.cfg.pds_public_url,
|
||||
}],
|
||||
})))
|
||||
}
|
||||
|
||||
async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerResp> {
|
||||
Json(DescribeServerResp {
|
||||
did: "did:web:pds.maarcadetweet.local".into(),
|
||||
// Derived from `PDS_PUBLIC_URL`, never hardcoded — see
|
||||
// `AppConfig::pds_did`. The same value ids the document at
|
||||
// `/.well-known/did.json`.
|
||||
did: state.cfg.pds_did(),
|
||||
available_user_domains: vec![state
|
||||
.cfg
|
||||
.pds_handle_dns_zone
|
||||
|
||||
@@ -38,11 +38,86 @@ async fn describe_server() {
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r["did"].is_string());
|
||||
// The DID is derived from `PDS_PUBLIC_URL`, not hardcoded — so we
|
||||
// assert the *shape* (any deployment must produce a did:web) and
|
||||
// leave the exact value to `at_shared::config`'s unit tests.
|
||||
let did = r["did"].as_str().expect("describeServer must return a did");
|
||||
assert!(did.starts_with("did:web:"), "did = {did}");
|
||||
assert!(r["available_user_domains"].is_array());
|
||||
assert_eq!(r["invite_code_required"], json!(false));
|
||||
}
|
||||
|
||||
/// `GET /.well-known/did.json` — the document the AppView fetches to
|
||||
/// learn the key our access tokens are signed with.
|
||||
///
|
||||
/// Two properties matter beyond "it returns JSON": the document's `id`
|
||||
/// must be the same DID `describeServer` advertises (otherwise a client
|
||||
/// that trusts one and resolves the other ends up at a different
|
||||
/// identity), and it must carry a usable `publicKeyMultibase`.
|
||||
#[tokio::test]
|
||||
async fn did_document_publishes_the_server_key() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let c = client().await;
|
||||
let doc: Value = c
|
||||
.get(format!("{}/.well-known/did.json", PDS_URL))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id = doc["id"].as_str().expect("did document needs an id");
|
||||
assert!(id.starts_with("did:web:"), "id = {id}");
|
||||
|
||||
let described: Value = c
|
||||
.get(format!("{}/xrpc/com.atproto.server.describeServer", PDS_URL))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
described["did"].as_str().unwrap(),
|
||||
id,
|
||||
"describeServer and the did document must name the same identity"
|
||||
);
|
||||
|
||||
let vm = &doc["verificationMethod"][0];
|
||||
assert_eq!(vm["type"], json!("Multikey"));
|
||||
assert_eq!(vm["controller"], json!(id));
|
||||
assert_eq!(vm["id"], json!(format!("{id}#atproto")));
|
||||
let key = vm["publicKeyMultibase"]
|
||||
.as_str()
|
||||
.expect("verificationMethod needs publicKeyMultibase");
|
||||
// base58-btc multibase — the `z` prefix the AppView's decoder wants.
|
||||
assert!(key.starts_with('z'), "key = {key}");
|
||||
|
||||
// And it really is the key our tokens verify against: mint a
|
||||
// session and check the access JWT against the published key.
|
||||
let handle = format!("didjson_{}.maarcadetweet.local", uuid::Uuid::new_v4().simple());
|
||||
let acc: Value = c
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", PDS_URL))
|
||||
.json(&json!({"handle": handle, "password": "hunter2hunter2"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let jwt = acc["access_jwt"].as_str().expect("access_jwt");
|
||||
let claims = at_crypto::jwt::verify_jwt(jwt, key)
|
||||
.expect("access token must verify against the published key");
|
||||
assert_eq!(claims.sub, acc["did"].as_str().unwrap());
|
||||
assert_eq!(claims.scope.as_deref(), Some("com.atproto.access"));
|
||||
// `iss` is the same did:web the document identifies.
|
||||
assert_eq!(claims.iss, id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_account_session_refresh_resolve() {
|
||||
if !wait_for_pds().await {
|
||||
|
||||
Reference in New Issue
Block a user