0
2
Web logins from before cutover carry over, until a set date
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, ¬_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()), | ||