OXYGENHUB

Sign in with GitHub or Google 83940d53664f

An administrator sets each provider up under Administration (client
id, a sealed secret, on or off; the callback URL shown); the login page
offers the usable ones. OAuth 2.0 authorization code with a MACed state
cookie; a known subject signs in, else the account with that verified
email is linked, else an account is made when registration is open.
Connect or disconnect under Settings › Security. closes #106
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 17:30 UTC
Signature
✔ signed by Andy McCall
Hash
83940d53664f90b19c6f5baa2c14c3c53ab01c1728d8936684dee85928050c6a
Depends on
19bb31ff1f5c 2a83f5303458 34beb1a7e376 3556d08e17f9 4ac01a732552 66fd9d6b1452 749d9a5fcd9f 89e89836cae7 93be9ce4653e a160b98fb922 b5668108487f c40c2d3edb51 d23464c0ac60 df71c7892d27 e71846aa07b9
Depended on by
a22e4aec2e28 737de0fac722 8597fb5ca16c c92a99490cba c8684d44d549 3b1bc27fe805 d1bf2d698777 5d90e0e045e2 3ff52534f69b 0024ccb1b050 3dd6582de060 810896d41be7 bff9106edcba 155cab142b63 e8e7158bbc73 efd29a0033e8
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 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
41 atoms, 3 tree operations, 15 files

Browse the tree at this change

added crates/hub/migrations/0041_account_logins.sql +11 −0
1 +
-- Sign-in with an external account (hub spec §5.1.2, #106): which
2 +
-- provider subject is which account, and the login name there.
3 +
CREATE TABLE account_logins (
4 +
    provider    TEXT NOT NULL,
5 +
    subject     TEXT NOT NULL,
6 +
    account_id  INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
7 +
    login       TEXT NOT NULL DEFAULT '',
8 +
    linked_ms   INTEGER NOT NULL,
9 +
    PRIMARY KEY (provider, subject)
10 +
);
11 +
CREATE INDEX account_logins_by_account ON account_logins (account_id);
added crates/hub/src/signin.rs +316 −0
1 +
//! Sign in with an external account (hub spec §5.1.2, oxygenhub#106):
2 +
//! GitHub and Google through OAuth 2.0's authorization code flow, set up
3 +
//! by an administrator per provider (client id, a sealed secret, on or
4 +
//! off). A first sign-in links the provider's subject to the account with
5 +
//! the same verified email, or makes an account when registration is
6 +
//! open; later sign-ins find the link.
7 +
8 +
use crate::db::{accounts, now_ms, Pool};
9 +
use crate::Shared;
10 +
use sqlx::FromRow;
11 +
12 +
/// A provider the hub knows how to talk to.
13 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14 +
pub struct Provider {
15 +
    pub id: &'static str,
16 +
    pub name: &'static str,
17 +
    authorize: &'static str,
18 +
    token: &'static str,
19 +
    userinfo: &'static str,
20 +
    pub scope: &'static str,
21 +
}
22 +
23 +
pub const PROVIDERS: [Provider; 2] = [
24 +
    Provider {
25 +
        id: "github",
26 +
        name: "GitHub",
27 +
        authorize: "https://github.com/login/oauth/authorize",
28 +
        token: "https://github.com/login/oauth/access_token",
29 +
        userinfo: "https://api.github.com/user",
30 +
        scope: "read:user user:email",
31 +
    },
32 +
    Provider {
33 +
        id: "google",
34 +
        name: "Google",
35 +
        authorize: "https://accounts.google.com/o/oauth2/v2/auth",
36 +
        token: "https://oauth2.googleapis.com/token",
37 +
        userinfo: "https://openidconnect.googleapis.com/v1/userinfo",
38 +
        scope: "openid email profile",
39 +
    },
40 +
];
41 +
42 +
pub fn provider(id: &str) -> Option<&'static Provider> {
43 +
    PROVIDERS.iter().find(|p| p.id == id)
44 +
}
45 +
46 +
impl Provider {
47 +
    /// The provider's endpoints, or a test double's when `[auth]
48 +
    /// signin_mock` names one: `{mock}/{id}/authorize|token|userinfo`.
49 +
    fn url(&self, app: &Shared, which: &str) -> String {
50 +
        match &app.config.auth.signin_mock {
51 +
            Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id),
52 +
            None => match which {
53 +
                "authorize" => self.authorize.to_string(),
54 +
                "token" => self.token.to_string(),
55 +
                _ => self.userinfo.to_string(),
56 +
            },
57 +
        }
58 +
    }
59 +
60 +
    /// Where the provider sends the person back.
61 +
    pub fn callback_url(&self, app: &Shared) -> String {
62 +
        format!("{}/login/{}/callback", app.config.base_url(), self.id)
63 +
    }
64 +
}
65 +
66 +
/// What an administrator set for a provider.
67 +
#[derive(Clone, Debug, Default)]
68 +
pub struct Settings {
69 +
    pub client_id: String,
70 +
    /// The client secret, opened; empty when none is set.
71 +
    pub secret: String,
72 +
    pub enabled: bool,
73 +
}
74 +
75 +
impl Settings {
76 +
    pub fn usable(&self) -> bool {
77 +
        self.enabled && !self.client_id.is_empty() && !self.secret.is_empty()
78 +
    }
79 +
}
80 +
81 +
fn key(id: &str, what: &str) -> String {
82 +
    format!("signin.{id}.{what}")
83 +
}
84 +
85 +
pub async fn settings(app: &Shared, id: &str) -> sqlx::Result<Settings> {
86 +
    let client_id = accounts::setting(&app.db, &key(id, "client_id")).await?.unwrap_or_default();
87 +
    let sealed = accounts::setting(&app.db, &key(id, "secret")).await?.unwrap_or_default();
88 +
    let secret = hex::decode(&sealed).ok().and_then(|b| app.secret_key.open(&b)).unwrap_or_default();
89 +
    let enabled = accounts::setting(&app.db, &key(id, "enabled")).await?.as_deref() == Some("1");
90 +
    Ok(Settings { client_id, secret, enabled })
91 +
}
92 +
93 +
/// Save a provider's settings; `secret` is `None` to keep the stored one.
94 +
pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool) -> sqlx::Result<()> {
95 +
    accounts::set_setting(&app.db, &key(id, "client_id"), client_id.trim()).await?;
96 +
    if let Some(s) = secret {
97 +
        accounts::set_setting(&app.db, &key(id, "secret"), &hex::encode(app.secret_key.seal(s.trim()))).await?;
98 +
    }
99 +
    accounts::set_setting(&app.db, &key(id, "enabled"), if enabled { "1" } else { "0" }).await?;
100 +
    Ok(())
101 +
}
102 +
103 +
/// The providers people may sign in with: usable ones, in order.
104 +
pub async fn enabled(app: &Shared) -> Vec<&'static Provider> {
105 +
    let mut out = vec![];
106 +
    for p in &PROVIDERS {
107 +
        if settings(app, p.id).await.map(|s| s.usable()).unwrap_or(false) {
108 +
            out.push(p);
109 +
        }
110 +
    }
111 +
    out
