| 1 | //! Signing in with GitHub, through g1t's GitHub App's user authorization: |
| 2 | //! the OAuth web flow with PKCE (S256). |
| 3 | //! |
| 4 | //! The site sends the browser to GitHub with a state it also keeps in a |
| 5 | //! short-lived cookie; this service keeps the state's hash and the PKCE |
| 6 | //! verifier, each usable once and for ten minutes. On the way back the site |
| 7 | //! checks the cookie against the state GitHub returns, and this service |
| 8 | //! redeems the state, exchanges the code, and reads the person's GitHub |
| 9 | //! account and verified emails. |
| 10 | //! |
| 11 | //! A GitHub account is known by its numeric id, never its login, which its |
| 12 | //! owner can change. One with no g1t account yet makes one; one whose |
| 13 | //! verified email belongs to an existing g1t account is never linked to it |
| 14 | //! silently: the person signs in to that account first. The app's user |
| 15 | //! tokens expire, so the refresh token is kept, sealed under IDENTITY_KEY, |
| 16 | //! and used when the access token is about to run out. Tokens are opaque |
| 17 | //! strings of any length. |
| 18 | //! |
| 19 | //! Configured with the vars GITHUB_APP_CLIENT_ID and the secret |
| 20 | //! GITHUB_APP_CLIENT_SECRET; without both, `github_enabled` is false and |
| 21 | //! everything else here says GitHub is not set up. |
| 22 | |
| 23 | use base64::Engine; |
| 24 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; |
| 25 | use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; |
| 26 | use g1t_contracts::github::*; |
| 27 | use g1t_contracts::identity::{SignedIn, UserArgs}; |
| 28 | use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after}; |
| 29 | use g1t_contracts::{FailureCode, Outcome, User, is_valid_namespace, new_id}; |
| 30 | use g1t_kit::now_ms; |
| 31 | use g1t_secrets::Sealer; |
| 32 | use serde::{Deserialize, Serialize}; |
| 33 | use serde_json::Value; |
| 34 | use sha2::{Digest, Sha256}; |
| 35 | use worker::{Fetch, Headers, Method, Request, RequestInit, Result, Url}; |
| 36 | |
| 37 | use crate::{Identity, crypto}; |
| 38 | |
| 39 | const STATE_TTL_SECONDS: u64 = 10 * 60; |
| 40 | const PENDING_TTL_SECONDS: u64 = 30 * 60; |
| 41 | /// An access token this close to expiring is refreshed before use. |
| 42 | const REFRESH_MARGIN_MS: u64 = 5 * 60 * 1000; |
| 43 | const AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize"; |
| 44 | const TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; |
| 45 | const API: &str = "https://api.github.com"; |
| 46 | const NOT_SET_UP: &str = "Signing in with GitHub is not set up on this g1t."; |
| 47 | const TRY_AGAIN: &str = "GitHub did not complete the sign-in. Try again."; |
| 48 | |
| 49 | /// The app's OAuth client, when this g1t has one. |
| 50 | struct Client { |
| 51 | id: String, |
| 52 | secret: String, |
| 53 | } |
| 54 | |
| 55 | fn client(env: &worker::Env) -> Option<Client> { |
| 56 | let id = env.var("GITHUB_APP_CLIENT_ID").ok()?.to_string(); |
| 57 | let secret = env.secret("GITHUB_APP_CLIENT_SECRET").ok()?.to_string(); |
| 58 | (!id.trim().is_empty() && !secret.trim().is_empty()).then(|| Client { |
| 59 | id: id.trim().to_owned(), |
| 60 | secret: secret.trim().to_owned(), |
| 61 | }) |
| 62 | } |
| 63 | |
| 64 | // --- Pure parts, tested below ---------------------------------------------- |
| 65 | |
| 66 | /// A PKCE code verifier: 32 random bytes, base64url, 43 characters. |
| 67 | pub fn new_verifier() -> String { |
| 68 | let mut bytes = [0u8; 32]; |
| 69 | getrandom::getrandom(&mut bytes).expect("no source of randomness"); |
| 70 | URL_SAFE_NO_PAD.encode(bytes) |
| 71 | } |
| 72 | |
| 73 | /// The S256 challenge for a verifier (RFC 7636). |
| 74 | pub fn pkce_challenge(verifier: &str) -> String { |
| 75 | URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) |
| 76 | } |
| 77 | |
| 78 | pub fn authorize_url(client_id: &str, redirect_uri: &str, state: &str, challenge: &str) -> String { |
| 79 | Url::parse_with_params( |
| 80 | AUTHORIZE_URL, |
| 81 | &[ |
| 82 | ("client_id", client_id), |
| 83 | ("redirect_uri", redirect_uri), |
| 84 | ("state", state), |
| 85 | ("code_challenge", challenge), |
| 86 | ("code_challenge_method", "S256"), |
| 87 | ("allow_signup", "true"), |
| 88 | ], |
| 89 | ) |
| 90 | .map(|url| url.to_string()) |
| 91 | .unwrap_or_default() |
| 92 | } |
| 93 | |
| 94 | /// One of `GET /user/emails`. |
| 95 | #[derive(Clone, Debug, Deserialize)] |
| 96 | pub struct GithubEmail { |
| 97 | pub email: String, |
| 98 | #[serde(default)] |
| 99 | pub primary: bool, |
| 100 | #[serde(default)] |
| 101 | pub verified: bool, |
| 102 | } |
| 103 | |
| 104 | /// The verified addresses, lowercased, the primary first. Unverified ones |
| 105 | /// prove nothing, and GitHub's private relay addresses belong to no inbox |
| 106 | /// g1t could write to. |
| 107 | pub fn verified_emails(emails: &[GithubEmail]) -> Vec<String> { |
| 108 | let mut kept: Vec<(bool, String)> = emails |
| 109 | .iter() |
| 110 | .filter(|email| email.verified) |
| 111 | .map(|email| (email.primary, email.email.trim().to_lowercase())) |
| 112 | .filter(|(_, email)| email.contains('@') && !email.ends_with("@users.noreply.github.com")) |
| 113 | .collect(); |
| 114 | // Primary first; otherwise as GitHub listed them. |
| 115 | kept.sort_by_key(|(primary, _)| !primary); |
| 116 | let mut out: Vec<String> = Vec::new(); |
| 117 | for (_, email) in kept { |
| 118 | if !out.contains(&email) { |
| 119 | out.push(email); |
| 120 | } |
| 121 | } |
| 122 | out |
| 123 | } |
| 124 | |
| 125 | /// A username made from a GitHub login: lowercased, with anything g1t does |
| 126 | /// not allow turned into single hyphens. |
| 127 | pub fn suggest_username(login: &str) -> String { |
| 128 | let mut out = String::new(); |
| 129 | for character in login.trim().to_lowercase().chars() { |
| 130 | if character.is_ascii_lowercase() || character.is_ascii_digit() { |
| 131 | out.push(character); |
| 132 | } else if !out.ends_with('-') { |
| 133 | out.push('-'); |
| 134 | } |
| 135 | } |
| 136 | let out: String = out.trim_matches('-').chars().take(39).collect(); |
| 137 | out.trim_end_matches('-').to_owned() |
| 138 | } |
| 139 | |
| 140 | /// What a return from GitHub should do. |
| 141 | #[derive(Debug, PartialEq, Eq)] |
| 142 | pub enum Decision { |
| 143 | /// Sign in to the account the GitHub account is linked to. |
| 144 | SignIn(String), |
| 145 | /// Link it to the signed-in account that asked. |
| 146 | Link(String), |
| 147 | /// Refused, with why. |
| 148 | Refuse(&'static str), |
| 149 | /// An account has one of its verified emails: sign in to it to link. |
| 150 | NeedsLink, |
| 151 | /// A new account with this username. |
| 152 | Create(String), |
| 153 | /// A new account, once the person picks a username; this one suggested. |
| 154 | NeedsUsername(String), |
| 155 | } |
| 156 | |
| 157 | /// Everything the decision depends on, as read from GitHub and the database. |
| 158 | #[derive(Debug, Default)] |
| 159 | pub struct Facts<'a> { |
| 160 | pub purpose: Option<GithubPurpose>, |
| 161 | /// The account that asked to link, for `link`. |
| 162 | pub asking: Option<&'a str>, |
| 163 | /// Whether the asking account already has another GitHub account. |
| 164 | pub asking_has_other: bool, |
| 165 | /// The account this GitHub account is linked to already. |
| 166 | pub linked_to: Option<&'a str>, |
| 167 | pub has_verified_email: bool, |
| 168 | /// Whether an existing account has one of its verified emails. |
| 169 | pub email_taken: bool, |
| 170 | /// The suggested username, and whether it can be registered. |
| 171 | pub suggestion: String, |
| 172 | pub suggestion_free: bool, |
| 173 | /// g1t is invite-only and no invite code came with the sign-in: a new |
| 174 | /// account waits for one. |
| 175 | pub invite_missing: bool, |
| 176 | } |
| 177 | |
| 178 | pub fn decide(facts: &Facts) -> Decision { |
| 179 | if facts.purpose == Some(GithubPurpose::Link) { |
| 180 | let Some(asking) = facts.asking else { |
| 181 | return Decision::Refuse("Sign in to g1t first, then link GitHub."); |
| 182 | }; |
| 183 | return match facts.linked_to { |
| 184 | Some(linked) if linked == asking => Decision::Link(asking.to_owned()), |
| 185 | Some(_) => Decision::Refuse("That GitHub account is linked to another g1t account."), |
| 186 | None if facts.asking_has_other => { |
| 187 | Decision::Refuse("Your account is linked to another GitHub account. Unlink it first.") |
| 188 | } |
| 189 | None => Decision::Link(asking.to_owned()), |
| 190 | }; |
| 191 | } |
| 192 | if let Some(linked) = facts.linked_to { |
| 193 | return Decision::SignIn(linked.to_owned()); |
| 194 | } |
| 195 | if !facts.has_verified_email { |
| 196 | return Decision::Refuse( |
| 197 | "Your GitHub account has no verified email address g1t can use. Verify one on GitHub, or create an account with your email.", |
| 198 | ); |
| 199 | } |
| 200 | // Never linked silently: whoever controls a GitHub account with the |
| 201 | // same address is not thereby the owner of the g1t account. |
| 202 | if facts.email_taken { |
| 203 | return Decision::NeedsLink; |
| 204 | } |
| 205 | if facts.suggestion_free && !facts.invite_missing { |
| 206 | Decision::Create(facts.suggestion.clone()) |
| 207 | } else { |
| 208 | Decision::NeedsUsername(facts.suggestion.clone()) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | /// A person's GitHub user tokens, as kept sealed. Times are milliseconds. |
| 213 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] |
| 214 | pub struct Tokens { |
| 215 | pub access_token: String, |
| 216 | #[serde(default)] |
| 217 | pub access_expires_at: Option<u64>, |
| 218 | #[serde(default)] |
| 219 | pub refresh_token: Option<String>, |
| 220 | #[serde(default)] |
| 221 | pub refresh_expires_at: Option<u64>, |
| 222 | } |
| 223 | |
| 224 | /// Reads GitHub's token answer, at `now`. `None` if it holds no token. |
| 225 | pub fn tokens_from(answer: &Value, now: u64) -> Option<Tokens> { |
| 226 | let access_token = answer["access_token"].as_str().filter(|token| !token.is_empty())?.to_owned(); |
| 227 | let after = |field: &str| answer[field].as_u64().map(|seconds| now + seconds * 1000); |
| 228 | Some(Tokens { |
| 229 | access_token, |
| 230 | access_expires_at: after("expires_in"), |
| 231 | refresh_token: answer["refresh_token"].as_str().filter(|token| !token.is_empty()).map(str::to_owned), |
| 232 | refresh_expires_at: after("refresh_token_expires_in"), |
| 233 | }) |
| 234 | } |
| 235 | |
| 236 | impl Tokens { |
| 237 | pub fn fresh(&self, now: u64) -> bool { |
| 238 | self.access_expires_at.is_none_or(|at| at > now + REFRESH_MARGIN_MS) |
| 239 | } |
| 240 | |
| 241 | pub fn refreshable(&self, now: u64) -> bool { |
| 242 | self.refresh_token.is_some() && self.refresh_expires_at.is_none_or(|at| at > now) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | // --- GitHub over HTTP -------------------------------------------------------- |
| 247 | |
| 248 | struct Answer { |
| 249 | status: u16, |
| 250 | body: Value, |
| 251 | } |
| 252 | |
| 253 | async fn send(method: Method, url: &str, bearer: Option<&str>, body: Option<Value>) -> Result<Answer> { |
| 254 | let headers = Headers::new(); |
| 255 | headers.set("user-agent", "g1t (+https://g1t.sh)")?; |
| 256 | headers.set("accept", "application/json")?; |
| 257 | if url.starts_with(API) { |
| 258 | headers.set("accept", "application/vnd.github+json")?; |
| 259 | headers.set("x-github-api-version", "2022-11-28")?; |
| 260 | } |
| 261 | if let Some(token) = bearer { |
| 262 | headers.set("authorization", &format!("Bearer {token}"))?; |
| 263 | } |
| 264 | let mut init = RequestInit::new(); |
| 265 | if let Some(body) = &body { |
| 266 | headers.set("content-type", "application/json")?; |
| 267 | init.with_body(Some(body.to_string().into())); |
| 268 | } |
| 269 | init.with_method(method).with_headers(headers); |
| 270 | let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?; |
| 271 | let text = response.text().await.unwrap_or_default(); |
| 272 | Ok(Answer { |
| 273 | status: response.status_code(), |
| 274 | body: serde_json::from_str(&text).unwrap_or(Value::Null), |
| 275 | }) |
| 276 | } |
| 277 | |
| 278 | /// Trades a code, or a refresh token, for tokens. |
| 279 | async fn token_request(client: &Client, grant: Value) -> Result<Option<Tokens>> { |
| 280 | let mut body = serde_json::json!({ "client_id": client.id, "client_secret": client.secret }); |
| 281 | if let (Some(body), Some(grant)) = (body.as_object_mut(), grant.as_object()) { |
| 282 | body.extend(grant.clone()); |
| 283 | } |
| 284 | let answer = send(Method::Post, TOKEN_URL, None, Some(body)).await?; |
| 285 | if answer.status != 200 || answer.body.get("error").is_some() { |
| 286 | // GitHub answers 200 with an `error`; its description names no secret. |
| 287 | worker::console_log!( |
| 288 | "github token request refused: {}", |
| 289 | answer.body["error"].as_str().unwrap_or("status") |
| 290 | ); |
| 291 | return Ok(None); |
| 292 | } |
| 293 | Ok(tokens_from(&answer.body, now_ms())) |
| 294 | } |
| 295 | |
| 296 | /// Who a user token belongs to, and their verified emails. |
| 297 | struct GithubUser { |
| 298 | id: u64, |
| 299 | login: String, |
| 300 | emails: Vec<String>, |
| 301 | } |
| 302 | |
| 303 | const INVITE_FOR_ANOTHER_ADDRESS: &str = "Your invite was sent to an address your GitHub account has not verified. Verify that address on GitHub and try again, or go back to the invite and create your account with your email and a password."; |
| 304 | |
| 305 | /// Moves `bound` to the front of a GitHub account's verified addresses, so |
| 306 | /// a new account is made with it. False if GitHub has not verified it. |
| 307 | fn put_first(emails: &mut Vec<String>, bound: &str) -> bool { |
| 308 | let Some(at) = emails.iter().position(|email| email.eq_ignore_ascii_case(bound.trim())) else { |
| 309 | return false; |
| 310 | }; |
| 311 | let email = emails.remove(at); |
| 312 | emails.insert(0, email); |
| 313 | true |
| 314 | } |
| 315 | |
| 316 | async fn read_user(token: &str) -> Result<Option<GithubUser>> { |
| 317 | let user = send(Method::Get, &format!("{API}/user"), Some(token), None).await?; |
| 318 | let (Some(id), Some(login)) = (user.body["id"].as_u64(), user.body["login"].as_str()) else { |
| 319 | return Ok(None); |
| 320 | }; |
| 321 | let listed = send(Method::Get, &format!("{API}/user/emails"), Some(token), None).await?; |
| 322 | let emails: Vec<GithubEmail> = serde_json::from_value(listed.body).unwrap_or_default(); |
| 323 | Ok(Some(GithubUser { |
| 324 | id, |
| 325 | login: login.to_owned(), |
| 326 | emails: verified_emails(&emails), |
| 327 | })) |
| 328 | } |
| 329 | |
| 330 | // --- Rows -------------------------------------------------------------------- |
| 331 | |
| 332 | #[derive(Deserialize)] |
| 333 | struct StateRow { |
| 334 | verifier: String, |
| 335 | purpose: String, |
| 336 | user_id: Option<String>, |
| 337 | redirect_uri: String, |
| 338 | next: String, |
| 339 | #[serde(default)] |
| 340 | invite_code: Option<String>, |
| 341 | } |
| 342 | |
| 343 | #[derive(Deserialize)] |
| 344 | struct PendingRow { |
| 345 | id: String, |
| 346 | github_id: u64, |
| 347 | login: String, |
| 348 | email: String, |
| 349 | kind: String, |
| 350 | suggestion: Option<String>, |
| 351 | tokens: Option<String>, |
| 352 | next: String, |
| 353 | #[serde(default)] |
| 354 | invite_code: Option<String>, |
| 355 | } |
| 356 | |
| 357 | #[derive(Deserialize)] |
| 358 | struct AccountRow { |
| 359 | user_id: String, |
| 360 | github_id: u64, |
| 361 | login: String, |
| 362 | tokens: Option<String>, |
| 363 | created_at: String, |
| 364 | } |
| 365 | |
| 366 | /// What a token is sealed to: the account row it belongs to. |
| 367 | fn bound(user_id: &str) -> String { |
| 368 | format!("github:{user_id}") |
| 369 | } |
| 370 | |
| 371 | impl Identity { |
| 372 | fn sealer(&self) -> Option<Sealer> { |
| 373 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) |
| 374 | } |
| 375 | |
| 376 | fn seal_tokens(&self, tokens: &Tokens, bound_to: &str) -> Option<String> { |
| 377 | Some(self.sealer()?.seal(&serde_json::to_string(tokens).ok()?, bound_to)) |
| 378 | } |
| 379 | |
| 380 | fn open_tokens(&self, sealed: Option<&str>, bound_to: &str) -> Option<Tokens> { |
| 381 | let plain = self.sealer()?.open(sealed?, bound_to)?; |
| 382 | serde_json::from_str(&plain).ok() |
| 383 | } |
| 384 | |
| 385 | pub fn github_enabled(&self) -> bool { |
| 386 | client(&self.env).is_some() |
| 387 | } |
| 388 | |
| 389 | pub async fn github_start(&self, a: GithubStartArgs) -> Result<Outcome<GithubStart>> { |
| 390 | let Some(client) = client(&self.env) else { |
| 391 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_SET_UP)); |
| 392 | }; |
| 393 | let redirect = Url::parse(&a.redirect_uri).ok(); |
| 394 | if !redirect.is_some_and(|url| url.scheme() == "https" || url.host_str() == Some("localhost")) { |
| 395 | return Ok(Outcome::fail(FailureCode::Invalid, "The callback must be an https address.")); |
| 396 | } |
| 397 | let user_id = match a.purpose { |
| 398 | GithubPurpose::Link => match &a.user { |
| 399 | Some(user) => Some(user.id.clone()), |
| 400 | None => return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t first.")), |
| 401 | }, |
| 402 | GithubPurpose::SignIn => None, |
| 403 | }; |
| 404 | let state = crypto::random_hex(32); |
| 405 | let verifier = new_verifier(); |
| 406 | self.db |
| 407 | .prepare(format!( |
| 408 | "INSERT INTO github_states (id, verifier, purpose, user_id, redirect_uri, next, invite_code, expires_at) |
| 409 | VALUES (?, ?, ?, ?, ?, ?, ?, {})", |
| 410 | sql_after(STATE_TTL_SECONDS) |
| 411 | )) |
| 412 | .bind(&[ |
| 413 | crypto::sha256_hex(&state).into(), |
| 414 | verifier.as_str().into(), |
| 415 | a.purpose.as_str().into(), |
| 416 | user_id.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 417 | a.redirect_uri.as_str().into(), |
| 418 | a.next.as_str().into(), |
| 419 | a.invite_code |
| 420 | .as_deref() |
| 421 | .map(str::trim) |
| 422 | .filter(|code| !code.is_empty()) |
| 423 | .map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 424 | ])? |
| 425 | .run() |
| 426 | .await?; |
| 427 | // Old states that were never used go now and then. |
| 428 | self.db |
| 429 | .prepare(format!("DELETE FROM github_states WHERE expires_at < {SQL_NOW}")) |
| 430 | .run() |
| 431 | .await?; |
| 432 | Ok(Outcome::Ok(GithubStart { |
| 433 | authorize_url: authorize_url(&client.id, &a.redirect_uri, &state, &pkce_challenge(&verifier)), |
| 434 | state, |
| 435 | })) |
| 436 | } |
| 437 | |
| 438 | async fn account_by_github(&self, github_id: u64) -> Result<Option<AccountRow>> { |
| 439 | self.db |
| 440 | .prepare("SELECT * FROM github_accounts WHERE github_id = ?") |
| 441 | .bind(&[(github_id as f64).into()])? |
| 442 | .first::<AccountRow>(None) |
| 443 | .await |
| 444 | } |
| 445 | |
| 446 | async fn account_of(&self, user_id: &str) -> Result<Option<AccountRow>> { |
| 447 | self.db |
| 448 | .prepare("SELECT * FROM github_accounts WHERE user_id = ?") |
| 449 | .bind(&[user_id.into()])? |
| 450 | .first::<AccountRow>(None) |
| 451 | .await |
| 452 | } |
| 453 | |
| 454 | /// Whether `username` could be registered now. |
| 455 | async fn username_free(&self, username: &str) -> Result<bool> { |
| 456 | if !is_valid_namespace(username) { |
| 457 | return Ok(false); |
| 458 | } |
| 459 | let taken = self |
| 460 | .db |
| 461 | .prepare("SELECT username FROM users WHERE username = ?1 UNION ALL SELECT slug FROM workspaces WHERE slug = ?1") |
| 462 | .bind(&[username.into()])? |
| 463 | .first::<Value>(None) |
| 464 | .await?; |
| 465 | Ok(taken.is_none() && !self.slug_held(username).await? && !self.slug_deleted(username).await?) |
| 466 | } |
| 467 | |
| 468 | /// Whether an account has confirmed one of these addresses, any of its |
| 469 | /// addresses, not only its primary (emails.rs). An address someone |
| 470 | /// added and never confirmed does not count: GitHub has confirmed it, |
| 471 | /// so a new account made with it wins it (first to confirm keeps it). |
| 472 | async fn email_taken(&self, emails: &[String]) -> Result<bool> { |
| 473 | for email in emails { |
| 474 | if self.user_with_verified_email(email).await?.is_some() { |
| 475 | return Ok(true); |
| 476 | } |
| 477 | } |
| 478 | Ok(false) |
| 479 | } |
| 480 | |
| 481 | /// Links a GitHub account to a user, keeping its tokens. |
| 482 | async fn link(&self, user_id: &str, github_id: u64, login: &str, tokens: Option<&Tokens>) -> Result<()> { |
| 483 | let sealed = tokens.and_then(|tokens| self.seal_tokens(tokens, &bound(user_id))); |
| 484 | let now = rfc3339(now_ms()); |
| 485 | self.db |
| 486 | .prepare( |
| 487 | "INSERT INTO github_accounts (user_id, github_id, login, tokens, created_at, updated_at) |
| 488 | VALUES (?1, ?2, ?3, ?4, ?5, ?5) |
| 489 | ON CONFLICT (user_id) DO UPDATE SET login = excluded.login, |
| 490 | tokens = COALESCE(excluded.tokens, github_accounts.tokens), updated_at = excluded.updated_at", |
| 491 | ) |
| 492 | .bind(&[ |
| 493 | user_id.into(), |
| 494 | (github_id as f64).into(), |
| 495 | login.into(), |
| 496 | sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 497 | now.as_str().into(), |
| 498 | ])? |
| 499 | .run() |
| 500 | .await?; |
| 501 | Ok(()) |
| 502 | } |
| 503 | |
| 504 | async fn user_by_id(&self, user_id: &str) -> Result<Option<User>> { |
| 505 | self.find_user( |
| 506 | "SELECT id, username, email_verified_at IS NOT NULL AS verified, avatar FROM users WHERE id = ?", |
| 507 | user_id, |
| 508 | ) |
| 509 | .await |
| 510 | } |
| 511 | |
| 512 | /// Keeps a GitHub sign-in that has to wait on the person. |
| 513 | async fn hold( |
| 514 | &self, |
| 515 | user: &GithubUser, |
| 516 | kind: &str, |
| 517 | suggestion: Option<&str>, |
| 518 | tokens: &Tokens, |
| 519 | next: &str, |
| 520 | invite_code: Option<&str>, |
| 521 | ) -> Result<String> { |
| 522 | let pending = crypto::random_hex(32); |
| 523 | let id = crypto::sha256_hex(&pending); |
| 524 | let sealed = self.seal_tokens(tokens, &id); |
| 525 | self.db |
| 526 | .prepare(format!( |
| 527 | "INSERT INTO github_pending (id, github_id, login, email, kind, suggestion, tokens, next, invite_code, expires_at) |
| 528 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, {})", |
| 529 | sql_after(PENDING_TTL_SECONDS) |
| 530 | )) |
| 531 | .bind(&[ |
| 532 | id.as_str().into(), |
| 533 | (user.id as f64).into(), |
| 534 | user.login.as_str().into(), |
| 535 | user.emails.first().map(String::as_str).unwrap_or_default().into(), |
| 536 | kind.into(), |
| 537 | suggestion.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 538 | sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 539 | next.into(), |
| 540 | invite_code.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 541 | ])? |
| 542 | .run() |
| 543 | .await?; |
| 544 | Ok(pending) |
| 545 | } |
| 546 | |
| 547 | async fn pending_row(&self, pending: &str) -> Result<Option<PendingRow>> { |
| 548 | self.db |
| 549 | .prepare(format!("SELECT * FROM github_pending WHERE id = ? AND expires_at > {SQL_NOW}")) |
| 550 | .bind(&[crypto::sha256_hex(pending).into()])? |
| 551 | .first::<PendingRow>(None) |
| 552 | .await |
| 553 | } |
| 554 | |
| 555 | async fn drop_pending(&self, id: &str) -> Result<()> { |
| 556 | self.db.prepare("DELETE FROM github_pending WHERE id = ?").bind(&[id.into()])?.run().await?; |
| 557 | self.db |
| 558 | .prepare(format!("DELETE FROM github_pending WHERE expires_at < {SQL_NOW}")) |
| 559 | .run() |
| 560 | .await?; |
| 561 | Ok(()) |
| 562 | } |
| 563 | |
| 564 | /// Makes an account from a GitHub sign-in: its email is GitHub's |
| 565 | /// verified primary, confirmed already, and it has no password. |
| 566 | async fn create_from_github( |
| 567 | &self, |
| 568 | username: &str, |
| 569 | email: &str, |
| 570 | github_id: u64, |
| 571 | login: &str, |
| 572 | tokens: Option<&Tokens>, |
| 573 | invite_code: Option<&str>, |
| 574 | ) -> Result<Outcome<User>> { |
| 575 | // Made where every account is made, so the invite is checked and |
| 576 | // spent in one place, with registration's rules (invites.rs). |
| 577 | let user = match self |
| 578 | .create_account(crate::invites::NewAccount { |
| 579 | username, |
| 580 | email, |
| 581 | password_hash: "", |
| 582 | verified: true, |
| 583 | invite_code, |
| 584 | client: None, |
| 585 | }) |
| 586 | .await? |
| 587 | { |
| 588 | Outcome::Ok(user) => user, |
| 589 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 590 | }; |
| 591 | self.link(&user.id, github_id, login, tokens).await?; |
| 592 | self.announce_user(username, Some(&user.id)).await; |
| 593 | Ok(Outcome::Ok(user)) |
| 594 | } |
| 595 | |
| 596 | /// Whether new accounts need an invite code: REGISTRATION_MODE, read |
| 597 | /// by invites.rs. Unset means they do. |
| 598 | fn github_invites_required(&self) -> bool { |
| 599 | self.invites_required() |
| 600 | } |
| 601 | |
| 602 | pub async fn github_finish(&self, a: GithubFinishArgs) -> Result<Outcome<GithubFinished>> { |
| 603 | let Some(client) = client(&self.env) else { |
| 604 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_SET_UP)); |
| 605 | }; |
| 606 | // Single use: the state is gone whatever happens next. |
| 607 | let state = self |
| 608 | .db |
| 609 | .prepare(format!( |
| 610 | "DELETE FROM github_states WHERE id = ? AND expires_at > {SQL_NOW} |
| 611 | RETURNING verifier, purpose, user_id, redirect_uri, next, invite_code" |
| 612 | )) |
| 613 | .bind(&[crypto::sha256_hex(&a.state).into()])? |
| 614 | .first::<StateRow>(None) |
| 615 | .await?; |
| 616 | let Some(state) = state else { |
| 617 | return Ok(Outcome::fail(FailureCode::Invalid, "This sign-in link has expired. Start again.")); |
| 618 | }; |
| 619 | let grant = serde_json::json!({ |
| 620 | "code": a.code, |
| 621 | "redirect_uri": state.redirect_uri, |
| 622 | "code_verifier": state.verifier, |
| 623 | }); |
| 624 | let Some(tokens) = token_request(&client, grant).await? else { |
| 625 | return Ok(Outcome::fail(FailureCode::Invalid, TRY_AGAIN)); |
| 626 | }; |
| 627 | let Some(mut github) = read_user(&tokens.access_token).await? else { |
| 628 | return Ok(Outcome::fail(FailureCode::Invalid, TRY_AGAIN)); |
| 629 | }; |
| 630 | // An invite sent to one address makes the account with that one, |
| 631 | // when GitHub has confirmed it too; otherwise the invite is not |
| 632 | // this GitHub account's to use. |
| 633 | let bound = match state.invite_code.as_deref() { |
| 634 | Some(code) => self.bound_email_of(code).await?, |
| 635 | None => None, |
| 636 | }; |
| 637 | let bound_elsewhere = bound.as_deref().is_some_and(|bound| !put_first(&mut github.emails, bound)); |
| 638 | let purpose = if state.purpose == "link" { GithubPurpose::Link } else { GithubPurpose::SignIn }; |
| 639 | let linked = self.account_by_github(github.id).await?; |
| 640 | let asking_has_other = match &state.user_id { |
| 641 | Some(user_id) => self.account_of(user_id).await?.is_some_and(|row| row.github_id != github.id), |
| 642 | None => false, |
| 643 | }; |
| 644 | let suggestion = suggest_username(&github.login); |
| 645 | let facts = Facts { |
| 646 | purpose: Some(purpose), |
| 647 | asking: state.user_id.as_deref(), |
| 648 | asking_has_other, |
| 649 | linked_to: linked.as_ref().map(|row| row.user_id.as_str()), |
| 650 | has_verified_email: !github.emails.is_empty(), |
| 651 | email_taken: linked.is_none() && self.email_taken(&github.emails).await?, |
| 652 | suggestion_free: linked.is_none() && self.username_free(&suggestion).await?, |
| 653 | suggestion: suggestion.clone(), |
| 654 | invite_missing: self.github_invites_required() && state.invite_code.is_none(), |
| 655 | }; |
| 656 | let next = state.next; |
| 657 | let decision = decide(&facts); |
| 658 | if bound_elsewhere && matches!(decision, Decision::Create(_) | Decision::NeedsUsername(_)) { |
| 659 | return Ok(Outcome::fail(FailureCode::Conflict, INVITE_FOR_ANOTHER_ADDRESS)); |
| 660 | } |
| 661 | Ok(match decision { |
| 662 | Decision::Refuse(reason) => Outcome::fail(FailureCode::Conflict, reason), |
| 663 | Decision::Link(user_id) => { |
| 664 | self.link(&user_id, github.id, &github.login, Some(&tokens)).await?; |
| 665 | if let Some(user) = self.user_by_id(&user_id).await? { |
| 666 | self.audit_github(&user, "github.linked", format!("Linked GitHub account @{}", github.login)).await; |
| 667 | } |
| 668 | Outcome::Ok(GithubFinished::Linked { login: github.login, next }) |
| 669 | } |
| 670 | Decision::SignIn(user_id) => { |
| 671 | self.link(&user_id, github.id, &github.login, Some(&tokens)).await?; |
| 672 | let Some(user) = self.user_by_id(&user_id).await? else { |
| 673 | return Ok(Outcome::fail(FailureCode::NotFound, TRY_AGAIN)); |
| 674 | }; |
| 675 | self.audit_github(&user, "github.sign_in", format!("Signed in with GitHub (@{})", github.login)).await; |
| 676 | self.signed_in(user, false, next).await? |
| 677 | } |
| 678 | Decision::NeedsLink => { |
| 679 | let pending = self.hold(&github, "link", None, &tokens, &next, None).await?; |
| 680 | Outcome::Ok(GithubFinished::NeedsLink { pending, login: github.login, next }) |
| 681 | } |
| 682 | Decision::Create(username) => { |
| 683 | let email = github.emails[0].clone(); |
| 684 | let invite = state.invite_code.as_deref(); |
| 685 | match self.create_from_github(&username, &email, github.id, &github.login, Some(&tokens), invite).await? { |
| 686 | Outcome::Ok(user) => self.signed_in(user, true, next).await?, |
| 687 | // A code that did not pass: the person can enter another. |
| 688 | Outcome::Fail(_) => { |
| 689 | let pending = self.hold(&github, "username", Some(&username), &tokens, &next, None).await?; |
| 690 | Outcome::Ok(GithubFinished::NeedsUsername { |
| 691 | pending, |
| 692 | login: github.login, |
| 693 | suggestion: username, |
| 694 | next, |
| 695 | invite_required: self.github_invites_required(), |
| 696 | }) |
| 697 | } |
| 698 | } |
| 699 | } |
| 700 | Decision::NeedsUsername(suggestion) => { |
| 701 | let invite = state.invite_code.as_deref(); |
| 702 | let pending = self.hold(&github, "username", Some(&suggestion), &tokens, &next, invite).await?; |
| 703 | Outcome::Ok(GithubFinished::NeedsUsername { |
| 704 | pending, |
| 705 | login: github.login, |
| 706 | suggestion, |
| 707 | next, |
| 708 | invite_required: self.github_invites_required() && invite.is_none(), |
| 709 | }) |
| 710 | } |
| 711 | }) |
| 712 | } |
| 713 | |
| 714 | async fn signed_in(&self, user: User, created: bool, next: String) -> Result<Outcome<GithubFinished>> { |
| 715 | Ok(match self.start_session(user).await? { |
| 716 | Outcome::Ok(signed_in) => Outcome::Ok(GithubFinished::SignedIn { signed_in, created, next }), |
| 717 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 718 | }) |
| 719 | } |
| 720 | |
| 721 | pub async fn github_pending(&self, a: GithubPendingArgs) -> Result<Outcome<GithubPending>> { |
| 722 | let Some(row) = self.pending_row(&a.pending).await? else { |
| 723 | return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again.")); |
| 724 | }; |
| 725 | Ok(Outcome::Ok(GithubPending { |
| 726 | invite_required: row.kind == "username" && self.github_invites_required() && row.invite_code.is_none(), |
| 727 | login: row.login, |
| 728 | kind: row.kind, |
| 729 | suggestion: row.suggestion, |
| 730 | next: row.next, |
| 731 | })) |
| 732 | } |
| 733 | |
| 734 | pub async fn github_sign_up(&self, a: GithubSignUpArgs) -> Result<Outcome<SignedIn>> { |
| 735 | let Some(row) = self.pending_row(&a.pending).await?.filter(|row| row.kind == "username") else { |
| 736 | return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again.")); |
| 737 | }; |
| 738 | let username = a.username.trim().to_lowercase(); |
| 739 | if !is_valid_namespace(&username) { |
| 740 | return Ok(Outcome::fail( |
| 741 | FailureCode::Invalid, |
| 742 | "Usernames use lowercase letters, digits and single hyphens, up to 39 characters.", |
| 743 | )); |
| 744 | } |
| 745 | if !self.username_free(&username).await? { |
| 746 | return Ok(Outcome::fail(FailureCode::Conflict, "That username is taken. Choose another.")); |
| 747 | } |
| 748 | // Checked again: either could have changed while the person chose. |
| 749 | if self.account_by_github(row.github_id).await?.is_some() || self.email_taken(std::slice::from_ref(&row.email)).await? { |
| 750 | return Ok(Outcome::fail(FailureCode::Conflict, "An account already uses this GitHub account or email. Sign in instead.")); |
| 751 | } |
| 752 | let tokens = self.open_tokens(row.tokens.as_deref(), &row.id); |
| 753 | let given = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty()); |
| 754 | let invite = given.or(row.invite_code.as_deref()); |
| 755 | let user = match self |
| 756 | .create_from_github(&username, &row.email, row.github_id, &row.login, tokens.as_ref(), invite) |
| 757 | .await? |
| 758 | { |
| 759 | Outcome::Ok(user) => user, |
| 760 | Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)), |
| 761 | }; |
| 762 | self.drop_pending(&row.id).await?; |
| 763 | self.start_session(user).await |
| 764 | } |
| 765 | |
| 766 | /// Links a held GitHub sign-in to the account the person then signed in |
| 767 | /// to: they have proved both. |
| 768 | pub async fn github_claim(&self, a: GithubClaimArgs) -> Result<Outcome<GithubAccount>> { |
| 769 | let Some(row) = self.pending_row(&a.pending).await? else { |
| 770 | return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again.")); |
| 771 | }; |
| 772 | if let Some(linked) = self.account_by_github(row.github_id).await? |
| 773 | && linked.user_id != a.user.id |
| 774 | { |
| 775 | return Ok(Outcome::fail(FailureCode::Conflict, "That GitHub account is linked to another g1t account.")); |
| 776 | } |
| 777 | if self.account_of(&a.user.id).await?.is_some_and(|linked| linked.github_id != row.github_id) { |
| 778 | return Ok(Outcome::fail(FailureCode::Conflict, "Your account is linked to another GitHub account. Unlink it first.")); |
| 779 | } |
| 780 | let tokens = self.open_tokens(row.tokens.as_deref(), &row.id); |
| 781 | self.link(&a.user.id, row.github_id, &row.login, tokens.as_ref()).await?; |
| 782 | self.drop_pending(&row.id).await?; |
| 783 | self.audit_github(&a.user, "github.linked", format!("Linked GitHub account @{}", row.login)).await; |
| 784 | Ok(Outcome::Ok(GithubAccount { |
| 785 | github_id: row.github_id, |
| 786 | login: row.login, |
| 787 | linked_at: rfc3339(now_ms()), |
| 788 | authorized: tokens.is_some(), |
| 789 | })) |
| 790 | } |
| 791 | |
| 792 | async fn has_password(&self, user_id: &str) -> Result<bool> { |
| 793 | Ok(self |
| 794 | .db |
| 795 | .prepare("SELECT 1 AS yes FROM users WHERE id = ? AND password_hash LIKE 'pbkdf2$%'") |
| 796 | .bind(&[user_id.into()])? |
| 797 | .first::<Value>(None) |
| 798 | .await? |
| 799 | .is_some()) |
| 800 | } |
| 801 | |
| 802 | pub async fn github_account(&self, a: UserArgs) -> Result<GithubAccountView> { |
| 803 | let row = self.account_of(&a.user.id).await?; |
| 804 | Ok(GithubAccountView { |
| 805 | enabled: self.github_enabled(), |
| 806 | account: row.map(|row| GithubAccount { |
| 807 | authorized: self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)).is_some(), |
| 808 | github_id: row.github_id, |
| 809 | login: row.login, |
| 810 | linked_at: row.created_at, |
| 811 | }), |
| 812 | has_password: self.has_password(&a.user.id).await?, |
| 813 | }) |
| 814 | } |
| 815 | |
| 816 | pub async fn github_unlink(&self, a: UserArgs) -> Result<Outcome<bool>> { |
| 817 | let Some(row) = self.account_of(&a.user.id).await? else { |
| 818 | return Ok(Outcome::Ok(false)); |
| 819 | }; |
| 820 | if !self.has_password(&a.user.id).await? { |
| 821 | return Ok(Outcome::fail( |
| 822 | FailureCode::Conflict, |
| 823 | "GitHub is the only way you sign in. Set a password first: sign out and use Forgot your password.", |
| 824 | )); |
| 825 | } |
| 826 | self.db |
| 827 | .prepare("DELETE FROM github_accounts WHERE user_id = ?") |
| 828 | .bind(&[a.user.id.as_str().into()])? |
| 829 | .run() |
| 830 | .await?; |
| 831 | // Best effort: also end g1t's authorization on GitHub's side. |
| 832 | if let (Some(client), Some(tokens)) = (client(&self.env), self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id))) { |
| 833 | let _ = revoke_grant(&client, &tokens.access_token).await; |
| 834 | } |
| 835 | self.audit_github(&a.user, "github.unlinked", format!("Unlinked GitHub account @{}", row.login)).await; |
| 836 | Ok(Outcome::Ok(true)) |
| 837 | } |
| 838 | |
| 839 | /// A working user token for the person, refreshed when it is about to |
| 840 | /// expire. For the integrations service, to list installations. |
| 841 | pub async fn github_user_token(&self, a: GithubUserTokenArgs) -> Result<Outcome<String>> { |
| 842 | const RELINK: &str = "Link your GitHub account again in your settings: g1t's access to it has ended."; |
| 843 | let Some(row) = self.account_of(&a.user_id).await? else { |
| 844 | return Ok(Outcome::fail(FailureCode::NotFound, "Link your GitHub account first.")); |
| 845 | }; |
| 846 | let Some(tokens) = self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)) else { |
| 847 | return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK)); |
| 848 | }; |
| 849 | let now = now_ms(); |
| 850 | if tokens.fresh(now) { |
| 851 | return Ok(Outcome::Ok(tokens.access_token)); |
| 852 | } |
| 853 | let (Some(client), true) = (client(&self.env), tokens.refreshable(now)) else { |
| 854 | self.forget_tokens(&row.user_id).await?; |
| 855 | return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK)); |
| 856 | }; |
| 857 | let grant = serde_json::json!({ |
| 858 | "grant_type": "refresh_token", |
| 859 | "refresh_token": tokens.refresh_token, |
| 860 | }); |
| 861 | let Some(refreshed) = token_request(&client, grant).await? else { |
| 862 | self.forget_tokens(&row.user_id).await?; |
| 863 | return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK)); |
| 864 | }; |
| 865 | let sealed = self.seal_tokens(&refreshed, &bound(&row.user_id)); |
| 866 | self.db |
| 867 | .prepare(format!("UPDATE github_accounts SET tokens = ?, updated_at = {SQL_NOW} WHERE user_id = ?")) |
| 868 | .bind(&[ |
| 869 | sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), |
| 870 | row.user_id.as_str().into(), |
| 871 | ])? |
| 872 | .run() |
| 873 | .await?; |
| 874 | Ok(Outcome::Ok(refreshed.access_token)) |
| 875 | } |
| 876 | |
| 877 | async fn forget_tokens(&self, user_id: &str) -> Result<()> { |
| 878 | self.db |
| 879 | .prepare("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?") |
| 880 | .bind(&[user_id.into()])? |
| 881 | .run() |
| 882 | .await?; |
| 883 | Ok(()) |
| 884 | } |
| 885 | |
| 886 | /// The person revoked g1t's authorization on GitHub: its tokens go. |
| 887 | /// The link stays, so they can still sign in with GitHub. |
| 888 | pub async fn github_revoked(&self, a: GithubRevokedArgs) -> Result<u32> { |
| 889 | let changed = self |
| 890 | .db |
| 891 | .prepare("UPDATE github_accounts SET tokens = NULL WHERE github_id = ? RETURNING user_id") |
| 892 | .bind(&[(a.github_id as f64).into()])? |
| 893 | .all() |
| 894 | .await? |
| 895 | .results::<Value>()?; |
| 896 | Ok(changed.len() as u32) |
| 897 | } |
| 898 | |
| 899 | /// The g1t usernames of linked GitHub accounts, by GitHub id, for |
| 900 | /// showing who wrote what was imported. |
| 901 | pub async fn github_usernames(&self, a: GithubUsernamesArgs) -> Result<std::collections::HashMap<String, String>> { |
| 902 | #[derive(Deserialize)] |
| 903 | struct Named { |
| 904 | github_id: u64, |
| 905 | username: String, |
| 906 | } |
| 907 | let ids: Vec<u64> = a.github_ids.into_iter().take(100).collect(); |
| 908 | let mut names = std::collections::HashMap::new(); |
| 909 | if ids.is_empty() { |
| 910 | return Ok(names); |
| 911 | } |
| 912 | let marks = vec!["?"; ids.len()].join(", "); |
| 913 | let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| (*id as f64).into()).collect(); |
| 914 | let rows = self |
| 915 | .db |
| 916 | .prepare(format!( |
| 917 | "SELECT github_accounts.github_id, users.username FROM github_accounts |
| 918 | JOIN users ON users.id = github_accounts.user_id WHERE github_id IN ({marks})" |
| 919 | )) |
| 920 | .bind(&bind)? |
| 921 | .all() |
| 922 | .await? |
| 923 | .results::<Named>()?; |
| 924 | for row in rows { |
| 925 | names.insert(row.github_id.to_string(), row.username); |
| 926 | } |
| 927 | Ok(names) |
| 928 | } |
| 929 | |
| 930 | /// Recorded in the audit log of every workspace the person belongs to, |
| 931 | /// which is where their workspaces' owners look. |
| 932 | async fn audit_github(&self, user: &User, action: &str, message: String) { |
| 933 | let (Ok(events), Ok(memberships)) = (self.env.service("EVENTS"), self.memberships(&user.id).await) else { |
| 934 | return; |
| 935 | }; |
| 936 | let entries: Vec<NewAuditEntry> = memberships |
| 937 | .into_iter() |
| 938 | .map(|membership| NewAuditEntry { |
| 939 | actor: AuditActor::of(user), |
| 940 | action: action.to_owned(), |
| 941 | surface: Surface::Web, |
| 942 | target: AuditTarget { |
| 943 | workspace: membership.slug, |
| 944 | ..AuditTarget::default() |
| 945 | }, |
| 946 | outcome: AuditOutcome::Allowed, |
| 947 | rule: "github".to_owned(), |
| 948 | result: Some("ok".to_owned()), |
| 949 | message: Some(message.clone()), |
| 950 | request_id: new_id("req", now_ms()), |
| 951 | }) |
| 952 | .collect(); |
| 953 | if entries.is_empty() { |
| 954 | return; |
| 955 | } |
| 956 | let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await; |
| 957 | if let Err(error) = recorded { |
| 958 | worker::console_error!("{action} not recorded: {error}"); |
| 959 | } |
| 960 | } |
| 961 | } |
| 962 | |
| 963 | /// `DELETE /applications/{client_id}/grant`, with the client's own |
| 964 | /// credentials. |
| 965 | async fn revoke_grant(client: &Client, access_token: &str) -> Result<()> { |
| 966 | let headers = Headers::new(); |
| 967 | headers.set("user-agent", "g1t (+https://g1t.sh)")?; |
| 968 | headers.set("accept", "application/vnd.github+json")?; |
| 969 | headers.set("content-type", "application/json")?; |
| 970 | let basic = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", client.id, client.secret)); |
| 971 | headers.set("authorization", &format!("Basic {basic}"))?; |
| 972 | let mut init = RequestInit::new(); |
| 973 | init.with_method(Method::Delete) |
| 974 | .with_headers(headers) |
| 975 | .with_body(Some(serde_json::json!({ "access_token": access_token }).to_string().into())); |
| 976 | let url = format!("{API}/applications/{}/grant", client.id); |
| 977 | Fetch::Request(Request::new_with_init(&url, &init)?).send().await?; |
| 978 | Ok(()) |
| 979 | } |
| 980 | |
| 981 | #[cfg(test)] |
| 982 | mod tests { |
| 983 | use super::*; |
| 984 | |
| 985 | #[test] |
| 986 | fn the_challenge_is_rfc_7636s() { |
| 987 | // RFC 7636, appendix B. |
| 988 | assert_eq!( |
| 989 | pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), |
| 990 | "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" |
| 991 | ); |
| 992 | let verifier = new_verifier(); |
| 993 | assert_eq!(verifier.len(), 43); |
| 994 | assert_ne!(verifier, new_verifier()); |
| 995 | } |
| 996 | |
| 997 | #[test] |
| 998 | fn the_authorize_url_carries_state_and_challenge() { |
| 999 | let url = authorize_url("Iv23liZS94alfjIUn1eW", "https://g1t.sh/auth/github/callback", "abc", "xyz"); |
| 1000 | let parsed = Url::parse(&url).unwrap(); |
| 1001 | let query: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect(); |
| 1002 | assert_eq!(parsed.host_str(), Some("github.com")); |
| 1003 | assert_eq!(query["redirect_uri"], "https://g1t.sh/auth/github/callback"); |
| 1004 | assert_eq!(query["state"], "abc"); |
| 1005 | assert_eq!(query["code_challenge"], "xyz"); |
| 1006 | assert_eq!(query["code_challenge_method"], "S256"); |
| 1007 | } |
| 1008 | |
| 1009 | fn email(address: &str, primary: bool, verified: bool) -> GithubEmail { |
| 1010 | GithubEmail { |
| 1011 | email: address.to_owned(), |
| 1012 | primary, |
| 1013 | verified, |
| 1014 | } |
| 1015 | } |
| 1016 | |
| 1017 | #[test] |
| 1018 | fn only_verified_emails_count_primary_first() { |
| 1019 | let emails = [ |
| 1020 | email("unverified@example.com", false, false), |
| 1021 | email("Work@Example.com", false, true), |
| 1022 | email("1+me@users.noreply.github.com", false, true), |
| 1023 | email("me@example.com", true, true), |
| 1024 | ]; |
| 1025 | assert_eq!(verified_emails(&emails), vec!["me@example.com", "work@example.com"]); |
| 1026 | assert!(verified_emails(&[email("primary@example.com", true, false)]).is_empty()); |
| 1027 | } |
| 1028 | |
| 1029 | #[test] |
| 1030 | fn usernames_come_from_logins() { |
| 1031 | assert_eq!(suggest_username("Octo-Cat"), "octo-cat"); |
| 1032 | assert_eq!(suggest_username("a_b..c"), "a-b-c"); |
| 1033 | assert_eq!(suggest_username("-x-"), "x"); |
| 1034 | assert_eq!(suggest_username(&"a".repeat(50)).len(), 39); |
| 1035 | } |
| 1036 | |
| 1037 | fn facts() -> Facts<'static> { |
| 1038 | Facts { |
| 1039 | purpose: Some(GithubPurpose::SignIn), |
| 1040 | has_verified_email: true, |
| 1041 | suggestion: "octocat".to_owned(), |
| 1042 | suggestion_free: true, |
| 1043 | ..Facts::default() |
| 1044 | } |
| 1045 | } |
| 1046 | |
| 1047 | #[test] |
| 1048 | fn a_linked_account_signs_in() { |
| 1049 | let facts = Facts { linked_to: Some("usr_1"), email_taken: true, ..facts() }; |
| 1050 | assert_eq!(decide(&facts), Decision::SignIn("usr_1".to_owned())); |
| 1051 | } |
| 1052 | |
| 1053 | #[test] |
| 1054 | fn a_matching_email_is_never_linked_silently() { |
| 1055 | let facts = Facts { email_taken: true, ..facts() }; |
| 1056 | assert_eq!(decide(&facts), Decision::NeedsLink); |
| 1057 | } |
| 1058 | |
| 1059 | #[test] |
| 1060 | fn a_new_person_gets_their_login_or_chooses() { |
| 1061 | assert_eq!(decide(&facts()), Decision::Create("octocat".to_owned())); |
| 1062 | let taken = Facts { suggestion_free: false, ..facts() }; |
| 1063 | assert_eq!(decide(&taken), Decision::NeedsUsername("octocat".to_owned())); |
| 1064 | let no_email = Facts { has_verified_email: false, ..facts() }; |
| 1065 | assert!(matches!(decide(&no_email), Decision::Refuse(_))); |
| 1066 | } |
| 1067 | |
| 1068 | #[test] |
| 1069 | fn an_invite_for_one_address_makes_the_account_with_it() { |
| 1070 | let mut emails = vec!["ada@work.example".to_owned(), "ada@home.example".to_owned()]; |
| 1071 | assert!(put_first(&mut emails, "Ada@Home.example")); |
| 1072 | assert_eq!(emails, ["ada@home.example", "ada@work.example"]); |
| 1073 | assert!(!put_first(&mut emails, "eve@example.com")); |
| 1074 | assert_eq!(emails, ["ada@home.example", "ada@work.example"]); |
| 1075 | } |
| 1076 | |
| 1077 | #[test] |
| 1078 | fn an_invite_only_g1t_waits_for_a_code() { |
| 1079 | let waiting = Facts { invite_missing: true, ..facts() }; |
| 1080 | assert_eq!(decide(&waiting), Decision::NeedsUsername("octocat".to_owned())); |
| 1081 | // Existing accounts sign in and link without one. |
| 1082 | let linked = Facts { invite_missing: true, linked_to: Some("usr_1"), ..facts() }; |
| 1083 | assert_eq!(decide(&linked), Decision::SignIn("usr_1".to_owned())); |
| 1084 | let matching = Facts { invite_missing: true, email_taken: true, ..facts() }; |
| 1085 | assert_eq!(decide(&matching), Decision::NeedsLink); |
| 1086 | } |
| 1087 | |
| 1088 | #[test] |
| 1089 | fn linking_is_for_the_account_that_asked() { |
| 1090 | let link = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), ..facts() }; |
| 1091 | assert_eq!(decide(&link), Decision::Link("usr_1".to_owned())); |
| 1092 | let elsewhere = Facts { linked_to: Some("usr_2"), ..link }; |
| 1093 | assert!(matches!(decide(&elsewhere), Decision::Refuse(_))); |
| 1094 | let other = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), asking_has_other: true, ..facts() }; |
| 1095 | assert!(matches!(decide(&other), Decision::Refuse(_))); |
| 1096 | let nobody = Facts { purpose: Some(GithubPurpose::Link), ..facts() }; |
| 1097 | assert!(matches!(decide(&nobody), Decision::Refuse(_))); |
| 1098 | } |
| 1099 | |
| 1100 | #[test] |
| 1101 | fn tokens_expire_and_refresh() { |
| 1102 | let answer = serde_json::json!({ |
| 1103 | "access_token": format!("ghu_{}", "a".repeat(516)), |
| 1104 | "expires_in": 28800, |
| 1105 | "refresh_token": "ghr_x", |
| 1106 | "refresh_token_expires_in": 15897600, |
| 1107 | "token_type": "bearer", |
| 1108 | }); |
| 1109 | let tokens = tokens_from(&answer, 1_000).unwrap(); |
| 1110 | assert_eq!(tokens.access_token.len(), 520); |
| 1111 | assert_eq!(tokens.access_expires_at, Some(1_000 + 28_800_000)); |
| 1112 | assert!(tokens.fresh(1_000)); |
| 1113 | assert!(!tokens.fresh(1_000 + 28_800_000 - 60_000)); |
| 1114 | assert!(tokens.refreshable(1_000 + 28_800_000)); |
| 1115 | assert!(tokens_from(&serde_json::json!({ "error": "bad_verification_code" }), 0).is_none()); |
| 1116 | // Tokens that never expire, as when expiry is turned off on the app. |
| 1117 | let lasting = tokens_from(&serde_json::json!({ "access_token": "gho_x" }), 0).unwrap(); |
| 1118 | assert!(lasting.fresh(u64::MAX / 2)); |
| 1119 | assert!(!lasting.refreshable(0)); |
| 1120 | } |
| 1121 | |
| 1122 | #[test] |
| 1123 | fn a_long_token_survives_sealing() { |
| 1124 | let sealer = Sealer::new("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f").unwrap(); |
| 1125 | let tokens = Tokens { |
| 1126 | access_token: format!("ghs_{}", "z".repeat(516)), |
| 1127 | access_expires_at: None, |
| 1128 | refresh_token: None, |
| 1129 | refresh_expires_at: None, |
| 1130 | }; |
| 1131 | let sealed = sealer.seal(&serde_json::to_string(&tokens).unwrap(), &bound("usr_1")); |
| 1132 | let opened: Tokens = serde_json::from_str(&sealer.open(&sealed, &bound("usr_1")).unwrap()).unwrap(); |
| 1133 | assert_eq!(opened, tokens); |
| 1134 | assert!(sealer.open(&sealed, &bound("usr_2")).is_none()); |
| 1135 | } |
| 1136 | } |