pr_01m47d24b0e6n91zwymwxg0vpx/services/identity/src/github.rs

1,102 lines45,577 bytesCodeBlame
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
23use base64::Engine;
24use base64::engine::general_purpose::URL_SAFE_NO_PAD;
25use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
26use g1t_contracts::github::*;
27use g1t_contracts::identity::{SignedIn, UserArgs};
28use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
29use g1t_contracts::{FailureCode, Outcome, User, is_valid_namespace, new_id};
30use g1t_kit::now_ms;
31use g1t_secrets::Sealer;
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use sha2::{Digest, Sha256};
35use worker::{Fetch, Headers, Method, Request, RequestInit, Result, Url};
36
37use crate::{Identity, crypto};
38
39const STATE_TTL_SECONDS: u64 = 10 * 60;
40const PENDING_TTL_SECONDS: u64 = 30 * 60;
41/// An access token this close to expiring is refreshed before use.
42const REFRESH_MARGIN_MS: u64 = 5 * 60 * 1000;
43const AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize";
44const TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
45const API: &str = "https://api.github.com";
46const NOT_SET_UP: &str = "Signing in with GitHub is not set up on this g1t.";
47const TRY_AGAIN: &str = "GitHub did not complete the sign-in. Try again.";
48
49/// The app's OAuth client, when this g1t has one.
50struct Client {
51 id: String,
52 secret: String,
53}
54
55fn 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.
67pub 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).
74pub fn pkce_challenge(verifier: &str) -> String {
75 URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
76}
77
78pub 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)]
96pub 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.
107pub 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.
127pub 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)]
142pub 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)]
159pub 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
178pub 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)]
214pub 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.
225pub 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
236impl 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
248struct Answer {
249 status: u16,
250 body: Value,
251}
252
253async 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.
279async 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.
297struct GithubUser {
298 id: u64,
299 login: String,
300 emails: Vec<String>,
301}
302
303async 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)]
320struct 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)]
331struct 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)]
345struct 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.
354fn bound(user_id: &str) -> String {
355 format!("github:{user_id}")
356}
357
358impl 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.
940async 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)]
957mod 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}