112 +
}
113 +
114 +
// ------------------------------------------------------------- the flow
115 +
116 +
/// The state cookie's name: a nonce, what the sign-in is for, and a MAC.
117 +
pub const STATE_COOKIE: &str = "oxyhub_signin";
118 +
119 +
/// A state value: `NONCE.MODE.MAC`, where MODE is `in` (sign in) or
120 +
/// `link:ACCOUNT_ID` (connect to a signed-in account).
121 +
pub fn state_value(app: &Shared, mode: &str) -> String {
122 +
    let nonce = crate::auth::random_hex(16);
123 +
    let mac = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes());
124 +
    format!("{nonce}.{mode}.{mac}")
125 +
}
126 +
127 +
/// The mode a state value carries, when its MAC holds.
128 +
pub fn state_mode(app: &Shared, value: &str) -> Option<String> {
129 +
    let (nonce, rest) = value.split_once('.')?;
130 +
    let (mode, mac) = rest.rsplit_once('.')?;
131 +
    let want = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes());
132 +
    crate::auth::mac_equal(&want, mac).then(|| mode.to_string())
133 +
}
134 +
135 +
/// Where to send the person to sign in with `p`.
136 +
pub fn authorize_url(app: &Shared, p: &Provider, client_id: &str, state: &str) -> String {
137 +
    let q = |s: &str| crate::web::settings::urlencode(s);
138 +
    format!(
139 +
        "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code{}",
140 +
        p.url(app, "authorize"),
141 +
        q(client_id),
142 +
        q(&p.callback_url(app)),
143 +
        q(p.scope),
144 +
        q(state),
145 +
        if p.id == "google" { "&access_type=online&prompt=select_account" } else { "" }
146 +
    )
147 +
}
148 +
149 +
/// Who the provider says the person is.
150 +
#[derive(Clone, Debug)]
151 +
pub struct External {
152 +
    pub provider: &'static str,
153 +
    pub subject: String,
154 +
    pub login: String,
155 +
    pub name: String,
156 +
    /// A verified email, when the provider vouches for one.
157 +
    pub email: Option<String>,
158 +
}
159 +
160 +
/// Swap the code for a token and ask who it belongs to.
161 +
pub async fn identify(app: &Shared, p: &'static Provider, s: &Settings, code: &str) -> Result<External, String> {
162 +
    let client = reqwest::Client::builder()
163 +
        .timeout(std::time::Duration::from_secs(20))
164 +
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
165 +
        .build()
166 +
        .map_err(|e| e.to_string())?;
167 +
    let token: serde_json::Value = client
168 +
        .post(p.url(app, "token"))
169 +
        .header("accept", "application/json")
170 +
        .form(&[
171 +
            ("client_id", s.client_id.as_str()),
172 +
            ("client_secret", s.secret.as_str()),
173 +
            ("code", code),
174 +
            ("redirect_uri", &p.callback_url(app)),
175 +
            ("grant_type", "authorization_code"),
176 +
        ])
177 +
        .send()
178 +
        .await
179 +
        .map_err(|e| format!("{} did not answer: {e}", p.name))?
180 +
        .json()
181 +
        .await
182 +
        .map_err(|_| format!("{} answered with something other than a token", p.name))?;
183 +
    let Some(access) = token["access_token"].as_str() else {
184 +
        let why = token["error_description"].as_str().or(token["error"].as_str()).unwrap_or("no token");
185 +
        return Err(format!("{} refused the sign-in: {why}", p.name));
186 +
    };
187 +
    let get = |url: String| {
188 +
        let client = client.clone();
189 +
        let access = access.to_string();
190 +
        async move {
191 +
            client
192 +
                .get(url)
193 +
                .bearer_auth(access)
194 +
                .header("accept", "application/json")
195 +
                .send()
196 +
                .await
197 +
                .map_err(|e| format!("{} did not answer: {e}", p.name))?
198 +
                .json::<serde_json::Value>()
199 +
                .await
200 +
                .map_err(|_| format!("{} answered with something unexpected", p.name))
201 +
        }
202 +
    };
203 +
    let user = get(p.url(app, "userinfo")).await?;
204 +
    Ok(match p.id {
205 +
        "github" => {
206 +
            let subject =
207 +
                user["id"].as_i64().map(|i| i.to_string()).or_else(|| user["id"].as_str().map(String::from)).ok_or("GitHub gave no id")?;
208 +
            // The primary verified address is on its own endpoint.
209 +
            let emails = get(format!("{}/emails", p.url(app, "userinfo"))).await.unwrap_or_default();
210 +
            let email = emails
211 +
                .as_array()
212 +
                .and_then(|list| {
213 +
                    list.iter()
214 +
                        .filter(|e| e["verified"] == true)
215 +
                        .max_by_key(|e| e["primary"] == true)
216 +
                        .and_then(|e| e["email"].as_str().map(String::from))
217 +
                })
218 +
                .or_else(|| user["email"].as_str().map(String::from).filter(|_| false));
219 +
            External {
220 +
                provider: p.id,
221 +
                subject,
222 +
                login: user["login"].as_str().unwrap_or("").to_string(),
223 +
                name: user["name"].as_str().unwrap_or("").to_string(),
224 +
                email,
225 +
            }
226 +
        }
227 +
        _ => {
228 +
            let subject = user["sub"].as_str().ok_or("Google gave no subject")?.to_string();
229 +
            let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from);
230 +
            let login = email.as_deref().and_then(|e| e.split('@').next()).unwrap_or("").to_string();
231 +
            External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email }
232 +
        }
233 +
    })
