OXYGENHUB

Sessions are server-side records: random ids, expiry, listed and ended per browser 0024ccb1b050

The cookie holds a random id; the hub keeps only its hash, with the
account's salt at sign-in, when it was made and used, and the address and
browser. Good while the salt is the account's, used in the last 14 days and
under 30 days old. Every sign-in is a new session; signing out deletes the
record; Settings > Security lists browsers with Sign out on each; ended
sessions are swept nightly. Old name.mac cookies no longer work: one fresh
sign-in.

Closes #223
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 00:57 UTC
Signature
✔ signed by Claude Code
Hash
0024ccb1b05029300c29ddf888be7513897fa381fc0e0e7b8661e3f00229d460
Depends on
18e381ea88c2 19bb31ff1f5c 34beb1a7e376 3dd6582de060 6a1780558353 83940d53664f 8a3334b7a364 93be9ce4653e b6b7e4cf6b24 c40c2d3edb51 d23464c0ac60
Depended on by
a22e4aec2e28 8597fb5ca16c 8dcaeeb848c6 965b950f9b96 c8684d44d549 97b18869acf5 5d90e0e045e2 3ff52534f69b
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
46 atoms, 2 tree operations, 14 files

Browse the tree at this change

added crates/hub/migrations/0068_sessions.sql +15 −0
1 +
-- Sessions as server-side records (oxygenhub#223). The cookie holds a
2 +
-- random id; only its hash is kept here. A session is good while its salt
3 +
-- matches the account's (a new password or "sign out everywhere" ends them
4 +
-- all), it has been used in the last 14 days, and it is under 30 days old.
5 +
CREATE TABLE sessions (
6 +
    id INTEGER PRIMARY KEY,
7 +
    account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
8 +
    hash TEXT NOT NULL UNIQUE,
9 +
    salt TEXT NOT NULL,
10 +
    created_ms INTEGER NOT NULL,
11 +
    seen_ms INTEGER NOT NULL,
12 +
    address TEXT NOT NULL DEFAULT '',
13 +
    agent TEXT NOT NULL DEFAULT ''
14 +
);
15 +
CREATE INDEX sessions_account ON sessions(account_id);
added crates/hub/src/db/sessions.rs +124 −0
1 +
//! Browser sessions as server-side records (oxygenhub#223).
2 +
3 +
use super::{now_ms, Pool};
4 +
use sqlx::FromRow;
5 +
6 +
/// A session ends after this long unused.
7 +
pub const IDLE_MS: i64 = 14 * 24 * 60 * 60_000;
8 +
/// And after this long whatever happens.
9 +
pub const ABSOLUTE_MS: i64 = 30 * 24 * 60 * 60_000;
10 +
/// `seen_ms` is written at most this often per session.
11 +
const TOUCH_EVERY_MS: i64 = 60_000;
12 +
13 +
#[derive(Clone, Debug, FromRow)]
14 +
pub struct Session {
15 +
    pub id: i64,
16 +
    pub account_id: i64,
17 +
    pub salt: String,
18 +
    pub created_ms: i64,
19 +
    pub seen_ms: i64,
20 +
    pub address: String,
21 +
    pub agent: String,
22 +
}
23 +
24 +
impl Session {
25 +
    /// Whether it is still good at `now`, for an account whose salt is `salt`.
26 +
    pub fn live(&self, salt: &str, now: i64) -> bool {
27 +
        self.salt == salt && now - self.seen_ms < IDLE_MS && now - self.created_ms < ABSOLUTE_MS
28 +
    }
29 +
}
30 +
31 +
const COLUMNS: &str = "id, account_id, salt, created_ms, seen_ms, address, agent";
32 +
33 +
pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result<i64> {
34 +
    let now = now_ms();
35 +
    let r = sqlx::query("INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms) VALUES (?, ?, ?, ?, ?)")
36 +
        .bind(account_id)
37 +
        .bind(hash)
38 +
        .bind(salt)
39 +
        .bind(now)
40 +
        .bind(now)
41 +
        .execute(pool)
42 +
        .await?;
43 +
    Ok(r.last_insert_rowid())
44 +
}
45 +
46 +
pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Session>> {
47 +
    sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE hash = ?")).bind(hash).fetch_optional(pool).await
48 +
}
49 +
50 +
/// Note a use: the time (at most once a minute) and, the first time, the
51 +
/// address and browser it came from.
52 +
pub async fn touch(pool: &Pool, s: &Session, address: &str, agent: &str, now: i64) -> sqlx::Result<()> {
53 +
    if s.agent.is_empty() && s.address.is_empty() && (!agent.is_empty() || !address.is_empty()) {
54 +
        sqlx::query("UPDATE sessions SET seen_ms = ?, address = ?, agent = ? WHERE id = ?")
55 +
            .bind(now)
56 +
            .bind(address)
57 +
            .bind(agent)
58 +
            .bind(s.id)
59 +
            .execute(pool)
60 +
            .await?;
61 +
    } else if now - s.seen_ms >= TOUCH_EVERY_MS {
62 +
        sqlx::query("UPDATE sessions SET seen_ms = ? WHERE id = ?").bind(now).bind(s.id).execute(pool).await?;
63 +
    }
64 +
    Ok(())
65 +
}
66 +
67 +
/// An account's live sessions, most recently used first.
68 +
pub async fn of_account(pool: &Pool, account_id: i64, salt: &str) -> sqlx::Result<Vec<Session>> {
69 +
    let now = now_ms();
70 +
    let all: Vec<Session> = sqlx::query_as(&format!("SELECT {COLUMNS} FROM sessions WHERE account_id = ? ORDER BY seen_ms DESC"))
71 +
        .bind(account_id)
72 +
        .fetch_all(pool)
73 +
        .await?;
74 +
    Ok(all.into_iter().filter(|s| s.live(salt, now)).collect())
75 +
}
76 +
77 +
/// End one session of an account's; whether there was one.
78 +
pub async fn end(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<bool> {
79 +
    let r = sqlx::query("DELETE FROM sessions WHERE id = ? AND account_id = ?").bind(id).bind(account_id).execute(pool).await?;
80 +
    Ok(r.rows_affected() > 0)
81 +
}
82 +
83 +
pub async fn end_hash(pool: &Pool, hash: &str) -> sqlx::Result<()> {
84 +
    sqlx::query("DELETE FROM sessions WHERE hash = ?").bind(hash).execute(pool).await?;
85 +
    Ok(())
86 +
}
87 +
88 +
/// Every session of an account's but `keep`.
89 +
pub async fn end_others(pool: &Pool, account_id: i64, keep: i64) -> sqlx::Result<()> {
90 +
    sqlx::query("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(account_id).bind(keep).execute(pool).await?;
91 +
    Ok(())
92 +
}
93 +
94 +
/// Forget sessions that can no longer be used; how many.
95 +
pub async fn sweep(pool: &Pool) -> sqlx::Result<u64> {
96 +
    let now = now_ms();
97 +
    let r = sqlx::query(
98 +
        "DELETE FROM sessions WHERE seen_ms < ? OR created_ms < ? \
99 +
         OR salt != (SELECT session_salt FROM accounts WHERE accounts.id = sessions.account_id)",
100 +
    )
101 +
    .bind(now - IDLE_MS)
102 +
    .bind(now - ABSOLUTE_MS)
103 +
    .execute(pool)
104 +
    .await?;
105 +
    Ok(r.rows_affected())
106 +
}
107 +
108 +
#[cfg(test)]
109 +
mod tests {
110 +
    use super::*;
111 +
112 +
    #[test]
113 +
    fn a_session_ends_on_a_new_salt_when_idle_or_when_old() {
114 +
        let t0 = 1_000_000_000_000;
115 +
        let s =
116 +
            Session { id: 1, account_id: 1, salt: "a".into(), created_ms: t0, seen_ms: t0, address: String::new(), agent: String::new() };
117 +
        assert!(s.live("a", t0 + 1000));
118 +
        assert!(!s.live("b", t0 + 1000), "a new salt: signed out everywhere");
119 +
        assert!(!s.live("a", t0 + IDLE_MS), "unused for 14 days");
120 +
        let busy = Session { seen_ms: t0 + ABSOLUTE_MS - 1000, ..s.clone() };
121 +
        assert!(busy.live("a", t0 + ABSOLUTE_MS - 1));
122 +
        assert!(!busy.live("a", t0 + ABSOLUTE_MS), "30 days, however busy");
123 +
    }
124 +
}
modified crates/hub/src/auth/mod.rs +7 −5
…
89 unchanged lines
90 90
    hex::encode(blake3::hash(secret.as_bytes()).as_bytes())
91 91
}
92 92
93 -
/// The session cookie's value for an account: `username.mac`.
94 -
pub fn session_value(key: &CookieKey, username: &str, session_salt: &str) -> String {
95 -
    format!("{username}.{}", key.mac(format!("session:{username}:{session_salt}").as_bytes()))
93 +
/// A new session id for the cookie (oxygenhub#223): random, and kept on
94 +
/// the server only as its [`token_hash`].
95 +
pub fn new_session_id() -> String {
96 +
    random_hex(32)
96 97
}
97 98
98 99
/// Proof, for the code step of a sign-in with two-factor authentication,
…
42 unchanged lines
141 142
        let a = CookieKey::load_or_create(dir.path()).unwrap();
142 143
        let b = CookieKey::load_or_create(dir.path()).unwrap();
143 144
        assert_eq!(a.mac(b"x"), b.mac(b"x"));
144 -
        let v = session_value(&a, "andy", "salt");
145 -
        assert!(v.starts_with("andy."));
145 +
        let v = new_session_id();
146 +
        assert_eq!(v.len(), 64);
147 +
        assert_ne!(v, new_session_id());
146 148
        assert_ne!(csrf_token(&a, &v), csrf_token(&a, "other"));
147 149
    }
148 150
}
modified crates/hub/src/auth/viewer.rs +31 −20
1 1
//! Who is asking: the session cookie resolved to an account, as an axum
2 2
//! extractor every page handler takes.
3 3
4 -
use super::{csrf_token, mac_equal, session_value, SESSION_COOKIE};
4 +
use super::{csrf_token, mac_equal, token_hash, SESSION_COOKIE};
5 5
use crate::db::{self, accounts::Account};
6 6
use crate::Shared;
7 7
use axum::extract::FromRequestParts;
…
6 unchanged lines
14 14
    pub account: Account,
