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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

Web logins from before cutover carry over, until a set date

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

Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.

.knotless/workflows/rust.yml+5−4
@@ -11,15 +11,16 @@ on:
pull: { base: [refs/heads/main, refs/heads/feature/*] }
cache: [target, /usr/local/cargo/registry, /usr/local/rustup]
steps:
# grain-import, the one-shot cutover from the old server's database, is
# the only Rust that may name it: the rest describes grain on its own
# terms. The pattern is bracketed so that this file does not match it.
# grain-import, the one-shot cutover from the old server's database, and
# grain-serve's login carryover, which reads the cookies it set until a
# date after cutover, are the only Rust that may name it: the rest
# describes grain on its own terms. Both are deleted after cutover. The pattern is bracketed so that this file does not match it.
# git grep exits 1 when it finds nothing, and more when it cannot search.
- name: the old server is named only in grain-import
run: |
git config --global --add safe.directory "$PWD"
rc=0
git grep -in 'h[a]tk' -- crates harness .knotless/workflows/rust.yml ':(exclude)crates/grain-import' || rc=$?
git grep -in 'h[a]tk' -- crates harness .knotless/workflows/rust.yml ':(exclude)crates/grain-import' ':(exclude)crates/grain-serve/src/login/carryover.rs' || rc=$?
case $rc in
1) exit 0 ;;
0) echo "named outside crates/grain-import, above"; exit 1 ;;
crates/grain-import/src/lib.rs+214−9
@@ -60,10 +60,30 @@
//! JWK as hatk kept it: grain's OAuth client goes on signing with the key
//! its JWKS has always named, so each PDS goes on knowing the client.
//!
//! Not imported: hatk's other OAuth keys and its sessions, web and app
//! alike (every account logs in again: nothing grain-serve runs reads the
//! cookies that named them), its
//! search tables, classifier scores, space sync state, and records of
//! # Web sessions
//!
//! Each of `_oauth_sessions` is a `web_session`, so that the web stays
//! logged in across the move: the session hatk held at each account's
//! PDS, as the confidential client `--public-url` (or `GRAIN_PUBLIC_URL`)
//! names, `<url>/oauth-client-metadata.json`, as hatk's was. hatk bound
//! every one to the DPoP key `appview-oauth-key`, and a session is
//! carried with that key; one bound to another (its `dpop_jkt`), or with
//! no refresh token, is not, and its account logs in again. Its access
//! token is taken as expired where hatk kept no expiry, so it is
//! refreshed before it is used. With no public URL, none is carried.
//! These are counted, not checked: an account that logs in again has
//! lost nothing.
//!
//! hatk sealed its session cookies with AES-256-GCM under a key drawn by
//! HKDF-SHA256 (no salt, info `hatk-session-cookie`) from
//! `appview-oauth-key`, the private JWK as JSON with its members sorted
//! by name. That key's 32 bytes are `carried_cookie_key`'s, all grain-serve
//! keeps of it, for its carryover to open the cookies browsers still hold
//! (`grain_serve::login::carryover`). The JWK itself is not imported.
//!
//! Not imported: hatk's other OAuth keys, its OAuth server's own tables
//! (the apps' logins to hatk, which end with it), its search tables,
//! classifier scores, space sync state, and records of
//! collections the index does not keep. The summary names each such table
//! and its rows.
//!
@@ -79,15 +99,18 @@ use grain::lexicon::{self, Lexicons};
use grain_serve::index::records::{self, Any};
use grain_serve::index::{migrate, record_uri, schema};
use rusqlite::types::{Value as Sql, ValueRef};
use rusqlite::{Connection, OpenFlags, ToSql};
use rusqlite::{Connection, OpenFlags, OptionalExtension, ToSql};
use serde_json::{Map, Value, json};
use std::collections::BTreeSet;
use std::fmt;
use std::path::{Path, PathBuf};
pub const USAGE: &str = "usage: grain-import <hatk's grain.db> [--into <index>] [--strict]
[--public-url <url>]
reads hatk's database into the index at --into, or GRAIN_INDEX, which
must be new or empty; --strict fails on any record that does not fit";
must be new or empty; --strict fails on any record that does not fit;
the web sessions hatk held carry over for the client at --public-url,
or GRAIN_PUBLIC_URL";
/// What `grain-import` was asked to do.
#[derive(Debug, Clone, PartialEq)]
@@ -98,6 +121,9 @@ pub struct Options {
pub into: PathBuf,
/// Whether a record that does not fit its lexicon fails the import.
pub strict: bool,
/// Where the web is served, which names the client hatk's web
/// sessions were granted to; none carries none.
pub public_url: Option<String>,
}
/// The options `args` give, the index being
@@ -111,11 +137,15 @@ pub fn parse_args(
.filter(|v| !v.is_empty())
.map(PathBuf::from);
let mut strict = false;
let mut public_url = var("GRAIN_PUBLIC_URL").filter(|v| !v.is_empty());
let mut it = args.iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--into" => into = Some(it.next().ok_or("--into takes a path")?.into()),
"--strict" => strict = true,
"--public-url" => {
public_url = Some(it.next().ok_or("--public-url takes a URL")?.into());
}
flag if flag.starts_with("--") => return Err(format!("{flag}: no such flag\n{USAGE}")),
path if from.is_none() => from = Some(PathBuf::from(path)),
extra => return Err(format!("{extra}: import reads one database\n{USAGE}")),
@@ -123,7 +153,12 @@ pub fn parse_args(
}
let from = from.ok_or(USAGE)?;
let into = into.ok_or("import needs --into or GRAIN_INDEX")?;
Ok(Options { from, into, strict })
Ok(Options {
from,
into,
strict,
public_url,
})
}
/// `grain-import`, of its arguments `args`: the summary on stdout,
@@ -136,7 +171,8 @@ pub fn main(args: &[String], var: impl Fn(&str) -> Option<String>) -> i32 {
return 2;
}
};
match import(&opts.from, &opts.into, opts.strict) {
let public_url = opts.public_url.as_deref();
match import(&opts.from, &opts.into, opts.strict, public_url) {
Ok(summary) => {
print!("{summary}");
i32::from(!summary.written)
@@ -190,6 +226,12 @@ pub struct Summary {
pub failed: Vec<String>,
/// `_repos.status` values hatk never wrote, and how many of each.
pub unrecognized: Vec<(String, i64)>,
/// hatk's web sessions, and how many carried over.
pub sessions: Tally,
/// Why none of them was, if none was.
pub sessions_left: Option<String>,
/// Whether the key of hatk's session cookies was carried.
pub cookie_key: bool,
/// hatk's tables not imported, and their rows.
pub left: Vec<(String, i64)>,
pub strict: bool,
@@ -232,6 +274,23 @@ impl fmt::Display for Summary {
for why in &self.failed {
writeln!(f, "not kept: {why}")?;
}
let t = &self.sessions;
let name = format!(" {} ({})", t.table, t.from);
writeln!(
f,
"{:<52}{:>10}{:>10}",
"web sessions", "in hatk", "carried"
)?;
writeln!(f, "{name:<52}{:>10}{:>10}", t.source, t.imported)?;
if let Some(why) = &self.sessions_left {
writeln!(f, " none carried: {why}")?;
}
let carried = if self.cookie_key {
"carried"
} else {
"not carried: no appview-oauth-key"
};
writeln!(f, " the session cookies' key: {carried}")?;
for (status, n) in &self.unrecognized {
let odd = format!("_repos.status {status:?}, which hatk never wrote: {n}");
writeln!(f, "{odd}, made pending")?;
@@ -426,7 +485,12 @@ pub const KEPT: &[Kept] = &[
/// Reads hatk's database at `from` into a new index at `into`, `strict`
/// failing on any record refused; what it read and wrote. An error is one
/// that stopped it: nothing was written.
pub fn import(from: &Path, into: &Path, strict: bool) -> anyhow::Result<Summary> {
pub fn import(
from: &Path,
into: &Path,
strict: bool,
public_url: Option<&str>,
) -> anyhow::Result<Summary> {
let src = Connection::open_with_flags(from, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("opening {}", from.display()))?;
// One snapshot, for the counts to be of the rows read.
@@ -455,6 +519,13 @@ pub fn import(from: &Path, into: &Path, strict: bool) -> anyhow::Result<Summary>
.kept
.push(copy_kept(&src, &tx, kept, present, &mut summary.failed)?);
}
let server = server_key(&src, &tables)?;
summary.cookie_key = match &server {
Some(jwk) => copy_cookie_key(&tx, jwk)?,
None => false,
};
let carried = copy_sessions(&src, &tx, &tables, server.as_deref(), public_url)?;
(summary.sessions, summary.sessions_left) = carried;
let sql = format!(
"SELECT status, count(*) FROM _repos WHERE status NOT IN {STATUSES} GROUP BY status"
);
@@ -465,6 +536,7 @@ pub fn import(from: &Path, into: &Path, strict: bool) -> anyhow::Result<Summary>
.iter()
.map(|(nsid, _)| *nsid)
.chain(KEPT.iter().map(|k| k.source))
.chain(summary.sessions_left.is_none().then_some("_oauth_sessions"))
.collect();
for table in tables.iter().filter(|t| !read.contains(t.as_str())) {
// hatk's full-text search, a table and its shadows a collection.
@@ -761,5 +833,138 @@ fn copy_kept(
Ok(tally)
}
/// hatk's web sessions, `_oauth_sessions`, carried into `web_session`
/// as [the module](self) says: how many there were and were carried, and
/// why none was, if none was.
fn copy_sessions(
src: &Connection,
dst: &Connection,
tables: &BTreeSet<String>,
server: Option<&str>,
public_url: Option<&str>,
) -> anyhow::Result<(Tally, Option<String>)> {
let mut tally = Tally::new("web_session", "_oauth_sessions");
if !tables.contains("_oauth_sessions") {
return Ok((tally, Some("hatk kept no sessions".into())));
}
tally.source = src.query_row("SELECT count(*) FROM _oauth_sessions", [], |r| r.get(0))?;
let Some(url) = public_url.map(|u| u.trim_end_matches('/')) else {
return Ok((tally, Some("no --public-url or GRAIN_PUBLIC_URL".into())));
};
let Some(key) = server.and_then(|jwk| grain_serve::login::from_jwk(jwk).ok()) else {
let why = format!("no {SERVER_KID} to prove them with");
return Ok((tally, Some(why)));
};
let jkt = thumbprint(&key);
let dpop = base64::engine::general_purpose::STANDARD.encode(key.to_bytes());
// Columns a hatk from before them lacks are read as none.
let has: BTreeSet<String> = {
let mut stmt = src.prepare("SELECT name FROM pragma_table_info('_oauth_sessions')")?;
let names = stmt.query_map([], |r| r.get(0))?;
names.collect::<rusqlite::Result<_>>()?
};
let column = |name: &str| match has.contains(name) {
true => format!("s.{name}"),
false => "NULL".into(),
};
let sql = format!(
"SELECT s.did, coalesce(r.handle, ''), s.pds_endpoint, s.access_token, s.refresh_token,
s.dpop_jkt, {}, {}, {}, {}
FROM _oauth_sessions s LEFT JOIN _repos r ON r.did = s.did",
column("pds_auth_server"),
column("pds_token_endpoint"),
column("token_expires_at"),
column("requested_scope"),
);
let mut stmt = src.prepare(&sql)?;
let mut rows = stmt.query([])?;
let mut insert = dst.prepare("INSERT INTO web_session VALUES (?1, ?2, 0, ?3)")?;
let now = grain::keys::now_micros() as i64;
while let Some(row) = rows.next()? {
let (did, handle, pds): (String, String, String) = (row.get(0)?, row.get(1)?, row.get(2)?);
let (access, refresh, bound): (String, Option<String>, String) =
(row.get(3)?, row.get(4)?, row.get(5)?);
let Some(refresh) = refresh.filter(|_| bound == jkt) else {
continue;
};
let issuer: String = row
.get::<_, Option<String>>(6)?
.unwrap_or_else(|| pds.clone());
let token = row.get::<_, Option<String>>(7)?;
let token_endpoint = token.unwrap_or_else(|| format!("{issuer}/oauth/token"));
let session = grain::session::Session {
did: did.clone(),
handle,
service: pds,
access_jwt: access,
refresh_jwt: refresh,
oauth: Some(grain::session::OAuth {
key: dpop.clone(),
issuer,
token_endpoint,
revocation_endpoint: None,
client_id: Some(format!("{url}{}", grain_serve::login::METADATA)),
loopback_id: None,
scope: row.get(9)?,
expires_at: row.get::<_, Option<i64>>(8)?.unwrap_or(0).max(0) as u64,
}),
};
let json = serde_json::to_string(&session)?;
insert.execute(rusqlite::params![did, json, now])?;
tally.imported += 1;
}
Ok((tally, None))
}
/// hatk's own key, which every session it held was bound to, and which
/// sealed its session cookies.
const SERVER_KID: &str = "appview-oauth-key";
/// [`SERVER_KID`]'s private JWK, as hatk kept it, if it did.
fn server_key(src: &Connection, tables: &BTreeSet<String>) -> rusqlite::Result<Option<String>> {
if !tables.contains("_oauth_keys") {
return Ok(None);
}
let sql = "SELECT private_key FROM _oauth_keys WHERE kid = ?1";
src.query_row(sql, [SERVER_KID], |r| r.get(0)).optional()
}
/// The key hatk's session cookies were sealed under, drawn from `jwk` as
/// [the module](self) says.
pub fn cookie_key(jwk: &str) -> anyhow::Result<[u8; 32]> {
let jwk: std::collections::BTreeMap<String, Value> = serde_json::from_str(jwk)?;
let ikm = serde_json::to_vec(&jwk)?;
let mut key = [0u8; 32];
hkdf::Hkdf::<sha2::Sha256>::new(None, &ikm)
.expand(b"hatk-session-cookie", &mut key)
.expect("32 bytes is a length HKDF gives");
Ok(key)
}
/// Keeps the key of the cookies hatk sealed under `jwk` as
/// `carried_cookie_key`; whether `jwk` was one to draw it from.
fn copy_cookie_key(dst: &Connection, jwk: &str) -> anyhow::Result<bool> {
let Ok(key) = cookie_key(jwk) else {
return Ok(false);
};
dst.execute(
"INSERT INTO carried_cookie_key (key) VALUES (?1)",
[&key[..]],
)?;
Ok(true)
}
/// The JWK thumbprint of `key`'s public half (RFC 7638), as a session's
/// `dpop_jkt` names the key it is bound to.
fn thumbprint(key: &grain::crypto::P256Key) -> String {
let jwk = key.jwk();
let canonical = format!(
r#"{{"crv":"P-256","kty":"EC","x":{},"y":{}}}"#,
jwk["x"], jwk["y"]
);
let digest = <sha2::Sha256 as sha2::Digest>::digest(canonical.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}
#[cfg(test)]
mod tests;
crates/grain-import/src/tests.rs+190−20
@@ -85,7 +85,10 @@ CREATE TABLE _push_tokens (did TEXT NOT NULL, token TEXT NOT NULL, platform TEXT
CREATE TABLE _oauth_keys (kid TEXT PRIMARY KEY, private_key TEXT NOT NULL,
public_key TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE _oauth_sessions (did TEXT PRIMARY KEY, pds_endpoint TEXT NOT NULL,
access_token TEXT NOT NULL, refresh_token TEXT, dpop_jkt TEXT NOT NULL);
pds_auth_server TEXT, pds_token_endpoint TEXT, access_token TEXT NOT NULL,
refresh_token TEXT, dpop_jkt TEXT NOT NULL, token_expires_at INTEGER, requested_scope TEXT,
granted_scope TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE _mutes (did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY (did, subject));
CREATE TABLE _space_support (pds_endpoint TEXT PRIMARY KEY, supported INTEGER NOT NULL,
@@ -142,9 +145,15 @@ INSERT INTO _space_invites VALUES
INSERT INTO _space_support VALUES
('https://pds.example', 1, '[]', '2025-01-01T00:00:00.000Z'),
('https://old.example', 0, '["com.atproto.space.createSpace"]', '2025-01-02T00:00:00.000Z');
INSERT INTO _oauth_keys VALUES ('appview-oauth-key', 'server', 'public', '2025-01-01 00:00:00'),
INSERT INTO _oauth_keys VALUES ('appview-oauth-key', '$HATK_KEY', 'public', '2025-01-01 00:00:00'),
('pds-client-auth-key', '{"key_ops":["sign"],"ext":true,"kty":"EC","x":"6wVOcgpdJpSGVwhFhUNcCtEGehv3oVbm3Vfspb8z_7w","y":"ezlUp0nPpJHIlRVbUBOb0KitkEVV7uLvtdfWFUmHgzw","crv":"P-256","d":"7YBtWZgfb9lUSihC1h2-HySNJopUjlS4GBsoVC-rZd4"}', 'public', '2025-01-01 00:00:00');
INSERT INTO _oauth_sessions VALUES ('did:plc:alice', 'https://pds.example', 'a', 'r', 'jkt');
INSERT INTO _oauth_sessions (did, pds_endpoint, pds_auth_server, pds_token_endpoint,
access_token, refresh_token, dpop_jkt, token_expires_at, requested_scope) VALUES
('did:plc:alice', 'https://pds.example', 'https://auth.example',
'https://auth.example/oauth/token', 'a', 'r', '$JKT', 1800000000, 'atproto'),
('did:plc:erin', 'https://pds.example', NULL, NULL, 'a', 'r', '$JKT', NULL, NULL),
('did:plc:carol', 'https://pds.example', NULL, NULL, 'a', NULL, '$JKT', NULL, NULL),
('did:plc:dave', 'https://pds.example', NULL, NULL, 'a', 'r', 'another key', NULL, NULL);
INSERT INTO _fts_social_grain_gallery VALUES ('at://did:plc:alice/social.grain.gallery/3l3qo2vuowo2a', 'Fog');
"#;
@@ -276,9 +285,19 @@ const CLIENT_KEY: &str = r#"{"key_ops":["sign"],"ext":true,"kty":"EC","x":"6wVOc
/// The whole of hatk's fixture database in `dir`.
fn full(dir: &Path) -> PathBuf {
hatk(dir, &format!("{KEPT_ROWS}{}", record_rows()))
hatk(dir, &format!("{}{}", kept_rows(), record_rows()))
}
/// [`KEPT_ROWS`], with hatk's key in it, and what its sessions are bound to.
fn kept_rows() -> String {
let key = grain_serve::login::from_jwk(CLIENT_KEY).unwrap();
KEPT_ROWS
.replace("$HATK_KEY", CLIENT_KEY)
.replace("$JKT", &thumbprint(&key))
}
const URL: Option<&str> = Some("https://grain.social");
/// Each row `sql` reads, its values joined by `|`.
fn dump(db: &Connection, sql: &str) -> Vec<String> {
let mut stmt = db.prepare(sql).unwrap();
@@ -307,7 +326,7 @@ fn every_row_of_what_the_network_cannot_rebuild_is_imported() {
let dir = tempfile::tempdir().unwrap();
let old = full(dir.path());
let into = dir.path().join("index.db");
let summary = import(&old, &into, false).unwrap();
let summary = import(&old, &into, false, URL).unwrap();
assert!(summary.written, "{summary}");
let db = Connection::open(&into).unwrap();
let rows = |sql| dump(&db, sql);
@@ -458,6 +477,25 @@ fn every_row_of_what_the_network_cannot_rebuild_is_imported() {
rows("SELECT kid, jwk FROM oauth_key"),
[format!("pds-client-auth-key|{CLIENT_KEY}")]
);
// The key hatk's cookies were sealed under, drawn as hatk draws it:
// the bytes grain-serve's carryover tests open hatk's own cookies with.
let key = "a0fb2a23a97fa7176425a2702c7ca2a8591deb89b7246a792454bc61e52a6903";
assert_eq!(
rows("SELECT hex(key) FROM carried_cookie_key"),
[key.to_uppercase()]
);
assert!(summary.cookie_key);
assert!(cookie_key("{").is_err());
// The web sessions bound to hatk's key, with a refresh token to go on.
let carried = &summary.sessions;
assert_eq!((carried.source, carried.imported), (4, 2));
assert_eq!(summary.sessions_left, None);
assert_eq!(
rows("SELECT did, since FROM web_session ORDER BY did"),
["did:plc:alice|0", "did:plc:erin|0"]
);
assert_eq!(summary.unrecognized, [("weird".to_string(), 1)]);
// As the index reads them.
@@ -498,6 +536,123 @@ fn every_row_of_what_the_network_cannot_rebuild_is_imported() {
exp: None,
});
assert_eq!(label.unwrap(), 10, "labels go on numbering past hatk's");
// A session as hatk held it, for grain's client, bound to hatk's key.
let alice = index.web_session("did:plc:alice").unwrap().unwrap().session;
let key = grain_serve::login::from_jwk(CLIENT_KEY).unwrap();
let o = alice.oauth.clone().unwrap();
assert_eq!(o.key().unwrap().to_bytes(), key.to_bytes());
assert_eq!(
(
alice.handle.as_str(),
alice.service.as_str(),
alice.access_jwt.as_str(),
alice.refresh_jwt.as_str()
),
("alice.test", "https://pds.example", "a", "r")
);
assert_eq!(o.issuer, "https://auth.example");
assert_eq!(o.token_endpoint, "https://auth.example/oauth/token");
assert_eq!(
o.client_id.as_deref(),
Some("https://grain.social/oauth-client-metadata.json")
);
assert_eq!(
(o.scope.as_deref(), o.expires_at),
(Some("atproto"), 1800000000)
);
// One that kept less is asked of its PDS, and refreshed before use.
let erin = index.web_session("did:plc:erin").unwrap().unwrap().session;
let o = erin.oauth.clone().unwrap();
assert_eq!(
(erin.handle.as_str(), o.issuer.as_str()),
("", "https://pds.example")
);
assert_eq!(o.token_endpoint, "https://pds.example/oauth/token");
assert_eq!((o.scope, o.expires_at), (None, 0));
assert!(erin.expiring());
let said = summary.to_string();
assert!(said.contains("web_session (_oauth_sessions)"), "{said}");
}
#[test]
fn web_sessions_carry_only_with_what_names_and_proves_them() {
let dir = tempfile::tempdir().unwrap();
let carried = |old: &Path, url: Option<&str>| {
let into = old.with_extension("index");
let summary = import(old, &into, false, url).unwrap();
assert!(summary.written, "{summary}");
(
summary.sessions.source,
summary.sessions.imported,
summary.sessions_left.clone(),
summary,
)
};
// With no public URL, none: they are left, and the summary says why.
let (source, imported, why, summary) = carried(&full(dir.path()), None);
assert_eq!((source, imported), (4, 0));
assert_eq!(why.as_deref(), Some("no --public-url or GRAIN_PUBLIC_URL"));
assert!(summary.left.contains(&("_oauth_sessions".to_string(), 4)));
assert!(
summary
.to_string()
.contains("none carried: no --public-url")
);
// With no key of hatk's to prove them with, none.
let dir = tempfile::tempdir().unwrap();
let keyless = "DELETE FROM _oauth_keys WHERE kid = 'appview-oauth-key';";
let old = hatk(dir.path(), &format!("{}{keyless}", kept_rows()));
let (_, imported, why, summary) = carried(&old, URL);
assert_eq!(
(imported, why.as_deref()),
(0, Some("no appview-oauth-key to prove them with"))
);
assert!(!summary.cookie_key);
assert!(summary.to_string().contains("cookies' key: not carried"));
// A key that is no JWK: no cookie key either.
let dir = tempfile::tempdir().unwrap();
let broken = "UPDATE _oauth_keys SET private_key = '{' WHERE kid = 'appview-oauth-key';";
let old = hatk(dir.path(), &format!("{}{broken}", kept_rows()));
assert!(!carried(&old, URL).3.cookie_key);
// A hatk from before it kept sessions, or their endpoints and expiry.
let dir = tempfile::tempdir().unwrap();
let old = hatk(
dir.path(),
"DROP TABLE _oauth_sessions; DROP TABLE _oauth_keys;",
);
let (source, _, why, _) = carried(&old, URL);
assert_eq!((source, why.as_deref()), (0, Some("hatk kept no sessions")));
let dir = tempfile::tempdir().unwrap();
let key = grain_serve::login::from_jwk(CLIENT_KEY).unwrap();
let old = hatk(
dir.path(),
&format!(
"DROP TABLE _oauth_sessions;
CREATE TABLE _oauth_sessions (did TEXT PRIMARY KEY, pds_endpoint TEXT NOT NULL,
access_token TEXT NOT NULL, refresh_token TEXT, dpop_jkt TEXT NOT NULL);
INSERT INTO _oauth_keys VALUES ('appview-oauth-key', '{}', '', '');
INSERT INTO _oauth_sessions VALUES ('did:plc:a', 'https://pds.example', 'a', 'r', '{}');",
CLIENT_KEY,
thumbprint(&key)
),
);
let into = old.with_extension("index");
assert_eq!(carried(&old, URL).1, 1);
let index = Index::open(&into).unwrap();
let a = index
.web_session("did:plc:a")
.unwrap()
.unwrap()
.session
.oauth
.unwrap();
assert_eq!(
(a.issuer.as_str(), a.expires_at),
("https://pds.example", 0)
);
}
#[test]
@@ -505,7 +660,7 @@ fn every_record_is_imported_as_it_was_indexed() {
let dir = tempfile::tempdir().unwrap();
let old = full(dir.path());
let into = dir.path().join("index.db");
let summary = import(&old, &into, false).unwrap();
let summary = import(&old, &into, false, URL).unwrap();
assert!(summary.written, "{summary}");
let tallies: HashMap<_, _> = summary
.records
@@ -632,7 +787,6 @@ fn every_record_is_imported_as_it_was_indexed() {
// What is not imported, named with its rows.
let left: HashMap<_, _> = summary.left.iter().cloned().collect();
let expected = [
("_oauth_sessions", 1),
("app.bsky.actor.profile", 1),
("app.bsky.feed.post__entities", 0),
("social.grain.gallery__labels_self_labels", 1),
@@ -653,7 +807,7 @@ fn strict_refuses_an_import_with_a_record_refused() {
let dir = tempfile::tempdir().unwrap();
let old = full(dir.path());
let into = dir.path().join("index.db");
let summary = import(&old, &into, true).unwrap();
let summary = import(&old, &into, true, URL).unwrap();
assert!(!summary.written);
let said = summary.to_string();
assert!(said.ends_with("nothing written: a table differs, or --strict refused\n"));
@@ -665,7 +819,7 @@ fn strict_refuses_an_import_with_a_record_refused() {
assert_eq!(held("SELECT count(*) FROM gallery").unwrap(), 0);
assert_eq!(held("SELECT count(*) FROM label").unwrap(), 0);
// What is left is empty, and taken again.
assert!(import(&old, &into, false).unwrap().written);
assert!(import(&old, &into, false, URL).unwrap().written);
assert_eq!(held("SELECT count(*) FROM label").unwrap(), 4);
}
@@ -674,9 +828,9 @@ fn a_row_the_index_does_not_take_fails_the_import() {
let dir = tempfile::tempdir().unwrap();
// hatk let a preference's time be null; the index does not.
let null = "INSERT INTO _preferences VALUES ('did:plc:erin', 'k', '1', NULL);";
let old = hatk(dir.path(), &format!("{KEPT_ROWS}{null}"));
let old = hatk(dir.path(), &format!("{}{null}", kept_rows()));
let into = dir.path().join("index.db");
let summary = import(&old, &into, false).unwrap();
let summary = import(&old, &into, false, URL).unwrap();
assert!(!summary.written);
let preference = kept(&summary, "preference");
assert_eq!((preference.source, preference.imported), (2, 1));
@@ -707,8 +861,8 @@ fn a_seen_time_that_is_no_time_fails_the_import() {
let dir = tempfile::tempdir().unwrap();
let odd = "INSERT INTO _preferences VALUES
('did:plc:erin', 'lastSeenNotifications', '\"yesterday\"', '2025-01-01T00:00:00.000Z');";
let old = hatk(dir.path(), &format!("{KEPT_ROWS}{odd}"));
let summary = import(&old, &dir.path().join("index.db"), false).unwrap();
let old = hatk(dir.path(), &format!("{}{odd}", kept_rows()));
let summary = import(&old, &dir.path().join("index.db"), false, URL).unwrap();
assert!(!summary.written);
let seen = kept(&summary, "notification_seen");
assert_eq!((seen.source, seen.imported), (3, 2));
@@ -732,7 +886,7 @@ fn hatk_with_its_repos_alone_imports() {
)
.unwrap();
let into = dir.path().join("index.db");
let summary = import(&old, &into, true).unwrap();
let summary = import(&old, &into, true, URL).unwrap();
assert!(summary.written, "{summary}");
assert!(summary.records.iter().all(|t| t.source == 0));
assert_eq!(kept(&summary, "repo").imported, 1);
@@ -745,15 +899,15 @@ fn it_imports_only_into_an_empty_index() {
let dir = tempfile::tempdir().unwrap();
let old = full(dir.path());
let into = dir.path().join("index.db");
assert!(import(&old, &into, false).unwrap().written);
let again = import(&old, &into, false).unwrap_err().to_string();
assert!(import(&old, &into, false, URL).unwrap().written);
let again = import(&old, &into, false, URL).unwrap_err().to_string();
assert!(again.contains("holds"), "{again}");
assert!(
again.contains("import only into a new or empty index"),
"{again}"
);
// Not into hatk's own, which is left as it was.
let itself = import(&old, &old, false).unwrap_err().to_string();
let itself = import(&old, &old, false, URL).unwrap_err().to_string();
assert!(
itself.contains("is not an index: it has _cursor"),
"{itself}"
@@ -761,7 +915,7 @@ fn it_imports_only_into_an_empty_index() {
let tables = names(&Connection::open(&old).unwrap()).unwrap();
assert!(!tables.contains("gallery"));
let nowhere = dir.path().join("no/such/dir/index.db");
let why = format!("{:#}", import(&old, &nowhere, false).unwrap_err());
let why = format!("{:#}", import(&old, &nowhere, false, URL).unwrap_err());
assert!(why.starts_with("opening "), "{why}");
}
@@ -770,14 +924,14 @@ fn it_reads_only_hatks_database() {
let dir = tempfile::tempdir().unwrap();
let into = dir.path().join("index.db");
let missing = dir.path().join("missing.db");
let why = format!("{:#}", import(&missing, &into, false).unwrap_err());
let why = format!("{:#}", import(&missing, &into, false, URL).unwrap_err());
assert!(why.starts_with("opening "), "{why}");
let other = dir.path().join("other.db");
Connection::open(&other)
.unwrap()
.execute_batch("CREATE TABLE t (x)")
.unwrap();
let why = import(&other, &into, false).unwrap_err().to_string();
let why = import(&other, &into, false, URL).unwrap_err().to_string();
assert!(
why.ends_with("has no _repos: it is not hatk's database"),
"{why}"
@@ -794,6 +948,7 @@ fn arguments_name_the_database_and_the_index() {
from: "/old/grain.db".into(),
into: "/data/index.db".into(),
strict: false,
public_url: None,
};
assert_eq!(parsed, want);
let parsed = parse_args(
@@ -806,7 +961,22 @@ fn arguments_name_the_database_and_the_index() {
..want
};
assert_eq!(parsed.unwrap(), want);
let named = parse_args(
&args(&["/old/grain.db", "--public-url", "https://g.test"]),
index,
);
assert_eq!(named.unwrap().public_url.as_deref(), Some("https://g.test"));
let public = |name: &str| match name {
"GRAIN_PUBLIC_URL" => Some("https://grain.social".to_string()),
_ => index(name),
};
let from_var = parse_args(&args(&["/old/grain.db"]), public).unwrap();
assert_eq!(from_var.public_url.as_deref(), Some("https://grain.social"));
let wrong = |a: &[&str]| parse_args(&args(a), none).unwrap_err();
assert_eq!(
wrong(&["/old/grain.db", "--into", "/i.db", "--public-url"]),
"--public-url takes a URL"
);
assert_eq!(wrong(&[]), USAGE);
assert_eq!(
wrong(&["/old/grain.db"]),
crates/grain-import/Cargo.toml+2−0
@@ -11,8 +11,10 @@ grain.workspace = true
grain-serve.workspace = true
anyhow.workspace = true
base64.workspace = true
hkdf.workspace = true
rusqlite.workspace = true
serde_json.workspace = true
sha2.workspace = true
[dev-dependencies]
tempfile.workspace = true
crates/grain-serve/src/index/schema.rs+11−1
@@ -76,6 +76,9 @@
//! 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;
//! - `carried_cookie_key`, the key the cookies a browser held before
//! grain-serve were sealed under, put there by the import and read by
//! [`crate::login::carryover`] until it is removed;
//! - `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:
@@ -93,7 +96,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, V7];
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5, V6, V7, V8];
const V1: &str = "
CREATE TABLE gallery (
@@ -459,3 +462,10 @@ pub fn unhidden(uri: &str) -> String {
AND n.val = l.val AND n.neg = 1 AND n.cts > l.cts))"
)
}
/// The key that opens the session cookies browsers held before grain-serve
/// set its own, for as long as [`crate::login::carryover`] carries them:
/// one row, if any, of 32 bytes.
const V8: &str = "
CREATE TABLE carried_cookie_key (key BLOB NOT NULL CHECK (length(key) = 32));
";
crates/grain-serve/src/index/sessions.rs+46−0
@@ -29,6 +29,26 @@ impl Index {
Ok(jwk)
}
/// The key of the cookies browsers held before grain-serve's, if the
/// import carried one ([`crate::login::carryover`]).
pub fn carried_cookie_key(&self) -> rusqlite::Result<Option<[u8; 32]>> {
let db = self.db.lock().unwrap();
let sql = "SELECT key FROM carried_cookie_key LIMIT 1";
let key: Option<Vec<u8>> = db.query_row(sql, [], |r| r.get(0)).optional()?;
Ok(key.and_then(|k| k.try_into().ok()))
}
/// `time`, an RFC 3339 time, in seconds since the epoch.
pub fn epoch_secs(&self, time: &str) -> anyhow::Result<u64> {
anyhow::ensure!(
grain::lexicon::valid_datetime(time),
"{time}: not an RFC 3339 time"
);
let db = self.db.lock().unwrap();
let secs: i64 = db.query_row("SELECT unixepoch(?1)", [time], |r| r.get(0))?;
Ok(secs.max(0) as u64)
}
/// 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>> {
@@ -103,6 +123,32 @@ mod tests {
assert_eq!(index.oauth_key("other", || "two".into()).unwrap(), "two");
}
#[test]
fn times_read_as_seconds() {
let index = Index::memory().unwrap();
assert_eq!(
index.epoch_secs("2026-12-01T00:00:00Z").unwrap(),
1796083200
);
assert_eq!(
index.epoch_secs("2026-12-01T01:00:00+01:00").unwrap(),
1796083200
);
assert!(index.epoch_secs("2026-12-01").is_err());
}
#[test]
fn a_carried_cookie_key_is_32_bytes() {
let index = Index::memory().unwrap();
assert_eq!(index.carried_cookie_key().unwrap(), None);
let db = || index.db.lock().unwrap();
let short = db().execute("INSERT INTO carried_cookie_key VALUES (x'00')", []);
assert!(short.is_err());
db().execute("INSERT INTO carried_cookie_key VALUES (zeroblob(32))", [])
.unwrap();
assert_eq!(index.carried_cookie_key().unwrap(), Some([0; 32]));
}
#[test]
fn sessions_keep_when_their_login_was() {
let index = Index::memory().unwrap();
crates/grain-serve/src/login/carryover.rsnew+250−0
@@ -0,0 +1,250 @@
//! Browsers logged in to grain.social before grain-serve served it stay
//! logged in: the one runtime file that reads hatk's session cookies, and
//! deleted once `GRAIN_CARRY_COOKIES_UNTIL` has passed, with the
//! `carried_cookie_key` table and its import.
//!
//! hatk sealed `__hatk_session`, `{did, handle, ts}`, and
//! `__hatk_session_accounts`, `{accounts: [{did, handle}], ts}`, with
//! AES-256-GCM: the value is the IV and the sealed bytes, each base64url,
//! a `.` between. Each lasts 30 days from `ts`. The import keeps the
//! key, 32 bytes, as `carried_cookie_key`, and the sessions hatk held as
//! `web_session`s.
//!
//! On a request with `__hatk_session` and no `grain_session`, before
//! `GRAIN_CARRY_COOKIES_UNTIL`, [`carry`] opens it, and each account it
//! and its accounts cookie name that has a session here, not taken down,
//! is logged in as a login of this server's would log it in: the request
//! goes on with those cookies, so it is answered as that account, and the
//! answer sets them. hatk's cookies are cleared once read. Each account
//! carried is logged, by its DID, so that the logs say when they stop.
use super::{COOKIE, LASTS, MOST, OTHERS, Web, cookie, held, set_others};
use crate::App;
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use axum::extract::{Request, State};
use axum::http::{HeaderValue, header};
use axum::middleware::Next;
use axum::response::Response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use grain::keys;
use serde::Deserialize;
use std::sync::Arc;
/// hatk's session cookie.
pub const SESSION: &str = "__hatk_session";
/// hatk's cookie of the other accounts a browser was logged in to.
pub const ACCOUNTS: &str = "__hatk_session_accounts";
/// How long hatk held a cookie to, in seconds from its `ts`.
const MAX_AGE: u64 = 30 * 24 * 60 * 60;
/// What opens hatk's cookies, and until when.
pub struct Carryover {
cipher: Aes256Gcm,
/// The last moment a cookie is carried, in seconds since the epoch.
until: u64,
}
#[derive(Deserialize)]
struct Account {
did: String,
}
#[derive(Deserialize)]
struct Sealed {
did: Option<String>,
accounts: Option<Vec<Account>>,
ts: Option<u64>,
}
impl Carryover {
pub fn new(key: &[u8; 32], until: u64) -> Carryover {
Carryover {
cipher: Aes256Gcm::new(key.into()),
until,
}
}
/// What `value` holds, if it was sealed under this key and is no
/// older than hatk held it to at `now`, in seconds.
fn open(&self, value: &str, now: u64) -> Option<Sealed> {
let (iv, sealed) = value.split_once('.')?;
let iv: [u8; 12] = URL_SAFE_NO_PAD.decode(iv).ok()?.try_into().ok()?;
let sealed = URL_SAFE_NO_PAD.decode(sealed).ok()?;
let plain = self
.cipher
.decrypt(&Nonce::from(iv), sealed.as_ref())
.ok()?;
let opened: Sealed = serde_json::from_slice(&plain).ok()?;
(now.saturating_sub(opened.ts?) <= MAX_AGE).then_some(opened)
}
/// The account a session cookie names, while it lasts.
fn session(&self, value: &str, now: u64) -> Option<String> {
self.open(value, now)?.did
}
/// The accounts an accounts cookie names, while it lasts, the one
/// logged in to last first.
fn accounts(&self, value: &str, now: u64) -> Vec<String> {
let accounts = self.open(value, now).and_then(|o| o.accounts);
let dids = accounts.unwrap_or_default().into_iter().map(|a| a.did);
// hatk kept them oldest first.
dids.rev().collect()
}
}
/// `payload` sealed as hatk sealed its cookies, under `key`: what the
/// tests carry.
#[doc(hidden)]
pub fn seal(key: &[u8; 32], payload: &serde_json::Value) -> String {
let cipher = Aes256Gcm::new(key.into());
let iv = Aes256Gcm::generate_nonce(&mut OsRng);
let plain = serde_json::to_vec(payload).expect("a payload serializes");
let sealed = cipher.encrypt(&iv, plain.as_ref()).expect("AES-GCM seals");
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(iv),
URL_SAFE_NO_PAD.encode(sealed)
)
}
impl Web {
/// This web, carrying hatk's cookies until `until`, in seconds since
/// the epoch, if `index` keeps their key; as it was otherwise.
pub fn carrying(
mut self,
index: &crate::index::Index,
until: Option<u64>,
) -> rusqlite::Result<Web> {
let key = match until {
Some(_) => index.carried_cookie_key()?,
None => None,
};
self.carryover = key
.zip(until)
.map(|(key, until)| Carryover::new(&key, until));
Ok(self)
}
}
/// Trades hatk's cookies for this server's, as [the module](self) says.
pub async fn carry(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
let now = keys::now_secs();
let carrying = app
.web
.as_ref()
.and_then(|w| Some((w, w.carryover.as_ref()?)));
let theirs = cookie(req.headers(), SESSION);
let (Some((web, carryover)), Some(theirs)) = (carrying, theirs) else {
return next.run(req).await;
};
if now > carryover.until {
return next.run(req).await;
}
// The account in use first, then the others, each once, and only
// those with a session here; none if the one in use has none.
let mut carried: Vec<(String, String)> = Vec::new();
let named = carryover
.session(&theirs, now)
.filter(|_| cookie(req.headers(), COOKIE).is_none());
if let Some(did) = named.clone() {
let accounts = cookie(req.headers(), ACCOUNTS);
let others = accounts.map(|a| carryover.accounts(&a, now));
for did in std::iter::once(did).chain(others.unwrap_or_default()) {
let token = web.token(&did);
if !carried.iter().any(|(d, _)| *d == did) && held(&app, &token).is_some() {
tracing::info!(%did, "carried a web login");
carried.push((did, token));
}
}
}
let mut set = Vec::new();
if !carried.is_empty() && carried.first().map(|(d, _)| d) == named.as_ref() {
let (_, token) = carried.remove(0);
let mut pairs = format!("{COOKIE}={token}");
if !carried.is_empty() {
set.push(set_others(web, &carried));
let others: Vec<&str> = carried.iter().take(MOST).map(|(_, t)| t.as_str()).collect();
pairs = format!("{pairs}; {OTHERS}={}", others.join("~"));
}
// Ahead of what the browser sent, so that they are the ones read.
let was = req.headers().get_all(header::COOKIE).into_iter();
let was = was.filter_map(|v| v.to_str().ok());
let all = std::iter::once(pairs.as_str()).chain(was);
let all = all.collect::<Vec<_>>().join("; ");
let all = HeaderValue::from_str(&all).expect("cookies are a header");
req.headers_mut().insert(header::COOKIE, all);
set.push(web.set_cookie(COOKIE, &token, LASTS));
}
let mut res = next.run(req).await;
let headers = res.headers_mut();
for cookie in set {
headers.append(header::SET_COOKIE, cookie);
}
for name in [SESSION, ACCOUNTS] {
headers.append(header::SET_COOKIE, web.set_cookie(name, "", 0));
}
res
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use serde_json::json;
/// The key hatk's code draws from its fixture JWK (the one
/// `grain-import`'s tests draw it from), and cookies hatk's own
/// `encrypt` (`packages/hatk/src/oauth/session.ts`) sealed under it,
/// on Node.
const KEY: &str = "a0fb2a23a97fa7176425a2702c7ca2a8591deb89b7246a792454bc61e52a6903";
/// `{did: "did:plc:alice", handle: "alice.test", ts: TS}`.
const SEALED: &str = "I6P9CeNFQcdHEY0L.HElY4WaZK-ppTgpSHAM6qzTp79yV-I_sikV_ELstmDVG6kWDa8nO8i2X2LvmSs9V6kgO4uSGZcxkdLjd4by0zn-108EdnD_RsHOTYbU";
/// bob, then alice, as hatk adds them, at `TS`.
const SEALED_ACCOUNTS: &str = "iJ8hHV7f-dh9Z8ib.ggEwfCvNERTYOrDh7M4O4eT2TqyZYCFxNGadZxaAbH0iHRcwwKv0hwTJ3TiHfLUABp3lc7OsRdLO9l61LcQ8uxnWZ9gZzLIrb-Hxqzy6BoyXBG4fKqZ2tYmmI_ol4IgJznsxqiHPQOWNcx61AiBGMvQTX0wkaTsJmA5bAWOiU5MVQrhpWSo";
const TS: u64 = 1793500000;
pub(crate) fn key() -> [u8; 32] {
let bytes: Vec<u8> = (0..KEY.len())
.step_by(2)
.map(|i| u8::from_str_radix(&KEY[i..i + 2], 16).unwrap())
.collect();
bytes.try_into().unwrap()
}
#[test]
fn hatks_cookies_open_while_they_last() {
let c = Carryover::new(&key(), u64::MAX);
let alice = Some("did:plc:alice".to_string());
assert_eq!(c.session(SEALED, TS), alice);
assert_eq!(c.session(SEALED, TS + MAX_AGE), alice);
assert_eq!(c.session(SEALED, TS + MAX_AGE + 1), None, "past 30 days");
assert_eq!(
c.accounts(SEALED_ACCOUNTS, TS),
["did:plc:alice", "did:plc:bob"]
);
assert!(c.accounts(SEALED_ACCOUNTS, TS + MAX_AGE + 1).is_empty());
assert!(c.accounts(SEALED, TS).is_empty());
assert_eq!(c.session(SEALED_ACCOUNTS, TS), None);
// What the tests seal opens as hatk's does.
let ours = seal(&key(), &json!({ "did": "did:plc:carol", "ts": TS }));
assert_eq!(c.session(&ours, TS).as_deref(), Some("did:plc:carol"));
}
#[test]
fn anything_else_opens_as_nothing() {
let c = Carryover::new(&key(), u64::MAX);
let (iv, sealed) = SEALED.split_once('.').unwrap();
let mut bytes = URL_SAFE_NO_PAD.decode(sealed).unwrap();
bytes[0] ^= 1;
let tampered = format!("{iv}.{}", URL_SAFE_NO_PAD.encode(bytes));
let short = format!("AAAA.{sealed}");
let not_json = seal(&key(), &json!("a string"));
for wrong in ["", "no-dot", "!!.!!", &short, &tampered, &not_json] {
assert_eq!(c.session(wrong, TS), None, "{wrong}");
}
// Under another key, nothing opens.
assert_eq!(Carryover::new(&[7; 32], u64::MAX).session(SEALED, TS), None);
}
}
crates/grain-serve/src/lib.rs+20−1
@@ -55,6 +55,10 @@ pub struct Settings {
/// `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>,
/// `GRAIN_CARRY_COOKIES_UNTIL`: the last moment, an RFC 3339 time, the
/// web carries the cookies browsers held before grain-serve
/// ([`login::carryover`]). None carries none.
pub carry_cookies_until: Option<String>,
}
impl Settings {
@@ -76,6 +80,7 @@ impl Settings {
Some(url) => Some(url.trim_end_matches('/').into()),
},
sign_up_pds: set("GRAIN_SIGN_UP_PDS"),
carry_cookies_until: set("GRAIN_CARRY_COOKIES_UNTIL"),
}
}
@@ -119,7 +124,10 @@ impl Settings {
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)?))
let until = self.carry_cookies_until.as_deref();
let until = until.map(|t| index.epoch_secs(t)).transpose()?;
let web = login::Web::new(url, index, pds)?.carrying(index, until)?;
Some(Arc::new(web))
}
_ => None,
};
@@ -223,6 +231,10 @@ pub fn router(app: Arc<App>) -> Router {
.route("/oauth/logout", post(login::logout))
.route("/oauth/session", get(login::whoami))
.route("/xrpc/{*method}", any(not_implemented))
.layer(axum::middleware::from_fn_with_state(
app.clone(),
login::carryover::carry,
))
.with_state(app)
}
@@ -327,5 +339,12 @@ mod tests {
..Settings::default()
};
assert!(wrong.app(ctx()).is_err());
// A time to carry cookies until is one, or the server does not start.
let until = |t: &str| Settings {
carry_cookies_until: Some(t.into()),
..settings.clone()
};
assert!(until("2026-12-01T00:00:00Z").app(ctx()).is_ok());
assert!(until("next month").app(ctx()).is_err());
}
}
crates/grain-serve/src/login.rs+5−0
@@ -32,6 +32,8 @@
//! account at a time ([`session`]): a refresh token is spent once, and a
//! second refresh with it ends the whole grant.
pub mod carryover;
use crate::App;
use crate::xrpc::Failure;
use axum::Json;
@@ -118,6 +120,8 @@ pub struct Web {
logins: Mutex<HashMap<String, Pending>>,
/// A lock for each account whose session is being refreshed.
refreshing: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// What carries the cookies browsers held before, while it does.
carryover: Option<carryover::Carryover>,
}
/// A login gone to a PDS, waiting for the browser to come back.
@@ -149,6 +153,7 @@ impl Web {
sign_up_pds: sign_up_pds.trim_end_matches('/').to_string(),
logins: Mutex::new(HashMap::new()),
refreshing: Mutex::new(HashMap::new()),
carryover: None,
})
}
crates/grain-serve/tests/web/mod.rs+6−0
@@ -33,11 +33,17 @@ pub async fn site() -> Site {
/// [`site`], on `index`, sending sign-ups to `sign_up_pds`.
pub async fn site_on(index: Index, sign_up_pds: &str) -> Site {
site_with(index, sign_up_pds, None).await
}
/// [`site_on`], carrying the cookies browsers held before until `until`.
pub async fn site_with(index: Index, sign_up_pds: &str, until: Option<u64>) -> Site {
let dir = tempfile::tempdir().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = net::tls_proxy(listener.local_addr().unwrap().port());
let index = Arc::new(index);
let web = Web::new(&proxy.url, &index, sign_up_pds).unwrap();
let web = web.carrying(&index, until).unwrap();
let app = Arc::new(App {
ctx: net::ctx(dir.path(), &[]),
index: Some(index.clone()),
crates/grain-serve/tests/login.rs+116−2
crates/grain-serve/Cargo.toml+1−0
Cargo.lock+114−0
Cargo.toml+2−0