234 +
}
235 +
236 +
// --------------------------------------------------------------- links
237 +
238 +
#[derive(Clone, Debug, FromRow)]
239 +
pub struct Login {
240 +
    pub provider: String,
241 +
    pub subject: String,
242 +
    pub account_id: i64,
243 +
    pub login: String,
244 +
    pub linked_ms: i64,
245 +
}
246 +
247 +
pub async fn linked_account(pool: &Pool, provider: &str, subject: &str) -> sqlx::Result<Option<accounts::Account>> {
248 +
    let id: Option<i64> = sqlx::query_scalar("SELECT account_id FROM account_logins WHERE provider = ? AND subject = ?")
249 +
        .bind(provider)
250 +
        .bind(subject)
251 +
        .fetch_optional(pool)
252 +
        .await?;
253 +
    match id {
254 +
        Some(id) => accounts::account_by_id(pool, id).await,
255 +
        None => Ok(None),
256 +
    }
257 +
}
258 +
259 +
pub async fn link(pool: &Pool, provider: &str, subject: &str, account_id: i64, login: &str) -> sqlx::Result<()> {
260 +
    sqlx::query("INSERT OR REPLACE INTO account_logins (provider, subject, account_id, login, linked_ms) VALUES (?, ?, ?, ?, ?)")
261 +
        .bind(provider)
262 +
        .bind(subject)
263 +
        .bind(account_id)
264 +
        .bind(login)
265 +
        .bind(now_ms())
266 +
        .execute(pool)
267 +
        .await?;
268 +
    Ok(())
269 +
}
270 +
271 +
pub async fn unlink(pool: &Pool, provider: &str, account_id: i64) -> sqlx::Result<bool> {
272 +
    let r = sqlx::query("DELETE FROM account_logins WHERE provider = ? AND account_id = ?")
273 +
        .bind(provider)
274 +
        .bind(account_id)
275 +
        .execute(pool)
276 +
        .await?;
277 +
    Ok(r.rows_affected() > 0)
278 +
}
279 +
280 +
pub async fn links_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Login>> {
281 +
    sqlx::query_as("SELECT * FROM account_logins WHERE account_id = ? ORDER BY provider").bind(account_id).fetch_all(pool).await
282 +
}
283 +
284 +
/// A username for a new account from what the provider calls them:
285 +
/// valid, and free.
286 +
pub async fn free_username(pool: &Pool, wanted: &str, provider: &str) -> sqlx::Result<String> {
287 +
    let base: String = wanted.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')).take(40).collect();
288 +
    let base = base.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_string();
289 +
    let base = if base.is_empty() { provider.to_string() } else { base };
290 +
    let mut candidates = vec![base.clone(), format!("{base}-{provider}")];
291 +
    candidates.extend((2..100).map(|n| format!("{base}-{n}")));
292 +
    for c in candidates {
293 +
        if !crate::repos::names::valid_owner(&c) {
294 +
            continue;
295 +
        }
296 +
        let taken = accounts::account_by_name(pool, &c).await?.is_some()
297 +
            || accounts::org_by_name(pool, &c).await?.is_some()
298 +
            || accounts::username_retired(pool, &c).await?;
299 +
        if !taken {
300 +
            return Ok(c);
301 +
        }
302 +
    }
303 +
    Ok(format!("{provider}-{}", crate::auth::random_hex(4)))
304 +
}
305 +
306 +
#[cfg(test)]
307 +
mod tests {
308 +
    use super::*;
309 +
310 +
    #[test]
311 +
    fn providers_are_known() {
312 +
        assert_eq!(provider("github").map(|p| p.name), Some("GitHub"));
313 +
        assert_eq!(provider("google").map(|p| p.name), Some("Google"));
314 +
        assert!(provider("gitlab").is_none());
315 +
    }
316 +
}
added crates/hub/src/web/signin.rs +283 −0
1 +
//! Sign in with GitHub or Google on the web (hub spec §5.1.2, #106):
2 +
//! the start of the flow, the callback, the administrator's provider
3 +
//! settings, and connecting or disconnecting a provider from Settings ›
4 +
//! Security.
5 +
6 +
use super::session::signed_in;
7 +
use super::settings::{csrf, require};
8 +
use super::{Error, Shell};
9 +
use crate::auth::{self, Viewer};
10 +
use crate::db::accounts;
11 +
use crate::signin::{self, External, Provider, STATE_COOKIE};
12 +
use crate::Shared;
13 +
use axum::extract::{Path, Query, State};
14 +
use axum::http::{header, HeaderMap};
15 +
use axum::response::{IntoResponse, Redirect, Response};
16 +
use axum::Form;
17 +
use serde::Deserialize;
18 +
19 +
fn state_cookie(app: &Shared, value: &str, max_age: u32) -> String {
20 +
    let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" };
21 +
    format!("{STATE_COOKIE}={value}; Path=/login; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}")
22 +
}
23 +
24 +
fn cookie_value(headers: &HeaderMap) -> Option<String> {
25 +
    headers
26 +
        .get_all(header::COOKIE)
27 +
        .iter()
28 +
        .filter_map(|v| v.to_str().ok())
29 +
        .flat_map(|v| v.split(';'))
30 +
        .filter_map(|c| c.trim().strip_prefix(&format!("{STATE_COOKIE}=")))
31 +
        .map(String::from)
32 +
        .next()
33 +
}
34 +
35 +
/// Begin: a state cookie, then off to the provider.
36 +
async fn begin(app: &Shared, p: &'static Provider, mode: &str) -> Result<Response, String> {
37 +
    let s = signin::settings(app, p.id).await.map_err(|e| e.to_string())?;
38 +
    if !s.usable() {
39 +
        return Err(format!("Signing in with {} is not set up on this hub.", p.name));
40 +
    }
41 +
    let state = signin::state_value(app, mode);
42 +
    let to = signin::authorize_url(app, p, &s.client_id, &state);
43 +
    Ok(([(header::SET_COOKIE, state_cookie(app, &state, 600))], Redirect::to(&to)).into_response())
44 +
}
45 +
46 +
/// `GET /login/{provider}`: sign in with the provider.
47 +
pub async fn start(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Result<Response, Error> {
48 +
    let shell = Shell::new(&app, "/login", &viewer);
49 +
    let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?;
50 +
    if viewer.signed_in() {
51 +
        return Ok(Redirect::to("/").into_response());
52 +
    }
53 +
    begin(&app, p, "in").await.map_err(|m| Error::bad(shell.clone(), m))
54 +
}
55 +
56 +
/// `GET /settings/security/connect/{provider}`: link the provider to the
57 +
/// signed-in account.
58 +
pub async fn connect(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Result<Response, Error> {
59 +
    let shell = Shell::new(&app, "/settings/security", &viewer);
60 +
    let me = require(&viewer, &shell)?;
61 +
    let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?;
62 +
    begin(&app, p, &format!("link:{}", me.account.id)).await.map_err(|m| Error::bad(shell.clone(), m))
63 +
}
64 +
65 +
#[derive(Deserialize)]
66 +
pub struct Callback {
67 +
    #[serde(default)]
68 +
    code: String,
69 +
    #[serde(default)]
70 +
    state: String,
71 +
    #[serde(default)]
72 +
    error: String,
73 +
    #[serde(default)]
74 +
    error_description: String,
75 +
}
76 +
77 +
fn to_login(msg: &str) -> Response {
78 +
    Redirect::to(&format!("/login?error={}", super::settings::urlencode(msg))).into_response()
79 +
}
80 +
81 +
fn to_security(msg: &str, ok: bool) -> Response {
82 +
    let k = if ok { "notice" } else { "error" };
83 +
    Redirect::to(&format!("/settings/security?{k}={}", super::settings::urlencode(msg))).into_response()
84 +
}
85 +
86 +
/// `GET /login/{provider}/callback`: the provider sent the person back.
87 +
pub async fn callback(
88 +
    State(app): State<Shared>,
89 +
    viewer: Viewer,
90 +
    Path(id): Path<String>,
91 +
    headers: HeaderMap,
92 +
    Query(q): Query<Callback>,
93 +
) -> Result<Response, Error> {
94 +
    let shell = Shell::new(&app, "/login", &viewer);
95 +
    let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?;
96 +
    // The state cookie goes, whatever else the answer sets (appended, so a
97 +
    // session cookie beside it is kept).
98 +
    let clear = state_cookie(&app, "", 0);
99 +
    let with_clear = |mut r: Response| {
100 +
        if let Ok(v) = header::HeaderValue::from_str(&clear) {
101 +
            r.headers_mut().append(header::SET_COOKIE, v);
102 +
        }
103 +
        r
104 +
    };
105 +
    if !q.error.is_empty() {
106 +
        let why = if q.error_description.is_empty() { q.error.clone() } else { q.error_description.clone() };
107 +
        return Ok(with_clear(to_login(&format!("{} did not sign you in: {why}", p.name))));
108 +
    }
109 +
    let Some(cookie) = cookie_value(&headers) else {
110 +
        return Ok(with_clear(to_login("The sign-in took too long or the browser kept no cookie; try again.")));
111 +
    };
112 +
    if cookie != q.state || q.code.is_empty() {
113 +
        return Ok(with_clear(to_login("The sign-in did not match the one started here; try again.")));
114 +
    }
115 +
    let Some(mode) = signin::state_mode(&app, &cookie) else {
116 +
        return Ok(with_clear(to_login("The sign-in did not match the one started here; try again.")));
117 +
    };
118 +
    let s = signin::settings(&app, p.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
119 +
    if !s.usable() {
120 +
        return Ok(with_clear(to_login(&format!("Signing in with {} is not set up on this hub.", p.name))));
121 +
    }
122 +
    let who = match signin::identify(&app, p, &s, &q.code).await {
123 +
        Ok(w) => w,
124 +
        Err(msg) => return Ok(with_clear(to_login(&msg))),
125 +
    };
126 +
    let e = |e| Error::from_db(shell.clone(), e);
127 +
    // Connecting to a signed-in account.
128 +
    if let Some(account_id) = mode.strip_prefix("link:").and_then(|i| i.parse::<i64>().ok()) {
129 +
        let Some(me) = viewer.account().filter(|a| a.id == account_id) else {
130 +
            return Ok(with_clear(to_security("Sign in first, then connect the account.", false)));
131 +
        };
132 +
        if let Some(other) = signin::linked_account(&app.db, p.id, &who.subject).await.map_err(e)? {
133 +
            if other.id != me.id {
134 +
                return Ok(with_clear(to_security(&format!("That {} account is connected to {} already.", p.name, other.username), false)));
135 +
            }
136 +
        }
137 +
        signin::link(&app.db, p.id, &who.subject, me.id, &who.login).await.map_err(e)?;
138 +
        accounts::audit(&app.db, Some(me), "signin.connected", &me.username, p.id).await.ok();
139 +
        return Ok(with_clear(to_security(&format!("{} connected as {}.", p.name, who.login), true)));
140 +
    }
141 +
    // Signing in: a known link, else the account with that verified email,
142 +
    // else a new account when registration is open.
143 +
    if let Some(account) = signin::linked_account(&app.db, p.id, &who.subject).await.map_err(e)? {
144 +
        if account.blocked() {
145 +
            return Ok(with_clear(to_login("This account cannot sign in.")));
146 +
        }
147 +
        accounts::audit(&app.db, Some(&account), "login", &account.username, p.id).await.ok();
148 +
        return Ok(with_clear(signed_in(&app, &account, "/")));
149 +
    }
150 +
    if let Some(email) = &who.email {
151 +
        if let Some(account) = accounts::account_by_verified_email(&app.db, email).await.map_err(e)? {
152 +
            if account.blocked() {
153 +
                return Ok(with_clear(to_login("This account cannot sign in.")));
154 +
            }
155 +
            signin::link(&app.db, p.id, &who.subject, account.id, &who.login).await.map_err(e)?;
156 +
            accounts::audit(&app.db, Some(&account), "signin.connected", &account.username, &format!("{} by email", p.id)).await.ok();
157 +
            accounts::audit(&app.db, Some(&account), "login", &account.username, p.id).await.ok();
158 +
            return Ok(with_clear(signed_in(&app, &account, "/")));
159 +
        }
160 +
    }
161 +
    let mode = super::session::registration_mode(&app).await;
162 +
    if mode != "open" {
163 +
        return Ok(with_clear(to_login(&format!(
164 +
            "No account here is connected to that {} account, and registration is {}. Register or sign in first, then connect it under Settings › Security.",
165 +
            p.name,
166 +
            if mode == "invite" { "by invitation" } else { "closed" }
167 +
        ))));
168 +
    }
169 +
    let account = register(&app, p, &who).await.map_err(e)?;
170 +
    Ok(with_clear(signed_in(&app, &account, "/")))
171 +
}
172 +
173 +
/// A new account for someone the provider vouches for: a username from
174 +
/// their login there, an unusable password, the email verified when the
175 +
/// provider says so.
176 +
async fn register(app: &Shared, p: &Provider, who: &External) -> sqlx::Result<accounts::Account> {
177 +
    let username = signin::free_username(&app.db, &who.login, p.id).await?;
178 +
    let hash = auth::hash_password(&auth::random_hex(24)).unwrap_or_default();
179 +
    let first = accounts::account_count(&app.db).await? == 0;
180 +
    let account =
181 +
        accounts::create_account(&app.db, &username, who.email.as_deref().unwrap_or(""), &hash, &auth::random_hex(16), first).await?;
182 +
    if who.email.is_some() {
183 +
        accounts::set_email_verified(&app.db, account.id, true).await?;
184 +
    }
185 +
    if !who.name.is_empty() {
186 +
        accounts::set_profile(&app.db, account.id, &who.name, who.email.as_deref().unwrap_or("")).await?;
187 +
        if who.email.is_some() {
188 +
            accounts::set_email_verified(&app.db, account.id, true).await?;
189 +
        }
190 +
    }
191 +
    signin::link(&app.db, p.id, &who.subject, account.id, &who.login).await?;
192 +
    accounts::audit(&app.db, Some(&account), "register", &username, &format!("with {}", p.id)).await.ok();
193 +
    accounts::account_by_id(&app.db, account.id).await.map(|a| a.unwrap_or(account))
194 +
}
195 +
196 +
#[derive(Deserialize)]
197 +
pub struct DisconnectForm {
198 +
    #[serde(rename = "_csrf")]
199 +
    csrf_token: String,
200 +
}
201 +
202 +
/// `POST /settings/security/disconnect/{provider}`.
203 +
pub async fn disconnect(
204 +
    State(app): State<Shared>,
205 +
    viewer: Viewer,
206 +
    Path(id): Path<String>,
207 +
    Form(f): Form<DisconnectForm>,
208 +
) -> Result<Response, Error> {
209 +
    let shell = Shell::new(&app, "/settings/security", &viewer);
210 +
    let me = require(&viewer, &shell)?;
211 +
    csrf(&viewer, &f.csrf_token, &shell)?;
212 +
    let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?;
213 +
    if signin::unlink(&app.db, p.id, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? {
214 +
        accounts::audit(&app.db, Some(&me.account), "signin.disconnected", &me.account.username, p.id).await.ok();
215 +
    }
216 +
    Ok(to_security(&format!("{} disconnected.", p.name), true))
217 +
}
218 +
219 +
/// A provider as the admin page shows it.
220 +
pub struct ProviderView {
221 +
    pub id: &'static str,
222 +
    pub name: &'static str,
223 +
    pub client_id: String,
224 +
    pub has_secret: bool,
225 +
    pub enabled: bool,
226 +
    pub callback: String,
227 +
}
228 +
229 +
pub async fn provider_views(app: &Shared) -> sqlx::Result<Vec<ProviderView>> {
230 +
    let mut out = vec![];
231 +
    for p in &signin::PROVIDERS {
232 +
        let s = signin::settings(app, p.id).await?;
233 +
        out.push(ProviderView {
234 +
            id: p.id,
235 +
            name: p.name,
236 +
            client_id: s.client_id,
237 +
            has_secret: !s.secret.is_empty(),
238 +
            enabled: s.enabled,
239 +
            callback: p.callback_url(app),
240 +
        });
241 +
    }
242 +
    Ok(out)
243 +
}
244 +
245 +
#[derive(Deserialize)]
246 +
pub struct ProviderForm {
247 +
    #[serde(rename = "_csrf")]
248 +
    pub csrf_token: String,
249 +
    pub provider: String,
250 +
    #[serde(default)]
251 +
    pub client_id: String,
252 +
    #[serde(default)]
253 +
    pub secret: String,
254 +
    #[serde(default)]
255 +
    pub enabled: Option<String>,
256 +
}
257 +
258 +
/// Apply the admin form: `Ok(notice)` or `Err(message)`.
259 +
pub async fn apply(app: &Shared, viewer: &Viewer, f: ProviderForm) -> sqlx::Result<Result<String, String>> {
260 +
    let Some(p) = signin::provider(&f.provider) else { return Ok(Err("Unknown provider.".into())) };
261 +
    let client_id = f.client_id.trim();
262 +
    if client_id.len() > 200 || client_id.contains(char::is_whitespace) && !client_id.is_empty() {
263 +
        return Ok(Err("A client id is one token.".into()));
264 +
    }
265 +
    let secret = f.secret.trim();
266 +
    let secret = if secret.is_empty() { None } else { Some(secret) };
267 +
    let enabled = f.enabled.is_some();
268 +
    signin::save(app, p.id, client_id, secret, enabled).await?;
269 +
    let s = signin::settings(app, p.id).await?;
270 +
    accounts::audit(&app.db, viewer.account(), "admin.signin", p.id, if s.usable() { "on" } else { "off" }).await.ok();
271 +
    Ok(Ok(if s.usable() {
272 +
        format!("Signing in with {} is on.", p.name)
273 +
    } else if enabled {
274 +
        format!("{} is saved but off until it has a client id and secret.", p.name)
275 +
    } else {
276 +
        format!("{} is off.", p.name)
277 +
    }))
278 +
}
279 +
280 +
/// A page's view of what people may sign in with.
281 +
pub async fn buttons(app: &Shared) -> Vec<(&'static str, &'static str)> {
282 +
    signin::enabled(app).await.into_iter().map(|p| (p.id, p.name)).collect()
283 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Sign in with GitHub or Google (#106).** Set up per provider under Administration (client id, sealed secret, on or off); the login page offers them. A first sign-in links the account with the same verified email, or makes an account when registration is open; connect or disconnect under Settings › Security.
8 +
7 9
- **Wikis (#104).** Each repository can have a wiki: Markdown pages kept as an Oxygen repository of their own, read and edited on the web (with history, a sidebar, a page list and search) or cloned and pushed as `OWNER/NAME.wiki`. Web edits are signed changes, as code edits are. Settings › Advanced deletes the wiki data.
8 10
9 11
- **Advanced settings: transfer, archive, delete (#108).** A repository's Delete section becomes Advanced: transfer the repository to a user or organisation (the old address redirects and `oxy` remotes keep working), archive it read-only with a reason and bring it back, delete it, and a placeholder for deleting wiki data ahead of wikis.
…
107 unchanged lines
modified crates/hub/src/config.rs +3 −0
…
137 unchanged lines
138 138
    /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty
139 139
    /// means every push is accepted, as `oxy serve` does.
140 140
    pub push_tokens: Vec<String>,
141 +
    /// For tests only: a base URL that stands in for every sign-in
142 +
    /// provider (`{base}/{provider}/authorize|token|userinfo`).
143 +
    pub signin_mock: Option<String>,
141 144
}
142 145
143 146
#[derive(Clone, Debug, Deserialize)]
…
227 unchanged lines
modified crates/hub/src/lib.rs +1 −0
…
23 unchanged lines
24 24
pub mod render;
25 25
pub mod repos;
26 26
pub mod secrets;
27 +
pub mod signin;
27 28
pub mod social;
28 29
pub mod variables;
29 30
pub mod web;
…
131 unchanged lines
modified crates/hub/src/web/admin.rs +17 −0
…
33 unchanged lines
34 34
    variables: super::variables::Panel,
35 35
    /// The site's Flow secrets (#107).
36 36
    secrets: super::secrets::Panel,
37 +
    /// Sign-in providers (#106).
38 +
    providers: Vec<super::signin::ProviderView>,
37 39
}
38 40
39 41
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
…
19 unchanged lines
59 61
    let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?;
60 62
    let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?;
61 63
    let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?;
64 +
    let providers = super::signin::provider_views(app).await.map_err(e)?;
62 65
    Ok(Page(AdminPage {
66 +
        providers,
63 67
        assistants,
64 68
        variables,
65 69
        secrets,
…
350 unchanged lines
416 420
    let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
417 421
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
418 422
}
423 +
424 +
/// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106).
425 +
pub async fn signin(
426 +
    State(app): State<Shared>,
427 +
    viewer: Viewer,
428 +
    Form(f): Form<super::signin::ProviderForm>,
429 +
) -> Result<Page<AdminPage>, Error> {
430 +
    let shell = Shell::new(&app, "/admin", &viewer);
431 +
    require_admin(&viewer, &shell)?;
432 +
    csrf(&viewer, &f.csrf_token, &shell)?;
433 +
    let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
434 +
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
435 +
}
modified crates/hub/src/web/mod.rs +6 −0
…
30 unchanged lines
31 31
mod secrets;
32 32
mod session;
33 33
pub(crate) mod settings;
34 +
mod signin;
34 35
mod statics;
35 36
mod variables;
36 37
mod webhooks;
…
79 unchanged lines
116 117
        .route("/admin/mail", post(admin::set_mail))
117 118
        .route("/admin/mail/test", post(admin::test_mail))
118 119
        .route("/admin/assistants", post(admin::assistants))
120 +
        .route("/admin/signin", post(admin::signin))
121 +
        .route("/login/{provider}", get(signin::start))
122 +
        .route("/login/{provider}/callback", get(signin::callback))
123 +
        .route("/settings/security/connect/{provider}", get(signin::connect))
124 +
        .route("/settings/security/disconnect/{provider}", post(signin::disconnect))
119 125
        .route("/admin/variables", post(admin::variables))
120 126
        .route("/admin/secrets", post(admin::secrets))
121 127
        .route("/admin/runners/{id}/remove", post(runners::remove))
…
571 unchanged lines
modified crates/hub/src/web/session.rs +16 −3
…
19 unchanged lines
20 20
    totp_for: Option<String>,
21 21
    /// Arrived from a password reset.
22 22
    reset: bool,
23 +
    /// Providers to sign in with (#106): (id, name).
24 +
    providers: Vec<(&'static str, &'static str)>,
23 25
}
24 26
25 27
#[derive(Deserialize)]
26 28
pub struct NextQuery {
27 29
    next: Option<String>,
28 30
    reset: Option<String>,
31 +
    #[serde(default)]
32 +
    error: Option<String>,
29 33
}
30 34
31 35
fn safe_next(next: Option<String>) -> String {
…
8 unchanged lines
40 44
        return Redirect::to(&safe_next(q.next)).into_response();
41 45
    }
42 46
    let reset = q.reset.is_some();
43 -
    Page(LoginPage { shell: Shell::new(&app, "/login", &viewer), error: None, next: safe_next(q.next), totp_for: None, reset })
44 -
        .into_response()
47 +
    let providers = super::signin::buttons(&app).await;
48 +
    Page(LoginPage {
49 +
        shell: Shell::new(&app, "/login", &viewer),
50 +
        error: q.error,
51 +
        next: safe_next(q.next),
52 +
        totp_for: None,
53 +
        reset,
54 +
        providers,
55 +
    })
56 +
    .into_response()
45 57
}
46 58
47 59
#[derive(Deserialize)]
…
10 unchanged lines
58 70
    let shell = Shell::new(&app, "/login", &viewer);
59 71
    let next = safe_next(Some(f.next.clone()));
60 72
    let fail = |msg: &str, totp_for: Option<String>| {
61 -
        Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, reset: false }).into_response()
73 +
        Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, reset: false, providers: vec![] })
74 +
            .into_response()
62 75
    };
63 76
    let username = f.username.trim().to_string();
64 77
    let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
143 unchanged lines
modified crates/hub/src/web/settings.rs +35 −4
…
5 unchanged lines
6 6
use crate::db::{self, accounts};
7 7
use crate::Shared;
8 8
use askama::Template;
9 -
use axum::extract::{Form, Multipart, Path, State};
9 +
use axum::extract::{Form, Multipart, Path, Query, State};
10 10
use axum::http::{header, HeaderMap, StatusCode};
11 11
use axum::response::{IntoResponse, Response};
12 12
use oxy_core::identity::{Identity, Signature};
…
257 unchanged lines
270 270
    /// Enrolment in progress: secret, URI and QR.
271 271
    enrol: Option<(String, String, String)>,
272 272
    recovery: Vec<String>,
273 +
    /// Providers on this hub and the login connected, if any (#106).
274 +
    connected: Vec<(&'static str, &'static str, Option<String>)>,
273 275
}
274 276
275 -
pub async fn security(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SecurityPage>, Error> {
277 +
#[derive(Deserialize)]
278 +
pub struct SecurityQuery {
279 +
    notice: Option<String>,
280 +
    error: Option<String>,
281 +
}
282 +
283 +
pub async fn security(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SecurityQuery>) -> Result<Page<SecurityPage>, Error> {
276 284
    let shell = Shell::new(&app, "/settings/security", &viewer);
277 285
    let me = require(&viewer, &shell)?;
278 -
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: None, recovery: vec![] }))
286 +
    let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
287 +
    Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected }))
