OXYGENHUB

Coverage / crates/hub/src/signin.rs 93.0%

240 of 258 lines run, 51 of 112 functions · run #543 on main · run missed

1
//! Sign in with an external account (hub spec §5.1.2, oxygenhub#106):
2
//! GitLab (gitlab.com or a self-managed GitLab), GitHub and Google
3
//! through OAuth 2.0's authorization code flow, set up
4
//! by an administrator per provider (client id, a sealed secret, on or
5
//! off). A first sign-in links the provider's subject to the account with
6
//! the same verified email, or makes an account when registration is
7
//! open; later sign-ins find the link.
8
9
use crate::db::{accounts, now_ms, Pool};
10
use crate::Shared;
11
use sqlx::FromRow;
12
13
/// A provider the hub knows how to talk to.
14
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15
pub struct Provider {
16
    pub id: &'static str,
17
    pub name: &'static str,
18
    authorize: &'static str,
19
    token: &'static str,
20
    userinfo: &'static str,
21
    pub scope: &'static str,
22
    /// For a provider that can be self-hosted: the address used when the
23
    /// administrator gives none. Its endpoints are then paths under the
24
    /// address (oxygenhub#213).
25
    pub default_base: Option<&'static str>,
26
}
27
28
pub const PROVIDERS: [Provider; 3] = [
29
    // GitLab's OpenID Connect endpoints, the same on gitlab.com and on a
30
    // self-managed GitLab.
31
    Provider {
32
        id: "gitlab",
33
        name: "GitLab",
34
        authorize: "/oauth/authorize",
35
        token: "/oauth/token",
36
        userinfo: "/oauth/userinfo",
37
        scope: "openid profile email",
38
        default_base: Some("https://gitlab.com"),
39
    },
40
    Provider {
41
        id: "github",
42
        name: "GitHub",
43
        authorize: "https://github.com/login/oauth/authorize",
44
        token: "https://github.com/login/oauth/access_token",
45
        userinfo: "https://api.github.com/user",
46
        scope: "read:user user:email",
47
        default_base: None,
48
    },
49
    Provider {
50
        id: "google",
51
        name: "Google",
52
        authorize: "https://accounts.google.com/o/oauth2/v2/auth",
53
        token: "https://oauth2.googleapis.com/token",
54
        userinfo: "https://openidconnect.googleapis.com/v1/userinfo",
55
        scope: "openid email profile",
56
        default_base: None,
57
    },
58
];
59
6030×
pub fn provider(id: &str) -> Option<&'static Provider> {
6163×
    PROVIDERS.iter().find(|p| p.id == id)
6230×
}
63
64
impl Provider {
65
    /// The provider's endpoints, or a test double's when `[auth]
66
    /// signin_mock` names one: `{mock}/{id}/authorize|token|userinfo`.
6729×
    fn url(&self, app: &Shared, s: &Settings, which: &str) -> String {
6829×
        let path = match which {
6929×
            "authorize" => self.authorize,
7021×
            "token" => self.token,
7113×
            _ => self.userinfo,
72
        };
7329×
        match &app.config.auth.signin_mock {
7429×
            Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id),
750×
            None => match self.default_base {
760×
                Some(default) => format!("{}{path}", s.base_or(default)),
770×
                None => path.to_string(),
78
            },
79
        }
8029×
    }
81
82
    /// Where the provider sends the person back.
83304×
    pub fn callback_url(&self, app: &Shared) -> String {
84304×
        format!("{}/login/{}/callback", app.config.base_url(), self.id)
85304×
    }