15 15
    /// The cookie value, which CSRF tokens are bound to.
16 16
    pub session: String,
17 +
    /// The session's record (oxygenhub#223): "this browser" in the list.
18 +
    pub session_id: i64,
17 19
    pub csrf: String,
18 20
    /// Unread notifications, for the header.
19 21
    pub unread: i64,
…
34 unchanged lines
54 56
    })
55 57
}
56 58
57 -
/// Resolve a session cookie value to its account.
58 -
pub async fn resolve(app: &Shared, value: &str) -> Option<ViewerAccount> {
59 -
    let (username, mac) = value.rsplit_once('.')?;
60 -
    let account = db::accounts::account_by_name(&app.db, username).await.ok()??;
61 -
    if account.blocked() {
59 +
/// Resolve a session cookie value to its account: a live session record
60 +
/// (oxygenhub#223) whose salt is still the account's.
61 +
pub async fn resolve(app: &Shared, value: &str) -> Option<(ViewerAccount, db::sessions::Session)> {
62 +
    if value.is_empty() || value.len() > 128 {
62 63
        return None;
63 64
    }
64 -
    let expected = session_value(&app.cookie_key, &account.username, &account.session_salt);
65 -
    let (_, expected_mac) = expected.rsplit_once('.')?;
66 -
    if !mac_equal(expected_mac, mac) {
65 +
    let session = db::sessions::by_hash(&app.db, &token_hash(value)).await.ok()??;
66 +
    let account = db::accounts::account_by_id(&app.db, session.account_id).await.ok()??;
67 +
    if account.blocked() || !session.live(&account.session_salt, db::now_ms()) {
67 68
        return None;
68 69
    }
69 -
    let csrf = csrf_token(&app.cookie_key, &expected);
70 +
    let csrf = csrf_token(&app.cookie_key, value);
70 71
    let unread = db::notify::unread(&app.db, account.id).await.unwrap_or(0);
71 -
    Some(ViewerAccount { account, session: expected, csrf, unread })
72 +
    let viewer = ViewerAccount { account, session: value.to_string(), session_id: session.id, csrf, unread };
73 +
    Some((viewer, session))
72 74
}
73 75
74 76
impl FromRequestParts<Shared> for Viewer {
…
1 unchanged lines
76 78
77 79
    async fn from_request_parts(parts: &mut Parts, app: &Shared) -> Result<Viewer, Infallible> {
78 80
        let Some(value) = cookie(&parts.headers, SESSION_COOKIE) else { return Ok(Viewer(None)) };
79 -
        let viewer = resolve(app, &value).await;
80 -
        if let Some(v) = &viewer {
81 -
            let pool = app.db.clone();
82 -
            let id = v.account.id;
83 -
            tokio::spawn(async move {
84 -
                let _ = db::accounts::touch_seen(&pool, id).await;
85 -
            });
86 -
        }
87 -
        Ok(Viewer(viewer))
81 +
        let Some((viewer, session)) = resolve(app, &value).await else { return Ok(Viewer(None)) };
82 +
        let address = crate::api::client_addr(app, &parts.headers, &parts.extensions);
83 +
        let agent: String = parts
84 +
            .headers
85 +
            .get(header::USER_AGENT)
86 +
            .and_then(|v| v.to_str().ok())
87 +
            .unwrap_or("")
88 +
            .chars()
89 +
            .filter(|c| !c.is_control())
90 +
            .take(300)
91 +
            .collect();
92 +
        let pool = app.db.clone();
93 +
        let id = viewer.account.id;
94 +
        tokio::spawn(async move {
95 +
            let _ = db::accounts::touch_seen(&pool, id).await;
96 +
            let _ = db::sessions::touch(&pool, &session, &address, &agent, db::now_ms()).await;
97 +
        });
98 +
        Ok(Viewer(Some(viewer)))
88 99
    }
89 100
}
modified crates/hub/src/db/mod.rs +1 −0
…
11 unchanged lines
12 12
pub mod releases;
13 13
pub mod repos;
14 14
pub mod runners;
15 +
pub mod sessions;
15 16
16 17
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
17 18
use sqlx::SqlitePool;
…
52 unchanged lines
modified crates/hub/src/maintenance.rs +5 −0
…
112 unchanged lines
113 113
            continue;
114 114
        }
115 115
        last_day = Some(day);
116 +
        match crate::db::sessions::sweep(&app.db).await {
117 +
            Ok(n) if n > 0 => tracing::info!("sessions: forgot {n} that had ended"),
118 +
            Ok(_) => {}
119 +
            Err(e) => tracing::warn!("sessions: sweep failed: {e}"),
120 +
        }
116 121
        let done = gc_all(&app).await;
117 122
        tracing::info!("gc: nightly run over {} repositories, {} failed", done.len(), done.iter().filter(|d| d.failed).count());
118 123
    }
…
1 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
99 unchanged lines
100 100
        .route("/settings/security/totp/enable", post(settings::totp_enable))
101 101
        .route("/settings/security/totp/disable", post(settings::totp_disable))
102 102
        .route("/settings/security/sessions", post(settings::sessions_reset))
103 +
        .route("/settings/security/sessions/{id}/end", post(settings::session_end))
103 104
        .route("/settings/keys", get(settings::keys))
104 105
        .route("/settings/keys/upload", post(settings::keys_upload))
105 106
        .route("/settings/keys/verify", post(settings::keys_verify))
…
680 unchanged lines
modified crates/hub/src/web/session.rs +14 −7
…
158 unchanged lines
159 159
    }