279 288
}
280 289
290 +
/// Each sign-in provider this hub offers, with the login connected to the account.
291 +
pub async fn connected_of(app: &Shared, account_id: i64) -> sqlx::Result<Vec<(&'static str, &'static str, Option<String>)>> {
292 +
    let links = crate::signin::links_of(&app.db, account_id).await?;
293 +
    let mut out = vec![];
294 +
    for p in crate::signin::enabled(app).await {
295 +
        out.push((p.id, p.name, links.iter().find(|l| l.provider == p.id).map(|l| l.login.clone())));
296 +
    }
297 +
    Ok(out)
298 +
}
299 +
281 300
#[derive(Deserialize)]
282 301
pub struct TotpForm {
283 302
    #[serde(rename = "_csrf")]
…
14 unchanged lines
298 317
    let secret = totp::new_secret();
299 318
    let uri = totp::uri(&app.config.site.name, &me.account.username, &secret);
300 319
    let qr = totp::qr_svg(&uri).unwrap_or_default();
301 -
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: Some((secret, uri, qr)), recovery: vec![] }))
320 +
    Ok(Page(SecurityPage {
321 +
        shell,
322 +
        me: me.account,
323 +
        notice: None,
324 +
        error: None,
325 +
        enrol: Some((secret, uri, qr)),
326 +
        recovery: vec![],
327 +
        connected: vec![],
328 +
    }))
