0
2
grain-serve logs the web in, holding its OAuth session
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain/src/oauth.rs+48−7
| @@ -213,8 +213,10 @@ pub struct Client { | ||
key: Option<P256Key>, | ||
} | ||
| ||
/// The key ID a confidential client's assertions and JWKS name. | ||
const KID: &str = "grain-client"; | ||
/// The key ID a confidential client's assertions and JWKS name, which | ||
/// stays what it is: a PDS binds each session to the key ID that | ||
/// authenticated it, and refuses a refresh under another. | ||
pub const KID: &str = "pds-client-auth-key"; | ||
| ||
impl Client { | ||
pub fn loopback() -> Client { | ||
| @@ -343,15 +345,42 @@ async fn post<T: DeserializeOwned>( | ||
} | ||
let (ok, value) = answer; | ||
if !ok { | ||
bail!( | ||
"{}: {}", | ||
value["error"].as_str().unwrap_or("refused"), | ||
value["error_description"].as_str().unwrap_or_default() | ||
); | ||
return Err(Refused { | ||
error: value["error"].as_str().unwrap_or("refused").to_string(), | ||
description: value["error_description"] | ||
.as_str() | ||
.unwrap_or_default() | ||
.to_string(), | ||
} | ||
.into()); | ||
} | ||
Ok(serde_json::from_value(value)?) | ||
} | ||
| ||
/// An authorization server refusing a request, as OAuth answers: its | ||
/// `error` and `error_description`. A refresh refused `invalid_grant` is | ||
/// a session that has ended, which no retry brings back. | ||
#[derive(Debug, Clone, PartialEq, Eq)] | ||
pub struct Refused { | ||
pub error: String, | ||
pub description: String, | ||
} | ||
| ||
impl Refused { | ||
/// The refusal `err` is, if it is one. | ||
pub fn of(err: &anyhow::Error) -> Option<&Refused> { | ||
err.downcast_ref() | ||
} | ||
} | ||
| ||
impl std::fmt::Display for Refused { | ||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
write!(f, "{}: {}", self.error, self.description) | ||
} | ||
} | ||
| ||
impl std::error::Error for Refused {} | ||
| ||
#[derive(Deserialize)] | ||
struct Tokens { | ||
access_token: String, | ||
| @@ -771,6 +800,18 @@ pub(crate) mod tests { | ||
))); | ||
} | ||
| ||
#[test] | ||
fn refusals_are_known_by_their_error() { | ||
let refused = Refused { | ||
error: "invalid_grant".into(), | ||
description: "refresh token replayed".into(), | ||
}; | ||
let err = anyhow::Error::new(refused.clone()).context("refreshing"); | ||
assert_eq!(Refused::of(&err), Some(&refused)); | ||
assert_eq!(refused.to_string(), "invalid_grant: refresh token replayed"); | ||
assert!(Refused::of(&anyhow::anyhow!("down")).is_none()); | ||
} | ||
| ||
#[test] | ||
fn proofs() { | ||
let key = P256Key::generate(); | ||
crates/grain-serve/src/index/mod.rs+1−0
| @@ -18,6 +18,7 @@ pub mod backfill; | ||
pub mod plan; | ||
pub mod records; | ||
pub mod schema; | ||
pub mod sessions; | ||
| ||
use anyhow::{Context, bail}; | ||
use grain::lexicon::{self, Invalid, Lexicons}; | ||
crates/grain-serve/src/index/schema.rs+19−1
| @@ -71,6 +71,16 @@ | ||
//! shown in the app or not, and from anyone or only from accounts it | ||
//! follows. A kind with no row reaches it every way, from anyone. | ||
//! | ||
//! - `oauth_key`, the keys grain-serve's OAuth client holds, by key ID, as | ||
//! private JWKs: `pds-client-auth-key` signs what proves the client to | ||
//! each PDS, which binds every session to it, so a new one signs every | ||
//! web login out; `web-cookie` signs the web's cookies, and a new one | ||
//! signs every browser out; | ||
//! - `web_session`, the OAuth session of each account logged in to the | ||
//! web ([`grain::session::Session`], as JSON), with `since`, when its | ||
//! login was kept with none before it, in microseconds since the epoch: | ||
//! a cookie signed before then names no one. | ||
//! | ||
//! A migration may drop and remake a record table, which backfill refills; | ||
//! these it only alters in place. | ||
//! | ||
| @@ -83,7 +93,7 @@ | ||
| ||
/// Each step of the schema, run once, in order, by `PRAGMA user_version`. | ||
/// A step is only ever appended: one that has run is never changed. | ||
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5, V6]; | ||
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5, V6, V7]; | ||
| ||
const V1: &str = " | ||
CREATE TABLE gallery ( | ||
| @@ -408,6 +418,14 @@ INSERT OR IGNORE INTO mention (did, uri) | ||
AND json_type(g.facets, t.path || '.did') = 'text'; | ||
"; | ||
| ||
/// The web's logins: the OAuth client's keys and the sessions it holds. | ||
const V7: &str = " | ||
CREATE TABLE oauth_key (kid TEXT PRIMARY KEY, jwk TEXT NOT NULL) WITHOUT ROWID; | ||
CREATE TABLE web_session ( | ||
did TEXT PRIMARY KEY, session TEXT NOT NULL, since INTEGER NOT NULL, | ||
updated_at INTEGER NOT NULL) WITHOUT ROWID; | ||
"; | ||
| ||
/// The label values that leave what carries them out of feeds: the | ||
/// imperative ones, and those whose default is to hide. | ||
pub const HIDDEN_LABELS: [&str; 9] = [ | ||
crates/grain-serve/src/index/sessions.rsnew+155−0
| @@ -0,0 +1,155 @@ | ||
//! The web's logins as the index keeps them: the OAuth client's keys | ||
//! (`oauth_key`) and each account's session (`web_session`), which | ||
//! [`crate::login`] reads and writes. Neither is rebuilt from the network. | ||
| ||
use super::Index; | ||
use grain::session::Session; | ||
use rusqlite::{OptionalExtension, params}; | ||
| ||
/// A session kept for the web, and since when its login has held. | ||
#[derive(Debug, Clone, PartialEq)] | ||
pub struct WebSession { | ||
pub session: Session, | ||
/// When its login was kept with none before it, in microseconds since | ||
/// the epoch. | ||
pub since: u64, | ||
} | ||
| ||
impl Index { | ||
/// The key `kid`, a private JWK as JSON, kept; or, with none kept, | ||
/// `made`, kept from then on. Whoever asks first keeps theirs. | ||
pub fn oauth_key(&self, kid: &str, made: impl FnOnce() -> String) -> rusqlite::Result<String> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "SELECT jwk FROM oauth_key WHERE kid = ?1"; | ||
if let Some(jwk) = db.query_row(sql, [kid], |r| r.get(0)).optional()? { | ||
return Ok(jwk); | ||
} | ||
let jwk = made(); | ||
db.execute("INSERT INTO oauth_key VALUES (?1, ?2)", [kid, &jwk])?; | ||
Ok(jwk) | ||
} | ||
| ||
/// The web's session for `did`, if it keeps one. A row that does not | ||
/// read as a session is none. | ||
pub fn web_session(&self, did: &str) -> rusqlite::Result<Option<WebSession>> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "SELECT session, since FROM web_session WHERE did = ?1"; | ||
let row: Option<(String, i64)> = db | ||
.query_row(sql, [did], |r| Ok((r.get(0)?, r.get(1)?))) | ||
.optional()?; | ||
Ok(row.and_then(|(json, since)| { | ||
let session = serde_json::from_str(&json).ok()?; | ||
Some(WebSession { | ||
session, | ||
since: since as u64, | ||
}) | ||
})) | ||
} | ||
| ||
/// Keeps `session` as the web's for its account, as of `now`, in | ||
/// microseconds: over the one kept before, if any, whose `since` it | ||
/// keeps; else since `now`. Returns its `since`. | ||
pub fn keep_web_session(&self, session: &Session, now: u64) -> rusqlite::Result<u64> { | ||
let json = serde_json::to_string(session).expect("a session serializes"); | ||
let db = self.db.lock().unwrap(); | ||
db.query_row( | ||
"INSERT INTO web_session VALUES (?1, ?2, ?3, ?3) | ||
ON CONFLICT (did) DO UPDATE SET session = excluded.session, | ||
updated_at = excluded.updated_at | ||
RETURNING since", | ||
params![session.did, json, now as i64], | ||
|r| r.get::<_, i64>(0).map(|since| since as u64), | ||
) | ||
} | ||
| ||
/// Keeps `session`, refreshed, over the one kept for its account, as | ||
/// of `now`, in microseconds; whether there was one to keep it over. | ||
/// One ended while it was refreshed stays ended. | ||
pub fn refresh_web_session(&self, session: &Session, now: u64) -> rusqlite::Result<bool> { | ||
let json = serde_json::to_string(session).expect("a session serializes"); | ||
let db = self.db.lock().unwrap(); | ||
let sql = "UPDATE web_session SET session = ?2, updated_at = ?3 WHERE did = ?1"; | ||
Ok(db.execute(sql, params![session.did, json, now as i64])? > 0) | ||
} | ||
| ||
/// Forgets the web's session for `did`; whether there was one. | ||
pub fn end_web_session(&self, did: &str) -> rusqlite::Result<bool> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "DELETE FROM web_session WHERE did = ?1"; | ||
Ok(db.execute(sql, [did])? > 0) | ||
} | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
fn session(did: &str, access: &str) -> Session { | ||
Session { | ||
did: did.into(), | ||
handle: "alice.test".into(), | ||
service: "https://pds.test".into(), | ||
access_jwt: access.into(), | ||
refresh_jwt: "refresh".into(), | ||
oauth: None, | ||
} | ||
} | ||
| ||
#[test] | ||
fn keys_are_made_once() { | ||
let index = Index::memory().unwrap(); | ||
let first = index.oauth_key("k", || "one".into()).unwrap(); | ||
assert_eq!(first, "one"); | ||
assert_eq!(index.oauth_key("other", || "two".into()).unwrap(), "two"); | ||
} | ||
| ||
#[test] | ||
fn sessions_keep_when_their_login_was() { | ||
let index = Index::memory().unwrap(); | ||
assert_eq!(index.web_session("did:plc:a").unwrap(), None); | ||
assert_eq!( | ||
index | ||
.keep_web_session(&session("did:plc:a", "1"), 10) | ||
.unwrap(), | ||
10 | ||
); | ||
// Refreshed, or logged in again over it: since its first login. | ||
assert_eq!( | ||
index | ||
.keep_web_session(&session("did:plc:a", "2"), 20) | ||
.unwrap(), | ||
10 | ||
); | ||
let kept = index.web_session("did:plc:a").unwrap().unwrap(); | ||
assert_eq!((kept.session.access_jwt.as_str(), kept.since), ("2", 10)); | ||
assert!(index.end_web_session("did:plc:a").unwrap()); | ||
assert!(!index.end_web_session("did:plc:a").unwrap()); | ||
assert_eq!( | ||
index | ||
.keep_web_session(&session("did:plc:a", "3"), 30) | ||
.unwrap(), | ||
30 | ||
); | ||
// A refresh keeps its since, and ends with a session ended. | ||
assert!( | ||
index | ||
.refresh_web_session(&session("did:plc:a", "4"), 40) | ||
.unwrap() | ||
); | ||
assert_eq!(index.web_session("did:plc:a").unwrap().unwrap().since, 30); | ||
assert!( | ||
!index | ||
.refresh_web_session(&session("did:plc:b", "1"), 40) | ||
.unwrap() | ||
); | ||
assert_eq!(index.web_session("did:plc:b").unwrap(), None); | ||
// A row that is no session is none. | ||
index | ||
.db | ||
.lock() | ||
.unwrap() | ||
.execute("UPDATE web_session SET session = '{'", []) | ||
.unwrap(); | ||
assert_eq!(index.web_session("did:plc:a").unwrap(), None); | ||
} | ||
} | ||
crates/grain-serve/src/lib.rs+35−3
| @@ -1,13 +1,15 @@ | ||
//! grain's server. It answers atproto's health check, | ||
//! `social.grain.sync.indexRecords` ([`sync`]), the queries that read | ||
//! the index alone ([`read`]) and what an account has seen of its | ||
//! notifications ([`notification`]); the feeds, login, writes and push join it | ||
//! pull by pull on `feature/rust`, as the plan orders them. | ||
//! notifications ([`notification`]), and logs the web in ([`login`]); the | ||
//! web's writes and push join it pull by pull on `feature/rust`, as the | ||
//! plan orders them. | ||
| ||
pub mod backfill; | ||
pub mod cdn; | ||
pub mod index; | ||
pub mod jetstream; | ||
pub mod login; | ||
pub mod notification; | ||
pub mod read; | ||
pub mod sync; | ||
| @@ -47,6 +49,9 @@ pub struct Settings { | ||
/// [`read::search::BSKY_APPVIEW`]. None, as when it is unset, empty | ||
/// or `0`, asks none, as tests and dev run. | ||
pub bsky_appview: Option<String>, | ||
/// `GRAIN_SIGN_UP_PDS`: where someone making an account on the web | ||
/// makes it. None is [`login::SIGN_UP_PDS`]. | ||
pub sign_up_pds: Option<String>, | ||
} | ||
| ||
impl Settings { | ||
| @@ -67,6 +72,7 @@ impl Settings { | ||
Some("1") => Some(read::search::BSKY_APPVIEW.into()), | ||
Some(url) => Some(url.trim_end_matches('/').into()), | ||
}, | ||
sign_up_pds: set("GRAIN_SIGN_UP_PDS"), | ||
} | ||
} | ||
| ||
| @@ -107,12 +113,20 @@ impl Settings { | ||
Some(path) => Some(Arc::new(index::Index::open(path)?)), | ||
None => None, | ||
}; | ||
let web = match (&self.public_url, &index) { | ||
(Some(url), Some(index)) => { | ||
let pds = self.sign_up_pds.as_deref().unwrap_or(login::SIGN_UP_PDS); | ||
Some(Arc::new(login::Web::new(url, index, pds)?)) | ||
} | ||
_ => None, | ||
}; | ||
Ok(App { | ||
ctx, | ||
index, | ||
did, | ||
cdn, | ||
bsky_appview: self.bsky_appview.clone(), | ||
web, | ||
}) | ||
} | ||
} | ||
| @@ -129,6 +143,8 @@ pub struct App { | ||
pub cdn: cdn::Cdn, | ||
/// The Bluesky appview typeahead asks too, if any. | ||
pub bsky_appview: Option<String>, | ||
/// The web's logins, for a server with a public URL and an index. | ||
pub web: Option<Arc<login::Web>>, | ||
} | ||
| ||
impl App { | ||
| @@ -141,6 +157,7 @@ impl App { | ||
did: None, | ||
cdn: cdn::Cdn::default(), | ||
bsky_appview: None, | ||
web: None, | ||
} | ||
} | ||
} | ||
| @@ -170,6 +187,13 @@ pub fn router(app: Arc<App>) -> Router { | ||
&format!("/xrpc/{}", notification::UPDATE_SEEN), | ||
post(notification::update_seen), | ||
) | ||
.route(login::METADATA, get(login::client_metadata)) | ||
.route(login::JWKS, get(login::client_jwks)) | ||
.route("/oauth/login", post(login::login)) | ||
.route(login::CALLBACK, get(login::callback)) | ||
.route("/oauth/switch", post(login::switch)) | ||
.route("/oauth/logout", post(login::logout)) | ||
.route("/oauth/session", get(login::whoami)) | ||
.route("/xrpc/{*method}", any(not_implemented)) | ||
.with_state(app) | ||
} | ||
| @@ -226,6 +250,7 @@ mod tests { | ||
"GRAIN_PUBLIC_URL" => Some("https://grain.social".into()), | ||
"GRAIN_CDN_URL" => Some("https://cdn.grain.social".into()), | ||
"GRAIN_BSKY_APPVIEW" => Some("1".into()), | ||
"GRAIN_SIGN_UP_PDS" => Some("https://pds.test".into()), | ||
_ => None, | ||
}); | ||
assert_eq!( | ||
| @@ -246,6 +271,7 @@ mod tests { | ||
); | ||
assert_eq!(set.index, Some(PathBuf::from("/data/index.db"))); | ||
assert_eq!(set.public_url.as_deref(), Some("https://grain.social")); | ||
assert_eq!(set.sign_up_pds.as_deref(), Some("https://pds.test")); | ||
} | ||
| ||
#[test] | ||
| @@ -260,8 +286,14 @@ mod tests { | ||
let app = settings.app(ctx()).unwrap(); | ||
assert!(app.index.is_some()); | ||
assert_eq!(app.did.as_deref(), Some("did:web:grain.social")); | ||
let web = app.web.as_ref().expect("a public URL and an index log in"); | ||
assert_eq!( | ||
web.client.id, | ||
"https://grain.social/oauth-client-metadata.json" | ||
); | ||
assert_eq!(web.sign_up_pds, login::SIGN_UP_PDS); | ||
let bare = Settings::default().app(ctx()).unwrap(); | ||
assert!(bare.index.is_none() && bare.did.is_none()); | ||
assert!(bare.index.is_none() && bare.did.is_none() && bare.web.is_none()); | ||
let wrong = Settings { | ||
public_url: Some("grain.social".into()), | ||
..Settings::default() | ||
crates/grain-serve/src/login.rsnew+660−0
660 lines changed: not loaded until asked for.
crates/grain-serve/src/notification.rs+1−2
| @@ -41,8 +41,7 @@ pub async fn update_seen( | ||
why, | ||
)); | ||
}; | ||
let viewer = crate::viewer::viewer(&app, &headers, UPDATE_SEEN).await?; | ||
let did = viewer.ok_or_else(Failure::authentication_required)?; | ||
let did = crate::viewer::writer(&app, &headers, UPDATE_SEEN).await?; | ||
let input: Seen = serde_json::from_slice(&body) | ||
.map_err(|e| Failure::invalid(format!("the input is not {{seenAt}}: {e}")))?; | ||
let invalid = || Failure::invalid("seenAt must be a valid datetime"); | ||
crates/grain-serve/src/viewer.rs+22−6
| @@ -3,20 +3,22 @@ | ||
//! | ||
//! The apps and the command line say who they are with a service token | ||
//! from their own PDS, for grain-serve's DID and the method called, sent | ||
//! direct or through their PDS's `atproto-proxy`. Each way of saying it | ||
//! is one arm of [`viewer`]: the web's cookie session joins it there, and | ||
//! nothing past it asks how the viewer was known. | ||
//! direct or through their PDS's `atproto-proxy`. The web says it with | ||
//! the session cookie its login set ([`crate::login`]), when it sends no | ||
//! token. Each way of saying it is one arm of [`viewer`], and nothing past | ||
//! it asks how the viewer was known. | ||
| ||
use crate::App; | ||
use crate::xrpc::Failure; | ||
use axum::http::{HeaderMap, StatusCode, header}; | ||
| ||
/// The account calling `lxm`, from `headers`; none for a caller who | ||
/// says nothing. One who says something that does not hold is refused, | ||
/// as Bluesky's appview refuses them, rather than answered as no one. | ||
/// says nothing, or whose cookie no longer holds, whom the queries answer | ||
/// as anyone. One whose token does not hold is refused, as Bluesky's | ||
/// appview refuses them, rather than answered as no one. | ||
pub async fn viewer(app: &App, headers: &HeaderMap, lxm: &str) -> Result<Option<String>, Failure> { | ||
let Some(value) = headers.get(header::AUTHORIZATION) else { | ||
return Ok(None); | ||
return Ok(crate::login::viewer(app, headers)); | ||
}; | ||
let refused = |why: String| Failure::new(StatusCode::UNAUTHORIZED, "InvalidToken", why); | ||
let token = value.to_str().ok().and_then(|v| v.strip_prefix("Bearer ")); | ||
| @@ -29,3 +31,17 @@ pub async fn viewer(app: &App, headers: &HeaderMap, lxm: &str) -> Result<Option< | ||
let did = grain::auth::verify(&app.ctx, token, aud, lxm).await; | ||
did.map(Some).map_err(|e| refused(format!("{e:#}"))) | ||
} | ||
| ||
/// The account calling `lxm`, a procedure, as [`viewer`] finds it, or a | ||
/// refusal: a procedure the cookie calls is refused unless a page of this | ||
/// site sends it ([`crate::login::same_site`]), as one another site's page | ||
/// sends would carry the cookie too. | ||
pub async fn writer(app: &App, headers: &HeaderMap, lxm: &str) -> Result<String, Failure> { | ||
let did = viewer(app, headers, lxm).await?; | ||
let by_cookie = !headers.contains_key(header::AUTHORIZATION); | ||
if by_cookie && did.is_some() && !crate::login::same_site(app, headers) { | ||
let why = "not from a page of this site"; | ||
return Err(Failure::new(StatusCode::FORBIDDEN, "Forbidden", why)); | ||
} | ||
did.ok_or_else(Failure::authentication_required) | ||
} | ||
crates/grain-serve/tests/common/mod.rs+29−0
| @@ -22,6 +22,11 @@ pub async fn served() -> Served { | ||
/// `app`, serving on a free port until the test's runtime ends. | ||
pub async fn served_app(app: grain_serve::App) -> Served { | ||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
served_on(listener, app) | ||
} | ||
| ||
/// `app`, serving on `listener` until the test's runtime ends. | ||
pub fn served_on(listener: tokio::net::TcpListener, app: grain_serve::App) -> Served { | ||
let base = format!("http://{}", listener.local_addr().unwrap()); | ||
tokio::spawn(grain_serve::serve(listener, std::sync::Arc::new(app))); | ||
Served { | ||
| @@ -63,6 +68,30 @@ impl Served { | ||
self.answer(path, res).await | ||
} | ||
| ||
/// A query sent with `headers`, such as a session cookie. | ||
pub async fn get_headed(&self, path: &str, headers: &[(&str, &str)]) -> (u16, Value) { | ||
let mut res = self.http.get(format!("{}{path}", self.base)); | ||
for (k, v) in headers { | ||
res = res.header(*k, *v); | ||
} | ||
self.answer(path, res).await | ||
} | ||
| ||
/// A procedure sent with `headers`, such as a session cookie and the | ||
/// page's origin. | ||
pub async fn post_headed( | ||
&self, | ||
path: &str, | ||
headers: &[(&str, &str)], | ||
body: &Value, | ||
) -> (u16, Value) { | ||
let mut res = self.http.post(format!("{}{path}", self.base)).json(body); | ||
for (k, v) in headers { | ||
res = res.header(*k, *v); | ||
} | ||
self.answer(path, res).await | ||
} | ||
| ||
async fn answer(&self, path: &str, req: reqwest::RequestBuilder) -> (u16, Value) { | ||
let res = req.send().await.unwrap(); | ||
let status = res.status().as_u16(); | ||
crates/grain-serve/tests/index_records.rs+1−0
| @@ -58,6 +58,7 @@ async fn caller_with(extra: &[(&str, &str)]) -> Caller { | ||
did: Some(AUD.into()), | ||
cdn: Default::default(), | ||
bsky_appview: None, | ||
web: None, | ||
}) | ||
.await; | ||
let account = net::account().await; | ||
crates/grain-serve/tests/login.rsnew+584−0
584 lines changed: not loaded until asked for.
crates/grain-serve/tests/notifications.rs+1−0
| @@ -47,6 +47,7 @@ async fn world() -> World { | ||
did: Some(AUD.into()), | ||
cdn: Cdn::default(), | ||
bsky_appview: None, | ||
web: None, | ||
}) | ||
.await; | ||
let account = net::account().await; | ||