feat(appview): Bearer-Auth für Timeline und Notifications
Die AppView hatte keinerlei Authentifizierung: jeder konnte /api/notifications?did=<beliebig> lesen und per /seen als gelesen markieren. Mit Phase 8 sind das die ersten privaten Daten im System. Das Access-JWT der PDS trug von Anfang an sub, scope "com.atproto.access" und aud "did:web:appview…" — es war für die AppView ausgestellt, nur hat sie es nie geprüft. Neu ist deshalb vor allem die Schlüsselbeschaffung: auth.rs holt das DID-Dokument der PDS (PDS_INTERNAL_URL, sonst PDS_PUBLIC_URL), cached den Schlüssel und lädt ihn bei einem Verifikationsfehler nach — höchstens einmal pro Minute, damit Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein Schlüsselwechsel braucht damit keinen Neustart. Ist die PDS beim Start weg, warnt die AppView nur und startet trotzdem (sie indiziert den Firehose, der von der lokalen PDS unabhängig ist). Ist der Schlüssel beim Prüfen eines Tokens nicht zu beschaffen, gibt es 503 — fail closed. Geschützt: /api/timeline/home und die drei Notification-Endpoints, jeweils mit sub == did. Öffentlich bleiben Profile, Suche, Posts, Threads und die Follower-Listen; das sind in AT Proto öffentliche Records. 401 AuthMissing / 401 TokenInvalid / 403 Forbidden / 503 AuthUnavailable. TokenInvalid ist ein Vertrag mit dem Client: daran erkennt er, dass er sein Token erneuern und einmal wiederholen muss. Dazu CORS: statt Any für alles jetzt eine Allowlist über APPVIEW_CORS_ORIGINS (unset = altes Verhalten plus Warnung), und /internal/ingest-commit liegt außerhalb der CORS-Schicht — die Route wird server-zu-server aufgerufen, ein Allow-Origin darauf würde nur einer Webseite helfen, in den Index zu schreiben. APPVIEW_AUTH_REQUIRED=false stellt das alte Verhalten her (VPN-Instanz, fail-open-Tests) und warnt beim Start in Großbuchstaben. Co-Authored-By: Eifelduds <noreply@eifelduds.de> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
+129
-23
@@ -8,6 +8,30 @@
|
||||
//! - `ingest_commit`: the internal-only writer used by the PDS, owned
|
||||
//! in `crate::ingest`.
|
||||
//!
|
||||
//! ## Public vs. private
|
||||
//!
|
||||
//! Most of what the AppView serves is public by construction: in AT
|
||||
//! Proto a post, a profile, a follow edge and a like are records in a
|
||||
//! public repo, replicated over the firehose. Those endpoints
|
||||
//! (`/api/profile*`, `/api/search`, `/api/post/*`, `/api/thread*`,
|
||||
//! `/api/followers`, `/api/following`) need no credential.
|
||||
//!
|
||||
//! Two things are *not* public, and they are the reason this service
|
||||
//! has authentication at all:
|
||||
//!
|
||||
//! - `/api/notifications*` — who interacted with you, and the read
|
||||
//! state of that list. Nothing in the protocol makes it readable by
|
||||
//! anyone but the recipient.
|
||||
//! - `/api/timeline/home` — the timeline is assembled from the
|
||||
//! viewer's follow graph, so serving it to an arbitrary `did`
|
||||
//! parameter answers "what does this person's feed look like" for
|
||||
//! any DID a caller cares to type.
|
||||
//!
|
||||
//! Both require a valid PDS-issued access token whose `sub` equals the
|
||||
//! `did` in the request ([`crate::auth`]). The check is two steps on
|
||||
//! purpose: the extractor proves *who* the caller is, and
|
||||
//! `ensure_matches` proves they are asking about themselves.
|
||||
//!
|
||||
//! The cursor format used by `timeline_home` is opaque: it's a
|
||||
//! `base64url(micros):uri` pair, which is what [`cursor::encode`] and
|
||||
//! [`cursor::decode`] produce/consume.
|
||||
@@ -19,11 +43,14 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use axum::http::{header, HeaderValue, Method};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::auth::AuthedDid;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub mod cursor;
|
||||
@@ -35,25 +62,73 @@ use types::{
|
||||
ProfileResponse, SearchResponse, ThreadFullResponse, TimelineResponse,
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
// CORS: the Tauri webview's origin is the Vite dev server
|
||||
// (`http://127.0.0.1:1430`) in dev or the bundled `tauri://` /
|
||||
// `asset://` origin in production. Either way it's a cross-origin
|
||||
// fetch against this service's `http://127.0.0.1:2584` listen
|
||||
// address, so the browser blocks the response without an explicit
|
||||
// allow-origin header. We allow any origin — the AppView's
|
||||
// public read endpoints (`/api/...`) carry no auth cookie and
|
||||
// the AppView runs alongside the user's own PDS, not on the
|
||||
// open internet; production deployments behind a reverse proxy
|
||||
// can tighten this via the proxy itself.
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
/// Build the CORS layer for the browser-facing routes.
|
||||
///
|
||||
/// The Tauri webview is a cross-origin caller: in dev its origin is the
|
||||
/// Vite server (`http://127.0.0.1:1430`), in a packaged build it is a
|
||||
/// platform-specific scheme — `tauri://localhost` on macOS/iOS,
|
||||
/// `http://tauri.localhost` on Windows. Either way the browser drops
|
||||
/// the response unless we send `Access-Control-Allow-Origin`.
|
||||
///
|
||||
/// `APPVIEW_CORS_ORIGINS` is a comma-separated allowlist, e.g.
|
||||
/// `tauri://localhost,http://127.0.0.1:1430`. When it is unset we keep
|
||||
/// the historic wildcard so no existing deployment breaks on upgrade —
|
||||
/// [`crate::auth::log_startup_posture`] warns about that at startup.
|
||||
///
|
||||
/// `Authorization` has to be in `allow_headers`: it is not a
|
||||
/// CORS-safelisted header, so without it the browser's preflight fails
|
||||
/// and the authenticated endpoints become unreachable from the webview
|
||||
/// — with an error that looks nothing like an auth problem.
|
||||
///
|
||||
/// `allow_credentials` stays off. We authenticate with a bearer token
|
||||
/// the client attaches deliberately, never with an ambient cookie, so
|
||||
/// there is nothing for a hostile page to replay — and turning it on
|
||||
/// would additionally make the wildcard origin illegal.
|
||||
fn cors_layer(cfg: &at_shared::config::AppConfig) -> CorsLayer {
|
||||
let base = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers([
|
||||
header::AUTHORIZATION,
|
||||
header::CONTENT_TYPE,
|
||||
header::ACCEPT,
|
||||
]);
|
||||
|
||||
Router::new()
|
||||
if cfg.appview_cors_origins.is_empty() {
|
||||
return base.allow_origin(Any);
|
||||
}
|
||||
|
||||
// Anything unparseable as a header value is dropped with a warning
|
||||
// rather than panicking the process — a stray quote in the env var
|
||||
// must not take the service down. If *every* entry is bad we fall
|
||||
// back to the wildcard and say so, because an empty allowlist would
|
||||
// silently break every browser client.
|
||||
let mut origins: Vec<HeaderValue> = Vec::new();
|
||||
for raw in &cfg.appview_cors_origins {
|
||||
match HeaderValue::from_str(raw) {
|
||||
Ok(v) => origins.push(v),
|
||||
Err(_) => warn!(origin = %raw, "APPVIEW_CORS_ORIGINS: ignoring unparseable origin"),
|
||||
}
|
||||
}
|
||||
if origins.is_empty() {
|
||||
warn!("APPVIEW_CORS_ORIGINS contained no usable origin; falling back to allow-any");
|
||||
return base.allow_origin(Any);
|
||||
}
|
||||
base.allow_origin(AllowOrigin::list(origins))
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
let cors = cors_layer(&state.cfg);
|
||||
|
||||
// Browser-facing surface. Everything here answers a `fetch()` from
|
||||
// the desktop client, so it carries the CORS layer.
|
||||
let api = Router::new()
|
||||
.route("/", get(root))
|
||||
// --- private: token required, `sub` must equal `did` ---
|
||||
.route("/api/timeline/home", get(timeline_home))
|
||||
.route("/api/notifications", get(notifications))
|
||||
.route("/api/notifications/count", get(notifications_count))
|
||||
.route("/api/notifications/seen", post(notifications_seen))
|
||||
// --- public: AT Proto public records ---
|
||||
.route("/api/profile", get(profile_query))
|
||||
.route("/api/profile/:handle", get(profile_path))
|
||||
.route("/api/search", get(search))
|
||||
@@ -66,15 +141,20 @@ pub fn router(state: AppState) -> Router {
|
||||
// implementation, so they can't drift.
|
||||
.route("/api/thread", get(thread_query))
|
||||
.route("/api/thread/*uri", get(thread_path))
|
||||
.route("/api/notifications", get(notifications))
|
||||
.route("/api/notifications/count", get(notifications_count))
|
||||
.route("/api/notifications/seen", post(notifications_seen))
|
||||
.route("/api/followers", get(followers))
|
||||
.route("/api/following", get(following))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
.layer(cors);
|
||||
|
||||
// Server-to-server surface. `/internal/ingest-commit` is called by
|
||||
// the PDS with a shared secret, never by a browser, so it stays
|
||||
// outside the CORS layer: handing it an
|
||||
// `Access-Control-Allow-Origin` header would only ever help a web
|
||||
// page try to write to the index.
|
||||
let internal = Router::new()
|
||||
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit));
|
||||
|
||||
api.merge(internal).with_state(state)
|
||||
}
|
||||
|
||||
async fn root() -> Json<Value> {
|
||||
@@ -115,13 +195,21 @@ const MAX_LIMIT: i64 = 100;
|
||||
/// surface.
|
||||
const MAX_FOLLOWED_DIDS: usize = 1000;
|
||||
|
||||
/// `GET /api/timeline/home?did=…`
|
||||
///
|
||||
/// **Authenticated.** The timeline is derived from the viewer's follow
|
||||
/// graph, so `did` must be the caller's own DID — otherwise this
|
||||
/// endpoint would answer "what does this account's feed look like" for
|
||||
/// any DID at all.
|
||||
async fn timeline_home(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthedDid,
|
||||
Query(q): Query<TimelineQuery>,
|
||||
) -> Result<Json<TimelineResponse>, (StatusCode, Json<Value>)> {
|
||||
if q.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
auth.ensure_matches(&q.did)?;
|
||||
let limit = clamp_limit(q.limit);
|
||||
|
||||
// Look up the set of DIDs this user follows, then build the
|
||||
@@ -1046,13 +1134,19 @@ struct NotificationsQuery {
|
||||
/// of the list. The tiebreak here is the row's `id` rather than a URI
|
||||
/// (a notification has no URI of its own), which the shared
|
||||
/// [`cursor`] codec carries in its string slot.
|
||||
///
|
||||
/// **Authenticated**: `did` is the recipient, so the caller has to be
|
||||
/// that recipient. This is the endpoint that made authentication
|
||||
/// necessary in the first place.
|
||||
async fn notifications(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthedDid,
|
||||
Query(q): Query<NotificationsQuery>,
|
||||
) -> Result<Json<NotificationsResponse>, (StatusCode, Json<Value>)> {
|
||||
if q.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
auth.ensure_matches(&q.did)?;
|
||||
let limit = clamp_limit(q.limit);
|
||||
let keyset = decode_cursor(q.cursor.as_deref())?;
|
||||
// The cursor's string slot holds the row id. A client that hands
|
||||
@@ -1121,13 +1215,18 @@ struct NotificationCountQuery {
|
||||
/// scales with the number of *unread* rows, not the user's lifetime
|
||||
/// notification history. That matters because the client polls this
|
||||
/// for its tray badge.
|
||||
///
|
||||
/// **Authenticated**, same rule as the list itself: an unread count is
|
||||
/// still information about someone else's inbox.
|
||||
async fn notifications_count(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthedDid,
|
||||
Query(q): Query<NotificationCountQuery>,
|
||||
) -> Result<Json<NotificationCountResponse>, (StatusCode, Json<Value>)> {
|
||||
if q.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
auth.ensure_matches(&q.did)?;
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM notifications \
|
||||
WHERE recipient_did = $1 AND read_at IS NULL",
|
||||
@@ -1165,13 +1264,20 @@ struct NotificationsSeenReq {
|
||||
/// updates nothing and reports `updated: 0`. `read_at` is set to
|
||||
/// `now()` (when we recorded the ack), not to `seenAt` (which is a
|
||||
/// client-supplied watermark and could be arbitrarily far in the past).
|
||||
///
|
||||
/// **Authenticated**, and the only *write* among the private
|
||||
/// endpoints: without the check anyone could clear another user's
|
||||
/// unread badge. `AuthedDid` runs before `Json` because the body
|
||||
/// extractor consumes the request — axum requires body extractors last.
|
||||
async fn notifications_seen(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthedDid,
|
||||
Json(req): Json<NotificationsSeenReq>,
|
||||
) -> Result<Json<NotificationSeenResponse>, (StatusCode, Json<Value>)> {
|
||||
if req.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
auth.ensure_matches(&req.did)?;
|
||||
let res = sqlx::query(
|
||||
r#"UPDATE notifications
|
||||
SET read_at = now()
|
||||
|
||||
Reference in New Issue
Block a user