302 329
}
303 330
304 331
/// Step two: the first code proves the app holds the secret.
…
12 unchanged lines
317 344
            error: Some("That code did not match; scan again and enter the current code.".into()),
318 345
            enrol: Some((f.secret, uri, qr)),
319 346
            recovery: vec![],
347 +
            connected: vec![],
320 348
        }));
321 349
    }
322 350
    let (codes, stored) = totp::new_recovery_codes();
…
7 unchanged lines
330 358
        error: None,
331 359
        enrol: None,
332 360
        recovery: codes,
361 +
        connected: vec![],
333 362
    }))
334 363
}
335 364
…
9 unchanged lines
345 374
            error: Some("Your password is wrong.".into()),
346 375
            enrol: None,
347 376
            recovery: vec![],
377 +
            connected: vec![],
348 378
        }));
349 379
    }
350 380
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
…
6 unchanged lines
357 387
        error: None,
358 388
        enrol: None,
359 389
        recovery: vec![],
390 +
        connected: vec![],
360 391
    }))
361 392
}
362 393
…
245 unchanged lines
modified crates/hub/tests/accounts.rs +174 −11
…
49 unchanged lines
50 50
struct Res {
51 51
    status: StatusCode,
52 52
    body: String,
53 +
    /// The session cookie's value, when the response set one.
53 54
    cookie: Option<String>,
54 55
    location: Option<String>,
56 +
    /// Every Set-Cookie header, whole.
57 +
    raw_cookies: Vec<String>,
55 58
}
56 59
57 60
async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res {
61 +
    let header_value = cookie.map(|c| format!("oxyhub={c}"));
62 +
    send_with(app, method, path, header_value.as_deref(), form).await
63 +
}
64 +
65 +
/// As `send`, with the whole Cookie header given.
66 +
async fn send_with(app: &Shared, method: &str, path: &str, cookie_header: Option<&str>, form: Option<&str>) -> Res {
58 67
    let mut req = Request::builder().method(method).uri(path);
59 -
    if let Some(c) = cookie {
60 -
        req = req.header(header::COOKIE, format!("oxyhub={c}"));
68 +
    if let Some(c) = cookie_header {
69 +
        req = req.header(header::COOKIE, c.to_string());
61 70
    }
62 71
    let body = match form {
63 72
        Some(f) => {
…
4 unchanged lines
68 77
    };
69 78
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
70 79
    let status = res.status();
71 -
    let cookie = res
72 -
        .headers()
73 -
        .get(header::SET_COOKIE)
74 -
        .and_then(|v| v.to_str().ok())
75 -
        .and_then(|v| v.split(';').next())
76 -
        .and_then(|kv| kv.split_once('='))
77 -
        .map(|(_, v)| v.to_string());
80 +
    let raw_cookies: Vec<String> =
81 +
        res.headers().get_all(header::SET_COOKIE).iter().filter_map(|v| v.to_str().ok()).map(String::from).collect();
82 +
    let cookie =
83 +
        raw_cookies.iter().filter_map(|c| c.split(';').next()).filter_map(|kv| kv.strip_prefix("oxyhub=")).map(String::from).next();
78 84
    let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from);
79 85
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
80 -
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location }
86 +
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location, raw_cookies }
81 87
}
82 88
83 89
async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res {
…
235 unchanged lines
319 325
    let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
320 326
    let status = res.status();
321 327
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
322 -
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie: None, location: None }
328 +
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie: None, location: None, raw_cookies: vec![] }
323 329
}
324 330
325 331
/// Status, content type and bytes of a picture.
…
492 unchanged lines
818 824
    // Cleared.
