//! Sign in with an external account (hub spec ยง5.1.2, oxygenhub#106): //! 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, } pub const PROVIDERS: [Provider; 2] = [ 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", }, 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", }, ]; 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, which: &str) -> String { match &app.config.auth.signin_mock { Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id), None => match which { "authorize" => self.authorize.to_string(), "token" => self.token.to_string(), _ => self.userinfo.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, } impl Settings { pub fn usable(&self) -> bool { self.enabled && !self.client_id.is_empty() && !self.secret.is_empty() } } 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"); Ok(Settings { client_id, secret, enabled }) } /// 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) -> sqlx::Result<()> { 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, client_id: &str, 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, "authorize"), q(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, "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, "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, "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, } } _ => { 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 = ? AND subject = ?") .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 OR REPLACE INTO account_logins (provider, subject, account_id, login, linked_ms) VALUES (?, ?, ?, ?, ?)") .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 = ? AND account_id = ?") .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 = ? 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!(provider("gitlab").is_none()); } }