g1t/services/identity/src/github.rs

1,161 lines48,683 bytesCodeBlame
1//! Signing in with GitHub, through g1t's GitHub App's user authorization:
2//! the OAuth web flow with PKCE (S256).
3//!
4//! The site sends the browser to GitHub with a state it also keeps in a
5//! short-lived cookie; this service keeps the state's hash and the PKCE
6//! verifier, each usable once and for ten minutes. On the way back the site
7//! checks the cookie against the state GitHub returns, and this service
8//! redeems the state, exchanges the code, and reads the person's GitHub
9//! account and verified emails.
10//!
11//! A GitHub account is known by its numeric id, never its login, which its
12//! owner can change. One with no g1t account yet makes one; one whose
13//! verified email belongs to an existing g1t account is never linked to it
14//! silently: the person signs in to that account first. The app's user
15//! tokens expire, so the refresh token is kept, sealed under IDENTITY_KEY,
16//! and used when the access token is about to run out. Tokens are opaque
17//! strings of any length.
18//!
19//! Configured with the vars GITHUB_APP_CLIENT_ID and the secret
20//! GITHUB_APP_CLIENT_SECRET; without both, `github_enabled` is false and
21//! everything else here says GitHub is not set up.
22
23use base64::Engine;
24use base64::engine::general_purpose::URL_SAFE_NO_PAD;
25use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
26use g1t_contracts::github::*;
27use g1t_contracts::identity::{SignedIn, UserArgs};
28use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
29use g1t_contracts::{FailureCode, Outcome, User, 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 = ?",
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 self.link(&user_id, github.id, &github.login, Some(&tokens)).await?;
675 let Some(user) = self.user_by_id(&user_id).await? else {
676 return Ok(Outcome::fail(FailureCode::NotFound, TRY_AGAIN));
677 };
678 self.audit_github(&user, "github.sign_in", format!("Signed in with GitHub (@{})", github.login)).await;
679 self.signed_in(user, false, next).await?
680 }
681 Decision::NeedsLink => {
682 let pending = self.hold(&github, "link", None, &tokens, &next, None).await?;
683 Outcome::Ok(GithubFinished::NeedsLink { pending, login: github.login, next })
684 }
685 Decision::Create(username) => {
686 let email = github.emails[0].clone();
687 let invite = state.invite_code.as_deref();
688 match self.create_from_github(&username, &email, github.id, &github.login, Some(&tokens), invite).await? {
689 Outcome::Ok(user) => self.signed_in(user, true, next).await?,
690 // A code that did not pass: the person can enter another.
691 Outcome::Fail(_) => {
692 let pending = self.hold(&github, "username", Some(&username), &tokens, &next, None).await?;
693 Outcome::Ok(GithubFinished::NeedsUsername {
694 pending,
695 login: github.login,
696 suggestion: username,
697 next,
698 invite_required: self.github_invites_required(),
699 })
700 }
701 }
702 }
703 Decision::NeedsUsername(suggestion) => {
704 let invite = state.invite_code.as_deref();
705 let pending = self.hold(&github, "username", Some(&suggestion), &tokens, &next, invite).await?;
706 Outcome::Ok(GithubFinished::NeedsUsername {
707 pending,
708 login: github.login,
709 suggestion,
710 next,
711 invite_required: self.github_invites_required() && invite.is_none(),
712 })
713 }
714 })
715 }
716
717 async fn signed_in(&self, user: User, created: bool, next: String) -> Result<Outcome<GithubFinished>> {
718 Ok(match self.start_session(user).await? {
719 Outcome::Ok(signed_in) => Outcome::Ok(GithubFinished::SignedIn { signed_in, created, next }),
720 Outcome::Fail(failure) => Outcome::Fail(failure),
721 })
722 }
723
724 pub async fn github_pending(&self, a: GithubPendingArgs) -> Result<Outcome<GithubPending>> {
725 let Some(row) = self.pending_row(&a.pending).await? else {
726 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
727 };
728 Ok(Outcome::Ok(GithubPending {
729 invite_required: row.kind == "username" && self.github_invites_required() && row.invite_code.is_none(),
730 login: row.login,
731 kind: row.kind,
732 suggestion: row.suggestion,
733 next: row.next,
734 }))
735 }
736
737 pub async fn github_sign_up(&self, a: GithubSignUpArgs) -> Result<Outcome<SignedIn>> {
738 let Some(row) = self.pending_row(&a.pending).await?.filter(|row| row.kind == "username") else {
739 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
740 };
741 let Some(username) = claimable_namespace(&a.username) else {
742 return Ok(Outcome::fail(
743 FailureCode::Invalid,
744 "Usernames use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
745 ));
746 };
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);
755 let given = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty());
756 let invite = given.or(row.invite_code.as_deref());
757 let user = match self
758 .create_from_github(&username, &row.email, row.github_id, &row.login, tokens.as_ref(), invite)
759 .await?
760 {
761 Outcome::Ok(user) => user,
762 Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)),
763 };
764 self.drop_pending(&row.id).await?;
765 self.start_session(user).await
766 }
767
768 /// Links a held GitHub sign-in to the account the person then signed in
769 /// to: they have proved both.
770 pub async fn github_claim(&self, a: GithubClaimArgs) -> Result<Outcome<GithubAccount>> {
771 let Some(row) = self.pending_row(&a.pending).await? else {
772 return Ok(Outcome::fail(FailureCode::NotFound, "This GitHub sign-in has expired. Start again."));
773 };
774 if let Some(linked) = self.account_by_github(row.github_id).await?
775 && linked.user_id != a.user.id
776 {
777 return Ok(Outcome::fail(FailureCode::Conflict, "That GitHub account is linked to another g1t account."));
778 }
779 if self.account_of(&a.user.id).await?.is_some_and(|linked| linked.github_id != row.github_id) {
780 return Ok(Outcome::fail(FailureCode::Conflict, "Your account is linked to another GitHub account. Unlink it first."));
781 }
782 let tokens = self.open_tokens(row.tokens.as_deref(), &row.id);
783 self.link(&a.user.id, row.github_id, &row.login, tokens.as_ref()).await?;
784 self.drop_pending(&row.id).await?;
785 self.audit_github(&a.user, "github.linked", format!("Linked GitHub account @{}", row.login)).await;
786 Ok(Outcome::Ok(GithubAccount {
787 github_id: row.github_id,
788 login: row.login,
789 linked_at: rfc3339(now_ms()),
790 authorized: tokens.is_some(),
791 }))
792 }
793
794 async fn has_password(&self, user_id: &str) -> Result<bool> {
795 Ok(self
796 .db
797 .prepare("SELECT 1 AS yes FROM users WHERE id = ? AND password_hash LIKE 'pbkdf2$%'")
798 .bind(&[user_id.into()])?
799 .first::<Value>(None)
800 .await?
801 .is_some())
802 }
803
804 pub async fn github_account(&self, a: UserArgs) -> Result<GithubAccountView> {
805 let row = self.account_of(&a.user.id).await?;
806 Ok(GithubAccountView {
807 enabled: self.github_enabled(),
808 account: row.map(|row| GithubAccount {
809 authorized: self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)).is_some(),
810 github_id: row.github_id,
811 login: row.login,
812 linked_at: row.created_at,
813 }),
814 has_password: self.has_password(&a.user.id).await?,
815 })
816 }
817
818 pub async fn github_unlink(&self, a: UserArgs) -> Result<Outcome<bool>> {
819 let Some(row) = self.account_of(&a.user.id).await? else {
820 return Ok(Outcome::Ok(false));
821 };
822 if !self.has_password(&a.user.id).await? {
823 return Ok(Outcome::fail(
824 FailureCode::Conflict,
825 "GitHub is the only way you sign in. Set a password first: sign out and use Forgot your password.",
826 ));
827 }
828 self.db
829 .prepare("DELETE FROM github_accounts WHERE user_id = ?")
830 .bind(&[a.user.id.as_str().into()])?
831 .run()
832 .await?;
833 // Best effort: also end g1t's authorization on GitHub's side.
834 if let (Some(client), Some(tokens)) = (client(&self.env), self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id))) {
835 let _ = revoke_grant(&client, &tokens.access_token).await;
836 }
837 self.audit_github(&a.user, "github.unlinked", format!("Unlinked GitHub account @{}", row.login)).await;
838 Ok(Outcome::Ok(true))
839 }
840
841 /// A working user token for the person, refreshed when it is about to
842 /// expire. For the integrations service, to list installations.
843 pub async fn github_user_token(&self, a: GithubUserTokenArgs) -> Result<Outcome<String>> {
844 const RELINK: &str = "Link your GitHub account again in your settings: g1t's access to it has ended.";
845 let Some(row) = self.account_of(&a.user_id).await? else {
846 return Ok(Outcome::fail(FailureCode::NotFound, "Link your GitHub account first."));
847 };
848 let Some(tokens) = self.open_tokens(row.tokens.as_deref(), &bound(&row.user_id)) else {
849 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
850 };
851 let now = now_ms();
852 if tokens.fresh(now) {
853 return Ok(Outcome::Ok(tokens.access_token));
854 }
855 let (Some(client), true) = (client(&self.env), tokens.refreshable(now)) else {
856 self.forget_tokens(&row.user_id).await?;
857 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
858 };
859 let grant = serde_json::json!({
860 "grant_type": "refresh_token",
861 "refresh_token": tokens.refresh_token,
862 });
863 let Some(refreshed) = token_request(&client, grant).await? else {
864 self.forget_tokens(&row.user_id).await?;
865 return Ok(Outcome::fail(FailureCode::Unauthenticated, RELINK));
866 };
867 let sealed = self.seal_tokens(&refreshed, &bound(&row.user_id));
868 self.db
869 .prepare(format!("UPDATE github_accounts SET tokens = ?, updated_at = {SQL_NOW} WHERE user_id = ?"))
870 .bind(&[
871 sealed.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
872 row.user_id.as_str().into(),
873 ])?
874 .run()
875 .await?;
876 Ok(Outcome::Ok(refreshed.access_token))
877 }
878
879 async fn forget_tokens(&self, user_id: &str) -> Result<()> {
880 self.db
881 .prepare("UPDATE github_accounts SET tokens = NULL WHERE user_id = ?")
882 .bind(&[user_id.into()])?
883 .run()
884 .await?;
885 Ok(())
886 }
887
888 /// The person revoked g1t's authorization on GitHub: its tokens go.
889 /// The link stays, so they can still sign in with GitHub.
890 pub async fn github_revoked(&self, a: GithubRevokedArgs) -> Result<u32> {
891 let changed = self
892 .db
893 .prepare("UPDATE github_accounts SET tokens = NULL WHERE github_id = ? RETURNING user_id")
894 .bind(&[(a.github_id as f64).into()])?
895 .all()
896 .await?
897 .results::<Value>()?;
898 Ok(changed.len() as u32)
899 }
900
901 /// The g1t usernames of linked GitHub accounts, by GitHub id, for
902 /// showing who wrote what was imported.
903 pub async fn github_usernames(&self, a: GithubUsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
904 #[derive(Deserialize)]
905 struct Named {
906 github_id: u64,
907 username: String,
908 }
909 let ids: Vec<u64> = a.github_ids.into_iter().take(100).collect();
910 let mut names = std::collections::HashMap::new();
911 if ids.is_empty() {
912 return Ok(names);
913 }
914 let marks = vec!["?"; ids.len()].join(", ");
915 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| (*id as f64).into()).collect();
916 let rows = self
917 .db
918 .prepare(format!(
919 "SELECT github_accounts.github_id, users.username FROM github_accounts
920 JOIN users ON users.id = github_accounts.user_id WHERE github_id IN ({marks})"
921 ))
922 .bind(&bind)?
923 .all()
924 .await?
925 .results::<Named>()?;
926 for row in rows {
927 names.insert(row.github_id.to_string(), row.username);
928 }
929 Ok(names)
930 }
931
932 /// Recorded in the audit log of every workspace the person belongs to,
933 /// which is where their workspaces' owners look.
934 async fn audit_github(&self, user: &User, action: &str, message: String) {
935 let (Ok(events), Ok(memberships)) = (self.env.service("EVENTS"), self.memberships(&user.id).await) else {
936 return;
937 };
938 let entries: Vec<NewAuditEntry> = memberships
939 .into_iter()
940 .map(|membership| NewAuditEntry {
941 actor: AuditActor::of(user),
942 action: action.to_owned(),
943 surface: Surface::Web,
944 target: AuditTarget {
945 workspace: membership.slug,
946 ..AuditTarget::default()
947 },
948 outcome: AuditOutcome::Allowed,
949 rule: "github".to_owned(),
950 result: Some("ok".to_owned()),
951 message: Some(message.clone()),
952 request_id: new_id("req", now_ms()),
953 })
954 .collect();
955 if entries.is_empty() {
956 return;
957 }
958 let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await;
959 if let Err(error) = recorded {
960 worker::console_error!("{action} not recorded: {error}");
961 }
962 }
963}
964
965/// `DELETE /applications/{client_id}/grant`, with the client's own
966/// credentials.
967async fn revoke_grant(client: &Client, access_token: &str) -> Result<()> {
968 let headers = Headers::new();
969 headers.set("user-agent", "g1t (+https://g1t.sh)")?;
970 headers.set("accept", "application/vnd.github+json")?;
971 headers.set("content-type", "application/json")?;
972 let basic = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", client.id, client.secret));
973 headers.set("authorization", &format!("Basic {basic}"))?;
974 let mut init = RequestInit::new();
975 init.with_method(Method::Delete)
976 .with_headers(headers)
977 .with_body(Some(serde_json::json!({ "access_token": access_token }).to_string().into()));
978 let url = format!("{API}/applications/{}/grant", client.id);
979 Fetch::Request(Request::new_with_init(&url, &init)?).send().await?;
980 Ok(())
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986
987 #[test]
988 fn the_challenge_is_rfc_7636s() {
989 // RFC 7636, appendix B.
990 assert_eq!(
991 pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
992 "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
993 );
994 let verifier = new_verifier();
995 assert_eq!(verifier.len(), 43);
996 assert_ne!(verifier, new_verifier());
997 }
998
999 #[test]
1000 fn the_authorize_url_carries_state_and_challenge() {
1001 let url = authorize_url("Iv23liZS94alfjIUn1eW", "https://g1t.sh/auth/github/callback", "abc", "xyz");
1002 let parsed = Url::parse(&url).unwrap();
1003 let query: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
1004 assert_eq!(parsed.host_str(), Some("github.com"));
1005 assert_eq!(query["redirect_uri"], "https://g1t.sh/auth/github/callback");
1006 assert_eq!(query["state"], "abc");
1007 assert_eq!(query["code_challenge"], "xyz");
1008 assert_eq!(query["code_challenge_method"], "S256");
1009 }
1010
1011 fn email(address: &str, primary: bool, verified: bool) -> GithubEmail {
1012 GithubEmail {
1013 email: address.to_owned(),
1014 primary,
1015 verified,
1016 }
1017 }
1018
1019 #[test]
1020 fn only_verified_emails_count_primary_first() {
1021 let emails = [
1022 email("unverified@example.com", false, false),
1023 email("Work@Example.com", false, true),
1024 email("1+me@users.noreply.github.com", false, true),
1025 email("me@example.com", true, true),
1026 ];
1027 assert_eq!(verified_emails(&emails), vec!["me@example.com", "work@example.com"]);
1028 assert!(verified_emails(&[email("primary@example.com", true, false)]).is_empty());
1029 }
1030
1031 #[test]
1032 fn usernames_come_from_logins() {
1033 assert_eq!(suggest_username("Octo-Cat"), "octo-cat");
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");
1042 assert_eq!(suggest_username("G1T"), "g1t-gh");
1043 assert_eq!(suggest_username("g1t-agent"), "g1t-agent-gh");
1044 assert_eq!(suggest_username("G1t_Agent"), "g1t-agent-gh");
1045 assert_eq!(suggest_username("api"), "api-gh");
1046 assert!(is_valid_namespace(&suggest_username("g1t")));
1047 assert_eq!(suggest_username("g1t-fan"), "g1t-fan");
1048 // So signing up goes ahead, rather than failing on the login.
1049 let facts = Facts { suggestion: suggest_username("g1t"), ..facts() };
1050 assert_eq!(decide(&facts), Decision::Create("g1t-gh".to_owned()));
1051 }
1052
1053 #[test]
1054 fn a_chosen_username_cannot_be_g1ts() {
1055 // What github_sign_up takes from the form.
1056 for name in ["g1t", " G1T ", "g1t-agent", "G1T-AGENT"] {
1057 assert_eq!(claimable_namespace(name), None, "{name}");
1058 }
1059 assert_eq!(claimable_namespace(" Octo-Cat ").as_deref(), Some("octo-cat"));
1060 }
1061
1062 fn facts() -> Facts<'static> {
1063 Facts {
1064 purpose: Some(GithubPurpose::SignIn),
1065 has_verified_email: true,
1066 suggestion: "octocat".to_owned(),
1067 suggestion_free: true,
1068 ..Facts::default()
1069 }
1070 }
1071
1072 #[test]
1073 fn a_linked_account_signs_in() {
1074 let facts = Facts { linked_to: Some("usr_1"), email_taken: true, ..facts() };
1075 assert_eq!(decide(&facts), Decision::SignIn("usr_1".to_owned()));
1076 }
1077
1078 #[test]
1079 fn a_matching_email_is_never_linked_silently() {
1080 let facts = Facts { email_taken: true, ..facts() };
1081 assert_eq!(decide(&facts), Decision::NeedsLink);
1082 }
1083
1084 #[test]
1085 fn a_new_person_gets_their_login_or_chooses() {
1086 assert_eq!(decide(&facts()), Decision::Create("octocat".to_owned()));
1087 let taken = Facts { suggestion_free: false, ..facts() };
1088 assert_eq!(decide(&taken), Decision::NeedsUsername("octocat".to_owned()));
1089 let no_email = Facts { has_verified_email: false, ..facts() };
1090 assert!(matches!(decide(&no_email), Decision::Refuse(_)));
1091 }
1092
1093 #[test]
1094 fn an_invite_for_one_address_makes_the_account_with_it() {
1095 let mut emails = vec!["ada@work.example".to_owned(), "ada@home.example".to_owned()];
1096 assert!(put_first(&mut emails, "Ada@Home.example"));
1097 assert_eq!(emails, ["ada@home.example", "ada@work.example"]);
1098 assert!(!put_first(&mut emails, "eve@example.com"));
1099 assert_eq!(emails, ["ada@home.example", "ada@work.example"]);
1100 }
1101
1102 #[test]
1103 fn an_invite_only_g1t_waits_for_a_code() {
1104 let waiting = Facts { invite_missing: true, ..facts() };
1105 assert_eq!(decide(&waiting), Decision::NeedsUsername("octocat".to_owned()));
1106 // Existing accounts sign in and link without one.
1107 let linked = Facts { invite_missing: true, linked_to: Some("usr_1"), ..facts() };
1108 assert_eq!(decide(&linked), Decision::SignIn("usr_1".to_owned()));
1109 let matching = Facts { invite_missing: true, email_taken: true, ..facts() };
1110 assert_eq!(decide(&matching), Decision::NeedsLink);
1111 }
1112
1113 #[test]
1114 fn linking_is_for_the_account_that_asked() {
1115 let link = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), ..facts() };
1116 assert_eq!(decide(&link), Decision::Link("usr_1".to_owned()));
1117 let elsewhere = Facts { linked_to: Some("usr_2"), ..link };
1118 assert!(matches!(decide(&elsewhere), Decision::Refuse(_)));
1119 let other = Facts { purpose: Some(GithubPurpose::Link), asking: Some("usr_1"), asking_has_other: true, ..facts() };
1120 assert!(matches!(decide(&other), Decision::Refuse(_)));
1121 let nobody = Facts { purpose: Some(GithubPurpose::Link), ..facts() };
1122 assert!(matches!(decide(&nobody), Decision::Refuse(_)));
1123 }
1124
1125 #[test]
1126 fn tokens_expire_and_refresh() {
1127 let answer = serde_json::json!({
1128 "access_token": format!("ghu_{}", "a".repeat(516)),
1129 "expires_in": 28800,
1130 "refresh_token": "ghr_x",
1131 "refresh_token_expires_in": 15897600,
1132 "token_type": "bearer",
1133 });
1134 let tokens = tokens_from(&answer, 1_000).unwrap();
1135 assert_eq!(tokens.access_token.len(), 520);
1136 assert_eq!(tokens.access_expires_at, Some(1_000 + 28_800_000));
1137 assert!(tokens.fresh(1_000));
1138 assert!(!tokens.fresh(1_000 + 28_800_000 - 60_000));
1139 assert!(tokens.refreshable(1_000 + 28_800_000));
1140 assert!(tokens_from(&serde_json::json!({ "error": "bad_verification_code" }), 0).is_none());
1141 // Tokens that never expire, as when expiry is turned off on the app.
1142 let lasting = tokens_from(&serde_json::json!({ "access_token": "gho_x" }), 0).unwrap();
1143 assert!(lasting.fresh(u64::MAX / 2));
1144 assert!(!lasting.refreshable(0));
1145 }
1146
1147 #[test]
1148 fn a_long_token_survives_sealing() {
1149 let sealer = Sealer::new("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f").unwrap();
1150 let tokens = Tokens {
1151 access_token: format!("ghs_{}", "z".repeat(516)),
1152 access_expires_at: None,
1153 refresh_token: None,
1154 refresh_expires_at: None,
1155 };
1156 let sealed = sealer.seal(&serde_json::to_string(&tokens).unwrap(), &bound("usr_1"));
1157 let opened: Tokens = serde_json::from_str(&sealer.open(&sealed, &bound("usr_1")).unwrap()).unwrap();
1158 assert_eq!(opened, tokens);
1159 assert!(sealer.open(&sealed, &bound("usr_2")).is_none());
1160 }
1161}