Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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