g1t/services/identity/src/github.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | async fn read_user(token: &str) -> Result<Option<GithubUser>> { | |
| 304 | let user = send(Method::Get, &format!("{API}/user"), Some(token), None).await?; | |
| 305 | let (Some(id), Some(login)) = (user.body["id"].as_u64(), user.body["login"].as_str()) else { | |
| 306 | return Ok(None); | |
| 307 | }; | |
| 308 | let listed = send(Method::Get, &format!("{API}/user/emails"), Some(token), None).await?; | |
| 309 | let emails: Vec<GithubEmail> = serde_json::from_value(listed.body).unwrap_or_default(); | |
| 310 | Ok(Some(GithubUser { | |
| 311 | id, | |
| 312 | login: login.to_owned(), | |
| 313 | emails: verified_emails(&emails), | |
| 314 | })) | |
| 315 | } | |
| 316 | ||
| 317 | // --- Rows -------------------------------------------------------------------- | |
| 318 | ||
| 319 | #[derive(Deserialize)] | |
| 320 | struct StateRow { | |
| 321 | verifier: String, | |
| 322 | purpose: String, | |
| 323 | user_id: Option<String>, | |
| 324 | redirect_uri: String, | |
| 325 | next: String, | |
| 326 | #[serde(default)] | |
| 327 | invite_code: Option<String>, | |
| 328 | } | |
| 329 | ||
| 330 | #[derive(Deserialize)] | |
| 331 | struct PendingRow { | |
| 332 | id: String, | |
| 333 | github_id: u64, | |
| 334 | login: String, | |
| 335 | email: String, | |
| 336 | kind: String, | |
| 337 | suggestion: Option<String>, | |
| 338 | tokens: Option<String>, | |
| 339 | next: String, | |
| 340 | #[serde(default)] | |
| 341 | invite_code: Option<String>, | |
| 342 | } | |
| 343 | ||
| 344 | #[derive(Deserialize)] | |
| 345 | struct AccountRow { | |
| 346 | user_id: String, | |
| 347 | github_id: u64, | |
| 348 | login: String, | |
| 349 | tokens: Option<String>, | |
| 350 | created_at: String, | |
| 351 | } | |
| 352 | ||
| 353 | /// What a token is sealed to: the account row it belongs to. | |
| 354 | fn bound(user_id: &str) -> String { | |
| 355 | format!("github:{user_id}") | |
| 356 | } | |
| 357 | ||
| 358 | impl Identity { | |
| 359 | fn sealer(&self) -> Option<Sealer> { | |
| 360 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) | |
| 361 | } | |
| 362 | ||
| 363 | fn seal_tokens(&self, tokens: &Tokens, bound_to: &str) -> Option<String> { | |
| 364 | Some(self.sealer()?.seal(&serde_json::to_string(tokens).ok()?, bound_to)) | |
| 365 | } | |
| 366 | ||
| 367 | fn open_tokens(&self, sealed: Option<&str>, bound_to: &str) -> Option<Tokens> { | |
| 368 | let plain = self.sealer()?.open(sealed?, bound_to)?; | |
| 369 | serde_json::from_str(&plain).ok() | |
| 370 | } | |
| 371 | ||
| 372 | pub fn github_enabled(&self) -> bool { | |
| 373 | client(&self.env).is_some() | |
| 374 | } | |
| 375 | ||
| 376 | pub async fn github_start(&self, a: GithubStartArgs) -> Result<Outcome<GithubStart>> { | |
| 377 | let Some(client) = client(&self.env) else { | |
| 378 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_SET_UP)); | |
| 379 | }; | |
| 380 | let redirect = Url::parse(&a.redirect_uri).ok(); | |
| 381 | if !redirect.is_some_and(|url| url.scheme() == "https" || url.host_str() == Some("localhost")) { | |
| 382 | return Ok(Outcome::fail(FailureCode::Invalid, "The callback must be an https address.")); | |
| 383 | } | |
| 384 | let user_id = match a.purpose { | |
| 385 | GithubPurpose::Link => match &a.user { | |
| 386 | Some(user) => Some(user.id.clone()), | |
| 387 | None => return Ok(Outcome::fail(FailureCode::Unauthenticated, "Sign in to g1t first.")), | |
| 388 | }, | |
| 389 | GithubPurpose::SignIn => None, | |
| 390 | }; | |
| 391 | let state = crypto::random_hex(32); | |
| 392 | let verifier = new_verifier(); | |
| 393 | self.db | |
| 394 | .prepare(format!( | |
| 395 | "INSERT INTO github_states (id, verifier, purpose, user_id, redirect_uri, next, invite_code, expires_at) | |
| 396 | VALUES (?, ?, ?, ?, ?, ?, ?, {})", | |
| 397 | sql_after(STATE_TTL_SECONDS) | |
| 398 | )) | |
| 399 | .bind(&[ | |
| 400 | crypto::sha256_hex(&state).into(), | |
| 401 | verifier.as_str().into(), | |
| 402 | a.purpose.as_str().into(), | |
| 403 | user_id.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 404 | a.redirect_uri.as_str().into(), | |
| 405 | a.next.as_str().into(), | |
| 406 | a.invite_code | |
| 407 | .as_deref() | |
| 408 | .map(str::trim) | |
| 409 | .filter(|code| !code.is_empty()) | |
| 410 | .map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 411 | ])? | |
| 412 | .run() | |
| 413 | .await?; | |
| 414 | // Old states that were never used go now and then. | |
| 415 | self.db | |
| 416 | .prepare(format!("DELETE FROM github_states WHERE expires_at < {SQL_NOW}")) | |
| 417 | .run() | |
| 418 | .await?; | |
| 419 | Ok(Outcome::Ok(GithubStart { | |
| 420 | authorize_url: authorize_url(&client.id, &a.redirect_uri, &state, &pkce_challenge(&verifier)), | |
| 421 | state, | |
| 422 | })) | |
| 423 | } | |
| 424 | ||
| 425 | async fn account_by_github(&self, github_id: u64) -> Result<Option<AccountRow>> { | |
| 426 | self.db | |
| 427 | .prepare("SELECT * FROM github_accounts WHERE github_id = ?") | |
| 428 | .bind(&[(github_id as f64).into()])? | |
| 429 | .first::<AccountRow>(None) | |
| 430 | .await | |
| 431 | } | |
| 432 | ||
| 433 | async fn account_of(&self, user_id: &str) -> Result<Option<AccountRow>> { | |
| 434 | self.db | |
| 435 | .prepare("SELECT * FROM github_accounts WHERE user_id = ?") | |
| 436 | .bind(&[user_id.into()])? | |
| 437 | .first::<AccountRow>(None) | |
| 438 | .await | |
| 439 | } | |
| 440 | ||
| 441 | /// Whether `username` could be registered now. | |
| 442 | async fn username_free(&self, username: &str) -> Result<bool> { | |
| 443 | if !is_valid_namespace(username) { | |
| 444 | return Ok(false); | |
| 445 | } | |
| 446 | let taken = self | |
| 447 | .db | |
| 448 | .prepare("SELECT username FROM users WHERE username = ?1 UNION ALL SELECT slug FROM workspaces WHERE slug = ?1") | |
| 449 | .bind(&[username.into()])? | |
| 450 | .first::<Value>(None) | |
| 451 | .await?; | |
| 452 | Ok(taken.is_none() && !self.slug_held(username).await? && !self.slug_deleted(username).await?) | |
| 453 | } | |
| 454 | ||
| 455 | /// Whether an account has confirmed one of these addresses, any of its | |
| 456 | /// addresses, not only its primary (emails.rs). An address someone | |
| 457 | /// added and never confirmed does not count: GitHub has confirmed it, | |
| 458 | /// so a new account made with it wins it (first to confirm keeps it). | |
| 459 | async fn email_taken(&self, emails: &[String]) -> Result<bool> { | |
| 460 | for email in emails { | |
| 461 | if self.user_with_verified_email(email).await?.is_some() { | |
| 462 | return Ok(true); | |
| 463 | } | |
| 464 | } | |
| 465 | Ok(false) | |
| 466 | } | |
| 467 | ||
| 468 | /// Links a GitHub account to a user, keeping its tokens. | |
| 469 | async fn link(&self, user_id: &str, github_id: u64, login: &str, tokens: Option<&Tokens>) -> Result<()> { | |
| 470 | let sealed = tokens.and_then(|tokens| self.seal_tokens(tokens, &bound(user_id))); | |
| 471 | let now = rfc3339(now_ms()); | |
| 472 | self.db | |
| 473 | .prepare( | |
| 474 | "INSERT INTO github_accounts (user_id, github_id, login, tokens, created_at, updated_at) | |
| 475 | VALUES (?1, ?2, ?3, ?4, ?5, ?5) | |
| 476 | ON CONFLICT (user_id) DO UPDATE SET login = excluded.login, | |
| 477 | tokens = COALESCE(excluded.tokens, github_accounts.tokens), updated_at = excluded.updated_at", | |
| 478 | ) | |
| 479 | .bind(&[ | |
| 480 | user_id.into(), | |
| 481 | (github_id as f64).into(), | |
| 482 | login.into(), | |
| 483 | sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 484 | now.as_str().into(), | |
| 485 | ])? | |
| 486 | .run() | |
| 487 | .await?; | |
| 488 | Ok(()) | |
| 489 | } | |
| 490 | ||
| 491 | async fn user_by_id(&self, user_id: &str) -> Result<Option<User>> { | |
| 492 | self.find_user( | |
| 493 | "SELECT id, username, email_verified_at IS NOT NULL AS verified, avatar FROM users WHERE id = ?", | |
| 494 | user_id, | |
| 495 | ) | |
| 496 | .await | |
| 497 | } | |
| 498 | ||
| 499 | /// Keeps a GitHub sign-in that has to wait on the person. | |
| 500 | async fn hold( | |
| 501 | &self, | |
| 502 | user: &GithubUser, | |
| 503 | kind: &str, | |
| 504 | suggestion: Option<&str>, | |
| 505 | tokens: &Tokens, | |
| 506 | next: &str, | |
| 507 | invite_code: Option<&str>, | |
| 508 | ) -> Result<String> { | |
| 509 | let pending = crypto::random_hex(32); | |
| 510 | let id = crypto::sha256_hex(&pending); | |
| 511 | let sealed = self.seal_tokens(tokens, &id); | |
| 512 | self.db | |
| 513 | .prepare(format!( | |
| 514 | "INSERT INTO github_pending (id, github_id, login, email, kind, suggestion, tokens, next, invite_code, expires_at) | |
| 515 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, {})", | |
| 516 | sql_after(PENDING_TTL_SECONDS) | |
| 517 | )) | |
| 518 | .bind(&[ | |
| 519 | id.as_str().into(), | |
| 520 | (user.id as f64).into(), | |
| 521 | user.login.as_str().into(), | |
| 522 | user.emails.first().map(String::as_str).unwrap_or_default().into(), | |
| 523 | kind.into(), | |
| 524 | suggestion.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 525 | sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 526 | next.into(), | |
| 527 | invite_code.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 528 | ])? | |
| 529 | .run() | |
| 530 | .await?; | |
| 531 | Ok(pending) | |
| 532 | } | |
| 533 | ||
| 534 | async fn pending_row(&self, pending: &str) -> Result<Option<PendingRow>> { | |
| 535 | self.db | |
| 536 | .prepare(format!("SELECT * FROM github_pending WHERE id = ? AND expires_at > {SQL_NOW}")) | |
| 537 | .bind(&[crypto::sha256_hex(pending).into()])? | |
| 538 | .first::<PendingRow>(None) | |
| 539 | .await | |
| 540 | } | |
| 541 | ||
| 542 | async fn drop_pending(&self, id: &str) -> Result<()> { | |
| 543 | self.db.prepare("DELETE FROM github_pending WHERE id = ?").bind(&[id.into()])?.run().await?; | |
| 544 | self.db | |
| 545 | .prepare(format!("DELETE FROM github_pending WHERE expires_at < {SQL_NOW}")) | |
| 546 | .run() | |
| 547 | .await?; | |
| 548 | Ok(()) | |
| 549 | } | |
| 550 | ||
| 551 | /// Makes an account from a GitHub sign-in: its email is GitHub's | |
| 552 | /// verified primary, confirmed already, and it has no password. | |
| 553 | async fn create_from_github( | |
| 554 | &self, | |
| 555 | username: &str, | |
| 556 | email: &str, | |
| 557 | github_id: u64, | |
| 558 | login: &str, | |
| 559 | tokens: Option<&Tokens>, | |
| 560 | invite_code: Option<&str>, | |
| 561 | ) -> Result<Outcome<User>> { | |
| 562 | // Made where every account is made, so the invite is checked and | |
| 563 | // spent in one place, with registration's rules (invites.rs). | |
| 564 | let user = match self | |
| 565 | .create_account(crate::invites::NewAccount { | |
| 566 | username, | |
| 567 | email, | |
| 568 | password_hash: "", | |
| 569 | verified: true, | |
| 570 | invite_code, | |
| 571 | client: None, | |
| 572 | }) | |
| 573 | .await? | |
| 574 | { | |
| 575 | Outcome::Ok(user) => user, | |
| 576 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 577 | }; | |
| 578 | self.link(&user.id, github_id, login, tokens).await?; | |
| 579 | self.announce_user(username, Some(&user.id)).await; | |
| 580 | Ok(Outcome::Ok(user)) | |
| 581 | } | |
| 582 | ||
| 583 | /// Whether new accounts need an invite code: REGISTRATION_MODE, read | |
| 584 | /// by invites.rs. Unset means they do. | |
| 585 | fn github_invites_required(&self) -> bool { | |
| 586 | self.invites_required() | |
| 587 | } | |
| 588 | ||
| 589 | pub async fn github_finish(&self, a: GithubFinishArgs) -> Result<Outcome<GithubFinished>> { | |
| 590 | let Some(client) = client(&self.env) else { | |
| 591 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_SET_UP)); | |
| 592 | }; | |
| 593 | // Single use: the state is gone whatever happens next. | |
| 594 | let state = self | |
| 595 | .db | |
| 596 | .prepare(format!( | |
| 597 | "DELETE FROM github_states WHERE id = ? AND expires_at > {SQL_NOW} | |
| 598 | RETURNING verifier, purpose, user_id, redirect_uri, next, invite_code" | |
| 599 | )) | |
| 600 | .bind(&[crypto::sha256_hex(&a.state).into()])? | |
| 601 | .first::<StateRow>(None) | |
| 602 | .await?; | |
| 603 | let Some(state) = state else { | |
| 604 | return Ok(Outcome::fail(FailureCode::Invalid, "This sign-in link has expired. Start again.")); | |
| 605 | }; | |
| 606 | let grant = serde_json::json!({ | |
| 607 | "code": a.code, | |
| 608 | "redirect_uri": state.redirect_uri, | |
| 609 | "code_verifier": state.verifier, | |
| 610 | }); | |
| 611 | let Some(tokens) = token_request(&client, grant).await? else { | |
| 612 | return Ok(Outcome::fail(FailureCode::Invalid, TRY_AGAIN)); | |
| 613 | }; | |
| 614 | let Some(github) = read_user(&tokens.access_token).await? else { | |
| 615 | return Ok(Outcome::fail(FailureCode::Invalid, TRY_AGAIN)); | |
| 616 | }; | |
| 617 | let purpose = if state.purpose == "link" { GithubPurpose::Link } else { GithubPurpose::SignIn }; | |
| 618 | let linked = self.account_by_github(github.id).await?; | |
| 619 | let asking_has_other = match &state.user_id { | |
| 620 | Some(user_id) => self.account_of(user_id).await?.is_some_and(|row| row.github_id != github.id), | |
| 621 | None => false, | |
| 622 | }; | |
| 623 | let suggestion = suggest_username(&github.login); | |
| 624 | let facts = Facts { | |
| 625 | purpose: Some(purpose), | |
| 626 | asking: state.user_id.as_deref(), | |
| 627 | asking_has_other, | |
| 628 | linked_to: linked.as_ref().map(|row| row.user_id.as_str()), | |
| 629 | has_verified_email: !github.emails.is_empty(), | |
| 630 | email_taken: linked.is_none() && self.email_taken(&github.emails).await?, | |
| 631 | suggestion_free: linked.is_none() && self.username_free(&suggestion).await?, | |
| 632 | suggestion: suggestion.clone(), | |
| 633 | invite_missing: self.github_invites_required() && state.invite_code.is_none(), | |
| 634 | }; | |
| 635 | let next = state.next; | |
| 636 | Ok(match decide(&facts) { | |
| 637 | Decision::Refuse(reason) => Outcome::fail(FailureCode::Conflict, reason), | |
| 638 | Decision::Link(user_id) => { | |
| 639 | self.link(&user_id, github.id, &github.login, Some(&tokens)).await?; | |
| 640 | if let Some(user) = self.user_by_id(&user_id).await? { | |
| 641 | self.audit_github(&user, "github.linked", format!("Linked GitHub account @{}", github.login)).await; | |
| 642 | } | |
| 643 | Outcome::Ok(GithubFinished::Linked { login: github.login, next }) | |
| 644 | } | |
| 645 | Decision::SignIn(user_id) => { | |
| 646 | self.link(&user_id, github.id, &github.login, Some(&tokens)).await?; | |
| 647 | let Some(user) = self.user_by_id(&user_id).await? else { | |
| 648 | return Ok(Outcome::fail(FailureCode::NotFound, TRY_AGAIN)); | |
| 649 | }; | |
| 650 | self.audit_github(&user, "github.sign_in", format!("Signed in with GitHub (@{})", github.login)).await; | |
| 651 | self.signed_in(user, false, next).await? | |
| 652 | } | |
| 653 | Decision::NeedsLink => { | |
| 654 | let pending = self.hold(&github, "link", None, &tokens, &next, None).await?; | |
| 655 | Outcome::Ok(GithubFinished::NeedsLink { pending, login: github.login, next }) | |
| 656 | } | |
| 657 | Decision::Create(username) => { | |
| 658 | let email = github.emails[0].clone(); | |
| 659 | let invite = state.invite_code.as_deref(); | |
| 660 | match self.create_from_github(&username, &email, github.id, &github.login, Some(&tokens), invite).await? { | |
| 661 | Outcome::Ok(user) => self.signed_in(user, true, next).await?, | |
| 662 | // A code that did not pass: the person can enter another. | |
| 663 | Outcome::Fail(_) => { | |
| 664 | let pending = self.hold(&github, "username", Some(&username), &tokens, &next, None).await?; | |
| 665 | Outcome::Ok(GithubFinished::NeedsUsername { | |
| 666 | pending, | |
| 667 | login: github.login, | |
| 668 | suggestion: username, | |
| 669 | next, | |
| 670 | invite_required: self.github_invites_required(), | |
| 671 | }) | |
| 672 | } | |
| 673 | } | |
| 674 | } | |
| 675 | Decision::NeedsUsername(suggestion) => { | |
| 676 | let invite = state.invite_code.as_deref(); | |
| 677 | let pending = self.hold(&github, "username", Some(&suggestion), &tokens, &next, invite).await?; | |
| 678 | Outcome::Ok(GithubFinished::NeedsUsername { | |
| 679 | pending, | |
| 680 | login: github.login, | |
| 681 | suggestion, | |
| 682 | next, | |
| 683 | invite_required: self.github_invites_required() && invite.is_none(), | |
| 684 | }) | |
| 685 | } | |
| 686 | }) | |
| 687 | } | |
| 688 | ||
| 689 | async fn signed_in(&self, user: User, created: bool, next: String) -> Result<Outcome<GithubFinished>> { | |
| 690 | Ok(match self.start_session(user).await? { | |
| 691 | Outcome::Ok(signed_in) => Outcome::Ok(GithubFinished::SignedIn { signed_in, created, next }), | |
| 692 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 693 | }) | |
| 694 | } | |
| 695 | ||
| 696 | pub async fn github_pending(&self, a: GithubPendingArgs) -> Result<Outcome<GithubPending>> { | |
| 697 | let Some(row) = self.pending_row(&a.pending).await? else { | |
| 698 | return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again.")); | |
| 699 | }; | |
| 700 | Ok(Outcome::Ok(GithubPending { | |
| 701 | invite_required: row.kind == "username" && self.github_invites_required() && row.invite_code.is_none(), | |
| 702 | login: row.login, | |
| 703 | kind: row.kind, | |
| 704 | suggestion: row.suggestion, | |
| 705 | next: row.next, | |
| 706 | })) | |
| 707 | } | |
| 708 | ||
| 709 | pub async fn github_sign_up(&self, a: GithubSignUpArgs) -> Result<Outcome<SignedIn>> { | |
| 710 | let Some(row) = self.pending_row(&a.pending).await?.filter(|row| row.kind == "username") else { | |
| 711 | return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again.")); | |
| 712 | }; | |
| 713 | let username = a.username.trim().to_lowercase(); | |
| 714 | if !is_valid_namespace(&username) { | |
| 715 | return Ok(Outcome::fail( | |
| 716 | FailureCode::Invalid, | |
| 717 | "Usernames use lowercase letters, digits and single hyphens, up to 39 characters.", | |
| 718 | )); | |
| 719 | } | |
| 720 | if !self.username_free(&username).await? { | |
| 721 | return Ok(Outcome::fail(FailureCode::Conflict, "That username is taken. Choose another.")); | |
| 722 | } | |
| 723 | // Checked again: either could have changed while the person chose. | |
| 724 | if self.account_by_github(row.github_id).await?.is_some() || self.email_taken(std::slice::from_ref(&row.email)).await? { | |
| 725 | return Ok(Outcome::fail(FailureCode::Conflict, "An account already uses this GitHub account or email. Sign in instead.")); | |
| 726 | } | |
| 727 | let tokens = self.open_tokens(row.tokens.as_deref(), &row.id); | |
| 728 | let given = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty()); | |
| 729 | let invite = given.or(row.invite_code.as_deref()); | |
| 730 | let user = match self | |
| 731 | .create_from_github(&username, &row.email, row.github_id, &row.login, tokens.as_ref(), invite) | |
| 732 | .await? | |
| 733 | { | |
| 734 | Outcome::Ok(user) => user, | |
| 735 | Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)), | |
| 736 | }; | |
| 737 | self.drop_pending(&row.id).await?; | |
| 738 | self.start_session(user).await | |
| 739 | } | |
| 740 | ||
| 741 | /// Links a held GitHub sign-in to the account the person then signed in | |
| 742 | /// to: they have proved both. | |
| 743 | pub async fn github_claim(&self, a: GithubClaimArgs) -> Result<Outcome<GithubAccount>> { | |
| 744 | let Some(row) = self.pending_row(&a.pending).await? else { | |
| 745 | return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again.")); | |
| 746 | }; | |
| 747 | if let Some(linked) = self.account_by_github(row.github_id).await? | |
| 748 | && linked.user_id != a.user.id | |
| 749 | { | |
| 750 | return Ok(Outcome::fail(FailureCode::Conflict, "That GitHub account is linked to another g1t account.")); | |
| 751 | } | |
| 752 | if self.account_of(&a.user.id).await?.is_some_and(|linked| linked.github_id != row.github_id) { | |
| 753 | return Ok(Outcome::fail(FailureCode::Conflict, "Your account is linked to another GitHub account. Unlink it first.")); | |
| 754 | } | |
| 755 | let tokens = self.open_tokens(row.tokens.as_deref(), &row.id); | |
| 756 | self.link(&a.user.id, row.github_id, &row.login, tokens.as_ref()).await?; | |
| 757 | self.drop_pending(&row.id).await?; | |
| 758 | self.audit_github(&a.user, "github.linked", format!("Linked GitHub account @{}", row.login)).await; | |
| 759 | Ok(Outcome::Ok(GithubAccount { | |
| 760 | github_id: row.github_id, | |
| 761 | login: row.login, | |
| 762 | linked_at: rfc3339(now_ms()), | |
| 763 | authorized: tokens.is_some(), | |
| 764 | })) | |
| 765 | } | |
| 766 | ||
| 767 | async fn has_password(&self, user_id: &str) -> Result<bool> { | |
| 768 | Ok(self | |
| 769 | .db | |
| 770 | .prepare("SELECT 1 AS yes FROM users WHERE id = ? AND password_hash LIKE 'pbkdf2$%'") | |
| 771 | .bind(&[user_id.into()])? | |
| 772 | .first::<Value>(None) | |
| 773 | .await? | |
| 774 | .is_some()) | |
| 775 | } | |
| 776 | ||
| 777 | pub async fn github_account(&self, a: UserArgs) -> Result<GithubAccountView> { | |
| 778 | let row = self.account_of(&a.user.id).await?; | |
| 779 | Ok(GithubAccountView { | |
| 780 | enabled: self.github_enabled(), | |
| 781 | account: row.map(|row| GithubAccount { | |
| 782 | authorized: self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)).is_some(), | |
| 783 | github_id: row.github_id, | |
| 784 | login: row.login, | |
| 785 | linked_at: row.created_at, | |
| 786 | }), | |
| 787 | has_password: self.has_password(&a.user.id).await?, | |
| 788 | }) | |
| 789 | } | |
| 790 | ||
| 791 | pub async fn github_unlink(&self, a: UserArgs) -> Result<Outcome<bool>> { | |
| 792 | let Some(row) = self.account_of(&a.user.id).await? else { | |
| 793 | return Ok(Outcome::Ok(false)); | |
| 794 | }; | |
| 795 | if !self.has_password(&a.user.id).await? { | |
| 796 | return Ok(Outcome::fail( | |
| 797 | FailureCode::Conflict, | |
| 798 | "GitHub is the only way you sign in. Set a password first: sign out and use Forgot your password.", | |
| 799 | )); | |
| 800 | } | |
| 801 | self.db | |
| 802 | .prepare("DELETE FROM github_accounts WHERE user_id = ?") | |
| 803 | .bind(&[a.user.id.as_str().into()])? | |
| 804 | .run() | |
| 805 | .await?; | |
| 806 | // Best effort: also end g1t's authorization on GitHub's side. | |
| 807 | if let (Some(client), Some(tokens)) = (client(&self.env), self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id))) { | |
| 808 | let _ = revoke_grant(&client, &tokens.access_token).await; | |
| 809 | } | |
| 810 | self.audit_github(&a.user, "github.unlinked", format!("Unlinked GitHub account @{}", row.login)).await; | |
| 811 | Ok(Outcome::Ok(true)) | |
| 812 | } | |
| 813 | ||
| 814 | /// A working user token for the person, refreshed when it is about to | |
| 815 | /// expire. For the integrations service, to list installations. | |
| 816 | pub async fn github_user_token(&self, a: GithubUserTokenArgs) -> Result<Outcome<String>> { | |
| 817 | const RELINK: &str = "Link your GitHub account again in your settings: g1t's access to it has ended."; | |
| 818 | let Some(row) = self.account_of(&a.user_id).await? else { | |
| 819 | return Ok(Outcome::fail(FailureCode::NotFound, "Link your GitHub account first.")); | |
| 820 | }; | |
| 821 | let Some(tokens) = self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)) else { | |
| 822 | return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK)); | |
| 823 | }; | |
| 824 | let now = now_ms(); | |
| 825 | if tokens.fresh(now) { | |
| 826 | return Ok(Outcome::Ok(tokens.access_token)); | |
| 827 | } | |
| 828 | let (Some(client), true) = (client(&self.env), tokens.refreshable(now)) else { | |
| 829 | self.forget_tokens(&row.user_id).await?; | |
| 830 | return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK)); | |
| 831 | }; | |
| 832 | let grant = serde_json::json!({ | |
| 833 | "grant_type": "refresh_token", | |
| 834 | "refresh_token": tokens.refresh_token, | |
| 835 | }); | |
| 836 | let Some(refreshed) = token_request(&client, grant).await? else { | |
| 837 | self.forget_tokens(&row.user_id).await?; | |
| 838 | return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK)); | |
| 839 | }; | |
| 840 | let sealed = self.seal_tokens(&refreshed, &bound(&row.user_id)); | |
| 841 | self.db | |
| 842 | .prepare(format!("UPDATE github_accounts SET tokens = ?, updated_at = {SQL_NOW} WHERE user_id = ?")) | |
| 843 | .bind(&[ | |
| 844 | sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into), | |
| 845 | row.user_id.as_str().into(), | |
| 846 | ])? | |
| 847 | .run() | |
| 848 | .await?; | |
| 849 | Ok(Outcome::Ok(refreshed.access_token)) | |
| 850 | } | |
| 851 | ||
| 852 | async fn forget_tokens(&self, user_id: &str) -> Result<()> { | |
| 853 | self.db | |
| 854 | .prepare("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?") | |
| 855 | .bind(&[user_id.into()])? | |
| 856 | .run() | |
| 857 | .await?; | |
| 858 | Ok(()) | |
| 859 | } | |
| 860 | ||
| 861 | /// The person revoked g1t's authorization on GitHub: its tokens go. | |
| 862 | /// The link stays, so they can still sign in with GitHub. | |
| 863 | pub async fn github_revoked(&self, a: GithubRevokedArgs) -> Result<u32> { | |
| 864 | let changed = self | |
| 865 | .db | |
| 866 | .prepare("UPDATE github_accounts SET tokens = NULL WHERE github_id = ? RETURNING user_id") | |
| 867 | .bind(&[(a.github_id as f64).into()])? | |
| 868 | .all() | |
| 869 | .await? | |
| 870 | .results::<Value>()?; | |
| 871 | Ok(changed.len() as u32) | |
| 872 | } | |
| 873 | ||
| 874 | /// The g1t usernames of linked GitHub accounts, by GitHub id, for | |
| 875 | /// showing who wrote what was imported. | |
| 876 | pub async fn github_usernames(&self, a: GithubUsernamesArgs) -> Result<std::collections::HashMap<String, String>> { | |
| 877 | #[derive(Deserialize)] | |
| 878 | struct Named { | |
| 879 | github_id: u64, | |
| 880 | username: String, | |
| 881 | } | |
| 882 | let ids: Vec<u64> = a.github_ids.into_iter().take(100).collect(); | |
| 883 | let mut names = std::collections::HashMap::new(); | |
| 884 | if ids.is_empty() { | |
| 885 | return Ok(names); | |
| 886 | } | |
| 887 | let marks = vec!["?"; ids.len()].join(", "); | |
| 888 | let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| (*id as f64).into()).collect(); | |
| 889 | let rows = self | |
| 890 | .db | |
| 891 | .prepare(format!( | |
| 892 | "SELECT github_accounts.github_id, users.username FROM github_accounts | |
| 893 | JOIN users ON users.id = github_accounts.user_id WHERE github_id IN ({marks})" | |
| 894 | )) | |
| 895 | .bind(&bind)? | |
| 896 | .all() | |
| 897 | .await? | |
| 898 | .results::<Named>()?; | |
| 899 | for row in rows { | |
| 900 | names.insert(row.github_id.to_string(), row.username); | |
| 901 | } | |
| 902 | Ok(names) | |
| 903 | } | |
| 904 | ||
| 905 | /// Recorded in the audit log of every workspace the person belongs to, | |
| 906 | /// which is where their workspaces' owners look. | |
| 907 | async fn audit_github(&self, user: &User, action: &str, message: String) { | |
| 908 | let (Ok(events), Ok(memberships)) = (self.env.service("EVENTS"), self.memberships(&user.id).await) else { | |
| 909 | return; | |
| 910 | }; | |
| 911 | let entries: Vec<NewAuditEntry> = memberships | |
| 912 | .into_iter() | |
| 913 | .map(|membership| NewAuditEntry { | |
| 914 | actor: AuditActor::of(user), | |
| 915 | action: action.to_owned(), | |
| 916 | surface: Surface::Web, | |
| 917 | target: AuditTarget { | |
| 918 | workspace: membership.slug, | |
| 919 | ..AuditTarget::default() | |
| 920 | }, | |
| 921 | outcome: AuditOutcome::Allowed, | |
| 922 | rule: "github".to_owned(), | |
| 923 | result: Some("ok".to_owned()), | |
| 924 | message: Some(message.clone()), | |
| 925 | request_id: new_id("req", now_ms()), | |
| 926 | }) | |
| 927 | .collect(); | |
| 928 | if entries.is_empty() { | |
| 929 | return; | |
| 930 | } | |
| 931 | let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await; | |
| 932 | if let Err(error) = recorded { | |
| 933 | worker::console_error!("{action} not recorded: {error}"); | |
| 934 | } | |
| 935 | } | |
| 936 | } | |
| 937 | ||
| 938 | /// `DELETE /applications/{client_id}/grant`, with the client's own | |
| 939 | /// credentials. | |
| 940 | async fn revoke_grant(client: &Client, access_token: &str) -> Result<()> { | |
| 941 | let headers = Headers::new(); | |
| 942 | headers.set("user-agent", "g1t (+https://g1t.sh)")?; | |
| 943 | headers.set("accept", "application/vnd.github+json")?; | |
| 944 | headers.set("content-type", "application/json")?; | |
| 945 | let basic = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", client.id, client.secret)); | |
| 946 | headers.set("authorization", &format!("Basic {basic}"))?; | |
| 947 | let mut init = RequestInit::new(); | |
| 948 | init.with_method(Method::Delete) | |
| 949 | .with_headers(headers) | |
| 950 | .with_body(Some(serde_json::json!({ "access_token": access_token }).to_string().into())); | |
| 951 | let url = format!("{API}/applications/{}/grant", client.id); | |
| 952 | Fetch::Request(Request::new_with_init(&url, &init)?).send().await?; | |
| 953 | Ok(()) | |
| 954 | } | |
| 955 | ||
| 956 | #[cfg(test)] | |
| 957 | mod tests { | |
| 958 | use super::*; | |
| 959 | ||
| 960 | #[test] | |
| 961 | fn the_challenge_is_rfc_7636s() { | |
| 962 | // RFC 7636, appendix B. | |
| 963 | assert_eq!( | |
| 964 | pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), | |
| 965 | "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" | |
| 966 | ); | |
| 967 | let verifier = new_verifier(); | |
| 968 | assert_eq!(verifier.len(), 43); | |
| 969 | assert_ne!(verifier, new_verifier()); | |
| 970 | } | |
| 971 | ||
| 972 | #[test] | |
| 973 | fn the_authorize_url_carries_state_and_challenge() { | |
| 974 | let url = authorize_url("Iv23liZS94alfjIUn1eW", "https://g1t.sh/auth/github/callback", "abc", "xyz"); | |
| 975 | let parsed = Url::parse(&url).unwrap(); | |
| 976 | let query: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect(); | |
| 977 | assert_eq!(parsed.host_str(), Some("github.com")); | |
| 978 | assert_eq!(query["redirect_uri"], "https://g1t.sh/auth/github/callback"); | |
| 979 | assert_eq!(query["state"], "abc"); | |
| 980 | assert_eq!(query["code_challenge"], "xyz"); | |
| 981 | assert_eq!(query["code_challenge_method"], "S256"); | |
| 982 | } | |
| 983 | ||
| 984 | fn email(address: &str, primary: bool, verified: bool) -> GithubEmail { | |
| 985 | GithubEmail { | |
| 986 | email: address.to_owned(), | |
| 987 | primary, | |
| 988 | verified, | |
| 989 | } | |
| 990 | } | |
| 991 | ||
| 992 | #[test] | |
| 993 | fn only_verified_emails_count_primary_first() { | |
| 994 | let emails = [ | |
| 995 | email("unverified@example.com", false, false), | |
| 996 | email("Work@Example.com", false, true), | |
| 997 | email("1+me@users.noreply.github.com", false, true), | |
| 998 | email("me@example.com", true, true), | |
| 999 | ]; | |
| 1000 | assert_eq!(verified_emails(&emails), vec!["me@example.com", "work@example.com"]); | |
| 1001 | assert!(verified_emails(&[email("primary@example.com", true, false)]).is_empty()); | |
| 1002 | } | |
| 1003 | ||
| 1004 | #[test] | |
| 1005 | fn usernames_come_from_logins() { | |
| 1006 | assert_eq!(suggest_username("Octo-Cat"), "octo-cat"); | |
| 1007 | assert_eq!(suggest_username("a_b..c"), "a-b-c"); | |
| 1008 | assert_eq!(suggest_username("-x-"), "x"); | |
| 1009 | assert_eq!(suggest_username(&"a".repeat(50)).len(), 39); | |
| 1010 | } | |
| 1011 | ||
| 1012 | fn facts() -> Facts<'static> { | |
| 1013 | Facts { | |
| 1014 | purpose: Some(GithubPurpose::SignIn), | |
| 1015 | has_verified_email: true, | |
| 1016 | suggestion: "octocat".to_owned(), | |
| 1017 | suggestion_free: true, | |
| 1018 | ..Facts::default() | |
| 1019 | } | |
| 1020 | } | |
| 1021 | ||
| 1022 | #[test] | |
| 1023 | fn a_linked_account_signs_in() { | |
| 1024 | let facts = Facts { linked_to: Some("usr_1"), email_taken: true, ..facts() }; | |
| 1025 | assert_eq!(decide(&facts), Decision::SignIn("usr_1".to_owned())); | |
| 1026 | } | |
| 1027 | ||
| 1028 | #[test] | |
| 1029 | fn a_matching_email_is_never_linked_silently() { | |
| 1030 | let facts = Facts { email_taken: true, ..facts() }; | |
| 1031 | assert_eq!(decide(&facts), Decision::NeedsLink); | |
| 1032 | } | |
| 1033 | ||
| 1034 | #[test] | |
| 1035 | fn a_new_person_gets_their_login_or_chooses() { | |
| 1036 | assert_eq!(decide(&facts()), Decision::Create("octocat".to_owned())); | |
| 1037 | let taken = Facts { suggestion_free: false, ..facts() }; | |
| 1038 | assert_eq!(decide(&taken), Decision::NeedsUsername("octocat".to_owned())); | |
| 1039 | let no_email = Facts { has_verified_email: false, ..facts() }; | |
| 1040 | assert!(matches!(decide(&no_email), Decision::Refuse(_))); | |
| 1041 | } | |
| 1042 | ||
| 1043 | #[test] | |
| 1044 | fn an_invite_only_g1t_waits_for_a_code() { | |
| 1045 | let waiting = Facts { invite_missing: true, ..facts() }; | |
| 1046 | assert_eq!(decide(&waiting), Decision::NeedsUsername("octocat".to_owned())); | |
| 1047 | // Existing accounts sign in and link without one. | |
| 1048 | let linked = Facts { invite_missing: true, linked_to: Some("usr_1"), ..facts() }; | |
| 1049 | assert_eq!(decide(&linked), Decision::SignIn("usr_1".to_owned())); | |
| 1050 | let matching = Facts { invite_missing: true, email_taken: true, ..facts() }; | |
| 1051 | assert_eq!(decide(&matching), Decision::NeedsLink); | |
| 1052 | } | |
| 1053 | ||
| 1054 | #[test] | |
| 1055 | fn linking_is_for_the_account_that_asked() { | |
| 1056 | let link = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), ..facts() }; | |
| 1057 | assert_eq!(decide(&link), Decision::Link("usr_1".to_owned())); | |
| 1058 | let elsewhere = Facts { linked_to: Some("usr_2"), ..link }; | |
| 1059 | assert!(matches!(decide(&elsewhere), Decision::Refuse(_))); | |
| 1060 | let other = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), asking_has_other: true, ..facts() }; | |
| 1061 | assert!(matches!(decide(&other), Decision::Refuse(_))); | |
| 1062 | let nobody = Facts { purpose: Some(GithubPurpose::Link), ..facts() }; | |
| 1063 | assert!(matches!(decide(&nobody), Decision::Refuse(_))); | |
| 1064 | } | |
| 1065 | ||
| 1066 | #[test] | |
| 1067 | fn tokens_expire_and_refresh() { | |
| 1068 | let answer = serde_json::json!({ | |
| 1069 | "access_token": format!("ghu_{}", "a".repeat(516)), | |
| 1070 | "expires_in": 28800, | |
| 1071 | "refresh_token": "ghr_x", | |
| 1072 | "refresh_token_expires_in": 15897600, | |
| 1073 | "token_type": "bearer", | |
| 1074 | }); | |
| 1075 | let tokens = tokens_from(&answer, 1_000).unwrap(); | |
| 1076 | assert_eq!(tokens.access_token.len(), 520); | |
| 1077 | assert_eq!(tokens.access_expires_at, Some(1_000 + 28_800_000)); | |
| 1078 | assert!(tokens.fresh(1_000)); | |
| 1079 | assert!(!tokens.fresh(1_000 + 28_800_000 - 60_000)); | |
| 1080 | assert!(tokens.refreshable(1_000 + 28_800_000)); | |
| 1081 | assert!(tokens_from(&serde_json::json!({ "error": "bad_verification_code" }), 0).is_none()); | |
| 1082 | // Tokens that never expire, as when expiry is turned off on the app. | |
| 1083 | let lasting = tokens_from(&serde_json::json!({ "access_token": "gho_x" }), 0).unwrap(); | |
| 1084 | assert!(lasting.fresh(u64::MAX / 2)); | |
| 1085 | assert!(!lasting.refreshable(0)); | |
| 1086 | } | |
| 1087 | ||
| 1088 | #[test] | |
| 1089 | fn a_long_token_survives_sealing() { | |
| 1090 | let sealer = Sealer::new("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f").unwrap(); | |
| 1091 | let tokens = Tokens { | |
| 1092 | access_token: format!("ghs_{}", "z".repeat(516)), | |
| 1093 | access_expires_at: None, | |
| 1094 | refresh_token: None, | |
| 1095 | refresh_expires_at: None, | |
| 1096 | }; | |
| 1097 | let sealed = sealer.seal(&serde_json::to_string(&tokens).unwrap(), &bound("usr_1")); | |
| 1098 | let opened: Tokens = serde_json::from_str(&sealer.open(&sealed, &bound("usr_1")).unwrap()).unwrap(); | |
| 1099 | assert_eq!(opened, tokens); | |
| 1100 | assert!(sealer.open(&sealed, &bound("usr_2")).is_none()); | |
| 1101 | } | |
| 1102 | } |