Skip to content
1,176 linesCodeBlameRaw
1//! Signing in with GitHub, through g1t's GitHub App's user authorization:
2//! the OAuth web flow with PKCE (S256).
3//!
4//! The site sends the browser to GitHub with a state it also keeps in a
5//! short-lived cookie; this service keeps the state's hash and the PKCE
6//! verifier, each usable once and for ten minutes. On the way back the site
7//! checks the cookie against the state GitHub returns, and this service
8//! redeems the state, exchanges the code, and reads the person's GitHub
9//! account and verified emails.
10//!
11//! A GitHub account is known by its numeric id, never its login, which its
12//! owner can change. One with no g1t account yet makes one; one whose
13//! verified email belongs to an existing g1t account is never linked to it
14//! silently: the person signs in to that account first. The app's user
15//! tokens expire, so the refresh token is kept, sealed under IDENTITY_KEY,
16//! and used when the access token is about to run out. Tokens are opaque
17//! strings of any length.
18//!
19//! Configured with the vars GITHUB_APP_CLIENT_ID and the secret
20//! GITHUB_APP_CLIENT_SECRET; without both, `github_enabled` is false and
21//! everything else here says GitHub is not set up.
22
23use base64::Engine;
24use base64::engine::general_purpose::URL_SAFE_NO_PAD;
25use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
26use g1t_contracts::github::*;
27use g1t_contracts::identity::{SignedIn, UserArgs};
28use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
29use g1t_contracts::{FailureCode, Outcome, User, claimable_username, is_reserved_name, is_valid_namespace, new_id};
30use g1t_kit::now_ms;
31use g1t_secrets::Sealer;
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use sha2::{Digest, Sha256};
35use worker::{Fetch, Headers, Method, Request, RequestInit, Result, Url};
36
37use crate::{Identity, crypto};
38
39const STATE_TTL_SECONDS: u64 = 10 * 60;
40const PENDING_TTL_SECONDS: u64 = 30 * 60;
41/// An access token this close to expiring is refreshed before use.
42const REFRESH_MARGIN_MS: u64 = 5 * 60 * 1000;
43const AUTHORIZE_URL: &str = "https://github.com/login/oauth/authorize";
44const TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
45const API: &str = "https://api.github.com";
46const NOT_SET_UP: &str = "Signing in with GitHub is not set up on this g1t.";
47const TRY_AGAIN: &str = "GitHub did not complete the sign-in. Try again.";
48
49/// The app's OAuth client, when this g1t has one.
50struct Client {
51 id: String,
52 secret: String,
53}
54
55fn client(env: &worker::Env) -> Option<Client> {
56 let id = env.var("GITHUB_APP_CLIENT_ID").ok()?.to_string();
57 let secret = env.secret("GITHUB_APP_CLIENT_SECRET").ok()?.to_string();
58 (!id.trim().is_empty() && !secret.trim().is_empty()).then(|| Client {
59 id: id.trim().to_owned(),
60 secret: secret.trim().to_owned(),
61 })
62}
63
64// --- Pure parts, tested below ----------------------------------------------
65
66/// A PKCE code verifier: 32 random bytes, base64url, 43 characters.
67pub fn new_verifier() -> String {
68 let mut bytes = [0u8; 32];
69 getrandom::getrandom(&mut bytes).expect("no source of randomness");
70 URL_SAFE_NO_PAD.encode(bytes)
71}
72
73/// The S256 challenge for a verifier (RFC 7636).
74pub fn pkce_challenge(verifier: &str) -> String {
75 URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
76}
77
78pub fn authorize_url(client_id: &str, redirect_uri: &str, state: &str, challenge: &str) -> String {
79 Url::parse_with_params(
80 AUTHORIZE_URL,
81 &[
82 ("client_id", client_id),
83 ("redirect_uri", redirect_uri),
84 ("state", state),
85 ("code_challenge", challenge),
86 ("code_challenge_method", "S256"),
87 ("allow_signup", "true"),
88 ],
89 )
90 .map(|url| url.to_string())
91 .unwrap_or_default()
92}
93
94/// One of `GET /user/emails`.
95#[derive(Clone, Debug, Deserialize)]
96pub struct GithubEmail {
97 pub email: String,
98 #[serde(default)]
99 pub primary: bool,
100 #[serde(default)]
101 pub verified: bool,
102}
103
104/// The verified addresses, lowercased, the primary first. Unverified ones
105/// prove nothing, and GitHub's private relay addresses belong to no inbox
106/// g1t could write to.
107pub fn verified_emails(emails: &[GithubEmail]) -> Vec<String> {
108 let mut kept: Vec<(bool, String)> = emails
109 .iter()
110 .filter(|email| email.verified)
111 .map(|email| (email.primary, email.email.trim().to_lowercase()))
112 .filter(|(_, email)| email.contains('@') && !email.ends_with("@users.noreply.github.com"))
113 .collect();
114 // Primary first; otherwise as GitHub listed them.
115 kept.sort_by_key(|(primary, _)| !primary);
116 let mut out: Vec<String> = Vec::new();
117 for (_, email) in kept {
118 if !out.contains(&email) {
119 out.push(email);
120 }
121 }
122 out
123}
124
125/// A username made from a GitHub login: 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.
129pub fn suggest_username(login: &str) -> String {
130 let mut out = String::new();
131 for character in login.trim().chars() {
132 if character.is_ascii_alphanumeric() {
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('-');
140 if is_reserved_name(out) { format!("{out}-gh") } else { out.to_owned() }
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 /// 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
306const 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
319async 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 // Taken in any case: names are found lowercased.
460 let username = &username.to_ascii_lowercase();
461 if !is_valid_namespace(username) {
462 return Ok(false);
463 }
464 let taken = self
465 .db
466 .prepare("SELECT username FROM users WHERE username = ?1 UNION ALL SELECT slug FROM workspaces WHERE slug = ?1")
467 .bind(&[username.into()])?
468 .first::<Value>(None)
469 .await?;
470 Ok(taken.is_none() && !self.slug_held(username).await? && !self.slug_deleted(username).await?)
471 }
472
473 /// Whether an account has confirmed one of these addresses, any of its
474 /// addresses, not only its primary (emails.rs). An address someone
475 /// added and never confirmed does not count: GitHub has confirmed it,
476 /// so a new account made with it wins it (first to confirm keeps it).
477 async fn email_taken(&self, emails: &[String]) -> Result<bool> {
478 for email in emails {
479 if self.user_with_verified_email(email).await?.is_some() {
480 return Ok(true);
481 }
482 }
483 Ok(false)
484 }
485
486 /// Links a GitHub account to a user, keeping its tokens.
487 async fn link(&self, user_id: &str, github_id: u64, login: &str, tokens: Option<&Tokens>) -> Result<()> {
488 let sealed = tokens.and_then(|tokens| self.seal_tokens(tokens, &bound(user_id)));
489 let now = rfc3339(now_ms());
490 self.db
491 .prepare(
492 "INSERT INTO github_accounts (user_id, github_id, login, tokens, created_at, updated_at)
493 VALUES (?1, ?2, ?3, ?4, ?5, ?5)
494 ON CONFLICT (user_id) DO UPDATE SET login = excluded.login,
495 tokens = COALESCE(excluded.tokens, github_accounts.tokens), updated_at = excluded.updated_at",
496 )
497 .bind(&[
498 user_id.into(),
499 (github_id as f64).into(),
500 login.into(),
501 sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
502 now.as_str().into(),
503 ])?
504 .run()
505 .await?;
506 Ok(())
507 }
508
509 async fn user_by_id(&self, user_id: &str) -> Result<Option<User>> {
510 self.find_user(
511 "SELECT id, username, email_verified_at IS NOT NULL AS verified, avatar FROM users WHERE id = ? AND deleted_at IS NULL",
512 user_id,
513 )
514 .await
515 }
516
517 /// Keeps a GitHub sign-in that has to wait on the person.
518 async fn hold(
519 &self,
520 user: &GithubUser,
521 kind: &str,
522 suggestion: Option<&str>,
523 tokens: &Tokens,
524 next: &str,
525 invite_code: Option<&str>,
526 ) -> Result<String> {
527 let pending = crypto::random_hex(32);
528 let id = crypto::sha256_hex(&pending);
529 let sealed = self.seal_tokens(tokens, &id);
530 self.db
531 .prepare(format!(
532 "INSERT INTO github_pending (id, github_id, login, email, kind, suggestion, tokens, next, invite_code, expires_at)
533 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, {})",
534 sql_after(PENDING_TTL_SECONDS)
535 ))
536 .bind(&[
537 id.as_str().into(),
538 (user.id as f64).into(),
539 user.login.as_str().into(),
540 user.emails.first().map(String::as_str).unwrap_or_default().into(),
541 kind.into(),
542 suggestion.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
543 sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
544 next.into(),
545 invite_code.map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
546 ])?
547 .run()
548 .await?;
549 Ok(pending)
550 }
551
552 async fn pending_row(&self, pending: &str) -> Result<Option<PendingRow>> {
553 self.db
554 .prepare(format!("SELECT * FROM github_pending WHERE id = ? AND expires_at > {SQL_NOW}"))
555 .bind(&[crypto::sha256_hex(pending).into()])?
556 .first::<PendingRow>(None)
557 .await
558 }
559
560 async fn drop_pending(&self, id: &str) -> Result<()> {
561 self.db.prepare("DELETE FROM github_pending WHERE id = ?").bind(&[id.into()])?.run().await?;
562 self.db
563 .prepare(format!("DELETE FROM github_pending WHERE expires_at < {SQL_NOW}"))
564 .run()
565 .await?;
566 Ok(())
567 }
568
569 /// Makes an account from a GitHub sign-in: its email is GitHub's
570 /// verified primary, confirmed already, and it has no password.
571 async fn create_from_github(
572 &self,
573 username: &str,
574 email: &str,
575 github_id: u64,
576 login: &str,
577 tokens: Option<&Tokens>,
578 invite_code: Option<&str>,
579 ) -> Result<Outcome<User>> {
580 // Kept as chosen for showing, found lowercased.
581 let Some(chosen) = claimable_username(username) else {
582 return Ok(Outcome::fail(FailureCode::Invalid, crate::USERNAME_RULES));
583 };
584 let username = chosen.canonical.as_str();
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,
590 display_username: chosen.display_if_cased(),
591 email,
592 password_hash: "",
593 verified: true,
594 invite_code,
595 // GitHub has confirmed the address already.
596 email_proof: None,
597 client: None,
598 })
599 .await?
600 {
601 Outcome::Ok(user) => user,
602 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
603 };
604 self.link(&user.id, github_id, login, tokens).await?;
605 self.announce_user(username, Some(&user.id)).await;
606 Ok(Outcome::Ok(user))
607 }
608
609 /// Whether new accounts need an invite code: REGISTRATION_MODE, read
610 /// by invites.rs. Unset means they do.
611 fn github_invites_required(&self) -> bool {
612 self.invites_required()
613 }
614
615 pub async fn github_finish(&self, a: GithubFinishArgs) -> Result<Outcome<GithubFinished>> {
616 let Some(client) = client(&self.env) else {
617 return Ok(Outcome::fail(FailureCode::NotFound, NOT_SET_UP));
618 };
619 // Single use: the state is gone whatever happens next.
620 let state = self
621 .db
622 .prepare(format!(
623 "DELETE FROM github_states WHERE id = ? AND expires_at > {SQL_NOW}
624 RETURNING verifier, purpose, user_id, redirect_uri, next, invite_code"
625 ))
626 .bind(&[crypto::sha256_hex(&a.state).into()])?
627 .first::<StateRow>(None)
628 .await?;
629 let Some(state) = state else {
630 return Ok(Outcome::fail(FailureCode::Invalid, "This sign-in link has expired. Start again."));
631 };
632 let grant = serde_json::json!({
633 "code": a.code,
634 "redirect_uri": state.redirect_uri,
635 "code_verifier": state.verifier,
636 });
637 let Some(tokens) = token_request(&client, grant).await? else {
638 return Ok(Outcome::fail(FailureCode::Invalid, TRY_AGAIN));
639 };
640 let Some(mut github) = read_user(&tokens.access_token).await? else {
641 return Ok(Outcome::fail(FailureCode::Invalid, TRY_AGAIN));
642 };
643 // An invite sent to one address makes the account with that one,
644 // when GitHub has confirmed it too; otherwise the invite is not
645 // this GitHub account's to use.
646 let bound = match state.invite_code.as_deref() {
647 Some(code) => self.bound_email_of(code).await?,
648 None => None,
649 };
650 let bound_elsewhere = bound.as_deref().is_some_and(|bound| !put_first(&mut github.emails, bound));
651 let purpose = if state.purpose == "link" { GithubPurpose::Link } else { GithubPurpose::SignIn };
652 let linked = self.account_by_github(github.id).await?;
653 let asking_has_other = match &state.user_id {
654 Some(user_id) => self.account_of(user_id).await?.is_some_and(|row| row.github_id != github.id),
655 None => false,
656 };
657 let suggestion = suggest_username(&github.login);
658 let facts = Facts {
659 purpose: Some(purpose),
660 asking: state.user_id.as_deref(),
661 asking_has_other,
662 linked_to: linked.as_ref().map(|row| row.user_id.as_str()),
663 has_verified_email: !github.emails.is_empty(),
664 email_taken: linked.is_none() && self.email_taken(&github.emails).await?,
665 suggestion_free: linked.is_none() && self.username_free(&suggestion).await?,
666 suggestion: suggestion.clone(),
667 invite_missing: self.github_invites_required() && state.invite_code.is_none(),
668 };
669 let next = state.next;
670 let decision = decide(&facts);
671 if bound_elsewhere && matches!(decision, Decision::Create(_) | Decision::NeedsUsername(_)) {
672 return Ok(Outcome::fail(FailureCode::Conflict, INVITE_FOR_ANOTHER_ADDRESS));
673 }
674 Ok(match decision {
675 Decision::Refuse(reason) => Outcome::fail(FailureCode::Conflict, reason),
676 Decision::Link(user_id) => {
677 self.link(&user_id, github.id, &github.login, Some(&tokens)).await?;
678 if let Some(user) = self.user_by_id(&user_id).await? {
679 self.audit_github(&user, "github.linked", format!("Linked GitHub account @{}", github.login)).await;
680 }
681 Outcome::Ok(GithubFinished::Linked { login: github.login, next })
682 }
683 Decision::SignIn(user_id) => {
684 // A deleted account signs in to nothing, and g1t keeps no
685 // new GitHub token for it (account_deletion.rs).
686 let Some(user) = self.user_by_id(&user_id).await? else {
687 return Ok(Outcome::fail(FailureCode::NotFound, TRY_AGAIN));
688 };
689 self.link(&user_id, github.id, &github.login, Some(&tokens)).await?;
690 self.audit_github(&user, "github.sign_in", format!("Signed in with GitHub (@{})", github.login)).await;
691 self.signed_in(user, false, next).await?
692 }
693 Decision::NeedsLink => {
694 let pending = self.hold(&github, "link", None, &tokens, &next, None).await?;
695 Outcome::Ok(GithubFinished::NeedsLink { pending, login: github.login, next })
696 }
697 Decision::Create(username) => {
698 let email = github.emails[0].clone();
699 let invite = state.invite_code.as_deref();
700 match self.create_from_github(&username, &email, github.id, &github.login, Some(&tokens), invite).await? {
701 Outcome::Ok(user) => self.signed_in(user, true, next).await?,
702 // A code that did not pass: the person can enter another.
703 Outcome::Fail(_) => {
704 let pending = self.hold(&github, "username", Some(&username), &tokens, &next, None).await?;
705 Outcome::Ok(GithubFinished::NeedsUsername {
706 pending,
707 login: github.login,
708 suggestion: username,
709 next,
710 invite_required: self.github_invites_required(),
711 })
712 }
713 }
714 }
715 Decision::NeedsUsername(suggestion) => {
716 let invite = state.invite_code.as_deref();
717 let pending = self.hold(&github, "username", Some(&suggestion), &tokens, &next, invite).await?;
718 Outcome::Ok(GithubFinished::NeedsUsername {
719 pending,
720 login: github.login,
721 suggestion,
722 next,
723 invite_required: self.github_invites_required() && invite.is_none(),
724 })
725 }
726 })
727 }
728
729 async fn signed_in(&self, user: User, created: bool, next: String) -> Result<Outcome<GithubFinished>> {
730 Ok(match self.start_session(user).await? {
731 Outcome::Ok(signed_in) => Outcome::Ok(GithubFinished::SignedIn { signed_in, created, next }),
732 Outcome::Fail(failure) => Outcome::Fail(failure),
733 })
734 }
735
736 pub async fn github_pending(&self, a: GithubPendingArgs) -> Result<Outcome<GithubPending>> {
737 let Some(row) = self.pending_row(&a.pending).await? else {
738 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
739 };
740 Ok(Outcome::Ok(GithubPending {
741 invite_required: row.kind == "username" && self.github_invites_required() && row.invite_code.is_none(),
742 login: row.login,
743 kind: row.kind,
744 suggestion: row.suggestion,
745 next: row.next,
746 }))
747 }
748
749 pub async fn github_sign_up(&self, a: GithubSignUpArgs) -> Result<Outcome<SignedIn>> {
750 let Some(row) = self.pending_row(&a.pending).await?.filter(|row| row.kind == "username") else {
751 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
752 };
753 let Some(chosen) = claimable_username(&a.username) else {
754 return Ok(Outcome::fail(FailureCode::Invalid, crate::USERNAME_RULES));
755 };
756 // As chosen: create_from_github keeps the case for showing.
757 let username = chosen.display;
758 if !self.username_free(&username).await? {
759 return Ok(Outcome::fail(FailureCode::Conflict, "That username is taken. Choose another."));
760 }
761 // Checked again: either could have changed while the person chose.
762 if self.account_by_github(row.github_id).await?.is_some() || self.email_taken(std::slice::from_ref(&row.email)).await? {
763 return Ok(Outcome::fail(FailureCode::Conflict, "An account already uses this GitHub account or email. Sign in instead."));
764 }
765 let tokens = self.open_tokens(row.tokens.as_deref(), &row.id);
766 let given = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty());
767 let invite = given.or(row.invite_code.as_deref());
768 let user = match self
769 .create_from_github(&username, &row.email, row.github_id, &row.login, tokens.as_ref(), invite)
770 .await?
771 {
772 Outcome::Ok(user) => user,
773 Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)),
774 };
775 self.drop_pending(&row.id).await?;
776 self.start_session(user).await
777 }
778
779 /// Links a held GitHub sign-in to the account the person then signed in
780 /// to: they have proved both.
781 pub async fn github_claim(&self, a: GithubClaimArgs) -> Result<Outcome<GithubAccount>> {
782 let Some(row) = self.pending_row(&a.pending).await? else {
783 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
784 };
785 if let Some(linked) = self.account_by_github(row.github_id).await?
786 && linked.user_id != a.user.id
787 {
788 return Ok(Outcome::fail(FailureCode::Conflict, "That GitHub account is linked to another g1t account."));
789 }
790 if self.account_of(&a.user.id).await?.is_some_and(|linked| linked.github_id != row.github_id) {
791 return Ok(Outcome::fail(FailureCode::Conflict, "Your account is linked to another GitHub account. Unlink it first."));
792 }
793 let tokens = self.open_tokens(row.tokens.as_deref(), &row.id);
794 self.link(&a.user.id, row.github_id, &row.login, tokens.as_ref()).await?;
795 self.drop_pending(&row.id).await?;
796 self.audit_github(&a.user, "github.linked", format!("Linked GitHub account @{}", row.login)).await;
797 Ok(Outcome::Ok(GithubAccount {
798 github_id: row.github_id,
799 login: row.login,
800 linked_at: rfc3339(now_ms()),
801 authorized: tokens.is_some(),
802 }))
803 }
804
805 async fn has_password(&self, user_id: &str) -> Result<bool> {
806 Ok(self
807 .db
808 .prepare("SELECT 1 AS yes FROM users WHERE id = ? AND password_hash LIKE 'pbkdf2$%'")
809 .bind(&[user_id.into()])?
810 .first::<Value>(None)
811 .await?
812 .is_some())
813 }
814
815 pub async fn github_account(&self, a: UserArgs) -> Result<GithubAccountView> {
816 let row = self.account_of(&a.user.id).await?;
817 Ok(GithubAccountView {
818 enabled: self.github_enabled(),
819 account: row.map(|row| GithubAccount {
820 authorized: self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)).is_some(),
821 github_id: row.github_id,
822 login: row.login,
823 linked_at: row.created_at,
824 }),
825 has_password: self.has_password(&a.user.id).await?,
826 })
827 }
828
829 pub async fn github_unlink(&self, a: UserArgs) -> Result<Outcome<bool>> {
830 let Some(row) = self.account_of(&a.user.id).await? else {
831 return Ok(Outcome::Ok(false));
832 };
833 if !self.has_password(&a.user.id).await? {
834 return Ok(Outcome::fail(
835 FailureCode::Conflict,
836 "GitHub is the only way you sign in. Set a password first: sign out and use Forgot your password.",
837 ));
838 }
839 self.db
840 .prepare("DELETE FROM github_accounts WHERE user_id = ?")
841 .bind(&[a.user.id.as_str().into()])?
842 .run()
843 .await?;
844 // Best effort: also end g1t's authorization on GitHub's side.
845 if let (Some(client), Some(tokens)) = (client(&self.env), self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id))) {
846 let _ = revoke_grant(&client, &tokens.access_token).await;
847 }
848 self.audit_github(&a.user, "github.unlinked", format!("Unlinked GitHub account @{}", row.login)).await;
849 Ok(Outcome::Ok(true))
850 }
851
852 /// A working user token for the person, refreshed when it is about to
853 /// expire. For the integrations service, to list installations.
854 pub async fn github_user_token(&self, a: GithubUserTokenArgs) -> Result<Outcome<String>> {
855 const RELINK: &str = "Link your GitHub account again in your settings: g1t's access to it has ended.";
856 let Some(row) = self.account_of(&a.user_id).await? else {
857 return Ok(Outcome::fail(FailureCode::NotFound, "Link your GitHub account first."));
858 };
859 let Some(tokens) = self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)) else {
860 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
861 };
862 let now = now_ms();
863 if tokens.fresh(now) {
864 return Ok(Outcome::Ok(tokens.access_token));
865 }
866 let (Some(client), true) = (client(&self.env), tokens.refreshable(now)) else {
867 self.forget_tokens(&row.user_id).await?;
868 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
869 };
870 let grant = serde_json::json!({
871 "grant_type": "refresh_token",
872 "refresh_token": tokens.refresh_token,
873 });
874 let Some(refreshed) = token_request(&client, grant).await? else {
875 self.forget_tokens(&row.user_id).await?;
876 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
877 };
878 let sealed = self.seal_tokens(&refreshed, &bound(&row.user_id));
879 self.db
880 .prepare(format!("UPDATE github_accounts SET tokens = ?, updated_at = {SQL_NOW} WHERE user_id = ?"))
881 .bind(&[
882 sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
883 row.user_id.as_str().into(),
884 ])?
885 .run()
886 .await?;
887 Ok(Outcome::Ok(refreshed.access_token))
888 }
889
890 async fn forget_tokens(&self, user_id: &str) -> Result<()> {
891 self.db
892 .prepare("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?")
893 .bind(&[user_id.into()])?
894 .run()
895 .await?;
896 Ok(())
897 }
898
899 /// The person revoked g1t's authorization on GitHub: its tokens go.
900 /// The link stays, so they can still sign in with GitHub.
901 pub async fn github_revoked(&self, a: GithubRevokedArgs) -> Result<u32> {
902 let changed = self
903 .db
904 .prepare("UPDATE github_accounts SET tokens = NULL WHERE github_id = ? RETURNING user_id")
905 .bind(&[(a.github_id as f64).into()])?
906 .all()
907 .await?
908 .results::<Value>()?;
909 Ok(changed.len() as u32)
910 }
911
912 /// The g1t usernames of linked GitHub accounts, by GitHub id, for
913 /// showing who wrote what was imported.
914 pub async fn github_usernames(&self, a: GithubUsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
915 #[derive(Deserialize)]
916 struct Named {
917 github_id: u64,
918 username: String,
919 }
920 let ids: Vec<u64> = a.github_ids.into_iter().take(100).collect();
921 let mut names = std::collections::HashMap::new();
922 if ids.is_empty() {
923 return Ok(names);
924 }
925 let marks = vec!["?"; ids.len()].join(", ");
926 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| (*id as f64).into()).collect();
927 let rows = self
928 .db
929 .prepare(format!(
930 "SELECT github_accounts.github_id, users.username FROM github_accounts
931 JOIN users ON users.id = github_accounts.user_id WHERE github_id IN ({marks}) AND users.deleted_at IS NULL"
932 ))
933 .bind(&bind)?
934 .all()
935 .await?
936 .results::<Named>()?;
937 for row in rows {
938 names.insert(row.github_id.to_string(), row.username);
939 }
940 Ok(names)
941 }
942
943 /// Recorded in the audit log of every workspace the person belongs to,
944 /// which is where their workspaces' owners look.
945 async fn audit_github(&self, user: &User, action: &str, message: String) {
946 let (Ok(events), Ok(memberships)) = (self.env.service("EVENTS"), self.memberships(&user.id).await) else {
947 return;
948 };
949 let entries: Vec<NewAuditEntry> = memberships
950 .into_iter()
951 .map(|membership| NewAuditEntry {
952 actor: AuditActor::of(user),
953 action: action.to_owned(),
954 surface: Surface::Web,
955 target: AuditTarget {
956 workspace: membership.slug,
957 ..AuditTarget::default()
958 },
959 outcome: AuditOutcome::Allowed,
960 rule: "github".to_owned(),
961 result: Some("ok".to_owned()),
962 message: Some(message.clone()),
963 request_id: new_id("req", now_ms()),
964 })
965 .collect();
966 if entries.is_empty() {
967 return;
968 }
969 let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await;
970 if let Err(error) = recorded {
971 worker::console_error!("{action} not recorded: {error}");
972 }
973 }
974}
975
976/// `DELETE /applications/{client_id}/grant`, with the client's own
977/// credentials.
978async fn revoke_grant(client: &Client, access_token: &str) -> Result<()> {
979 let headers = Headers::new();
980 headers.set("user-agent", "g1t (+https://g1t.sh)")?;
981 headers.set("accept", "application/vnd.github+json")?;
982 headers.set("content-type", "application/json")?;
983 let basic = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", client.id, client.secret));
984 headers.set("authorization", &format!("Basic {basic}"))?;
985 let mut init = RequestInit::new();
986 init.with_method(Method::Delete)
987 .with_headers(headers)
988 .with_body(Some(serde_json::json!({ "access_token": access_token }).to_string().into()));
989 let url = format!("{API}/applications/{}/grant", client.id);
990 Fetch::Request(Request::new_with_init(&url, &init)?).send().await?;
991 Ok(())
992}
993
994#[cfg(test)]
995mod tests {
996 use super::*;
997
998 #[test]
999 fn the_challenge_is_rfc_7636s() {
1000 // RFC 7636, appendix B.
1001 assert_eq!(
1002 pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
1003 "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
1004 );
1005 let verifier = new_verifier();
1006 assert_eq!(verifier.len(), 43);
1007 assert_ne!(verifier, new_verifier());
1008 }
1009
1010 #[test]
1011 fn the_authorize_url_carries_state_and_challenge() {
1012 let url = authorize_url("Iv23liZS94alfjIUn1eW", "https://g1t.sh/auth/github/callback", "abc", "xyz");
1013 let parsed = Url::parse(&url).unwrap();
1014 let query: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
1015 assert_eq!(parsed.host_str(), Some("github.com"));
1016 assert_eq!(query["redirect_uri"], "https://g1t.sh/auth/github/callback");
1017 assert_eq!(query["state"], "abc");
1018 assert_eq!(query["code_challenge"], "xyz");
1019 assert_eq!(query["code_challenge_method"], "S256");
1020 }
1021
1022 fn email(address: &str, primary: bool, verified: bool) -> GithubEmail {
1023 GithubEmail {
1024 email: address.to_owned(),
1025 primary,
1026 verified,
1027 }
1028 }
1029
1030 #[test]
1031 fn only_verified_emails_count_primary_first() {
1032 let emails = [
1033 email("unverified@example.com", false, false),
1034 email("Work@Example.com", false, true),
1035 email("1+me@users.noreply.github.com", false, true),
1036 email("me@example.com", true, true),
1037 ];
1038 assert_eq!(verified_emails(&emails), vec!["me@example.com", "work@example.com"]);
1039 assert!(verified_emails(&[email("primary@example.com", true, false)]).is_empty());
1040 }
1041
1042 #[test]
1043 fn usernames_come_from_logins() {
1044 assert_eq!(suggest_username("Octo-Cat"), "Octo-Cat");
1045 assert_eq!(suggest_username("octocat"), "octocat");
1046 assert_eq!(suggest_username("a_b..c"), "a-b-c");
1047 assert_eq!(suggest_username("-x-"), "x");
1048 assert_eq!(suggest_username(&"a".repeat(50)).len(), 39);
1049 }
1050
1051 #[test]
1052 fn a_login_named_like_g1t_gets_a_name_of_its_own() {
1053 assert_eq!(suggest_username("g1t"), "g1t-gh");
1054 assert_eq!(suggest_username("G1T"), "G1T-gh");
1055 assert_eq!(suggest_username("g1t-agent"), "g1t-agent-gh");
1056 assert_eq!(suggest_username("G1t_Agent"), "G1t-Agent-gh");
1057 assert_eq!(suggest_username("api"), "api-gh");
1058 assert!(is_valid_namespace(&suggest_username("g1t")));
1059 assert!(claimable_username(&suggest_username("G1T")).is_some());
1060 assert_eq!(suggest_username("g1t-fan"), "g1t-fan");
1061 // So signing up goes ahead, rather than failing on the login.
1062 let facts = Facts { suggestion: suggest_username("g1t"), ..facts() };
1063 assert_eq!(decide(&facts), Decision::Create("g1t-gh".to_owned()));
1064 }
1065
1066 #[test]
1067 fn a_chosen_username_cannot_be_g1ts() {
1068 // What github_sign_up takes from the form.
1069 for name in ["g1t", " G1T ", "g1t-agent", "G1T-AGENT"] {
1070 assert!(claimable_username(name).is_none(), "{name}");
1071 }
1072 let chosen = claimable_username(" Octo-Cat ").unwrap();
1073 assert_eq!(chosen.canonical, "octo-cat");
1074 assert_eq!(chosen.display, "Octo-Cat");
1075 }
1076
1077 fn facts() -> Facts<'static> {
1078 Facts {
1079 purpose: Some(GithubPurpose::SignIn),
1080 has_verified_email: true,
1081 suggestion: "octocat".to_owned(),
1082 suggestion_free: true,
1083 ..Facts::default()
1084 }
1085 }
1086
1087 #[test]
1088 fn a_linked_account_signs_in() {
1089 let facts = Facts { linked_to: Some("usr_1"), email_taken: true, ..facts() };
1090 assert_eq!(decide(&facts), Decision::SignIn("usr_1".to_owned()));
1091 }
1092
1093 #[test]
1094 fn a_matching_email_is_never_linked_silently() {
1095 let facts = Facts { email_taken: true, ..facts() };
1096 assert_eq!(decide(&facts), Decision::NeedsLink);
1097 }
1098
1099 #[test]
1100 fn a_new_person_gets_their_login_or_chooses() {
1101 assert_eq!(decide(&facts()), Decision::Create("octocat".to_owned()));
1102 let taken = Facts { suggestion_free: false, ..facts() };
1103 assert_eq!(decide(&taken), Decision::NeedsUsername("octocat".to_owned()));
1104 let no_email = Facts { has_verified_email: false, ..facts() };
1105 assert!(matches!(decide(&no_email), Decision::Refuse(_)));
1106 }
1107
1108 #[test]
1109 fn an_invite_for_one_address_makes_the_account_with_it() {
1110 let mut emails = vec!["ada@work.example".to_owned(), "ada@home.example".to_owned()];
1111 assert!(put_first(&mut emails, "Ada@Home.example"));
1112 assert_eq!(emails, ["ada@home.example", "ada@work.example"]);
1113 assert!(!put_first(&mut emails, "eve@example.com"));
1114 assert_eq!(emails, ["ada@home.example", "ada@work.example"]);
1115 }
1116
1117 #[test]
1118 fn an_invite_only_g1t_waits_for_a_code() {
1119 let waiting = Facts { invite_missing: true, ..facts() };
1120 assert_eq!(decide(&waiting), Decision::NeedsUsername("octocat".to_owned()));
1121 // Existing accounts sign in and link without one.
1122 let linked = Facts { invite_missing: true, linked_to: Some("usr_1"), ..facts() };
1123 assert_eq!(decide(&linked), Decision::SignIn("usr_1".to_owned()));
1124 let matching = Facts { invite_missing: true, email_taken: true, ..facts() };
1125 assert_eq!(decide(&matching), Decision::NeedsLink);
1126 }
1127
1128 #[test]
1129 fn linking_is_for_the_account_that_asked() {
1130 let link = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), ..facts() };
1131 assert_eq!(decide(&link), Decision::Link("usr_1".to_owned()));
1132 let elsewhere = Facts { linked_to: Some("usr_2"), ..link };
1133 assert!(matches!(decide(&elsewhere), Decision::Refuse(_)));
1134 let other = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), asking_has_other: true, ..facts() };
1135 assert!(matches!(decide(&other), Decision::Refuse(_)));
1136 let nobody = Facts { purpose: Some(GithubPurpose::Link), ..facts() };
1137 assert!(matches!(decide(&nobody), Decision::Refuse(_)));
1138 }
1139
1140 #[test]
1141 fn tokens_expire_and_refresh() {
1142 let answer = serde_json::json!({
1143 "access_token": format!("ghu_{}", "a".repeat(516)),
1144 "expires_in": 28800,
1145 "refresh_token": "ghr_x",
1146 "refresh_token_expires_in": 15897600,
1147 "token_type": "bearer",
1148 });
1149 let tokens = tokens_from(&answer, 1_000).unwrap();
1150 assert_eq!(tokens.access_token.len(), 520);
1151 assert_eq!(tokens.access_expires_at, Some(1_000 + 28_800_000));
1152 assert!(tokens.fresh(1_000));
1153 assert!(!tokens.fresh(1_000 + 28_800_000 - 60_000));
1154 assert!(tokens.refreshable(1_000 + 28_800_000));
1155 assert!(tokens_from(&serde_json::json!({ "error": "bad_verification_code" }), 0).is_none());
1156 // Tokens that never expire, as when expiry is turned off on the app.
1157 let lasting = tokens_from(&serde_json::json!({ "access_token": "gho_x" }), 0).unwrap();
1158 assert!(lasting.fresh(u64::MAX / 2));
1159 assert!(!lasting.refreshable(0));
1160 }
1161
1162 #[test]
1163 fn a_long_token_survives_sealing() {
1164 let sealer = Sealer::new("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f").unwrap();
1165 let tokens = Tokens {
1166 access_token: format!("ghs_{}", "z".repeat(516)),
1167 access_expires_at: None,
1168 refresh_token: None,
1169 refresh_expires_at: None,
1170 };
1171 let sealed = sealer.seal(&serde_json::to_string(&tokens).unwrap(), &bound("usr_1"));
1172 let opened: Tokens = serde_json::from_str(&sealer.open(&sealed, &bound("usr_1")).unwrap()).unwrap();
1173 assert_eq!(opened, tokens);
1174 assert!(sealer.open(&sealed, &bound("usr_2")).is_none());
1175 }
1176}