160 160
    app.throttle.lock().unwrap().cleared(&keys[0]);
161 161
    accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok();
162 -
    Ok(signed_in(&app, &account, &next))
162 +
    Ok(signed_in(&app, &account, &next).await)
163 163
}
164 164
165 165
/// How long the code step of a sign-in may take after the password.
166 166
const PENDING_LOGIN_MS: i64 = 5 * 60 * 1000;
167 167
168 -
/// A redirect that sets the session cookie.
169 -
pub fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response {
170 -
    let value = auth::session_value(&app.cookie_key, &account.username, &account.session_salt);
168 +
/// A redirect that sets the session cookie: a new session each time, so
169 +
/// signing in (after the code, with two-factor) always rotates it
170 +
/// (oxygenhub#223).
171 +
pub async fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response {
172 +
    let value = auth::new_session_id();
173 +
    if let Err(e) = db::sessions::create(&app.db, account.id, &auth::token_hash(&value), &account.session_salt).await {
174 +
        tracing::warn!("session for {}: {e}", account.username);
175 +
        return (axum::http::StatusCode::SERVICE_UNAVAILABLE, "Signing in failed for a moment: try again.").into_response();
176 +
    }
171 177
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
172 178
    let cookie = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000{secure}", auth::SESSION_COOKIE);
173 179
    ([(header::SET_COOKIE, cookie)], Redirect::to(to)).into_response()
…
9 unchanged lines
183 189
    if !viewer.csrf_ok(&f.csrf) {
184 190
        return Redirect::to("/").into_response();
185 191
    }
186 -
    if let Some(a) = viewer.account() {
187 -
        accounts::audit(&app.db, Some(a), "logout", &a.username, "").await.ok();
192 +
    if let Some(v) = &viewer.0 {
193 +
        db::sessions::end_hash(&app.db, &auth::token_hash(&v.session)).await.ok();
194 +
        accounts::audit(&app.db, Some(&v.account), "logout", &v.account.username, "").await.ok();
188 195
    }
189 196
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
190 197
    let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}", auth::SESSION_COOKIE);
…
106 unchanged lines
297 304
        let title = format!("New account: {username} signed up on {}", app.config.site.name);
298 305
        crate::notify::tell(&app, "account.registered", &title, &format!("/{username}"), &username, None, admins).await;
299 306
    }
300 -
    Ok(signed_in(&app, &account, "/"))
307 +
    Ok(signed_in(&app, &account, "/").await)
301 308
}
modified crates/hub/src/web/settings.rs +115 −4
…
7 unchanged lines
8 8
use askama::Template;
9 9
use axum::extract::{Form, Multipart, Path, Query, State};
10 10
use axum::http::{header, HeaderMap, StatusCode};
11 -
use axum::response::{IntoResponse, Response};
11 +
use axum::response::{IntoResponse, Redirect, Response};
12 12
use oxy_core::identity::{Identity, Signature};
13 13
use serde::Deserialize;
14 14
…
273 unchanged lines
288 288
    accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
