Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Login, callback, handle selection, and logout.
Matt W2
Matt W3use axum::extract::{Query, State};
Matt W4use axum::response::{IntoResponse, Redirect, Response};
Matt W5use axum::Form;
Matt W6use axum_extra::extract::cookie::{Cookie, SameSite};
Matt W7use axum_extra::extract::CookieJar;
Matt W8use df_auth::provisioning;
Matt W9use df_auth::{oidc, session};
Matt W10use serde::Deserialize;
Matt W11
Matt W12use crate::error::{AppError, AppResult};
Matt W13use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W14use crate::views::{self, Chrome};
Matt W15
Matt W16#[derive(Deserialize)]
Matt W17pub struct LoginQuery {
Matt W18 /// Where to land after signing in. Validated as a local path.
Matt W19 pub next: Option<String>,
Matt W20 /// `sso` starts the OIDC round trip. Absent renders the sign-in page.
Matt W21 pub r#continue: Option<String>,
Matt W22}
Matt W23
Matt W24/// The sign-in page, and the start of the OIDC flow.
Matt W25///
Matt W26/// `/login` used to redirect to the provider immediately. It now renders a page
Matt W27/// first and starts the flow at `/login?continue=sso`, so the product has a
Matt W28/// sign-in screen of its own rather than handing the first impression to the
Matt W29/// identity provider. Every existing link to `/login` still lands somewhere
Matt W30/// sensible — one click further from the provider than before.
Matt W31pub async fn login(
Matt W32 State(state): State<AppState>,
Matt W33 Query(q): Query<LoginQuery>,
Matt W34 CurrentUser(user): CurrentUser,
Matt W35 CsrfToken(csrf): CsrfToken,
Matt W36 Nonce(nonce): Nonce,
Matt W37) -> AppResult<Response> {
Matt W38 if user.is_some() {
Matt W39 return Ok(Redirect::to("/").into_response());
Matt W40 }
Matt W41
Matt W42 // An unvalidated `next` here would be an open redirect handed to everyone
Matt W43 // who follows a login link. Validated once, here, and then carried through
Matt W44 // both the page and the flow.
Matt W45 let next = q.next.as_deref().and_then(oidc::safe_redirect);
Matt W46
Matt W47 if q.r#continue.as_deref() == Some("sso") {
Matt W48 let url = state.oidc.begin(&state.db, next.as_deref()).await?;
Matt W49 return Ok(Redirect::to(&url).into_response());
Matt W50 }
Matt W51
Matt W52 let sso_href = match &next {
Matt W53 // `next` has already been validated as a local path, and Maud escapes
Matt W54 // the attribute, so this cannot break out of the URL.
Matt W55 Some(n) => {
Matt W56 let encoded: String = form_urlencoded::byte_serialize(n.as_bytes()).collect();
Matt W57 format!("/login?continue=sso&next={encoded}")
Matt W58 }
Matt W59 None => "/login?continue=sso".to_string(),
Matt W60 };
Matt W61
Matt W62 // A public repository to point at, so "read anything public first" is an
Matt W63 // offer with a destination rather than a slogan.
Matt W64 let sample: Option<(String, String)> = sqlx::query_as(
Matt W65 r#"
Matt W66 SELECT COALESCE(ou.handle, og.handle) AS owner, r.name::text
Matt W67 FROM repos r
Matt W68 LEFT JOIN users ou ON ou.id = r.owner_user_id
Matt W69 LEFT JOIN orgs og ON og.id = r.owner_org_id
Matt W70 WHERE r.archived = false AND r.visibility = 'public'
Matt W71 ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
Matt W72 LIMIT 1
Matt W73 "#,
Matt W74 )
Matt W75 .fetch_optional(&state.db)
Matt W76 .await?;
Matt W77
Matt W78 let sample_path = sample.as_ref().map(|(o, n)| format!("{o}/{n}"));
Matt W79 let clone_hint = match &sample {
Matt W80 Some((o, n)) => format!("jj git clone {}", state.config.https_clone_url(o, n)),
Matt W81 None => format!(
Matt W82 "jj git clone {}",
Matt W83 state.config.https_clone_url("your-org", "your-repo")
Matt W84 ),
Matt W85 };
Matt W86
Matt W87 Ok(views::page(
Matt W88 Chrome { title: "Sign in", user: None, csrf: &csrf, nonce: &nonce },
Matt W89 views::pages::signin(&sso_href, &clone_hint, sample_path.as_deref()),
Matt W90 )
Matt W91 .into_response())
Matt W92}
Matt W93
Matt W94#[derive(Deserialize)]
Matt W95pub struct CallbackQuery {
Matt W96 pub code: Option<String>,
Matt W97 pub state: Option<String>,
Matt W98 pub error: Option<String>,
Matt W99 pub error_description: Option<String>,
Matt W100}
Matt W101
Matt W102/// OIDC redirect target.
Matt W103pub async fn callback(
Matt W104 State(state): State<AppState>,
Matt W105 jar: CookieJar,
Matt W106 Query(q): Query<CallbackQuery>,
Matt W107 CsrfToken(csrf): CsrfToken,
Matt W108 Nonce(nonce): Nonce,
Matt W109 headers: axum::http::HeaderMap,
Matt W110) -> AppResult<Response> {
Matt W111 if let Some(err) = q.error {
Matt W112 let detail = q.error_description.unwrap_or_default();
Matt W113 tracing::warn!("OIDC provider returned error: {err} {detail}");
Matt W114 return Ok(views::error_page(
Matt W115 Chrome { title: "Sign-in failed", user: None, csrf: &csrf, nonce: &nonce },
Matt W116 "Sign-in failed",
Matt W117 "The identity provider rejected the sign-in. Please try again.",
Matt W118 )
Matt W119 .into_response());
Matt W120 }
Matt W121
Matt W122 let (Some(code), Some(flow_state)) = (q.code, q.state) else {
Matt W123 return Err(AppError::BadRequest("missing code or state".into()));
Matt W124 };
Matt W125
Matt W126 let (identity, redirect_after) = state
Matt W127 .oidc
Matt W128 .complete(&state.db, &flow_state, &code)
Matt W129 .await
Matt W130 .map_err(|e| {
Matt W131 tracing::warn!("completing OIDC login failed: {e:#}");
Matt W132 AppError::BadRequest("Sign-in could not be completed. Please try again.".into())
Matt W133 })?;
Matt W134
Matt W135 // Returning user: sign straight in.
Matt W136 if let Some(user) = provisioning::find_by_subject(&state.db, &identity.subject).await? {
Matt W137 // Claims are not guaranteed on every login, so an account can exist
Matt W138 // with no email — which is the one thing that links pushed commits to
Matt W139 // it. Any login that does carry the claim repairs that. Never fatal:
Matt W140 // failing to backfill must not cost the user their sign-in.
Matt W141 if let Err(e) = provisioning::backfill_profile(&state.db, user.id, &identity).await {
Matt W142 tracing::warn!(user = %user.id, "backfilling profile failed: {e:#}");
Matt W143 }
Matt W144 let jar =
Matt W145 establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
Matt W146 .await?;
Matt W147 let target = redirect_after.as_deref().unwrap_or("/");
Matt W148 return Ok((jar, Redirect::to(target)).into_response());
Matt W149 }
Matt W150
Matt W151 // New identity: apply the invite/allowlist policy before creating anything.
Matt W152 let invited = match identity.email.as_deref() {
Matt W153 Some(email) => provisioning::has_invitation(&state.db, email).await?,
Matt W154 None => false,
Matt W155 };
Matt W156
Matt W157 if let Err(denied) = provisioning::admission(&identity, &state.config.allowlist, invited) {
Matt W158 tracing::info!(
Matt W159 subject = %identity.subject,
Matt W160 "login denied: {denied:?}"
Matt W161 );
Matt W162 return Ok(views::page(
Matt W163 Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce },
Matt W164 views::pages::not_invited(),
Matt W165 )
Matt W166 .into_response());
Matt W167 }
Matt W168
Matt W169 // Admitted. Derive a handle, or ask for one.
Matt W170 let suggested = match identity.suggested_handle() {
Matt W171 Some(base) => provisioning::unique_handle(&state.db, &base).await?,
Matt W172 None => None,
Matt W173 };
Matt W174
Matt W175 let Some(handle) = suggested else {
Matt W176 // Stash the pending identity and let the user choose.
Matt W177 let jar = stash_pending(jar, &state, &identity)?;
Matt W178 return Ok((
Matt W179 jar,
Matt W180 views::page(
Matt W181 Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce },
Matt W182 views::pages::choose_handle(None, &csrf, None),
Matt W183 ),
Matt W184 )
Matt W185 .into_response());
Matt W186 };
Matt W187
Matt W188 let user = provisioning::create_user(&state.db, &identity, &handle).await?;
Matt W189 tracing::info!(user = %user.id, handle = %handle, "provisioned new user");
Matt W190
Matt W191 let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
Matt W192 .await?;
Matt W193 let target = redirect_after.as_deref().unwrap_or("/");
Matt W194 Ok((jar, Redirect::to(target)).into_response())
Matt W195}
Matt W196
Matt W197/// Cookie holding a signed, pending identity between the callback and handle
Matt W198/// selection. Short-lived and self-contained, so no extra table is needed.
Matt W199const PENDING_COOKIE: &str = "dogfood_pending";
Matt W200
Matt W201/// How long a pending identity stays usable.
Matt W202///
Matt W203/// Enforced inside the signature, not only as the cookie's `Max-Age`: `Max-Age`
Matt W204/// is a request to the browser, and a copy of the cookie taken anywhere else
Matt W205/// would otherwise stay redeemable for an account forever.
Matt W206const PENDING_TTL_MINUTES: i64 = 15;
Matt W207
Matt W208fn stash_pending(
Matt W209 jar: CookieJar,
Matt W210 state: &AppState,
Matt W211 identity: &df_auth::Identity,
Matt W212) -> AppResult<CookieJar> {
Matt W213 let payload = serde_json::json!({
Matt W214 "sub": identity.subject,
Matt W215 "email": identity.email,
Matt W216 "name": identity.name,
Matt W217 "id_token": identity.id_token,
Matt W218 "exp": (chrono::Utc::now() + chrono::Duration::minutes(PENDING_TTL_MINUTES)).timestamp(),
Matt W219 })
Matt W220 .to_string();
Matt W221
Matt W222 let mac = sign_pending(&state.config.session_secret, &payload);
Matt W223 let value = format!("{}.{}", hex::encode(&payload), mac);
Matt W224
Matt W225 Ok(jar.add(
Matt W226 Cookie::build((PENDING_COOKIE, value))
Matt W227 .path("/")
Matt W228 .secure(state.config.secure_cookies())
Matt W229 .http_only(true)
Matt W230 .same_site(SameSite::Lax)
Matt W231 .max_age(time::Duration::minutes(PENDING_TTL_MINUTES))
Matt W232 .build(),
Matt W233 ))
Matt W234}
Matt W235
Matt W236fn sign_pending(secret: &[u8], payload: &str) -> String {
Matt W237 use hmac::{Hmac, Mac};
Matt W238 use sha2::Sha256;
Matt W239 let mut mac = <Hmac<Sha256>>::new_from_slice(secret).expect("hmac accepts any key");
Matt W240 mac.update(payload.as_bytes());
Matt W241 hex::encode(mac.finalize().into_bytes())
Matt W242}
Matt W243
Matt W244fn read_pending(secret: &[u8], jar: &CookieJar) -> Option<df_auth::Identity> {
Matt W245 verify_pending(secret, jar.get(PENDING_COOKIE)?.value())
Matt W246}
Matt W247
Matt W248/// Verify and decode a pending-identity cookie value.
Matt W249///
Matt W250/// Split out from [`read_pending`] so the signature and expiry rules are
Matt W251/// testable without building a cookie jar.
Matt W252fn verify_pending(secret: &[u8], raw: &str) -> Option<df_auth::Identity> {
Matt W253 use subtle::ConstantTimeEq;
Matt W254
Matt W255 let (payload_hex, mac) = raw.split_once('.')?;
Matt W256 let payload = String::from_utf8(hex::decode(payload_hex).ok()?).ok()?;
Matt W257
Matt W258 let expected = sign_pending(secret, &payload);
Matt W259 let ok: bool = expected.as_bytes().ct_eq(mac.as_bytes()).into();
Matt W260 if !ok {
Matt W261 return None;
Matt W262 }
Matt W263
Matt W264 let v: serde_json::Value = serde_json::from_str(&payload).ok()?;
Matt W265
Matt W266 // The signature proves we minted it; `exp` is what stops it being minted
Matt W267 // once and redeemed indefinitely. A payload without one predates this and
Matt W268 // is refused rather than grandfathered.
Matt W269 let exp = v.get("exp").and_then(serde_json::Value::as_i64)?;
Matt W270 if chrono::Utc::now().timestamp() > exp {
Matt W271 return None;
Matt W272 }
Matt W273
Matt W274 Some(df_auth::Identity {
Matt W275 subject: v.get("sub")?.as_str()?.to_string(),
Matt W276 email: v.get("email").and_then(|e| e.as_str()).map(str::to_string),
Matt W277 name: v.get("name").and_then(|e| e.as_str()).map(str::to_string),
Matt W278 preferred_username: None,
Matt W279 id_token: v.get("id_token").and_then(|e| e.as_str()).map(str::to_string),
Matt W280 })
Matt W281}
Matt W282
Matt W283#[derive(Deserialize)]
Matt W284pub struct HandleForm {
Matt W285 pub handle: String,
Matt W286}
Matt W287
Matt W288/// Complete provisioning with a user-chosen handle.
Matt W289pub async fn choose_handle(
Matt W290 State(state): State<AppState>,
Matt W291 jar: CookieJar,
Matt W292 CsrfToken(csrf): CsrfToken,
Matt W293 Nonce(nonce): Nonce,
Matt W294 headers: axum::http::HeaderMap,
Matt W295 Form(form): Form<HandleForm>,
Matt W296) -> AppResult<Response> {
Matt W297 let Some(identity) = read_pending(&state.config.session_secret, &jar) else {
Matt W298 return Ok(Redirect::to("/login").into_response());
Matt W299 };
Matt W300
Matt W301 // Re-check admission: the pending cookie proves who they are, not that
Matt W302 // policy still admits them.
Matt W303 let invited = match identity.email.as_deref() {
Matt W304 Some(e) => provisioning::has_invitation(&state.db, e).await?,
Matt W305 None => false,
Matt W306 };
Matt W307 if provisioning::admission(&identity, &state.config.allowlist, invited).is_err() {
Matt W308 return Ok(views::page(
Matt W309 Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce },
Matt W310 views::pages::not_invited(),
Matt W311 )
Matt W312 .into_response());
Matt W313 }
Matt W314
Matt W315 let handle = form.handle.trim().to_lowercase();
Matt W316
Matt W317 let reject = |msg: &str| -> Response {
Matt W318 views::page(
Matt W319 Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce },
Matt W320 views::pages::choose_handle(Some(&handle), &csrf, Some(msg)),
Matt W321 )
Matt W322 .into_response()
Matt W323 };
Matt W324
Matt W325 if !valid_handle(&handle) {
Matt W326 return Ok(reject(
Matt W327 "Handles must start with a letter or digit and contain only lowercase \
Matt W328 letters, digits and hyphens.",
Matt W329 ));
Matt W330 }
Matt W331 if !provisioning::handle_available(&state.db, &handle).await? {
Matt W332 return Ok(reject("That handle is already taken."));
Matt W333 }
Matt W334
Matt W335 let user = provisioning::create_user(&state.db, &identity, &handle).await?;
Matt W336 tracing::info!(user = %user.id, handle = %handle, "provisioned new user (chosen handle)");
Matt W337
Matt W338 let jar = jar.remove(Cookie::from(PENDING_COOKIE));
Matt W339 let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref())
Matt W340 .await?;
Matt W341 Ok((jar, Redirect::to("/")).into_response())
Matt W342}
Matt W343
Matt W344/// Mirror of the database CHECK constraint.
Matt W345fn valid_handle(h: &str) -> bool {
Matt W346 !h.is_empty()
Matt W347 && h.len() <= 39
Matt W348 && h.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
Matt W349 && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
Matt W350}
Matt W351
Matt W352async fn establish_session(
Matt W353 state: &AppState,
Matt W354 jar: CookieJar,
Matt W355 user_id: uuid::Uuid,
Matt W356 headers: &axum::http::HeaderMap,
Matt W357 id_token: Option<&str>,
Matt W358) -> AppResult<CookieJar> {
Matt W359 let ua = headers
Matt W360 .get(axum::http::header::USER_AGENT)
Matt W361 .and_then(|v| v.to_str().ok());
Matt W362
Matt W363 let s = session::create(
Matt W364 &state.db,
Matt W365 user_id,
Matt W366 state.config.session_ttl_days,
Matt W367 ua,
Matt W368 None,
Matt W369 id_token,
Matt W370 )
Matt W371 .await?;
Matt W372 let expires = s.expires_at;
Matt W373
Matt W374 // The cookie carries the token. The row id never leaves the server.
Matt W375 let cookie = Cookie::build((session::COOKIE_NAME, s.token))
Matt W376 .path("/")
Matt W377 .secure(state.config.secure_cookies())
Matt W378 .http_only(true)
Matt W379 .same_site(SameSite::Lax)
Matt W380 .expires(
Matt W381 time::OffsetDateTime::from_unix_timestamp(expires.timestamp())
Matt W382 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
Matt W383 )
Matt W384 .build();
Matt W385
Matt W386 Ok(jar.add(cookie))
Matt W387}
Matt W388
Matt W389/// Sign out: destroy the local session, then hand off to the provider's
Matt W390/// end-session endpoint when one is advertised.
Matt W391pub async fn logout(
Matt W392 State(state): State<AppState>,
Matt W393 jar: CookieJar,
Matt W394) -> AppResult<Response> {
Matt W395 let mut id_token_hint = None;
Matt W396 if let Some(raw) = jar.get(session::COOKIE_NAME) {
Matt W397 // Deletes the row and hands back the stashed ID token in one statement.
Matt W398 match session::destroy(&state.db, raw.value()).await {
Matt W399 Ok(hint) => id_token_hint = hint,
Matt W400 Err(e) => tracing::warn!("destroying session failed: {e}"),
Matt W401 }
Matt W402 }
Matt W403
Matt W404 let jar = jar.remove(Cookie::from(session::COOKIE_NAME));
Matt W405
Matt W406 // Hydra rejects `post_logout_redirect_uri` without `id_token_hint`
Matt W407 // (`invalid_request`), which would otherwise strand the user on an error
Matt W408 // page even though the local session above is already gone. Without a
Matt W409 // hint to offer, skip the provider round-trip and just go home.
Matt W410 let target = match (state.oidc.end_session_endpoint(), id_token_hint) {
Matt W411 (Some(endpoint), Some(hint)) => format!(
Matt W412 "{endpoint}?id_token_hint={}&post_logout_redirect_uri={}",
Matt W413 urlencoding_encode(&hint),
Matt W414 urlencoding_encode(&state.config.base_url)
Matt W415 ),
Matt W416 _ => "/".to_string(),
Matt W417 };
Matt W418
Matt W419 Ok((jar, Redirect::to(&target)).into_response())
Matt W420}
Matt W421
Matt W422fn urlencoding_encode(s: &str) -> String {
Matt W423 percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
Matt W424}
Matt W425
Matt W426#[cfg(test)]
Matt W427mod tests {
Matt W428 use super::*;
Matt W429
Matt W430 #[test]
Matt W431 fn handle_validation_mirrors_the_db_constraint() {
Matt W432 assert!(valid_handle("alice"));
Matt W433 assert!(valid_handle("a"));
Matt W434 assert!(valid_handle("9lives"));
Matt W435 assert!(valid_handle("with-hyphen"));
Matt W436
Matt W437 assert!(!valid_handle(""));
Matt W438 assert!(!valid_handle("-leading"));
Matt W439 assert!(!valid_handle("Upper"));
Matt W440 assert!(!valid_handle("under_score"));
Matt W441 assert!(!valid_handle("with space"));
Matt W442 assert!(!valid_handle("üñí"));
Matt W443 assert!(!valid_handle(&"a".repeat(40)));
Matt W444 assert!(valid_handle(&"a".repeat(39)));
Matt W445 }
Matt W446
Matt W447 #[test]
Matt W448 fn pending_identity_round_trips_and_rejects_tampering() {
Matt W449 let secret = b"secret-key-for-pending-identity-cookie";
Matt W450 let payload = r#"{"sub":"abc","email":"a@b.c","name":null}"#;
Matt W451 let mac = sign_pending(secret, payload);
Matt W452
Matt W453 // A wrong secret must not verify.
Matt W454 assert_ne!(mac, sign_pending(b"other-secret", payload));
Matt W455 // A changed payload must not verify against the old tag.
Matt W456 assert_ne!(mac, sign_pending(secret, r#"{"sub":"attacker"}"#));
Matt W457 }
Matt W458
Matt W459 const PENDING_SECRET: &[u8] = b"secret-key-for-pending-identity-cookie";
Matt W460
Matt W461 /// Build a cookie value the way `stash_pending` does, with a chosen expiry.
Matt W462 fn pending_cookie(exp: i64) -> String {
Matt W463 let payload = serde_json::json!({
Matt W464 "sub": "abc",
Matt W465 "email": "a@b.c",
Matt W466 "name": null,
Matt W467 "id_token": null,
Matt W468 "exp": exp,
Matt W469 })
Matt W470 .to_string();
Matt W471 format!(
Matt W472 "{}.{}",
Matt W473 hex::encode(&payload),
Matt W474 sign_pending(PENDING_SECRET, &payload)
Matt W475 )
Matt W476 }
Matt W477
Matt W478 #[test]
Matt W479 fn a_live_pending_cookie_verifies() {
Matt W480 let raw = pending_cookie(chrono::Utc::now().timestamp() + 600);
Matt W481 let identity = verify_pending(PENDING_SECRET, &raw).expect("should verify");
Matt W482 assert_eq!(identity.subject, "abc");
Matt W483 }
Matt W484
Matt W485 #[test]
Matt W486 fn an_expired_pending_cookie_is_refused_even_though_it_is_signed() {
Matt W487 // The attack `Max-Age` alone does not stop: the browser's copy is gone,
Matt W488 // but a copy taken anywhere else still carries our signature.
Matt W489 let raw = pending_cookie(chrono::Utc::now().timestamp() - 1);
Matt W490 assert!(verify_pending(PENDING_SECRET, &raw).is_none());
Matt W491 }
Matt W492
Matt W493 #[test]
Matt W494 fn a_pending_cookie_without_an_expiry_is_refused() {
Matt W495 // The pre-expiry format. Grandfathering it in would leave the old
Matt W496 // indefinitely-redeemable cookie working.
Matt W497 let payload = r#"{"sub":"abc","email":"a@b.c","name":null}"#;
Matt W498 let raw = format!(
Matt W499 "{}.{}",
Matt W500 hex::encode(payload),
Matt W501 sign_pending(PENDING_SECRET, payload)
Matt W502 );
Matt W503 assert!(verify_pending(PENDING_SECRET, &raw).is_none());
Matt W504 }
Matt W505
Matt W506 #[test]
Matt W507 fn a_pending_cookie_cannot_have_its_expiry_extended() {
Matt W508 let raw = pending_cookie(chrono::Utc::now().timestamp() - 1);
Matt W509 let (payload_hex, mac) = raw.split_once('.').unwrap();
Matt W510 let payload = String::from_utf8(hex::decode(payload_hex).unwrap()).unwrap();
Matt W511
Matt W512 // Push the expiry out without re-signing, which is all an attacker can do.
Matt W513 let forged = payload.replace(
Matt W514 &format!("\"exp\":{}", chrono::Utc::now().timestamp() - 1),
Matt W515 &format!("\"exp\":{}", chrono::Utc::now().timestamp() + 86_400),
Matt W516 );
Matt W517 assert_ne!(forged, payload, "the test must actually change the expiry");
Matt W518
Matt W519 let tampered = format!("{}.{mac}", hex::encode(&forged));
Matt W520 assert!(verify_pending(PENDING_SECRET, &tampered).is_none());
Matt W521 }
Matt W522}

522 lines · Rust