~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain-serve logs the web in, holding its OAuth session

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:web/login

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;
crates/grain-serve/tests/read.rs+3−0
harness/authorize.mjs+16−2