OXYGENHUB
# 4fa0da126b70 oxygenhub / crates / hub / src / signin.rs
11.8 KiB · Rust · last changed Sign in with GitHub or Google 1 month ago source blame history raw
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
}