Skip to content

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.sh
1,157 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.

Merge branch 'worktree-agent-a6a121745e81f639f'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};
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.29use g1t_contracts::{FailureCode, Outcome, User, claimable_username, is_action_owner_name, is_claimable_name, is_reserved_name, is_valid_namespace, new_id};
Merge branch 'worktree-agent-a6a121745e81f639f'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
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers125/// A username made from a GitHub login: its case kept, with anything g1t
126/// does not allow turned into single hyphens. A login that is a reserved
127/// name in any case, such as `g1t`, is suggested with `-gh` after it, so
128/// signing up still goes ahead under a name of its own.
Merge branch 'worktree-agent-a6a121745e81f639f'129pub fn suggest_username(login: &str) -> String {
130 let mut out = String::new();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers131 for character in login.trim().chars() {
132 if character.is_ascii_alphanumeric() {
Merge branch 'worktree-agent-a6a121745e81f639f'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();
139 let out = out.trim_end_matches('-');
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.140 if is_reserved_name(out) || is_action_owner_name(out) { format!("{out}-gh") } else { out.to_owned() }
Merge branch 'worktree-agent-a6a121745e81f639f'141}
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}
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 }
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.205 if facts.suggestion_free {
Merge branch 'worktree-agent-a6a121745e81f639f'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> {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers456 // Taken in any case: names are found lowercased.
457 let username = &username.to_ascii_lowercase();
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.458 if !is_claimable_name(username) {
Merge branch 'worktree-agent-a6a121745e81f639f'459 return Ok(false);
460 }
461 let taken = self
462 .db
463 .prepare("SELECT username FROM users WHERE username = ?1 UNION ALL SELECT slug FROM workspaces WHERE slug = ?1")
464 .bind(&[username.into()])?
465 .first::<Value>(None)
466 .await?;
467 Ok(taken.is_none() && !self.slug_held(username).await? && !self.slug_deleted(username).await?)
468 }
469
470 /// Whether an account has confirmed one of these addresses, any of its
471 /// addresses, not only its primary (emails.rs). An address someone
472 /// added and never confirmed does not count: GitHub has confirmed it,
473 /// so a new account made with it wins it (first to confirm keeps it).
474 async fn email_taken(&self, emails: &[String]) -> Result<bool> {
475 for email in emails {
476 if self.user_with_verified_email(email).await?.is_some() {
477 return Ok(true);
478 }
479 }
480 Ok(false)
481 }
482
483 /// Links a GitHub account to a user, keeping its tokens.
484 async fn link(&self, user_id: &str, github_id: u64, login: &str, tokens: Option<&Tokens>) -> Result<()> {
485 let sealed = tokens.and_then(|tokens| self.seal_tokens(tokens, &bound(user_id)));
486 let now = rfc3339(now_ms());
487 self.db
488 .prepare(
489 "INSERT INTO github_accounts (user_id, github_id, login, tokens, created_at, updated_at)
490 VALUES (?1, ?2, ?3, ?4, ?5, ?5)
491 ON CONFLICT (user_id) DO UPDATE SET login = excluded.login,
492 tokens = COALESCE(excluded.tokens, github_accounts.tokens), updated_at = excluded.updated_at",
493 )
494 .bind(&[
495 user_id.into(),
496 (github_id as f64).into(),
497 login.into(),
498 sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
499 now.as_str().into(),
500 ])?
501 .run()
502 .await?;
503 Ok(())
504 }
505
506 async fn user_by_id(&self, user_id: &str) -> Result<Option<User>> {
507 self.find_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)508 "SELECT id, username, email_verified_at IS NOT NULL AS verified, avatar FROM users WHERE id = ? AND deleted_at IS NULL",
Merge branch 'worktree-agent-a6a121745e81f639f'509 user_id,
510 )
511 .await
512 }
513
514 /// Keeps a GitHub sign-in that has to wait on the person.
515 async fn hold(
516 &self,
517 user: &GithubUser,
518 kind: &str,
519 suggestion: Option<&str>,
520 tokens: &Tokens,
521 next: &str,
522 invite_code: Option<&str>,
523 ) -> Result<String> {
524 let pending = crypto::random_hex(32);
525 let id = crypto::sha256_hex(&pending);
526 let sealed = self.seal_tokens(tokens, &id);
527 self.db
528 .prepare(format!(
529 "INSERT INTO github_pending (id, github_id, login, email, kind, suggestion, tokens, next, invite_code, expires_at)
530 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, {})",
531 sql_after(PENDING_TTL_SECONDS)
532 ))
533 .bind(&[
534 id.as_str().into(),
535 (user.id as f64).into(),
536 user.login.as_str().into(),
537 user.emails.first().map(String::as_str).unwrap_or_default().into(),
538 kind.into(),
539 suggestion.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
540 sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
541 next.into(),
542 invite_code.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
543 ])?
544 .run()
545 .await?;
546 Ok(pending)
547 }
548
549 async fn pending_row(&self, pending: &str) -> Result<Option<PendingRow>> {
550 self.db
551 .prepare(format!("SELECT * FROM github_pending WHERE id = ? AND expires_at > {SQL_NOW}"))
552 .bind(&[crypto::sha256_hex(pending).into()])?
553 .first::<PendingRow>(None)
554 .await
555 }
556
557 async fn drop_pending(&self, id: &str) -> Result<()> {
558 self.db.prepare("DELETE FROM github_pending WHERE id = ?").bind(&[id.into()])?.run().await?;
559 self.db
560 .prepare(format!("DELETE FROM github_pending WHERE expires_at < {SQL_NOW}"))
561 .run()
562 .await?;
563 Ok(())
564 }
565
566 /// Makes an account from a GitHub sign-in: its email is GitHub's
567 /// verified primary, confirmed already, and it has no password.
568 async fn create_from_github(
569 &self,
570 username: &str,
571 email: &str,
572 github_id: u64,
573 login: &str,
574 tokens: Option<&Tokens>,
575 invite_code: Option<&str>,
576 ) -> Result<Outcome<User>> {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers577 // Kept as chosen for showing, found lowercased.
578 let Some(chosen) = claimable_username(username) else {
579 return Ok(Outcome::fail(FailureCode::Invalid, crate::USERNAME_RULES));
580 };
581 let username = chosen.canonical.as_str();
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.582 if self.email_blocked(email).await? {
583 return Ok(Outcome::fail(FailureCode::Forbidden, crate::suspension::BLOCKED));
584 }
Merge branch 'worktree-agent-a6a121745e81f639f'585 // Made where every account is made, so the invite is checked and
586 // spent in one place, with registration's rules (invites.rs).
587 let user = match self
588 .create_account(crate::invites::NewAccount {
589 username,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers590 display_username: chosen.display_if_cased(),
Merge branch 'worktree-agent-a6a121745e81f639f'591 email,
592 password_hash: "",
593 verified: true,
594 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 confirm595 // GitHub has confirmed the address already.
596 email_proof: None,
Merge branch 'worktree-agent-a6a121745e81f639f'597 })
598 .await?
599 {
600 Outcome::Ok(user) => user,
601 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
602 };
603 self.link(&user.id, github_id, login, tokens).await?;
604 self.announce_user(username, Some(&user.id)).await;
605 Ok(Outcome::Ok(user))
606 }
607
608 pub async fn github_finish(&self, a: GithubFinishArgs) -> Result<Outcome<GithubFinished>> {
609 let Some(client) = client(&self.env) else {
610 return Ok(Outcome::fail(FailureCode::NotFound, NOT_SET_UP));
611 };
612 // Single use: the state is gone whatever happens next.
613 let state = self
614 .db
615 .prepare(format!(
616 "DELETE FROM github_states WHERE id = ? AND expires_at > {SQL_NOW}
617 RETURNING verifier, purpose, user_id, redirect_uri, next, invite_code"
618 ))
619 .bind(&[crypto::sha256_hex(&a.state).into()])?
620 .first::<StateRow>(None)
621 .await?;
622 let Some(state) = state else {
623 return Ok(Outcome::fail(FailureCode::Invalid, "This sign-in link has expired. Start again."));
624 };
625 let grant = serde_json::json!({
626 "code": a.code,
627 "redirect_uri": state.redirect_uri,
628 "code_verifier": state.verifier,
629 });
630 let Some(tokens) = token_request(&client, grant).await? else {
631 return Ok(Outcome::fail(FailureCode::Invalid, TRY_AGAIN));
632 };
633 let Some(mut github) = read_user(&tokens.access_token).await? else {
634 return Ok(Outcome::fail(FailureCode::Invalid, TRY_AGAIN));
635 };
636 // An invite sent to one address makes the account with that one,
637 // when GitHub has confirmed it too; otherwise the invite is not
638 // this GitHub account's to use.
639 let bound = match state.invite_code.as_deref() {
640 Some(code) => self.bound_email_of(code).await?,
641 None => None,
642 };
643 let bound_elsewhere = bound.as_deref().is_some_and(|bound| !put_first(&mut github.emails, bound));
644 let purpose = if state.purpose == "link" { GithubPurpose::Link } else { GithubPurpose::SignIn };
645 let linked = self.account_by_github(github.id).await?;
646 let asking_has_other = match &state.user_id {
647 Some(user_id) => self.account_of(user_id).await?.is_some_and(|row| row.github_id != github.id),
648 None => false,
649 };
650 let suggestion = suggest_username(&github.login);
651 let facts = Facts {
652 purpose: Some(purpose),
653 asking: state.user_id.as_deref(),
654 asking_has_other,
655 linked_to: linked.as_ref().map(|row| row.user_id.as_str()),
656 has_verified_email: !github.emails.is_empty(),
657 email_taken: linked.is_none() && self.email_taken(&github.emails).await?,
658 suggestion_free: linked.is_none() && self.username_free(&suggestion).await?,
659 suggestion: suggestion.clone(),
660 };
661 let next = state.next;
662 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 {
667 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).
Merge branch 'worktree-agent-a6a121745e81f639f'678 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?;
Merge branch 'worktree-agent-a6a121745e81f639f'682 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?,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.694 // The name was taken a moment ago: the person chooses another.
Merge branch 'worktree-agent-a6a121745e81f639f'695 Outcome::Fail(_) => {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.696 let pending = self.hold(&github, "username", Some(&username), &tokens, &next, invite).await?;
Merge branch 'worktree-agent-a6a121745e81f639f'697 Outcome::Ok(GithubFinished::NeedsUsername {
698 pending,
699 login: github.login,
700 suggestion: username,
701 next,
702 })
703 }
704 }
705 }
706 Decision::NeedsUsername(suggestion) => {
707 let invite = state.invite_code.as_deref();
708 let pending = self.hold(&github, "username", Some(&suggestion), &tokens, &next, invite).await?;
709 Outcome::Ok(GithubFinished::NeedsUsername {
710 pending,
711 login: github.login,
712 suggestion,
713 next,
714 })
715 }
716 })
717 }
718
719 async fn signed_in(&self, user: User, created: bool, next: String) -> Result<Outcome<GithubFinished>> {
720 Ok(match self.start_session(user).await? {
721 Outcome::Ok(signed_in) => Outcome::Ok(GithubFinished::SignedIn { signed_in, created, next }),
722 Outcome::Fail(failure) => Outcome::Fail(failure),
723 })
724 }
725
726 pub async fn github_pending(&self, a: GithubPendingArgs) -> Result<Outcome<GithubPending>> {
727 let Some(row) = self.pending_row(&a.pending).await? else {
728 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
729 };
730 Ok(Outcome::Ok(GithubPending {
731 login: row.login,
732 kind: row.kind,
733 suggestion: row.suggestion,
734 next: row.next,
735 }))
736 }
737
738 pub async fn github_sign_up(&self, a: GithubSignUpArgs) -> Result<Outcome<SignedIn>> {
739 let Some(row) = self.pending_row(&a.pending).await?.filter(|row| row.kind == "username") else {
740 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
741 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers742 let Some(chosen) = claimable_username(&a.username) else {
743 return Ok(Outcome::fail(FailureCode::Invalid, crate::USERNAME_RULES));
Merge branch 'worktree-agent-a6a121745e81f639f'744 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers745 // As chosen: create_from_github keeps the case for showing.
746 let username = chosen.display;
Merge branch 'worktree-agent-a6a121745e81f639f'747 if !self.username_free(&username).await? {
748 return Ok(Outcome::fail(FailureCode::Conflict, "That username is taken. Choose another."));
749 }
750 // Checked again: either could have changed while the person chose.
751 if self.account_by_github(row.github_id).await?.is_some() || self.email_taken(std::slice::from_ref(&row.email)).await? {
752 return Ok(Outcome::fail(FailureCode::Conflict, "An account already uses this GitHub account or email. Sign in instead."));
753 }
754 let tokens = self.open_tokens(row.tokens.as_deref(), &row.id);
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.755 let invite = row.invite_code.as_deref();
Merge branch 'worktree-agent-a6a121745e81f639f'756 let user = match self
757 .create_from_github(&username, &row.email, row.github_id, &row.login, tokens.as_ref(), invite)
758 .await?
759 {
760 Outcome::Ok(user) => user,
761 Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)),
762 };
763 self.drop_pending(&row.id).await?;
764 self.start_session(user).await
765 }
766
767 /// Links a held GitHub sign-in to the account the person then signed in
768 /// to: they have proved both.
769 pub async fn github_claim(&self, a: GithubClaimArgs) -> Result<Outcome<GithubAccount>> {
770 let Some(row) = self.pending_row(&a.pending).await? else {
771 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
772 };
773 if let Some(linked) = self.account_by_github(row.github_id).await?
774 && linked.user_id != a.user.id
775 {
776 return Ok(Outcome::fail(FailureCode::Conflict, "That GitHub account is linked to another g1t account."));
777 }
778 if self.account_of(&a.user.id).await?.is_some_and(|linked| linked.github_id != row.github_id) {
779 return Ok(Outcome::fail(FailureCode::Conflict, "Your account is linked to another GitHub account. Unlink it first."));
780 }
781 let tokens = self.open_tokens(row.tokens.as_deref(), &row.id);
782 self.link(&a.user.id, row.github_id, &row.login, tokens.as_ref()).await?;
783 self.drop_pending(&row.id).await?;
784 self.audit_github(&a.user, "github.linked", format!("Linked GitHub account @{}", row.login)).await;
785 Ok(Outcome::Ok(GithubAccount {
786 github_id: row.github_id,
787 login: row.login,
788 linked_at: rfc3339(now_ms()),
789 authorized: tokens.is_some(),
790 }))
791 }
792
793 async fn has_password(&self, user_id: &str) -> Result<bool> {
794 Ok(self
795 .db
796 .prepare("SELECT 1 AS yes FROM users WHERE id = ? AND password_hash LIKE 'pbkdf2$%'")
797 .bind(&[user_id.into()])?
798 .first::<Value>(None)
799 .await?
800 .is_some())
801 }
802
803 pub async fn github_account(&self, a: UserArgs) -> Result<GithubAccountView> {
804 let row = self.account_of(&a.user.id).await?;
805 Ok(GithubAccountView {
806 enabled: self.github_enabled(),
807 account: row.map(|row| GithubAccount {
808 authorized: self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)).is_some(),
809 github_id: row.github_id,
810 login: row.login,
811 linked_at: row.created_at,
812 }),
813 has_password: self.has_password(&a.user.id).await?,
814 })
815 }
816
817 pub async fn github_unlink(&self, a: UserArgs) -> Result<Outcome<bool>> {
818 let Some(row) = self.account_of(&a.user.id).await? else {
819 return Ok(Outcome::Ok(false));
820 };
821 if !self.has_password(&a.user.id).await? {
822 return Ok(Outcome::fail(
823 FailureCode::Conflict,
824 "GitHub is the only way you sign in. Set a password first: sign out and use Forgot your password.",
825 ));
826 }
827 self.db
828 .prepare("DELETE FROM github_accounts WHERE user_id = ?")
829 .bind(&[a.user.id.as_str().into()])?
830 .run()
831 .await?;
832 // Best effort: also end g1t's authorization on GitHub's side.
833 if let (Some(client), Some(tokens)) = (client(&self.env), self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id))) {
834 let _ = revoke_grant(&client, &tokens.access_token).await;
835 }
836 self.audit_github(&a.user, "github.unlinked", format!("Unlinked GitHub account @{}", row.login)).await;
837 Ok(Outcome::Ok(true))
838 }
839
840 /// A working user token for the person, refreshed when it is about to
841 /// expire. For the integrations service, to list installations.
842 pub async fn github_user_token(&self, a: GithubUserTokenArgs) -> Result<Outcome<String>> {
843 const RELINK: &str = "Link your GitHub account again in your settings: g1t's access to it has ended.";
844 let Some(row) = self.account_of(&a.user_id).await? else {
845 return Ok(Outcome::fail(FailureCode::NotFound, "Link your GitHub account first."));
846 };
847 let Some(tokens) = self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)) else {
848 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
849 };
850 let now = now_ms();
851 if tokens.fresh(now) {
852 return Ok(Outcome::Ok(tokens.access_token));
853 }
854 let (Some(client), true) = (client(&self.env), tokens.refreshable(now)) else {
855 self.forget_tokens(&row.user_id).await?;
856 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
857 };
858 let grant = serde_json::json!({
859 "grant_type": "refresh_token",
860 "refresh_token": tokens.refresh_token,
861 });
862 let Some(refreshed) = token_request(&client, grant).await? else {
863 self.forget_tokens(&row.user_id).await?;
864 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
865 };
866 let sealed = self.seal_tokens(&refreshed, &bound(&row.user_id));
867 self.db
868 .prepare(format!("UPDATE github_accounts SET tokens = ?, updated_at = {SQL_NOW} WHERE user_id = ?"))
869 .bind(&[
870 sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
871 row.user_id.as_str().into(),
872 ])?
873 .run()
874 .await?;
875 Ok(Outcome::Ok(refreshed.access_token))
876 }
877
878 async fn forget_tokens(&self, user_id: &str) -> Result<()> {
879 self.db
880 .prepare("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?")
881 .bind(&[user_id.into()])?
882 .run()
883 .await?;
884 Ok(())
885 }
886
887 /// The person revoked g1t's authorization on GitHub: its tokens go.
888 /// The link stays, so they can still sign in with GitHub.
889 pub async fn github_revoked(&self, a: GithubRevokedArgs) -> Result<u32> {
890 let changed = self
891 .db
892 .prepare("UPDATE github_accounts SET tokens = NULL WHERE github_id = ? RETURNING user_id")
893 .bind(&[(a.github_id as f64).into()])?
894 .all()
895 .await?
896 .results::<Value>()?;
897 Ok(changed.len() as u32)
898 }
899
900 /// The g1t usernames of linked GitHub accounts, by GitHub id, for
901 /// showing who wrote what was imported.
902 pub async fn github_usernames(&self, a: GithubUsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
903 #[derive(Deserialize)]
904 struct Named {
905 github_id: u64,
906 username: String,
907 }
908 let ids: Vec<u64> = a.github_ids.into_iter().take(100).collect();
909 let mut names = std::collections::HashMap::new();
910 if ids.is_empty() {
911 return Ok(names);
912 }
913 let marks = vec!["?"; ids.len()].join(", ");
914 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| (*id as f64).into()).collect();
915 let rows = self
916 .db
917 .prepare(format!(
918 "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)919 JOIN users ON users.id = github_accounts.user_id WHERE github_id IN ({marks}) AND users.deleted_at IS NULL"
Merge branch 'worktree-agent-a6a121745e81f639f'920 ))
921 .bind(&bind)?
922 .all()
923 .await?
924 .results::<Named>()?;
925 for row in rows {
926 names.insert(row.github_id.to_string(), row.username);
927 }
928 Ok(names)
929 }
930
931 /// Recorded in the audit log of every workspace the person belongs to,
932 /// which is where their workspaces' owners look.
933 async fn audit_github(&self, user: &User, action: &str, message: String) {
934 let (Ok(events), Ok(memberships)) = (self.env.service("EVENTS"), self.memberships(&user.id).await) else {
935 return;
936 };
937 let entries: Vec<NewAuditEntry> = memberships
938 .into_iter()
939 .map(|membership| NewAuditEntry {
940 actor: AuditActor::of(user),
941 action: action.to_owned(),
942 surface: Surface::Web,
943 target: AuditTarget {
944 workspace: membership.slug,
945 ..AuditTarget::default()
946 },
947 outcome: AuditOutcome::Allowed,
948 rule: "github".to_owned(),
949 result: Some("ok".to_owned()),
950 message: Some(message.clone()),
951 request_id: new_id("req", now_ms()),
952 })
953 .collect();
954 if entries.is_empty() {
955 return;
956 }
957 let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await;
958 if let Err(error) = recorded {
959 worker::console_error!("{action} not recorded: {error}");
960 }
961 }
962}
963
964/// `DELETE /applications/{client_id}/grant`, with the client's own
965/// credentials.
966async fn revoke_grant(client: &Client, access_token: &str) -> Result<()> {
967 let headers = Headers::new();
968 headers.set("user-agent", "g1t (+https://g1t.sh)")?;
969 headers.set("accept", "application/vnd.github+json")?;
970 headers.set("content-type", "application/json")?;
971 let basic = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", client.id, client.secret));
972 headers.set("authorization", &format!("Basic {basic}"))?;
973 let mut init = RequestInit::new();
974 init.with_method(Method::Delete)
975 .with_headers(headers)
976 .with_body(Some(serde_json::json!({ "access_token": access_token }).to_string().into()));
977 let url = format!("{API}/applications/{}/grant", client.id);
978 Fetch::Request(Request::new_with_init(&url, &init)?).send().await?;
979 Ok(())
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985
986 #[test]
987 fn the_challenge_is_rfc_7636s() {
988 // RFC 7636, appendix B.
989 assert_eq!(
990 pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
991 "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
992 );
993 let verifier = new_verifier();
994 assert_eq!(verifier.len(), 43);
995 assert_ne!(verifier, new_verifier());
996 }
997
998 #[test]
999 fn the_authorize_url_carries_state_and_challenge() {
1000 let url = authorize_url("Iv23liZS94alfjIUn1eW", "https://g1t.sh/auth/github/callback", "abc", "xyz");
1001 let parsed = Url::parse(&url).unwrap();
1002 let query: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
1003 assert_eq!(parsed.host_str(), Some("github.com"));
1004 assert_eq!(query["redirect_uri"], "https://g1t.sh/auth/github/callback");
1005 assert_eq!(query["state"], "abc");
1006 assert_eq!(query["code_challenge"], "xyz");
1007 assert_eq!(query["code_challenge_method"], "S256");
1008 }
1009
1010 fn email(address: &str, primary: bool, verified: bool) -> GithubEmail {
1011 GithubEmail {
1012 email: address.to_owned(),
1013 primary,
1014 verified,
1015 }
1016 }
1017
1018 #[test]
1019 fn only_verified_emails_count_primary_first() {
1020 let emails = [
1021 email("unverified@example.com", false, false),
1022 email("Work@Example.com", false, true),
1023 email("1+me@users.noreply.github.com", false, true),
1024 email("me@example.com", true, true),
1025 ];
1026 assert_eq!(verified_emails(&emails), vec!["me@example.com", "work@example.com"]);
1027 assert!(verified_emails(&[email("primary@example.com", true, false)]).is_empty());
1028 }
1029
1030 #[test]
1031 fn usernames_come_from_logins() {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1032 assert_eq!(suggest_username("Octo-Cat"), "Octo-Cat");
1033 assert_eq!(suggest_username("octocat"), "octocat");
Merge branch 'worktree-agent-a6a121745e81f639f'1034 assert_eq!(suggest_username("a_b..c"), "a-b-c");
1035 assert_eq!(suggest_username("-x-"), "x");
1036 assert_eq!(suggest_username(&"a".repeat(50)).len(), 39);
1037 }
1038
1039 #[test]
1040 fn a_login_named_like_g1t_gets_a_name_of_its_own() {
1041 assert_eq!(suggest_username("g1t"), "g1t-gh");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1042 assert_eq!(suggest_username("G1T"), "G1T-gh");
Merge branch 'worktree-agent-a6a121745e81f639f'1043 assert_eq!(suggest_username("g1t-agent"), "g1t-agent-gh");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1044 assert_eq!(suggest_username("G1t_Agent"), "G1t-Agent-gh");
Merge branch 'worktree-agent-a6a121745e81f639f'1045 assert_eq!(suggest_username("api"), "api-gh");
1046 assert!(is_valid_namespace(&suggest_username("g1t")));
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1047 assert!(claimable_username(&suggest_username("G1T")).is_some());
Merge branch 'worktree-agent-a6a121745e81f639f'1048 assert_eq!(suggest_username("g1t-fan"), "g1t-fan");
A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so.1049 // A GitHub account named like a well-known action owner signs up
1050 // under a name of its own too.
1051 assert_eq!(suggest_username("Docker"), "Docker-gh");
1052 assert!(claimable_username(&suggest_username("actions")).is_some());
Merge branch 'worktree-agent-a6a121745e81f639f'1053 // So signing up goes ahead, rather than failing on the login.
1054 let facts = Facts { suggestion: suggest_username("g1t"), ..facts() };
1055 assert_eq!(decide(&facts), Decision::Create("g1t-gh".to_owned()));
1056 }
1057
1058 #[test]
1059 fn a_chosen_username_cannot_be_g1ts() {
1060 // What github_sign_up takes from the form.
1061 for name in ["g1t", " G1T ", "g1t-agent", "G1T-AGENT"] {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1062 assert!(claimable_username(name).is_none(), "{name}");
Merge branch 'worktree-agent-a6a121745e81f639f'1063 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1064 let chosen = claimable_username(" Octo-Cat ").unwrap();
1065 assert_eq!(chosen.canonical, "octo-cat");
1066 assert_eq!(chosen.display, "Octo-Cat");
Merge branch 'worktree-agent-a6a121745e81f639f'1067 }
1068
1069 fn facts() -> Facts<'static> {
1070 Facts {
1071 purpose: Some(GithubPurpose::SignIn),
1072 has_verified_email: true,
1073 suggestion: "octocat".to_owned(),
1074 suggestion_free: true,
1075 ..Facts::default()
1076 }
1077 }
1078
1079 #[test]
1080 fn a_linked_account_signs_in() {
1081 let facts = Facts { linked_to: Some("usr_1"), email_taken: true, ..facts() };
1082 assert_eq!(decide(&facts), Decision::SignIn("usr_1".to_owned()));
1083 }
1084
1085 #[test]
1086 fn a_matching_email_is_never_linked_silently() {
1087 let facts = Facts { email_taken: true, ..facts() };
1088 assert_eq!(decide(&facts), Decision::NeedsLink);
1089 }
1090
1091 #[test]
1092 fn a_new_person_gets_their_login_or_chooses() {
1093 assert_eq!(decide(&facts()), Decision::Create("octocat".to_owned()));
1094 let taken = Facts { suggestion_free: false, ..facts() };
1095 assert_eq!(decide(&taken), Decision::NeedsUsername("octocat".to_owned()));
1096 let no_email = Facts { has_verified_email: false, ..facts() };
1097 assert!(matches!(decide(&no_email), Decision::Refuse(_)));
1098 }
1099
1100 #[test]
1101 fn an_invite_for_one_address_makes_the_account_with_it() {
1102 let mut emails = vec!["ada@work.example".to_owned(), "ada@home.example".to_owned()];
1103 assert!(put_first(&mut emails, "Ada@Home.example"));
1104 assert_eq!(emails, ["ada@home.example", "ada@work.example"]);
1105 assert!(!put_first(&mut emails, "eve@example.com"));
1106 assert_eq!(emails, ["ada@home.example", "ada@work.example"]);
1107 }
1108
1109 #[test]
1110 fn linking_is_for_the_account_that_asked() {
1111 let link = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), ..facts() };
1112 assert_eq!(decide(&link), Decision::Link("usr_1".to_owned()));
1113 let elsewhere = Facts { linked_to: Some("usr_2"), ..link };
1114 assert!(matches!(decide(&elsewhere), Decision::Refuse(_)));
1115 let other = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), asking_has_other: true, ..facts() };
1116 assert!(matches!(decide(&other), Decision::Refuse(_)));
1117 let nobody = Facts { purpose: Some(GithubPurpose::Link), ..facts() };
1118 assert!(matches!(decide(&nobody), Decision::Refuse(_)));
1119 }
1120
1121 #[test]
1122 fn tokens_expire_and_refresh() {
1123 let answer = serde_json::json!({
1124 "access_token": format!("ghu_{}", "a".repeat(516)),
1125 "expires_in": 28800,
1126 "refresh_token": "ghr_x",
1127 "refresh_token_expires_in": 15897600,
1128 "token_type": "bearer",
1129 });
1130 let tokens = tokens_from(&answer, 1_000).unwrap();
1131 assert_eq!(tokens.access_token.len(), 520);
1132 assert_eq!(tokens.access_expires_at, Some(1_000 + 28_800_000));
1133 assert!(tokens.fresh(1_000));
1134 assert!(!tokens.fresh(1_000 + 28_800_000 - 60_000));
1135 assert!(tokens.refreshable(1_000 + 28_800_000));
1136 assert!(tokens_from(&serde_json::json!({ "error": "bad_verification_code" }), 0).is_none());
1137 // Tokens that never expire, as when expiry is turned off on the app.
1138 let lasting = tokens_from(&serde_json::json!({ "access_token": "gho_x" }), 0).unwrap();
1139 assert!(lasting.fresh(u64::MAX / 2));
1140 assert!(!lasting.refreshable(0));
1141 }
1142
1143 #[test]
1144 fn a_long_token_survives_sealing() {
1145 let sealer = Sealer::new("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f").unwrap();
1146 let tokens = Tokens {
1147 access_token: format!("ghs_{}", "z".repeat(516)),
1148 access_expires_at: None,
1149 refresh_token: None,
1150 refresh_expires_at: None,
1151 };
1152 let sealed = sealer.seal(&serde_json::to_string(&tokens).unwrap(), &bound("usr_1"));
1153 let opened: Tokens = serde_json::from_str(&sealer.open(&sealed, &bound("usr_1")).unwrap()).unwrap();
1154 assert_eq!(opened, tokens);
1155 assert!(sealer.open(&sealed, &bound("usr_2")).is_none());
1156 }
1157}

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