86
}
87
88
/// What an administrator set for a provider.
89
#[derive(Clone, Debug, Default)]
90
pub struct Settings {
91
    pub client_id: String,
92
    /// The client secret, opened; empty when none is set.
93
    pub secret: String,
94
    pub enabled: bool,
95
    /// A self-hostable provider's address (GitLab), empty for its default.
96
    pub base: String,
97
}
98
99
impl Settings {
10087×
    pub fn usable(&self) -> bool {
10187×
        self.enabled && !self.client_id.is_empty() && !self.secret.is_empty()
10287×
    }
103
104
    /// The address to use: the one set, else `default`.
1052×
    pub fn base_or<'a>(&'a self, default: &'a str) -> &'a str {
1062×
        let b = self.base.trim().trim_end_matches('/');
1072×
        if b.is_empty() {
1081×
            default
109
        } else {
1101×
            b
111
        }
1122×
    }
113
}
114
115
/// Whether `base` may be a self-hosted provider's address: https, a host,
116
/// nothing after it but an optional path prefix, no query.
1178×
pub fn valid_base(base: &str) -> bool {
1188×
    let Some(rest) = base.strip_prefix("https://") else { return false };
1196×
    let host = rest.split('/').next().unwrap_or("");
1206×
    !host.is_empty() && !rest.contains(['?', '#', ' ']) && host.contains('.')
1218×
}
122
1231500×
fn key(id: &str, what: &str) -> String {
1241500×
    format!("signin.{id}.{what}")
1251500×
}
126
127371×
pub async fn settings(app: &Shared, id: &str) -> sqlx::Result<Settings> {
128371×
    let client_id = accounts::setting(&app.db, &key(id, "client_id")).await?.unwrap_or_default();
129371×
    let sealed = accounts::setting(&app.db, &key(id, "secret")).await?.unwrap_or_default();
130371×
    let secret = hex::decode(&sealed).ok().and_then(|b| app.secret_key.open(&b)).unwrap_or_default();
131371×
    let enabled = accounts::setting(&app.db, &key(id, "enabled")).await?.as_deref() == Some("1");
132371×
    let base = accounts::setting(&app.db, &key(id, "base")).await?.unwrap_or_default();
133371×
    Ok(Settings { client_id, secret, enabled, base })
134371×
}
135
136
/// Save a provider's settings; `secret` is `None` to keep the stored one.
1374×
pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool, base: &str) -> sqlx::Result<()> {
1384×
    accounts::set_setting(&app.db, &key(id, "base"), base.trim().trim_end_matches('/')).await?;
1394×
    accounts::set_setting(&app.db, &key(id, "client_id"), client_id.trim()).await?;
1404×
    if let Some(s) = secret {
1414×
        accounts::set_setting(&app.db, &key(id, "secret"), &hex::encode(app.secret_key.seal(s.trim()))).await?;
1420×
    }
1434×
    accounts::set_setting(&app.db, &key(id, "enabled"), if enabled { "1" } else { "0" }).await?;
1444×
    Ok(())
1454×
}
146
147
/// The providers people may sign in with: usable ones, in order.
14821×
pub async fn enabled(app: &Shared) -> Vec<&'static Provider> {
14921×
    let mut out = vec![];
15063×
    for p in &PROVIDERS {
15163×
        if settings(app, p.id).await.map(|s| s.usable()).unwrap_or(false) {
15212×
            out.push(p);
15351×
        }
154
    }
15521×
    out
15621×
}
157
158
// ------------------------------------------------------------- the flow
159
160
/// The state cookie's name: a nonce, what the sign-in is for, and a MAC.
161
pub const STATE_COOKIE: &str = "oxyhub_signin";
162
163
/// A state value: `NONCE.MODE.MAC`, where MODE is `in` (sign in) or
164
/// `link:ACCOUNT_ID` (connect to a signed-in account).
1658×
pub fn state_value(app: &Shared, mode: &str) -> String {
1668×
    let nonce = crate::auth::random_hex(16);
1678×
    let mac = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes());
1688×
    format!("{nonce}.{mode}.{mac}")
1698×
}
170
171
/// The mode a state value carries, when its MAC holds.
1728×
pub fn state_mode(app: &Shared, value: &str) -> Option<String> {
1738×
    let (nonce, rest) = value.split_once('.')?;
1748×
    let (mode, mac) = rest.rsplit_once('.')?;
1758×
    let want = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes());
1768×
    crate::auth::mac_equal(&want, mac).then(|| mode.to_string())