289 289
    accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok();
290 290
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
291 -
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
291 +
    Ok(super::session::signed_in(&app, &fresh, "/settings/security").await)
292 292
}
293 293
294 294
#[derive(Template)]
…
8 unchanged lines
303 303
    recovery: Vec<String>,
304 304
    /// Providers on this hub and the login connected, if any (#106).
305 305
    connected: Vec<(&'static str, &'static str, Option<String>)>,
306 +
    /// Where the account is signed in (oxygenhub#223).
307 +
    sessions: Vec<SessionView>,
306 308
}
307 309
310 +
/// One signed-in browser, as Settings › Security lists it.
311 +
pub struct SessionView {
312 +
    pub id: i64,
313 +
    pub current: bool,
314 +
    pub browser: String,
315 +
    pub address: String,
316 +
    pub seen_ms: i64,
317 +
    pub created_ms: i64,
318 +
}
319 +
320 +
/// "Firefox on Linux" from a User-Agent; "Unknown browser" when it says nothing useful.
321 +
pub fn browser_of(agent: &str) -> String {
322 +
    let has = |s: &str| agent.contains(s);
323 +
    let browser = if has("Edg/") {
324 +
        "Edge"
325 +
    } else if has("OPR/") {
326 +
        "Opera"
327 +
    } else if has("Firefox/") {
328 +
        "Firefox"
329 +
    } else if has("Chrome/") || has("Chromium/") {
330 +
        "Chrome"
331 +
    } else if has("Safari/") {
332 +
        "Safari"
333 +
    } else if has("curl/") {
334 +
        "curl"
335 +
    } else {
336 +
        ""
337 +
    };
338 +
    let os = if has("Android") {
339 +
        "Android"
340 +
    } else if has("iPhone") || has("iPad") {
341 +
        "iOS"
342 +
    } else if has("Windows") {
343 +
        "Windows"
344 +
    } else if has("Mac OS X") || has("Macintosh") {
345 +
        "macOS"
346 +
    } else if has("CrOS") {
347 +
        "ChromeOS"
348 +
    } else if has("Linux") {
349 +
        "Linux"
350 +
    } else {
351 +
        ""
352 +
    };
353 +
    match (browser, os) {
354 +
        ("", "") => "Unknown browser".into(),
355 +
        (b, "") => b.into(),
356 +
        ("", o) => format!("A browser on {o}"),
357 +
        (b, o) => format!("{b} on {o}"),
358 +
    }
359 +
}
360 +
361 +
async fn sessions_of(app: &Shared, me: &crate::auth::ViewerAccount) -> sqlx::Result<Vec<SessionView>> {
362 +
    Ok(crate::db::sessions::of_account(&app.db, me.account.id, &me.account.session_salt)
363 +
        .await?
364 +
        .into_iter()
365 +
        .map(|s| SessionView {
366 +
            id: s.id,
367 +
            current: s.id == me.session_id,
368 +
            browser: browser_of(&s.agent),
369 +
            address: s.address,
370 +
            seen_ms: s.seen_ms,
371 +
            created_ms: s.created_ms,
372 +
        })
373 +
        .collect())
374 +
}
375 +
308 376
#[derive(Deserialize)]
309 377
pub struct SecurityQuery {
310 378
    notice: Option<String>,
…
4 unchanged lines
315 383
    let shell = Shell::new(&app, "/settings/security", &viewer);
316 384
    let me = require(&viewer, &shell)?;
317 385
    let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
318 -
    Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected }))
