| 83940d53 Andy McCall ↶ |
1 |
|
| 810896d4 Andy McCall ↶ |
2 |
|
| 3 |
|
| 83940d53 Andy McCall ↶ |
4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
use crate::db::{accounts, now_ms, Pool}; |
| 10 |
use crate::Shared; |
| 11 |
use sqlx::FromRow; |
| 12 |
|
| 13 |
|
| 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 |
|
| 23 |
|
| 24 |
|
| 25 |
pub default_base: Option<&'static str>, |
| 83940d53 Andy McCall ↶ |
26 |
} |
| 27 |
|
| 810896d4 Andy McCall ↶ |
28 |
pub const PROVIDERS: [Provider; 3] = [ |
| 29 |
|
| 30 |
|
| 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 |
|
| 66 |
|
| 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 |
|
| 83 |
pub fn callback_url(&self, app: &Shared) -> String { |
| 84 |
format!("{}/login/{}/callback", app.config.base_url(), self.id) |
| 85 |
} |
| 86 |
} |
| 87 |
|
| 88 |
|
| 89 |
#[derive(Clone, Debug, Default)] |
| 90 |
pub struct Settings { |
| 91 |
pub client_id: String, |
| 92 |
|
| 93 |
pub secret: String, |
| 94 |
pub enabled: bool, |
| 810896d4 Andy McCall ↶ |
95 |
|
| 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 |
|
| 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 |
|
| 116 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 159 |
|
| 160 |
|
| 161 |
pub const STATE_COOKIE: &str = "oxyhub_signin"; |
| 162 |
|
| 163 |
|
| 164 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 201 |
pub email: Option<String>, |
| 202 |
} |
| 203 |
|
| 204 |
|
| 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 |
|
| 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 |
|
| 272 |
|
| 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 |
|
| 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<()> { |
| 5d90e0e0 Andy McCall |
316 |
sqlx::query("INSERT OR REPLACE INTO account_logins (provider, subject, account_id, login, linked_ms) VALUES ($1, $2, $3, $4, $5)") |
| 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 |
|
| 341 |
|
| 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 |
|
| 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 |
} |