1778×
}
178
179
/// Where to send the person to sign in with `p`.
1808×
pub fn authorize_url(app: &Shared, p: &Provider, s: &Settings, state: &str) -> String {
18132×
    let q = |s: &str| crate::web::settings::urlencode(s);
1828×
    format!(
183
        "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code{}",
1848×
        p.url(app, s, "authorize"),
1858×
        q(&s.client_id),
1868×
        q(&p.callback_url(app)),
1878×
        q(p.scope),
1888×
        q(state),
1898×
        if p.id == "google" { "&access_type=online&prompt=select_account" } else { "" }
190
    )
1918×
}
192
193
/// Who the provider says the person is.
194
#[derive(Clone, Debug)]
195
pub struct External {
196
    pub provider: &'static str,
197
    pub subject: String,
198
    pub login: String,
199
    pub name: String,
200
    /// A verified email, when the provider vouches for one.
201
    pub email: Option<String>,
202
}
203
204
/// Swap the code for a token and ask who it belongs to.
2058×
pub async fn identify(app: &Shared, p: &'static Provider, s: &Settings, code: &str) -> Result<External, String> {
2068×
    let client = reqwest::Client::builder()
2078×
        .timeout(std::time::Duration::from_secs(20))
2088×
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
2098×
        .build()
2108×
        .map_err(|e| e.to_string())?;
2118×
    let token: serde_json::Value = client
2128×
        .post(p.url(app, s, "token"))
2138×
        .header("accept", "application/json")
2148×
        .form(&[
2158×
            ("client_id", s.client_id.as_str()),
2168×
            ("client_secret", s.secret.as_str()),
2178×
            ("code", code),
2188×
            ("redirect_uri", &p.callback_url(app)),
2198×
            ("grant_type", "authorization_code"),
2208×
        ])
2218×
        .send()
2228×
        .await
2238×
        .map_err(|e| format!("{} did not answer: {e}", p.name))?
2248×
        .json()
2258×
        .await
2268×
        .map_err(|_| format!("{} answered with something other than a token", p.name))?;
2278×
    let Some(access) = token["access_token"].as_str() else {
2280×
        let why = token["error_description"].as_str().or(token["error"].as_str()).unwrap_or("no token");
2290×
        return Err(format!("{} refused the sign-in: {why}", p.name));
230
    };
23113×
    let get = |url: String| {
23213×
        let client = client.clone();
23313×
        let access = access.to_string();
23413×
        async move {
23513×
            client
23613×
                .get(url)
23713×
                .bearer_auth(access)
23813×
                .header("accept", "application/json")
23913×
                .send()
24013×
                .await
24113×
                .map_err(|e| format!("{} did not answer: {e}", p.name))?
24213×
                .json::<serde_json::Value>()
24313×
                .await
24413×
                .map_err(|_| format!("{} answered with something unexpected", p.name))
24513×
        }
24613×
    };
2478×
    let user = get(p.url(app, s, "userinfo")).await?;
2488×
    Ok(match p.id {
2498×
        "github" => {
2505×
            let subject =
2515×
                user["id"].as_i64().map(|i| i.to_string()).or_else(|| user["id"].as_str().map(String::from)).ok_or("GitHub gave no id")?;
252
            // The primary verified address is on its own endpoint.
2535×
            let emails = get(format!("{}/emails", p.url(app, s, "userinfo"))).await.unwrap_or_default();
2545×
            let email = emails
2555×
                .as_array()
2565×
                .and_then(|list| {
2575×
                    list.iter()
25810×
                        .filter(|e| e["verified"] == true)
2595×
                        .max_by_key(|e| e["primary"] == true)
2605×
                        .and_then(|e| e["email"].as_str().map(String::from))
2615×
                })
2625×
                .or_else(|| user["email"].as_str().map(String::from).filter(|_| false));
2635×
            External {
2645×
                provider: p.id,
2655×
                subject,
2665×
                login: user["login"].as_str().unwrap_or("").to_string(),
2675×
                name: user["name"].as_str().unwrap_or("").to_string(),
2685×
                email,
2695×
            }
270
        }
271
        // GitLab's OpenID Connect userinfo: the subject is its user id, the
272
        // username is `nickname`.
2733×
        "gitlab" => {
2741×
            let subject = user["sub"]
2751×
                .as_str()
2761×
                .map(String::from)
2771×
                .or_else(|| user["sub"].as_i64().map(|i| i.to_string()))
2781×
                .ok_or("GitLab gave no subject")?;
2791×
            let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from);
2801×
            let login = user["nickname"].as_str().or(user["preferred_username"].as_str()).unwrap_or("").to_string();
2811×
            External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email }
282
        }
283
        _ => {
2842×
            let subject = user["sub"].as_str().ok_or("Google gave no subject")?.to_string();
2852×
            let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from);
2862×
            let login = email.as_deref().and_then(|e| e.split('@').next()).unwrap_or("").to_string();
2872×
            External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email }
288
        }
289
    })