386 +
    let sessions = sessions_of(&app, &me).await.map_err(|e| Error::from_db(shell.clone(), e))?;
387 +
    Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected, sessions }))
319 388
}
320 389
321 390
/// Each sign-in provider this hub offers, with the login connected to the account.
…
34 unchanged lines
356 425
        enrol: Some((secret, uri, qr)),
357 426
        recovery: vec![],
358 427
        connected: vec![],
428 +
        sessions: vec![],
359 429
    }))
360 430
}
361 431
…
14 unchanged lines
376 446
            enrol: Some((f.secret, uri, qr)),
377 447
            recovery: vec![],
378 448
            connected: vec![],
449 +
            sessions: vec![],
379 450
        }));
380 451
    }
381 452
    let (codes, stored) = totp::new_recovery_codes();
…
8 unchanged lines
390 461
        enrol: None,
391 462
        recovery: codes,
392 463
        connected: vec![],
464 +
        sessions: vec![],
393 465
    }))
394 466
}
395 467
…
10 unchanged lines
406 478
            enrol: None,
407 479
            recovery: vec![],
408 480
            connected: vec![],
481 +
            sessions: vec![],
409 482
        }));
410 483
    }
411 484
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
7 unchanged lines
419 492
        enrol: None,
420 493
        recovery: vec![],