819 825
    post(app, "/settings", Some(&andy), &format!("{base}&bluesky=&mastodon=&github=")).await;
820 826
    assert!(!get(app, "/andy", None).await.body.contains("class=\"social\""));
827 +
}
828 +
829 +
/// A stand-in GitHub and Google: token exchanges and who the token is.
830 +
async fn mock_provider() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
831 +
    use std::sync::{Arc, Mutex};
832 +
    let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
833 +
    let s1 = seen.clone();
834 +
    let s2 = seen.clone();
835 +
    let router = axum::Router::new()
836 +
        .route(
837 +
            "/github/token",
838 +
            axum::routing::post(move |body: String| {
839 +
                let s1 = s1.clone();
840 +
                async move {
841 +
                    s1.lock().unwrap().push(format!("github token {body}"));
842 +
                    axum::Json(serde_json::json!({ "access_token": "gh-token", "token_type": "bearer" }))
843 +
                }
844 +
            }),
845 +
        )
846 +
        .route(
847 +
            "/github/userinfo",
848 +
            axum::routing::get(|headers: axum::http::HeaderMap| async move {
849 +
                assert_eq!(headers.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer gh-token"));
850 +
                axum::Json(serde_json::json!({ "id": 583231, "login": "octocat", "name": "The Octocat", "email": null }))
851 +
            }),
852 +
        )
853 +
        .route(
854 +
            "/github/userinfo/emails",
855 +
            axum::routing::get(|| async {
856 +
                axum::Json(serde_json::json!([
857 +
                    { "email": "[email protected]", "primary": false, "verified": false },
858 +
                    { "email": "[email protected]", "primary": true, "verified": true }
859 +
                ]))
860 +
            }),
861 +
        )
862 +
        .route(
863 +
            "/google/token",
864 +
            axum::routing::post(move |body: String| {
865 +
                let s2 = s2.clone();
866 +
                async move {
867 +
                    s2.lock().unwrap().push(format!("google token {body}"));
868 +
                    axum::Json(serde_json::json!({ "access_token": "g-token", "id_token": "x" }))
869 +
                }
870 +
            }),
871 +
        )
872 +
        .route(
873 +
            "/google/userinfo",
874 +
            axum::routing::get(|| async {
875 +
                axum::Json(serde_json::json!({ "sub": "1076915035000615071", "email": "[email protected]", "email_verified": true, "name": "Andy McCall" }))
876 +
            }),
877 +
        );
878 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
879 +
    let port = listener.local_addr().unwrap().port();
880 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
881 +
    (format!("http://127.0.0.1:{port}"), seen)
882 +
}
883 +
884 +
fn state_of(res: &Res) -> (String, String) {
885 +
    let cookie = res.raw_cookies.iter().find(|c| c.starts_with("oxyhub_signin=")).expect("state cookie").clone();
886 +
    let value = cookie.trim_start_matches("oxyhub_signin=").split(';').next().unwrap().to_string();
887 +
    (cookie, value)
888 +
}
889 +
890 +
#[tokio::test(flavor = "multi_thread")]
891 +
async fn people_sign_in_with_github_or_google_once_an_admin_sets_it_up() {
892 +
    hermetic();
893 +
    let (mock, seen) = mock_provider().await;
894 +
    let dir = tempfile::tempdir().unwrap();
895 +
    let mut config = Config::load(dir.path()).unwrap();
896 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
897 +
    let port = listener.local_addr().unwrap().port();
898 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
899 +
    config.registration.mode = "open".into();
900 +
    config.auth.signin_mock = Some(mock.clone());
901 +
    let app = oxygenhub::build(config).await.unwrap();
902 +
    let router = oxygenhub::router(app.clone());
903 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
904 +
    let app = &app;
905 +
    let andy = register(app, "andy", "").await;
906 +
    let andy_row = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap();
907 +
    db::accounts::set_email_verified(&app.db, andy_row.id, true).await.unwrap();
908 +
909 +
    // Nothing to sign in with until an administrator sets a provider up.
910 +
    assert!(!get(app, "/login", None).await.body.contains("/login/github"));
911 +
    let admin = get(app, "/admin", Some(&andy)).await.body;
912 +
    assert!(admin.contains("Sign in with") && admin.contains(&format!("http://127.0.0.1:{port}/login/github/callback")), "{admin}");
913 +
    let csrf = csrf_of(&admin);
914 +
    let r =
915 +
        post(app, "/admin/signin", Some(&andy), &format!("_csrf={csrf}&provider=github&client_id=gh-cid&secret=gh-sek&enabled=1")).await;
916 +
    assert!(r.body.contains("Signing in with GitHub is on.") && !r.body.contains("gh-sek"), "{}", r.body);
917 +
    let r = post(app, "/admin/signin", Some(&andy), &format!("_csrf={csrf}&provider=google&client_id=g-cid&secret=g-sek&enabled=1")).await;
918 +
    assert!(r.body.contains("Signing in with Google is on."), "{}", r.body);
919 +
    let login = get(app, "/login", None).await.body;
920 +
    assert!(login.contains("href=\"/login/github\"") && login.contains("href=\"/login/google\""), "{login}");
921 +
922 +
    // GitHub: off to the provider with a state, back with a code: a new account.
923 +
    let r = get(app, "/login/github", None).await;
924 +
    assert_eq!(r.status, StatusCode::SEE_OTHER);
925 +
    let to = r.location.clone().unwrap();
926 +
    assert!(to.starts_with(&format!("{mock}/github/authorize?client_id=gh-cid&redirect_uri=")) && to.contains("&state="), "{to}");
927 +
    let (cookie, state) = state_of(&r);
928 +
    let bad = send_with(app, "GET", "/login/github/callback?code=c1&state=wrong", Some(&cookie), None).await;
929 +
    assert!(bad.location.as_deref().unwrap_or("").starts_with("/login?error="), "{:?}", bad.location);
930 +
    let ok = send_with(app, "GET", &format!("/login/github/callback?code=c1&state={state}"), Some(&cookie), None).await;
931 +
    assert_eq!(ok.status, StatusCode::SEE_OTHER, "{}", ok.body);
932 +
    assert_eq!(ok.location.as_deref(), Some("/"));
933 +
    let octo_cookie = ok.cookie.clone().expect("signed in");
934 +
    let octo = db::accounts::account_by_name(&app.db, "octocat").await.unwrap().expect("account made from the GitHub login");
935 +
    assert!(
936 +
        (octo.email.as_str(), octo.email_verified, octo.display_name.as_str()) == ("[email protected]", true, "The Octocat"),
937 +
        "{octo:?}"
938 +
    );
939 +
    assert!(seen.lock().unwrap().iter().any(|s| s.contains("github token") && s.contains("code=c1") && s.contains("client_secret=gh-sek")));
940 +
    assert!(get(app, "/settings", Some(&octo_cookie)).await.body.contains("octocat"));
941 +
    // Again: the same account, no new one.
942 +
    let r = get(app, "/login/github", None).await;
943 +
    let (cookie, state) = state_of(&r);
944 +
    let ok = send_with(app, "GET", &format!("/login/github/callback?code=c2&state={state}"), Some(&cookie), None).await;
945 +
    assert_eq!(ok.location.as_deref(), Some("/"));
946 +
    assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2);