2908×
}
291
292
// --------------------------------------------------------------- links
293
294
#[derive(Clone, Debug, FromRow)]
295
pub struct Login {
296
    pub provider: String,
297
    pub subject: String,
298
    pub account_id: i64,
299
    pub login: String,
300
    pub linked_ms: i64,
301
}
302
3038×
pub async fn linked_account(pool: &Pool, provider: &str, subject: &str) -> sqlx::Result<Option<accounts::Account>> {
3048×
    let id: Option<i64> = sqlx::query_scalar("SELECT account_id FROM account_logins WHERE provider = $1 AND subject = $2")
3058×
        .bind(provider)
3068×
        .bind(subject)
3078×
        .fetch_optional(pool)
3088×
        .await?;
3098×
    match id {
3101×
        Some(id) => accounts::account_by_id(pool, id).await,
3117×
        None => Ok(None),
312
    }
3138×
}
314
3155×
pub async fn link(pool: &Pool, provider: &str, subject: &str, account_id: i64, login: &str) -> sqlx::Result<()> {
3165×
    sqlx::query("INSERT INTO account_logins (provider, subject, account_id, login, linked_ms) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (provider, subject) DO UPDATE SET account_id = excluded.account_id, login = excluded.login, linked_ms = excluded.linked_ms")
3175×
        .bind(provider)
3185×
        .bind(subject)
3195×
        .bind(account_id)
3205×
        .bind(login)
3215×
        .bind(now_ms())
3225×
        .execute(pool)
3235×
        .await?;
3245×
    Ok(())
3255×
}
326
3271×
pub async fn unlink(pool: &Pool, provider: &str, account_id: i64) -> sqlx::Result<bool> {
3281×
    let r = sqlx::query("DELETE FROM account_logins WHERE provider = $1 AND account_id = $2")
3291×
        .bind(provider)
3301×
        .bind(account_id)
3311×
        .execute(pool)
3321×
        .await?;
3331×
    Ok(r.rows_affected() > 0)
3341×
}
335
33610×
pub async fn links_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Login>> {
33710×
    sqlx::query_as("SELECT * FROM account_logins WHERE account_id = $1 ORDER BY provider").bind(account_id).fetch_all(pool).await
33810×
}
339
340
/// A username for a new account from what the provider calls them:
341
/// valid, and free.
3423×
pub async fn free_username(pool: &Pool, wanted: &str, provider: &str) -> sqlx::Result<String> {
34320×
    let base: String = wanted.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')).take(40).collect();
3446×
    let base = base.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_string();
3453×
    let base = if base.is_empty() { provider.to_string() } else { base };
3463×
    let mut candidates = vec![base.clone(), format!("{base}-{provider}")];
347294×
    candidates.extend((2..100).map(|n| format!("{base}-{n}")));
3483×
    for c in candidates {
3493×
        if !crate::repos::names::valid_owner(&c) {
3500×
            continue;
3513×
        }
3523×
        let taken = accounts::account_by_name(pool, &c).await?.is_some()
3533×
            || accounts::org_by_name(pool, &c).await?.is_some()
3543×
            || accounts::username_retired(pool, &c).await?;
3553×
        if !taken {
3563×
            return Ok(c);
3570×
        }
358
    }
3590×
    Ok(format!("{provider}-{}", crate::auth::random_hex(4)))
3603×
}
361
362
#[cfg(test)]
363
mod tests {
364
    use super::*;
365
366
    #[test]
3671×
    fn providers_are_known() {
3681×
        assert_eq!(provider("github").map(|p| p.name), Some("GitHub"));
3691×
        assert_eq!(provider("google").map(|p| p.name), Some("Google"));
3701×
        assert_eq!(provider("gitlab").map(|p| (p.name, p.default_base)), Some(("GitLab", Some("https://gitlab.com"))));
3711×
        assert!(provider("bitbucket").is_none());
372
        // A self-managed GitLab's address (#213).
3731×
        assert!(valid_base("https://gitlab.example.org") && valid_base("https://code.example.org/gitlab"));
3741×
        assert!(
3751×
            !valid_base("http://gitlab.example.org")
3761×
                && !valid_base("https://")
3771×
                && !valid_base("https://gitlab.example.org/?x=1")
3781×
                && !valid_base("https://localhost")
379
        );
3801×
        let s = Settings { base: "https://gitlab.example.org/".into(), ..Settings::default() };
3811×
        assert_eq!(s.base_or("https://gitlab.com"), "https://gitlab.example.org");
3821×
        assert_eq!(Settings::default().base_or("https://gitlab.com"), "https://gitlab.com");
3831×
    }
384
}