421 494
        connected: vec![],
495 +
        sessions: vec![],
422 496
    }))
423 497
}
424 498
…
6 unchanged lines
431 505
    accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
432 506
    accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok();
433 507
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
434 -
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
508 +
    Ok(super::session::signed_in(&app, &fresh, "/settings/security").await)
509 +
}
510 +
511 +
/// `POST /settings/security/sessions/{id}/end`: sign one browser out.
512 +
pub async fn session_end(
513 +
    State(app): State<Shared>,
514 +
    viewer: Viewer,
515 +
    Path(id): Path<i64>,
516 +
    Form(f): Form<super::session::CsrfForm>,
517 +
) -> Result<Response, Error> {
518 +
    let shell = Shell::new(&app, "/settings/security", &viewer);
519 +
    let me = require(&viewer, &shell)?;
520 +
    csrf(&viewer, &f.csrf, &shell)?;
521 +
    let ended = crate::db::sessions::end(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
522 +
    if ended {
523 +
        accounts::audit(&app.db, Some(&me.account), "session.ended", &me.account.username, &id.to_string()).await.ok();
524 +
    }
525 +
    let to = if id == me.session_id { "/" } else { "/settings/security?notice=That+browser+is+signed+out." };
526 +
    Ok(Redirect::to(to).into_response())
435 527
}
436 528
437 529
// ------------------------------------------------------------------- keys
…
261 unchanged lines
699 791
    accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
700 792
    accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok();
701 793
    tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await
794 +
}
795 +
796 +
#[cfg(test)]
797 +
mod tests {
798 +
    use super::browser_of;
799 +
800 +
    #[test]
801 +
    fn browsers_are_named_from_their_agent() {
802 +
        let ff = "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0";
803 +
        let chrome_mac =
804 +
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36";
805 +
        let edge = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36 Edg/129.0";
806 +
        let iphone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1";
807 +
        assert_eq!(browser_of(ff), "Firefox on Linux");
808 +
        assert_eq!(browser_of(chrome_mac), "Chrome on macOS");
809 +
        assert_eq!(browser_of(edge), "Edge on Windows");
810 +
        assert_eq!(browser_of(iphone), "Safari on iOS");
811 +
        assert_eq!(browser_of(""), "Unknown browser");
812 +
    }
702 813
}
modified crates/hub/src/web/signin.rs +3 −3
…
144 unchanged lines
145 145
            return Ok(with_clear(to_login("This account cannot sign in.")));
146 146
        }
147 147
        accounts::audit(&app.db, Some(&account), "login", &account.username, p.id).await.ok();
148 -
        return Ok(with_clear(signed_in(&app, &account, "/")));
148 +
        return Ok(with_clear(signed_in(&app, &account, "/").await));
149 149
    }
