| 1 | //! Login, callback, handle selection, and logout. |
| 2 | |
| 3 | use axum::extract::{Query, State}; |
| 4 | use axum::response::{IntoResponse, Redirect, Response}; |
| 5 | use axum::Form; |
| 6 | use axum_extra::extract::cookie::{Cookie, SameSite}; |
| 7 | use axum_extra::extract::CookieJar; |
| 8 | use df_auth::provisioning; |
| 9 | use df_auth::{oidc, session}; |
| 10 | use serde::Deserialize; |
| 11 | |
| 12 | use crate::error::{AppError, AppResult}; |
| 13 | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; |
| 14 | use crate::views::{self, Chrome}; |
| 15 | |
| 16 | #[derive(Deserialize)] |
| 17 | pub struct LoginQuery { |
| 18 | /// Where to land after signing in. Validated as a local path. |
| 19 | pub next: Option<String>, |
| 20 | /// `sso` starts the OIDC round trip. Absent renders the sign-in page. |
| 21 | pub r#continue: Option<String>, |
| 22 | } |
| 23 | |
| 24 | /// The sign-in page, and the start of the OIDC flow. |
| 25 | /// |
| 26 | /// `/login` used to redirect to the provider immediately. It now renders a page |
| 27 | /// first and starts the flow at `/login?continue=sso`, so the product has a |
| 28 | /// sign-in screen of its own rather than handing the first impression to the |
| 29 | /// identity provider. Every existing link to `/login` still lands somewhere |
| 30 | /// sensible — one click further from the provider than before. |
| 31 | pub async fn login( |
| 32 | State(state): State<AppState>, |
| 33 | Query(q): Query<LoginQuery>, |
| 34 | CurrentUser(user): CurrentUser, |
| 35 | CsrfToken(csrf): CsrfToken, |
| 36 | Nonce(nonce): Nonce, |
| 37 | ) -> AppResult<Response> { |
| 38 | if user.is_some() { |
| 39 | return Ok(Redirect::to("/").into_response()); |
| 40 | } |
| 41 | |
| 42 | // An unvalidated `next` here would be an open redirect handed to everyone |
| 43 | // who follows a login link. Validated once, here, and then carried through |
| 44 | // both the page and the flow. |
| 45 | let next = q.next.as_deref().and_then(oidc::safe_redirect); |
| 46 | |
| 47 | if q.r#continue.as_deref() == Some("sso") { |
| 48 | let url = state.oidc.begin(&state.db, next.as_deref()).await?; |
| 49 | return Ok(Redirect::to(&url).into_response()); |
| 50 | } |
| 51 | |
| 52 | let sso_href = match &next { |
| 53 | // `next` has already been validated as a local path, and Maud escapes |
| 54 | // the attribute, so this cannot break out of the URL. |
| 55 | Some(n) => { |
| 56 | let encoded: String = form_urlencoded::byte_serialize(n.as_bytes()).collect(); |
| 57 | format!("/login?continue=sso&next={encoded}") |
| 58 | } |
| 59 | None => "/login?continue=sso".to_string(), |
| 60 | }; |
| 61 | |
| 62 | // A public repository to point at, so "read anything public first" is an |
| 63 | // offer with a destination rather than a slogan. |
| 64 | let sample: Option<(String, String)> = sqlx::query_as( |
| 65 | r#" |
| 66 | SELECT COALESCE(ou.handle, og.handle) AS owner, r.name::text |
| 67 | FROM repos r |
| 68 | LEFT JOIN users ou ON ou.id = r.owner_user_id |
| 69 | LEFT JOIN orgs og ON og.id = r.owner_org_id |
| 70 | WHERE r.archived = false AND r.visibility = 'public' |
| 71 | ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC |
| 72 | LIMIT 1 |
| 73 | "#, |
| 74 | ) |
| 75 | .fetch_optional(&state.db) |
| 76 | .await?; |
| 77 | |
| 78 | let sample_path = sample.as_ref().map(|(o, n)| format!("{o}/{n}")); |
| 79 | let clone_hint = match &sample { |
| 80 | Some((o, n)) => format!("jj git clone {}", state.config.https_clone_url(o, n)), |
| 81 | None => format!( |
| 82 | "jj git clone {}", |
| 83 | state.config.https_clone_url("your-org", "your-repo") |
| 84 | ), |
| 85 | }; |
| 86 | |
| 87 | Ok(views::page( |
| 88 | Chrome { title: "Sign in", user: None, csrf: &csrf, nonce: &nonce }, |
| 89 | views::pages::signin(&sso_href, &clone_hint, sample_path.as_deref()), |
| 90 | ) |
| 91 | .into_response()) |
| 92 | } |
| 93 | |
| 94 | #[derive(Deserialize)] |
| 95 | pub struct CallbackQuery { |
| 96 | pub code: Option<String>, |
| 97 | pub state: Option<String>, |
| 98 | pub error: Option<String>, |
| 99 | pub error_description: Option<String>, |
| 100 | } |
| 101 | |
| 102 | /// OIDC redirect target. |
| 103 | pub async fn callback( |
| 104 | State(state): State<AppState>, |
| 105 | jar: CookieJar, |
| 106 | Query(q): Query<CallbackQuery>, |
| 107 | CsrfToken(csrf): CsrfToken, |
| 108 | Nonce(nonce): Nonce, |
| 109 | headers: axum::http::HeaderMap, |
| 110 | ) -> AppResult<Response> { |
| 111 | if let Some(err) = q.error { |
| 112 | let detail = q.error_description.unwrap_or_default(); |
| 113 | tracing::warn!("OIDC provider returned error: {err} {detail}"); |
| 114 | return Ok(views::error_page( |
| 115 | Chrome { title: "Sign-in failed", user: None, csrf: &csrf, nonce: &nonce }, |
| 116 | "Sign-in failed", |
| 117 | "The identity provider rejected the sign-in. Please try again.", |
| 118 | ) |
| 119 | .into_response()); |
| 120 | } |
| 121 | |
| 122 | let (Some(code), Some(flow_state)) = (q.code, q.state) else { |
| 123 | return Err(AppError::BadRequest("missing code or state".into())); |
| 124 | }; |
| 125 | |
| 126 | let (identity, redirect_after) = state |
| 127 | .oidc |
| 128 | .complete(&state.db, &flow_state, &code) |
| 129 | .await |
| 130 | .map_err(|e| { |
| 131 | tracing::warn!("completing OIDC login failed: {e:#}"); |
| 132 | AppError::BadRequest("Sign-in could not be completed. Please try again.".into()) |
| 133 | })?; |
| 134 | |
| 135 | // Returning user: sign straight in. |
| 136 | if let Some(user) = provisioning::find_by_subject(&state.db, &identity.subject).await? { |
| 137 | // Claims are not guaranteed on every login, so an account can exist |
| 138 | // with no email — which is the one thing that links pushed commits to |
| 139 | // it. Any login that does carry the claim repairs that. Never fatal: |
| 140 | // failing to backfill must not cost the user their sign-in. |
| 141 | if let Err(e) = provisioning::backfill_profile(&state.db, user.id, &identity).await { |
| 142 | tracing::warn!(user = %user.id, "backfilling profile failed: {e:#}"); |
| 143 | } |
| 144 | let jar = |
| 145 | establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref()) |
| 146 | .await?; |
| 147 | let target = redirect_after.as_deref().unwrap_or("/"); |
| 148 | return Ok((jar, Redirect::to(target)).into_response()); |
| 149 | } |
| 150 | |
| 151 | // New identity: apply the invite/allowlist policy before creating anything. |
| 152 | let invited = match identity.email.as_deref() { |
| 153 | Some(email) => provisioning::has_invitation(&state.db, email).await?, |
| 154 | None => false, |
| 155 | }; |
| 156 | |
| 157 | if let Err(denied) = provisioning::admission(&identity, &state.config.allowlist, invited) { |
| 158 | tracing::info!( |
| 159 | subject = %identity.subject, |
| 160 | "login denied: {denied:?}" |
| 161 | ); |
| 162 | return Ok(views::page( |
| 163 | Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce }, |
| 164 | views::pages::not_invited(), |
| 165 | ) |
| 166 | .into_response()); |
| 167 | } |
| 168 | |
| 169 | // Admitted. Derive a handle, or ask for one. |
| 170 | let suggested = match identity.suggested_handle() { |
| 171 | Some(base) => provisioning::unique_handle(&state.db, &base).await?, |
| 172 | None => None, |
| 173 | }; |
| 174 | |
| 175 | let Some(handle) = suggested else { |
| 176 | // Stash the pending identity and let the user choose. |
| 177 | let jar = stash_pending(jar, &state, &identity)?; |
| 178 | return Ok(( |
| 179 | jar, |
| 180 | views::page( |
| 181 | Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce }, |
| 182 | views::pages::choose_handle(None, &csrf, None), |
| 183 | ), |
| 184 | ) |
| 185 | .into_response()); |
| 186 | }; |
| 187 | |
| 188 | let user = provisioning::create_user(&state.db, &identity, &handle).await?; |
| 189 | tracing::info!(user = %user.id, handle = %handle, "provisioned new user"); |
| 190 | |
| 191 | let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref()) |
| 192 | .await?; |
| 193 | let target = redirect_after.as_deref().unwrap_or("/"); |
| 194 | Ok((jar, Redirect::to(target)).into_response()) |
| 195 | } |
| 196 | |
| 197 | /// Cookie holding a signed, pending identity between the callback and handle |
| 198 | /// selection. Short-lived and self-contained, so no extra table is needed. |
| 199 | const PENDING_COOKIE: &str = "dogfood_pending"; |
| 200 | |
| 201 | /// How long a pending identity stays usable. |
| 202 | /// |
| 203 | /// Enforced inside the signature, not only as the cookie's `Max-Age`: `Max-Age` |
| 204 | /// is a request to the browser, and a copy of the cookie taken anywhere else |
| 205 | /// would otherwise stay redeemable for an account forever. |
| 206 | const PENDING_TTL_MINUTES: i64 = 15; |
| 207 | |
| 208 | fn stash_pending( |
| 209 | jar: CookieJar, |
| 210 | state: &AppState, |
| 211 | identity: &df_auth::Identity, |
| 212 | ) -> AppResult<CookieJar> { |
| 213 | let payload = serde_json::json!({ |
| 214 | "sub": identity.subject, |
| 215 | "email": identity.email, |
| 216 | "name": identity.name, |
| 217 | "id_token": identity.id_token, |
| 218 | "exp": (chrono::Utc::now() + chrono::Duration::minutes(PENDING_TTL_MINUTES)).timestamp(), |
| 219 | }) |
| 220 | .to_string(); |
| 221 | |
| 222 | let mac = sign_pending(&state.config.session_secret, &payload); |
| 223 | let value = format!("{}.{}", hex::encode(&payload), mac); |
| 224 | |
| 225 | Ok(jar.add( |
| 226 | Cookie::build((PENDING_COOKIE, value)) |
| 227 | .path("/") |
| 228 | .secure(state.config.secure_cookies()) |
| 229 | .http_only(true) |
| 230 | .same_site(SameSite::Lax) |
| 231 | .max_age(time::Duration::minutes(PENDING_TTL_MINUTES)) |
| 232 | .build(), |
| 233 | )) |
| 234 | } |
| 235 | |
| 236 | fn sign_pending(secret: &[u8], payload: &str) -> String { |
| 237 | use hmac::{Hmac, Mac}; |
| 238 | use sha2::Sha256; |
| 239 | let mut mac = <Hmac<Sha256>>::new_from_slice(secret).expect("hmac accepts any key"); |
| 240 | mac.update(payload.as_bytes()); |
| 241 | hex::encode(mac.finalize().into_bytes()) |
| 242 | } |
| 243 | |
| 244 | fn read_pending(secret: &[u8], jar: &CookieJar) -> Option<df_auth::Identity> { |
| 245 | verify_pending(secret, jar.get(PENDING_COOKIE)?.value()) |
| 246 | } |
| 247 | |
| 248 | /// Verify and decode a pending-identity cookie value. |
| 249 | /// |
| 250 | /// Split out from [`read_pending`] so the signature and expiry rules are |
| 251 | /// testable without building a cookie jar. |
| 252 | fn verify_pending(secret: &[u8], raw: &str) -> Option<df_auth::Identity> { |
| 253 | use subtle::ConstantTimeEq; |
| 254 | |
| 255 | let (payload_hex, mac) = raw.split_once('.')?; |
| 256 | let payload = String::from_utf8(hex::decode(payload_hex).ok()?).ok()?; |
| 257 | |
| 258 | let expected = sign_pending(secret, &payload); |
| 259 | let ok: bool = expected.as_bytes().ct_eq(mac.as_bytes()).into(); |
| 260 | if !ok { |
| 261 | return None; |
| 262 | } |
| 263 | |
| 264 | let v: serde_json::Value = serde_json::from_str(&payload).ok()?; |
| 265 | |
| 266 | // The signature proves we minted it; `exp` is what stops it being minted |
| 267 | // once and redeemed indefinitely. A payload without one predates this and |
| 268 | // is refused rather than grandfathered. |
| 269 | let exp = v.get("exp").and_then(serde_json::Value::as_i64)?; |
| 270 | if chrono::Utc::now().timestamp() > exp { |
| 271 | return None; |
| 272 | } |
| 273 | |
| 274 | Some(df_auth::Identity { |
| 275 | subject: v.get("sub")?.as_str()?.to_string(), |
| 276 | email: v.get("email").and_then(|e| e.as_str()).map(str::to_string), |
| 277 | name: v.get("name").and_then(|e| e.as_str()).map(str::to_string), |
| 278 | preferred_username: None, |
| 279 | id_token: v.get("id_token").and_then(|e| e.as_str()).map(str::to_string), |
| 280 | }) |
| 281 | } |
| 282 | |
| 283 | #[derive(Deserialize)] |
| 284 | pub struct HandleForm { |
| 285 | pub handle: String, |
| 286 | } |
| 287 | |
| 288 | /// Complete provisioning with a user-chosen handle. |
| 289 | pub async fn choose_handle( |
| 290 | State(state): State<AppState>, |
| 291 | jar: CookieJar, |
| 292 | CsrfToken(csrf): CsrfToken, |
| 293 | Nonce(nonce): Nonce, |
| 294 | headers: axum::http::HeaderMap, |
| 295 | Form(form): Form<HandleForm>, |
| 296 | ) -> AppResult<Response> { |
| 297 | let Some(identity) = read_pending(&state.config.session_secret, &jar) else { |
| 298 | return Ok(Redirect::to("/login").into_response()); |
| 299 | }; |
| 300 | |
| 301 | // Re-check admission: the pending cookie proves who they are, not that |
| 302 | // policy still admits them. |
| 303 | let invited = match identity.email.as_deref() { |
| 304 | Some(e) => provisioning::has_invitation(&state.db, e).await?, |
| 305 | None => false, |
| 306 | }; |
| 307 | if provisioning::admission(&identity, &state.config.allowlist, invited).is_err() { |
| 308 | return Ok(views::page( |
| 309 | Chrome { title: "Not invited", user: None, csrf: &csrf, nonce: &nonce }, |
| 310 | views::pages::not_invited(), |
| 311 | ) |
| 312 | .into_response()); |
| 313 | } |
| 314 | |
| 315 | let handle = form.handle.trim().to_lowercase(); |
| 316 | |
| 317 | let reject = |msg: &str| -> Response { |
| 318 | views::page( |
| 319 | Chrome { title: "Choose a handle", user: None, csrf: &csrf, nonce: &nonce }, |
| 320 | views::pages::choose_handle(Some(&handle), &csrf, Some(msg)), |
| 321 | ) |
| 322 | .into_response() |
| 323 | }; |
| 324 | |
| 325 | if !valid_handle(&handle) { |
| 326 | return Ok(reject( |
| 327 | "Handles must start with a letter or digit and contain only lowercase \ |
| 328 | letters, digits and hyphens.", |
| 329 | )); |
| 330 | } |
| 331 | if !provisioning::handle_available(&state.db, &handle).await? { |
| 332 | return Ok(reject("That handle is already taken.")); |
| 333 | } |
| 334 | |
| 335 | let user = provisioning::create_user(&state.db, &identity, &handle).await?; |
| 336 | tracing::info!(user = %user.id, handle = %handle, "provisioned new user (chosen handle)"); |
| 337 | |
| 338 | let jar = jar.remove(Cookie::from(PENDING_COOKIE)); |
| 339 | let jar = establish_session(&state, jar, user.id, &headers, identity.id_token.as_deref()) |
| 340 | .await?; |
| 341 | Ok((jar, Redirect::to("/")).into_response()) |
| 342 | } |
| 343 | |
| 344 | /// Mirror of the database CHECK constraint. |
| 345 | fn valid_handle(h: &str) -> bool { |
| 346 | !h.is_empty() |
| 347 | && h.len() <= 39 |
| 348 | && h.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) |
| 349 | && h.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') |
| 350 | } |
| 351 | |
| 352 | async fn establish_session( |
| 353 | state: &AppState, |
| 354 | jar: CookieJar, |
| 355 | user_id: uuid::Uuid, |
| 356 | headers: &axum::http::HeaderMap, |
| 357 | id_token: Option<&str>, |
| 358 | ) -> AppResult<CookieJar> { |
| 359 | let ua = headers |
| 360 | .get(axum::http::header::USER_AGENT) |
| 361 | .and_then(|v| v.to_str().ok()); |
| 362 | |
| 363 | let s = session::create( |
| 364 | &state.db, |
| 365 | user_id, |
| 366 | state.config.session_ttl_days, |
| 367 | ua, |
| 368 | None, |
| 369 | id_token, |
| 370 | ) |
| 371 | .await?; |
| 372 | let expires = s.expires_at; |
| 373 | |
| 374 | // The cookie carries the token. The row id never leaves the server. |
| 375 | let cookie = Cookie::build((session::COOKIE_NAME, s.token)) |
| 376 | .path("/") |
| 377 | .secure(state.config.secure_cookies()) |
| 378 | .http_only(true) |
| 379 | .same_site(SameSite::Lax) |
| 380 | .expires( |
| 381 | time::OffsetDateTime::from_unix_timestamp(expires.timestamp()) |
| 382 | .unwrap_or(time::OffsetDateTime::UNIX_EPOCH), |
| 383 | ) |
| 384 | .build(); |
| 385 | |
| 386 | Ok(jar.add(cookie)) |
| 387 | } |
| 388 | |
| 389 | /// Sign out: destroy the local session, then hand off to the provider's |
| 390 | /// end-session endpoint when one is advertised. |
| 391 | pub async fn logout( |
| 392 | State(state): State<AppState>, |
| 393 | jar: CookieJar, |
| 394 | ) -> AppResult<Response> { |
| 395 | let mut id_token_hint = None; |
| 396 | if let Some(raw) = jar.get(session::COOKIE_NAME) { |
| 397 | // Deletes the row and hands back the stashed ID token in one statement. |
| 398 | match session::destroy(&state.db, raw.value()).await { |
| 399 | Ok(hint) => id_token_hint = hint, |
| 400 | Err(e) => tracing::warn!("destroying session failed: {e}"), |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | let jar = jar.remove(Cookie::from(session::COOKIE_NAME)); |
| 405 | |
| 406 | // Hydra rejects `post_logout_redirect_uri` without `id_token_hint` |
| 407 | // (`invalid_request`), which would otherwise strand the user on an error |
| 408 | // page even though the local session above is already gone. Without a |
| 409 | // hint to offer, skip the provider round-trip and just go home. |
| 410 | let target = match (state.oidc.end_session_endpoint(), id_token_hint) { |
| 411 | (Some(endpoint), Some(hint)) => format!( |
| 412 | "{endpoint}?id_token_hint={}&post_logout_redirect_uri={}", |
| 413 | urlencoding_encode(&hint), |
| 414 | urlencoding_encode(&state.config.base_url) |
| 415 | ), |
| 416 | _ => "/".to_string(), |
| 417 | }; |
| 418 | |
| 419 | Ok((jar, Redirect::to(&target)).into_response()) |
| 420 | } |
| 421 | |
| 422 | fn urlencoding_encode(s: &str) -> String { |
| 423 | percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() |
| 424 | } |
| 425 | |
| 426 | #[cfg(test)] |
| 427 | mod tests { |
| 428 | use super::*; |
| 429 | |
| 430 | #[test] |
| 431 | fn handle_validation_mirrors_the_db_constraint() { |
| 432 | assert!(valid_handle("alice")); |
| 433 | assert!(valid_handle("a")); |
| 434 | assert!(valid_handle("9lives")); |
| 435 | assert!(valid_handle("with-hyphen")); |
| 436 | |
| 437 | assert!(!valid_handle("")); |
| 438 | assert!(!valid_handle("-leading")); |
| 439 | assert!(!valid_handle("Upper")); |
| 440 | assert!(!valid_handle("under_score")); |
| 441 | assert!(!valid_handle("with space")); |
| 442 | assert!(!valid_handle("üñí")); |
| 443 | assert!(!valid_handle(&"a".repeat(40))); |
| 444 | assert!(valid_handle(&"a".repeat(39))); |
| 445 | } |
| 446 | |
| 447 | #[test] |
| 448 | fn pending_identity_round_trips_and_rejects_tampering() { |
| 449 | let secret = b"secret-key-for-pending-identity-cookie"; |
| 450 | let payload = r#"{"sub":"abc","email":"a@b.c","name":null}"#; |
| 451 | let mac = sign_pending(secret, payload); |
| 452 | |
| 453 | // A wrong secret must not verify. |
| 454 | assert_ne!(mac, sign_pending(b"other-secret", payload)); |
| 455 | // A changed payload must not verify against the old tag. |
| 456 | assert_ne!(mac, sign_pending(secret, r#"{"sub":"attacker"}"#)); |
| 457 | } |
| 458 | |
| 459 | const PENDING_SECRET: &[u8] = b"secret-key-for-pending-identity-cookie"; |
| 460 | |
| 461 | /// Build a cookie value the way `stash_pending` does, with a chosen expiry. |
| 462 | fn pending_cookie(exp: i64) -> String { |
| 463 | let payload = serde_json::json!({ |
| 464 | "sub": "abc", |
| 465 | "email": "a@b.c", |
| 466 | "name": null, |
| 467 | "id_token": null, |
| 468 | "exp": exp, |
| 469 | }) |
| 470 | .to_string(); |
| 471 | format!( |
| 472 | "{}.{}", |
| 473 | hex::encode(&payload), |
| 474 | sign_pending(PENDING_SECRET, &payload) |
| 475 | ) |
| 476 | } |
| 477 | |
| 478 | #[test] |
| 479 | fn a_live_pending_cookie_verifies() { |
| 480 | let raw = pending_cookie(chrono::Utc::now().timestamp() + 600); |
| 481 | let identity = verify_pending(PENDING_SECRET, &raw).expect("should verify"); |
| 482 | assert_eq!(identity.subject, "abc"); |
| 483 | } |
| 484 | |
| 485 | #[test] |
| 486 | fn an_expired_pending_cookie_is_refused_even_though_it_is_signed() { |
| 487 | // The attack `Max-Age` alone does not stop: the browser's copy is gone, |
| 488 | // but a copy taken anywhere else still carries our signature. |
| 489 | let raw = pending_cookie(chrono::Utc::now().timestamp() - 1); |
| 490 | assert!(verify_pending(PENDING_SECRET, &raw).is_none()); |
| 491 | } |
| 492 | |
| 493 | #[test] |
| 494 | fn a_pending_cookie_without_an_expiry_is_refused() { |
| 495 | // The pre-expiry format. Grandfathering it in would leave the old |
| 496 | // indefinitely-redeemable cookie working. |
| 497 | let payload = r#"{"sub":"abc","email":"a@b.c","name":null}"#; |
| 498 | let raw = format!( |
| 499 | "{}.{}", |
| 500 | hex::encode(payload), |
| 501 | sign_pending(PENDING_SECRET, payload) |
| 502 | ); |
| 503 | assert!(verify_pending(PENDING_SECRET, &raw).is_none()); |
| 504 | } |
| 505 | |
| 506 | #[test] |
| 507 | fn a_pending_cookie_cannot_have_its_expiry_extended() { |
| 508 | let raw = pending_cookie(chrono::Utc::now().timestamp() - 1); |
| 509 | let (payload_hex, mac) = raw.split_once('.').unwrap(); |
| 510 | let payload = String::from_utf8(hex::decode(payload_hex).unwrap()).unwrap(); |
| 511 | |
| 512 | // Push the expiry out without re-signing, which is all an attacker can do. |
| 513 | let forged = payload.replace( |
| 514 | &format!("\"exp\":{}", chrono::Utc::now().timestamp() - 1), |
| 515 | &format!("\"exp\":{}", chrono::Utc::now().timestamp() + 86_400), |
| 516 | ); |
| 517 | assert_ne!(forged, payload, "the test must actually change the expiry"); |
| 518 | |
| 519 | let tampered = format!("{}.{mac}", hex::encode(&forged)); |
| 520 | assert!(verify_pending(PENDING_SECRET, &tampered).is_none()); |
| 521 | } |
| 522 | } |
522 lines · Rust