947 +
948 +
    // Google, for an address Andy has verified: linked to Andy, signed in as Andy.
949 +
    let r = get(app, "/login/google", None).await;
950 +
    assert!(r.location.clone().unwrap().contains("prompt=select_account"));
951 +
    let (cookie, state) = state_of(&r);
952 +
    let ok = send_with(app, "GET", &format!("/login/google/callback?code=c3&state={state}"), Some(&cookie), None).await;
953 +
    assert_eq!(ok.location.as_deref(), Some("/"), "{}", ok.body);
954 +
    let as_andy = ok.cookie.clone().unwrap();
955 +
    let sec = get(app, "/settings/security", Some(&as_andy)).await.body;
956 +
    assert!(
957 +
        sec.contains("Connected accounts")
958 +
            && sec.contains("Google: <strong>andy</strong>")
959 +
            && sec.contains("GitHub: <a href=\"/settings/security/connect/github\">connect</a>"),
960 +
        "{sec}"
961 +
    );
962 +
    assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2, "no third account");
963 +
    // Disconnect, then connect again from Settings › Security.
964 +
    let csrf_s = csrf_of(&sec);
965 +
    let r = post(app, "/settings/security/disconnect/google", Some(&as_andy), &format!("_csrf={csrf_s}")).await;
966 +
    assert!(r.location.as_deref().unwrap_or("").contains("notice=Google%20disconnected"), "{:?}", r.location);