150 150
    if let Some(email) = &who.email {
151 151
        if let Some(account) = accounts::account_by_verified_email(&app.db, email).await.map_err(e)? {
…
3 unchanged lines
155 155
            signin::link(&app.db, p.id, &who.subject, account.id, &who.login).await.map_err(e)?;
156 156
            accounts::audit(&app.db, Some(&account), "signin.connected", &account.username, &format!("{} by email", p.id)).await.ok();
157 157
            accounts::audit(&app.db, Some(&account), "login", &account.username, p.id).await.ok();
158 -
            return Ok(with_clear(signed_in(&app, &account, "/")));
158 +
            return Ok(with_clear(signed_in(&app, &account, "/").await));
159 159
        }
160 160
    }
161 161
    let mode = super::session::registration_mode(&app).await;
…
5 unchanged lines
167 167
        ))));
168 168
    }
169 169
    let account = register(&app, p, &who).await.map_err(e)?;
170 -
    Ok(with_clear(signed_in(&app, &account, "/")))
170 +
    Ok(with_clear(signed_in(&app, &account, "/").await))
171 171
}
172 172
173 173
/// A new account for someone the provider vouches for: a username from
…
125 unchanged lines
modified crates/hub/tests/accounts.rs +36 −0
…
1359 unchanged lines
1360 1360
    set("", "").await;
1361 1361
    assert_eq!(get(app, "/security.txt", None).await.status, StatusCode::NOT_FOUND);
