Skip to content
1,165 linesCodeBlameRaw

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

This file's history is long; its oldest lines are credited to the oldest commit read.