967 +
    assert!(!get(app, "/settings/security", Some(&as_andy)).await.body.contains("Google: <strong>andy</strong>"));
968 +
    let r = get(app, "/settings/security/connect/google", Some(&as_andy)).await;
969 +
    assert_eq!(r.status, StatusCode::SEE_OTHER);
970 +
    let (cookie, state) = state_of(&r);
971 +
    let both = format!("{cookie}; oxyhub={as_andy}");
972 +
    let ok = send_with(app, "GET", &format!("/login/google/callback?code=c4&state={state}"), Some(&both), None).await;
973 +
    assert!(ok.location.as_deref().unwrap_or("").contains("notice=Google%20connected%20as%20andy"), "{:?} {}", ok.location, ok.body);
974 +
975 +
    // With registration closed, an unknown GitHub account is turned away.
976 +
    db::accounts::set_setting(&app.db, "registration_mode", "closed").await.unwrap();
977 +
    sqlx::query("DELETE FROM account_logins WHERE provider = 'github'").execute(&app.db).await.unwrap();
978 +
    sqlx::query("UPDATE accounts SET email_verified = 0 WHERE username = 'octocat'").execute(&app.db).await.unwrap();
979 +
    let r = get(app, "/login/github", None).await;
980 +
    let (cookie, state) = state_of(&r);
981 +
    let no = send_with(app, "GET", &format!("/login/github/callback?code=c5&state={state}"), Some(&cookie), None).await;
982 +
    assert!(no.location.as_deref().unwrap_or("").contains("registration%20is%20closed"), "{:?}", no.location);
983 +
    assert!(no.cookie.is_none());
821 984
}
modified docs/spec.md +4 −0
…
161 unchanged lines
162 162
163 163
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.
164 164
165 +
#### 5.1.2 Sign in with GitHub or Google (done 2026-10-04)
166 +
167 +
An administrator sets up each provider under Administration › Sign in with: the OAuth client id, the client secret (sealed under `keys/secrets.key`, never shown again) and a switch; the page shows the callback URL to register with the provider (`/login/{provider}/callback`). The login page then offers the usable providers (oxygenhub#106). The flow is OAuth 2.0's authorization code: `/login/{provider}` sets a state cookie (a nonce and the purpose, MACed with the cookie key, ten minutes, path `/login`) and sends the person to the provider; the callback checks the state against the cookie, swaps the code for a token server to server, and asks who it is (GitHub: `/user` and the primary verified address from `/user/emails`; Google: the OpenID `userinfo`, the address only when `email_verified`). Then: a subject already linked signs in; else an account with that verified email is linked and signed in; else, when registration is open, an account is made from the provider's login name (made valid and free), with an unusable password and the email verified when the provider vouched for it; otherwise the person is told to register or sign in and connect. A signed-in person connects or disconnects a provider under Settings › Security; a subject can be linked to one account. Links live in `account_logins`. Audited as `admin.signin`, `signin.connected`, `signin.disconnected`, `login` and `register` with the provider. For tests, `[auth] signin_mock` names a base URL that stands in for every provider.
168 +
165 169
#### 5.1.1 Email verification and password reset
166 170
167 171
Both use **account links**: 32 random bytes as hex in a URL, stored only as their BLAKE3 hash, bound to one account and one purpose, expiring, and consumed on first use.
…
545 unchanged lines
modified templates/admin.html +13 −0
…
44 unchanged lines
45 45
  <ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul>
46 46
  <h3>Organisations</h3>
47 47
  <ul class="plain">{% for o in orgs %}<li><a href="/orgs/{{ o.name }}/settings">{{ o.name }}</a></li>{% endfor %}</ul>
48 +
  <h3>Sign in with</h3>
49 +
  {% for p in providers %}
50 +
  <form class="card" method="post" action="/admin/signin">
51 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="provider" value="{{ p.id }}">
52 +
    <h4>{{ p.name }} {% if p.enabled && p.has_secret && !p.client_id.is_empty() %}<span class="tag head">on</span>{% else %}<span class="tag dim">off</span>{% endif %}</h4>
53 +
    <label for="cid-{{ p.id }}">Client id</label><input type="text" id="cid-{{ p.id }}" name="client_id" value="{{ p.client_id }}" autocomplete="off">
54 +
    <label for="sec-{{ p.id }}">Client secret</label><input type="password" id="sec-{{ p.id }}" name="secret" placeholder="{% if p.has_secret %}unchanged{% else %}none{% endif %}" autocomplete="new-password">
55 +
    <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if p.enabled %} checked{% endif %}> on</label></div>
56 +
    <p class="small mute">Callback URL to register with {{ p.name }}: <code>{{ p.callback }}</code></p>
57 +
    <button type="submit">SAVE</button>
58 +
  </form>
59 +
  {% endfor %}
60 +
  <p class="small mute">A first sign-in links the account with the same verified email, or makes an account when registration is open. Secrets are kept sealed.</p>
48 61
  <h3>Mail</h3>
49 62
  <form class="card" method="post" action="/admin/mail">
50 63
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
14 unchanged lines
modified templates/login.html +1 −0
…
17 unchanged lines
18 18
  {% endif %}
19 19
  <button type="submit">SIGN IN</button>
20 20
</form>
21 +
{% if !providers.is_empty() %}<div class="card signin-with"><p class="small mute">Or sign in with</p>{% for (id, name) in providers %}<a class="go" href="/login/{{ id }}">{{ name }}</a> {% endfor %}</div>{% endif %}
21 22
<p class="small mute"><a href="/login/reset">Forgot your password?</a> · No account? <a href="/register">Register</a>.</p>
22 23
{% endblock %}
modified templates/settings_security.html +5 −0
…
33 unchanged lines
34 34
  <button type="submit">SET UP</button>
35 35
</form>
36 36
{% endif %}
37 +
{% if !connected.is_empty() %}<section class="panel" id="connected">
38 +
  <h2>Connected accounts</h2>
39 +
  <p class="small">Sign in with these instead of a password.</p>
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 +
</section>{% endif %}
37 42
<form class="card" method="post" action="/settings/security/sessions">
38 43
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
39 44
  <h2>Sessions</h2>
…
4 unchanged lines