flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/identity/src/github.rs

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