| 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