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