1362 1362
}
1363 +
1364 +
#[tokio::test]
1365 +
async fn sessions_are_records_that_can_be_listed_ended_and_expire() {
1366 +
    let h = hub().await;
1367 +
    let app = &h.app;
1368 +
    let a = register(app, "andy", "").await;
1369 +
    assert_eq!(a.len(), 64, "a random id, not the account's name");
1370 +
    let b = post(app, "/login", None, "username=andy&password=correct-horse-battery").await.cookie.expect("signed in again");
1371 +
    assert_ne!(a, b, "each sign-in is a new session");
1372 +
1373 +
    let page = get(app, "/settings/security", Some(&a)).await.body;
1374 +
    assert_eq!(page.matches("/end\"").count(), 2, "both browsers listed");
1375 +
    // Each entry is an <li>; the other browser's is the one without the tag.
1376 +
    let entries: Vec<&str> = page.split("<li><strong>").skip(1).filter(|e| e.contains("/end\"")).collect();
1377 +
    assert_eq!(entries.iter().filter(|e| e.contains("<span class=\"tag\">this browser</span>")).count(), 1);
1378 +
    let other = entries.iter().find(|e| !e.contains("class=\"tag\">this browser")).unwrap();
1379 +
    let other: i64 = other.split("/settings/security/sessions/").nth(1).unwrap().split('/').next().unwrap().parse().unwrap();
1380 +
    let csrf = csrf_of(&page);
1381 +
    let r = post(app, &format!("/settings/security/sessions/{other}/end"), Some(&a), &format!("_csrf={csrf}")).await;
1382 +
    assert_eq!(r.status, StatusCode::SEE_OTHER);
1383 +
    let signed_in = |c: String| async move { get(app, "/settings/security", Some(&c)).await.body.contains("SIGN OUT EVERYWHERE") };
1384 +
    assert!(signed_in(a.clone()).await, "this browser stays");
1385 +
    assert!(!signed_in(b.clone()).await, "that browser is signed out");
1386 +
1387 +
    // Unused for 14 days, it ends.
1388 +
    sqlx::query("UPDATE sessions SET seen_ms = seen_ms - 15 * 86400000").execute(&app.db).await.unwrap();
1389 +
    assert!(!signed_in(a.clone()).await, "idle too long");
1390 +
1391 +
    // Signing out ends the record itself, not just the cookie.
1392 +
    let c = post(app, "/login", None, "username=andy&password=correct-horse-battery").await.cookie.unwrap();
1393 +
    let csrf = csrf_of(&get(app, "/settings/security", Some(&c)).await.body);
1394 +
    post(app, "/logout", Some(&c), &format!("_csrf={csrf}")).await;
1395 +
    assert!(!signed_in(c).await, "the old cookie no longer works");
1396 +
    // A cookie of the old form (name.mac) is nobody.
1397 +
    assert!(!signed_in("andy.0123456789abcdef".into()).await);
1398 +
}
modified docs/plan.md +1 −1
…
18 unchanged lines
19 19
## 0.2 Accounts and access (2026-09-27)
20 20
21 21
- [x] Schema: accounts, organisations, members, teams, collaborators, tokens, identities, challenges, invitations, settings, audit; pushes carry the pusher.
22 -
- [x] Passwords (Argon2id), signed session cookie bound to a per-account salt, CSRF tokens on every form, same-origin check on every non-GET request, TOTP with recovery codes, sign out everywhere.
22 +
- [x] Passwords (Argon2id), server-side sessions (random id, hash kept; idle and absolute expiry; listed and ended per browser, #223), CSRF tokens on every form, same-origin check on every non-GET request, TOTP with recovery codes, sign out everywhere.
23 23
- [x] Registration modes (open, invite, closed; first account is the administrator), invitations, admin page (accounts, ban, promote, registration mode, audit log).
24 24
- [x] Roles: owner and site admin → admin; organisation owners → admin, members → the organisation's default role; collaborators (accounts and teams) → their role; visibility public/internal/private decides the anonymous and signed-in floor. A repository the viewer may not read is a 404 everywhere, including the protocol.
25 25
- [x] Personal access tokens: hashed, scoped (`repo:read`, `repo:write`, `repo:admin`, `user:read`), optionally limited to repositories, with expiry; the protocol checks role and scope; site tokens from `hub.toml` remain for bootstrapping.
…
67 unchanged lines
modified docs/spec.md +1 −1
…
170 unchanged lines
171 171
172 172
An account has a username (same grammar as an owner name), a display name, an email (verified before it is used for anything), an Argon2id password hash, an optional TOTP secret, a session salt (rotating it signs out every session), and a set of linked Oxygen identities (§5.3). The first account created becomes the site administrator. Registration is open, invitation only, or closed, per configuration.
173 173
174 -
Sessions are a signed cookie `oxyhub=<username>.<mac>` where `mac = HMAC-SHA256(cookie_key, "session:" || username || ":" || session_salt)`, `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Password changes and "sign out everywhere" rotate the salt. Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement.
174 +
Sessions are server-side records (oxygenhub#223): the cookie `oxyhub=<id>` holds 32 random bytes in hex, and the hub keeps only the id's BLAKE3 hash, with the account, the account's session salt at sign-in, the times it was made and last used, and the address and browser it came from. A session is good while its salt is still the account's, it has been used in the last 14 days, and it is under 30 days old; the cookie is `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Every sign-in (after the code, with two-factor) makes a new session; signing out deletes its record. Password changes, resets and "sign out everywhere" rotate the salt, which ends every session at once; Settings › Security lists the signed-in browsers and signs any one out. Ended sessions are swept nightly. CSRF tokens are bound to the session id. Sign-in is also slowed against guessing (§15.6, oxygenhub#221). Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement.
175 175
176 176
#### 5.1.2 Sign in with GitHub or Google (done 2026-10-04)
177 177
…
582 unchanged lines
modified templates/settings_security.html +6 −1
…
38 unchanged lines
39 39
  <p class="small">Sign in with these instead of a password.</p>
40 40
  <ul class="plain">{% for (id, name, login) in connected %}<li>{{ name }}: {% if let Some(l) = login %}<strong>{{ l }}</strong> <form class="tiny inline" method="post" action="/settings/security/disconnect/{{ id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">disconnect</button></form>{% else %}<a href="/settings/security/connect/{{ id }}">connect</a>{% endif %}</li>{% endfor %}</ul>
41 41
</section>{% endif %}
42 +
{% if !sessions.is_empty() %}<section class="panel" id="sessions">
43 +
  <h2>Signed in</h2>
44 +
  <ul class="plain sessions">{% for x in sessions %}<li><strong>{{ x.browser }}</strong>{% if x.current %} <span class="tag">this browser</span>{% endif %}<span class="small mute">{% if !x.address.is_empty() %} · {{ x.address }}{% endif %} · used {{ x.seen_ms|ago_i }} · signed in {{ x.created_ms|ago_i }}</span>
45 +
    <form class="tiny inline" method="post" action="/settings/security/sessions/{{ x.id }}/end"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">sign out</button></form></li>{% endfor %}</ul>
46 +
</section>{% endif %}
42 47
<form class="card" method="post" action="/settings/security/sessions">
43 48
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
44 49
  <h2>Sessions</h2>
45 -
  <p class="small">Sign out every browser except this one.</p>
50 +
  <p class="small">Sign out every browser except this one. A session ends by itself after 14 days unused, or 30 days after signing in.</p>
46 51
  <button type="submit">SIGN OUT EVERYWHERE</button>
47 52
</form>
48 53
{% endblock %}