//! Sign in with an external account (hub spec ยง5.1.2, oxygenhub#106): //! GitLab (gitlab.com or a self-managed GitLab), GitHub and Google //! through OAuth 2.0's authorization code flow, set up //! by an administrator per provider (client id, a sealed secret, on or //! off). A first sign-in links the provider's subject to the account with //! the same verified email, or makes an account when registration is //! open; later sign-ins find the link. use crate::db::{accounts, now_ms, Pool}; use crate::Shared; use sqlx::FromRow; /// A provider the hub knows how to talk to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Provider { pub id: &'static str, pub name: &'static str, authorize: &'static str, token: &'static str, userinfo: &'static str, pub scope: &'static str, /// For a provider that can be self-hosted: the address used when the /// administrator gives none. Its endpoints are then paths under the /// address (oxygenhub#213). pub default_base: Option<&'static str>, } pub const PROVIDERS: [Provider; 3] = [ // GitLab's OpenID Connect endpoints, the same on gitlab.com and on a // self-managed GitLab. Provider { id: "gitlab", name: "GitLab", authorize: "/oauth/authorize", token: "/oauth/token", userinfo: "/oauth/userinfo", scope: "openid profile email", default_base: Some("https://gitlab.com"), }, Provider { id: "github", name: "GitHub", authorize: "https://github.com/login/oauth/authorize", token: "https://github.com/login/oauth/access_token", userinfo: "https://api.github.com/user", scope: "read:user user:email", default_base: None, }, Provider { id: "google", name: "Google", authorize: "https://accounts.google.com/o/oauth2/v2/auth", token: "https://oauth2.googleapis.com/token", userinfo: "https://openidconnect.googleapis.com/v1/userinfo", scope: "openid email profile", default_base: None, }, ]; pub fn provider(id: &str) -> Option<&'static Provider> { PROVIDERS.iter().find(|p| p.id == id) } impl Provider { /// The provider's endpoints, or a test double's when `[auth] /// signin_mock` names one: `{mock}/{id}/authorize|token|userinfo`. fn url(&self, app: &Shared, s: &Settings, which: &str) -> String { let path = match which { "authorize" => self.authorize, "token" => self.token, _ => self.userinfo, }; match &app.config.auth.signin_mock { Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id), None => match self.default_base { Some(default) => format!("{}{path}", s.base_or(default)), None => path.to_string(), }, } } /// Where the provider sends the person back. pub fn callback_url(&self, app: &Shared) -> String { format!("{}/login/{}/callback", app.config.base_url(), self.id) } } /// What an administrator set for a provider. #[derive(Clone, Debug, Default)] pub struct Settings { pub client_id: String, /// The client secret, opened; empty when none is set. pub secret: String, pub enabled: bool, /// A self-hostable provider's address (GitLab), empty for its default. pub base: String, } impl Settings { pub fn usable(&self) -> bool { self.enabled && !self.client_id.is_empty() && !self.secret.is_empty() } /// The address to use: the one set, else `default`. pub fn base_or<'a>(&'a self, default: &'a str) -> &'a str { let b = self.base.trim().trim_end_matches('/'); if b.is_empty() { default } else { b } } } /// Whether `base` may be a self-hosted provider's address: https, a host, /// nothing after it but an optional path prefix, no query. pub fn valid_base(base: &str) -> bool { let Some(rest) = base.strip_prefix("https://") else { return false }; let host = rest.split('/').next().unwrap_or(""); !host.is_empty() && !rest.contains(['?', '#', ' ']) && host.contains('.') } fn key(id: &str, what: &str) -> String { format!("signin.{id}.{what}") } pub async fn settings(app: &Shared, id: &str) -> sqlx::Result { let client_id = accounts::setting(&app.db, &key(id, "client_id")).await?.unwrap_or_default(); let sealed = accounts::setting(&app.db, &key(id, "secret")).await?.unwrap_or_default(); let secret = hex::decode(&sealed).ok().and_then(|b| app.secret_key.open(&b)).unwrap_or_default(); let enabled = accounts::setting(&app.db, &key(id, "enabled")).await?.as_deref() == Some("1"); let base = accounts::setting(&app.db, &key(id, "base")).await?.unwrap_or_default(); Ok(Settings { client_id, secret, enabled, base }) } /// Save a provider's settings; `secret` is `None` to keep the stored one. pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool, base: &str) -> sqlx::Result<()> { accounts::set_setting(&app.db, &key(id, "base"), base.trim().trim_end_matches('/')).await?; accounts::set_setting(&app.db, &key(id, "client_id"), client_id.trim()).await?; if let Some(s) = secret { accounts::set_setting(&app.db, &key(id, "secret"), &hex::encode(app.secret_key.seal(s.trim()))).await?; } accounts::set_setting(&app.db, &key(id, "enabled"), if enabled { "1" } else { "0" }).await?; Ok(()) } /// The providers people may sign in with: usable ones, in order. pub async fn enabled(app: &Shared) -> Vec<&'static Provider> { let mut out = vec![]; for p in &PROVIDERS { if settings(app, p.id).await.map(|s| s.usable()).unwrap_or(false) { out.push(p); } } out } // ------------------------------------------------------------- the flow /// The state cookie's name: a nonce, what the sign-in is for, and a MAC. pub const STATE_COOKIE: &str = "oxyhub_signin"; /// A state value: `NONCE.MODE.MAC`, where MODE is `in` (sign in) or /// `link:ACCOUNT_ID` (connect to a signed-in account). pub fn state_value(app: &Shared, mode: &str) -> String { let nonce = crate::auth::random_hex(16); let mac = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes()); format!("{nonce}.{mode}.{mac}") } /// The mode a state value carries, when its MAC holds. pub fn state_mode(app: &Shared, value: &str) -> Option { let (nonce, rest) = value.split_once('.')?; let (mode, mac) = rest.rsplit_once('.')?; let want = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes()); crate::auth::mac_equal(&want, mac).then(|| mode.to_string()) } /// Where to send the person to sign in with `p`. pub fn authorize_url(app: &Shared, p: &Provider, s: &Settings, state: &str) -> String { let q = |s: &str| crate::web::settings::urlencode(s); format!( "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code{}", p.url(app, s, "authorize"), q(&s.client_id), q(&p.callback_url(app)), q(p.scope), q(state), if p.id == "google" { "&access_type=online&prompt=select_account" } else { "" } ) } /// Who the provider says the person is. #[derive(Clone, Debug)] pub struct External { pub provider: &'static str, pub subject: String, pub login: String, pub name: String, /// A verified email, when the provider vouches for one. pub email: Option, } /// Swap the code for a token and ask who it belongs to. pub async fn identify(app: &Shared, p: &'static Provider, s: &Settings, code: &str) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(20)) .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) .build() .map_err(|e| e.to_string())?; let token: serde_json::Value = client .post(p.url(app, s, "token")) .header("accept", "application/json") .form(&[ ("client_id", s.client_id.as_str()), ("client_secret", s.secret.as_str()), ("code", code), ("redirect_uri", &p.callback_url(app)), ("grant_type", "authorization_code"), ]) .send() .await .map_err(|e| format!("{} did not answer: {e}", p.name))? .json() .await .map_err(|_| format!("{} answered with something other than a token", p.name))?; let Some(access) = token["access_token"].as_str() else { let why = token["error_description"].as_str().or(token["error"].as_str()).unwrap_or("no token"); return Err(format!("{} refused the sign-in: {why}", p.name)); }; let get = |url: String| { let client = client.clone(); let access = access.to_string(); async move { client .get(url) .bearer_auth(access) .header("accept", "application/json") .send() .await .map_err(|e| format!("{} did not answer: {e}", p.name))? .json::() .await .map_err(|_| format!("{} answered with something unexpected", p.name)) } }; let user = get(p.url(app, s, "userinfo")).await?; Ok(match p.id { "github" => { let subject = user["id"].as_i64().map(|i| i.to_string()).or_else(|| user["id"].as_str().map(String::from)).ok_or("GitHub gave no id")?; // The primary verified address is on its own endpoint. let emails = get(format!("{}/emails", p.url(app, s, "userinfo"))).await.unwrap_or_default(); let email = emails .as_array() .and_then(|list| { list.iter() .filter(|e| e["verified"] == true) .max_by_key(|e| e["primary"] == true) .and_then(|e| e["email"].as_str().map(String::from)) }) .or_else(|| user["email"].as_str().map(String::from).filter(|_| false)); External { provider: p.id, subject, login: user["login"].as_str().unwrap_or("").to_string(), name: user["name"].as_str().unwrap_or("").to_string(), email, } } // GitLab's OpenID Connect userinfo: the subject is its user id, the // username is `nickname`. "gitlab" => { let subject = user["sub"] .as_str() .map(String::from) .or_else(|| user["sub"].as_i64().map(|i| i.to_string())) .ok_or("GitLab gave no subject")?; let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from); let login = user["nickname"].as_str().or(user["preferred_username"].as_str()).unwrap_or("").to_string(); External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email } } _ => { let subject = user["sub"].as_str().ok_or("Google gave no subject")?.to_string(); let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from); let login = email.as_deref().and_then(|e| e.split('@').next()).unwrap_or("").to_string(); External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email } } }) } // --------------------------------------------------------------- links #[derive(Clone, Debug, FromRow)] pub struct Login { pub provider: String, pub subject: String, pub account_id: i64, pub login: String, pub linked_ms: i64, } pub async fn linked_account(pool: &Pool, provider: &str, subject: &str) -> sqlx::Result> { let id: Option = sqlx::query_scalar("SELECT account_id FROM account_logins WHERE provider = $1 AND subject = $2") .bind(provider) .bind(subject) .fetch_optional(pool) .await?; match id { Some(id) => accounts::account_by_id(pool, id).await, None => Ok(None), } } pub async fn link(pool: &Pool, provider: &str, subject: &str, account_id: i64, login: &str) -> sqlx::Result<()> { 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") .bind(provider) .bind(subject) .bind(account_id) .bind(login) .bind(now_ms()) .execute(pool) .await?; Ok(()) } pub async fn unlink(pool: &Pool, provider: &str, account_id: i64) -> sqlx::Result { let r = sqlx::query("DELETE FROM account_logins WHERE provider = $1 AND account_id = $2") .bind(provider) .bind(account_id) .execute(pool) .await?; Ok(r.rows_affected() > 0) } pub async fn links_of(pool: &Pool, account_id: i64) -> sqlx::Result> { sqlx::query_as("SELECT * FROM account_logins WHERE account_id = $1 ORDER BY provider").bind(account_id).fetch_all(pool).await } /// A username for a new account from what the provider calls them: /// valid, and free. pub async fn free_username(pool: &Pool, wanted: &str, provider: &str) -> sqlx::Result { let base: String = wanted.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')).take(40).collect(); let base = base.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_string(); let base = if base.is_empty() { provider.to_string() } else { base }; let mut candidates = vec![base.clone(), format!("{base}-{provider}")]; candidates.extend((2..100).map(|n| format!("{base}-{n}"))); for c in candidates { if !crate::repos::names::valid_owner(&c) { continue; } let taken = accounts::account_by_name(pool, &c).await?.is_some() || accounts::org_by_name(pool, &c).await?.is_some() || accounts::username_retired(pool, &c).await?; if !taken { return Ok(c); } } Ok(format!("{provider}-{}", crate::auth::random_hex(4))) } #[cfg(test)] mod tests { use super::*; #[test] fn providers_are_known() { assert_eq!(provider("github").map(|p| p.name), Some("GitHub")); assert_eq!(provider("google").map(|p| p.name), Some("Google")); assert_eq!(provider("gitlab").map(|p| (p.name, p.default_base)), Some(("GitLab", Some("https://gitlab.com")))); assert!(provider("bitbucket").is_none()); // A self-managed GitLab's address (#213). assert!(valid_base("https://gitlab.example.org") && valid_base("https://code.example.org/gitlab")); assert!( !valid_base("http://gitlab.example.org") && !valid_base("https://") && !valid_base("https://gitlab.example.org/?x=1") && !valid_base("https://localhost") ); let s = Settings { base: "https://gitlab.example.org/".into(), ..Settings::default() }; assert_eq!(s.base_or("https://gitlab.com"), "https://gitlab.example.org"); assert_eq!(Settings::default().base_or("https://gitlab.com"), "https://gitlab.com"); } }