OXYGENHUB
◆ v0.9.3 oxygenhub / crates / hub / src / signin.rs
15.2 KiB · Rust · last changed Postgres, step 2a: ON CONFLICT instead of INSERT OR IGNORE/REPLACE 20 days ago source blame history raw
83940d53 Andy McCall ↶ 1
//! Sign in with an external account (hub spec §5.1.2, oxygenhub#106):
810896d4 Andy McCall ↶ 2
//! GitLab (gitlab.com or a self-managed GitLab), GitHub and Google
3
//! through OAuth 2.0's authorization code flow, set up
83940d53 Andy McCall ↶ 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,
810896d4 Andy McCall ↶ 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>,
83940d53 Andy McCall ↶ 26
}
27
810896d4 Andy McCall ↶ 28
pub const PROVIDERS: [Provider; 3] = [
29
    // GitLab's OpenID Connect endpoints, the same on gitlab.com and on a
30
    // self-managed GitLab.
83940d53 Andy McCall ↶ 31
    Provider {
810896d4 Andy McCall ↶ 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 {
83940d53 Andy McCall ↶ 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",
810896d4 Andy McCall ↶ 47
        default_base: None,
83940d53 Andy McCall ↶ 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",
810896d4 Andy McCall ↶ 56
        default_base: None,
83940d53 Andy McCall ↶ 57
    },
58
];
59
60
pub fn provider(id: &str) -> Option<&'static Provider> {
61
    PROVIDERS.iter().find(|p| p.id == id)
62
}
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`.
810896d4 Andy McCall ↶ 67
    fn url(&self, app: &Shared, s: &Settings, which: &str) -> String {
68
        let path = match which {
69
            "authorize" => self.authorize,
70
            "token" => self.token,
71
            _ => self.userinfo,
72
        };
83940d53 Andy McCall ↶ 73
        match &app.config.auth.signin_mock {
74
            Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id),
810896d4 Andy McCall ↶ 75
            None => match self.default_base {
76
                Some(default) => format!("{}{path}", s.base_or(default)),
77
                None => path.to_string(),
83940d53 Andy McCall ↶ 78
            },
79
        }
80
    }
81
82
    /// Where the provider sends the person back.
83
    pub fn callback_url(&self, app: &Shared) -> String {
84
        format!("{}/login/{}/callback", app.config.base_url(), self.id)
85
    }
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,
810896d4 Andy McCall ↶ 95
    /// A self-hostable provider's address (GitLab), empty for its default.
96
    pub base: String,
83940d53 Andy McCall ↶ 97
}
98
99
impl Settings {
100
    pub fn usable(&self) -> bool {
101
        self.enabled && !self.client_id.is_empty() && !self.secret.is_empty()
810896d4 Andy McCall ↶ 102
    }
103
104
    /// The address to use: the one set, else `default`.
105
    pub fn base_or<'a>(&'a self, default: &'a str) -> &'a str {
106
        let b = self.base.trim().trim_end_matches('/');
107
        if b.is_empty() {
108
            default
109
        } else {
110
            b
111
        }
83940d53 Andy McCall ↶ 112
    }
113
}
114
810896d4 Andy McCall ↶ 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.
117
pub fn valid_base(base: &str) -> bool {
118
    let Some(rest) = base.strip_prefix("https://") else { return false };
119
    let host = rest.split('/').next().unwrap_or("");
120
    !host.is_empty() && !rest.contains(['?', '#', ' ']) && host.contains('.')
121
}
122
83940d53 Andy McCall ↶ 123
fn key(id: &str, what: &str) -> String {
124
    format!("signin.{id}.{what}")
125
}
126
127
pub async fn settings(app: &Shared, id: &str) -> sqlx::Result<Settings> {
128
    let client_id = accounts::setting(&app.db, &key(id, "client_id")).await?.unwrap_or_default();
129
    let sealed = accounts::setting(&app.db, &key(id, "secret")).await?.unwrap_or_default();
130
    let secret = hex::decode(&sealed).ok().and_then(|b| app.secret_key.open(&b)).unwrap_or_default();
131
    let enabled = accounts::setting(&app.db, &key(id, "enabled")).await?.as_deref() == Some("1");
810896d4 Andy McCall ↶ 132
    let base = accounts::setting(&app.db, &key(id, "base")).await?.unwrap_or_default();
133
    Ok(Settings { client_id, secret, enabled, base })
83940d53 Andy McCall ↶ 134
}
135
136
/// Save a provider's settings; `secret` is `None` to keep the stored one.
810896d4 Andy McCall ↶ 137
pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool, base: &str) -> sqlx::Result<()> {
138
    accounts::set_setting(&app.db, &key(id, "base"), base.trim().trim_end_matches('/')).await?;
83940d53 Andy McCall ↶ 139
    accounts::set_setting(&app.db, &key(id, "client_id"), client_id.trim()).await?;
140
    if let Some(s) = secret {
141
        accounts::set_setting(&app.db, &key(id, "secret"), &hex::encode(app.secret_key.seal(s.trim()))).await?;
142
    }
143
    accounts::set_setting(&app.db, &key(id, "enabled"), if enabled { "1" } else { "0" }).await?;
144
    Ok(())
145
}
146
147
/// The providers people may sign in with: usable ones, in order.
148
pub async fn enabled(app: &Shared) -> Vec<&'static Provider> {
149
    let mut out = vec![];
150
    for p in &PROVIDERS {
151
        if settings(app, p.id).await.map(|s| s.usable()).unwrap_or(false) {
152
            out.push(p);
153
        }
154
    }
155
    out
156
}
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).
165
pub fn state_value(app: &Shared, mode: &str) -> String {
166
    let nonce = crate::auth::random_hex(16);
167
    let mac = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes());
168
    format!("{nonce}.{mode}.{mac}")
169
}
170
171
/// The mode a state value carries, when its MAC holds.
172
pub fn state_mode(app: &Shared, value: &str) -> Option<String> {
173
    let (nonce, rest) = value.split_once('.')?;
174
    let (mode, mac) = rest.rsplit_once('.')?;
175
    let want = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes());
176
    crate::auth::mac_equal(&want, mac).then(|| mode.to_string())
177
}
178
179
/// Where to send the person to sign in with `p`.
810896d4 Andy McCall ↶ 180
pub fn authorize_url(app: &Shared, p: &Provider, s: &Settings, state: &str) -> String {
83940d53 Andy McCall ↶ 181
    let q = |s: &str| crate::web::settings::urlencode(s);
182
    format!(
183
        "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code{}",
810896d4 Andy McCall ↶ 184
        p.url(app, s, "authorize"),
185
        q(&s.client_id),
83940d53 Andy McCall ↶ 186
        q(&p.callback_url(app)),
187
        q(p.scope),
188
        q(state),
189
        if p.id == "google" { "&access_type=online&prompt=select_account" } else { "" }
190
    )
191
}
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.
205
pub async fn identify(app: &Shared, p: &'static Provider, s: &Settings, code: &str) -> Result<External, String> {
206
    let client = reqwest::Client::builder()
207
        .timeout(std::time::Duration::from_secs(20))
208
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
209
        .build()
210
        .map_err(|e| e.to_string())?;
211
    let token: serde_json::Value = client
810896d4 Andy McCall ↶ 212
        .post(p.url(app, s, "token"))
83940d53 Andy McCall ↶ 213
        .header("accept", "application/json")
214
        .form(&[
215
            ("client_id", s.client_id.as_str()),
216
            ("client_secret", s.secret.as_str()),
217
            ("code", code),
218
            ("redirect_uri", &p.callback_url(app)),
219
            ("grant_type", "authorization_code"),
220
        ])
221
        .send()
222
        .await
223
        .map_err(|e| format!("{} did not answer: {e}", p.name))?
224
        .json()
225
        .await
226
        .map_err(|_| format!("{} answered with something other than a token", p.name))?;
227
    let Some(access) = token["access_token"].as_str() else {
228
        let why = token["error_description"].as_str().or(token["error"].as_str()).unwrap_or("no token");
229
        return Err(format!("{} refused the sign-in: {why}", p.name));
230
    };
231
    let get = |url: String| {
232
        let client = client.clone();
233
        let access = access.to_string();
234
        async move {
235
            client
236
                .get(url)
237
                .bearer_auth(access)
238
                .header("accept", "application/json")
239
                .send()
240
                .await
241
                .map_err(|e| format!("{} did not answer: {e}", p.name))?
242
                .json::<serde_json::Value>()
243
                .await
244
                .map_err(|_| format!("{} answered with something unexpected", p.name))
245
        }
246
    };
810896d4 Andy McCall ↶ 247
    let user = get(p.url(app, s, "userinfo")).await?;
83940d53 Andy McCall ↶ 248
    Ok(match p.id {
249
        "github" => {
250
            let subject =
251
                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.
810896d4 Andy McCall ↶ 253
            let emails = get(format!("{}/emails", p.url(app, s, "userinfo"))).await.unwrap_or_default();
83940d53 Andy McCall ↶ 254
            let email = emails
255
                .as_array()
256
                .and_then(|list| {
257
                    list.iter()
258
                        .filter(|e| e["verified"] == true)
259
                        .max_by_key(|e| e["primary"] == true)
260
                        .and_then(|e| e["email"].as_str().map(String::from))
261
                })
262
                .or_else(|| user["email"].as_str().map(String::from).filter(|_| false));
263
            External {
264
                provider: p.id,
265
                subject,
266
                login: user["login"].as_str().unwrap_or("").to_string(),
267
                name: user["name"].as_str().unwrap_or("").to_string(),
268
                email,
269
            }
810896d4 Andy McCall ↶ 270
        }
271
        // GitLab's OpenID Connect userinfo: the subject is its user id, the
272
        // username is `nickname`.
273
        "gitlab" => {
274
            let subject = user["sub"]
275
                .as_str()
276
                .map(String::from)
277
                .or_else(|| user["sub"].as_i64().map(|i| i.to_string()))
278
                .ok_or("GitLab gave no subject")?;
279
            let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from);
280
            let login = user["nickname"].as_str().or(user["preferred_username"].as_str()).unwrap_or("").to_string();
281
            External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email }
83940d53 Andy McCall ↶ 282
        }
283
        _ => {
284
            let subject = user["sub"].as_str().ok_or("Google gave no subject")?.to_string();
285
            let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from);
286
            let login = email.as_deref().and_then(|e| e.split('@').next()).unwrap_or("").to_string();
287
            External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email }
288
        }
289
    })
290
}
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
303
pub async fn linked_account(pool: &Pool, provider: &str, subject: &str) -> sqlx::Result<Option<accounts::Account>> {
5d90e0e0 Andy McCall 304
    let id: Option<i64> = sqlx::query_scalar("SELECT account_id FROM account_logins WHERE provider = $1 AND subject = $2")
83940d53 Andy McCall ↶ 305
        .bind(provider)
306
        .bind(subject)
307
        .fetch_optional(pool)
308
        .await?;
309
    match id {
310
        Some(id) => accounts::account_by_id(pool, id).await,
311
        None => Ok(None),
312
    }
313
}
314
315
pub async fn link(pool: &Pool, provider: &str, subject: &str, account_id: i64, login: &str) -> sqlx::Result<()> {
d1bf2d69 Andy McCall ↶ 316
    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")
83940d53 Andy McCall ↶ 317
        .bind(provider)
318
        .bind(subject)
319
        .bind(account_id)
320
        .bind(login)
321
        .bind(now_ms())
322
        .execute(pool)
323
        .await?;
324
    Ok(())
325
}
326
327
pub async fn unlink(pool: &Pool, provider: &str, account_id: i64) -> sqlx::Result<bool> {
5d90e0e0 Andy McCall 328
    let r = sqlx::query("DELETE FROM account_logins WHERE provider = $1 AND account_id = $2")
83940d53 Andy McCall ↶ 329
        .bind(provider)
330
        .bind(account_id)
331
        .execute(pool)
332
        .await?;
333
    Ok(r.rows_affected() > 0)
334
}
335
336
pub async fn links_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Login>> {
5d90e0e0 Andy McCall 337
    sqlx::query_as("SELECT * FROM account_logins WHERE account_id = $1 ORDER BY provider").bind(account_id).fetch_all(pool).await
83940d53 Andy McCall ↶ 338
}
339
340
/// A username for a new account from what the provider calls them:
341
/// valid, and free.
342
pub async fn free_username(pool: &Pool, wanted: &str, provider: &str) -> sqlx::Result<String> {
343
    let base: String = wanted.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')).take(40).collect();
344
    let base = base.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_string();
345
    let base = if base.is_empty() { provider.to_string() } else { base };
346
    let mut candidates = vec![base.clone(), format!("{base}-{provider}")];
347
    candidates.extend((2..100).map(|n| format!("{base}-{n}")));
348
    for c in candidates {
349
        if !crate::repos::names::valid_owner(&c) {
350
            continue;
351
        }
352
        let taken = accounts::account_by_name(pool, &c).await?.is_some()
353
            || accounts::org_by_name(pool, &c).await?.is_some()
354
            || accounts::username_retired(pool, &c).await?;
355
        if !taken {
356
            return Ok(c);
357
        }
358
    }
359
    Ok(format!("{provider}-{}", crate::auth::random_hex(4)))
360
}
361
362
#[cfg(test)]
363
mod tests {
364
    use super::*;
365
366
    #[test]
367
    fn providers_are_known() {
368
        assert_eq!(provider("github").map(|p| p.name), Some("GitHub"));
369
        assert_eq!(provider("google").map(|p| p.name), Some("Google"));
810896d4 Andy McCall ↶ 370
        assert_eq!(provider("gitlab").map(|p| (p.name, p.default_base)), Some(("GitLab", Some("https://gitlab.com"))));
371
        assert!(provider("bitbucket").is_none());
372
        // A self-managed GitLab's address (#213).
373
        assert!(valid_base("https://gitlab.example.org") && valid_base("https://code.example.org/gitlab"));
374
        assert!(
375
            !valid_base("http://gitlab.example.org")
376
                && !valid_base("https://")
377
                && !valid_base("https://gitlab.example.org/?x=1")
378
                && !valid_base("https://localhost")
379
        );
380
        let s = Settings { base: "https://gitlab.example.org/".into(), ..Settings::default() };
381
        assert_eq!(s.base_or("https://gitlab.com"), "https://gitlab.example.org");
382
        assert_eq!(Settings::default().base_or("https://gitlab.com"), "https://gitlab.com");
83940d53 Andy McCall ↶ 383
    }
384
}