| 1 | //! The single sign-on hand-off, end to end, against a stand-in provider. |
| 2 | //! |
| 3 | //! No test can hold a passkey up to a real identity provider, so this file *is* |
| 4 | //! the provider: a small axum server that publishes a discovery document and a |
| 5 | //! JWK set, and mints id tokens with a real RS256 signature over the claims the |
| 6 | //! test asks for. Everything on anvil's side of the wire is the real thing — |
| 7 | //! the actual router, the actual handlers, the actual verification. |
| 8 | //! |
| 9 | //! That makes it a genuine test of the flow (start a login, come back with a |
| 10 | //! code, get a session and an account), plus the failures that matter: a forged |
| 11 | //! signature, a swapped state, a replayed nonce, a token for someone else's |
| 12 | //! client, and an unverified address that would otherwise adopt an account. |
| 13 | |
| 14 | use std::{ |
| 15 | collections::HashMap, |
| 16 | sync::{ |
| 17 | Arc, |
| 18 | Mutex, |
| 19 | OnceLock, |
| 20 | }, |
| 21 | }; |
| 22 | |
| 23 | use anvil_core::{ |
| 24 | App, |
| 25 | Config, |
| 26 | users, |
| 27 | }; |
| 28 | use axum::{ |
| 29 | Json, |
| 30 | Router, |
| 31 | body::Body, |
| 32 | extract::{ |
| 33 | Form, |
| 34 | State, |
| 35 | }, |
| 36 | http::{ |
| 37 | Request, |
| 38 | StatusCode, |
| 39 | header, |
| 40 | }, |
| 41 | routing::{ |
| 42 | get, |
| 43 | post, |
| 44 | }, |
| 45 | }; |
| 46 | use base64::{ |
| 47 | Engine, |
| 48 | engine::general_purpose::URL_SAFE_NO_PAD, |
| 49 | }; |
| 50 | use rsa::{ |
| 51 | RsaPrivateKey, |
| 52 | pkcs1v15::SigningKey, |
| 53 | rand_core::OsRng, |
| 54 | signature::{ |
| 55 | SignatureEncoding, |
| 56 | Signer, |
| 57 | }, |
| 58 | traits::PublicKeyParts, |
| 59 | }; |
| 60 | use serde_json::{ |
| 61 | Value, |
| 62 | json, |
| 63 | }; |
| 64 | use sha2::Sha256; |
| 65 | use tower::ServiceExt; |
| 66 | |
| 67 | /// The provider's signing key, generated once for the whole test binary rather |
| 68 | /// than per test — 2048 bits costs a couple of seconds in a debug build, and |
| 69 | /// every test here wants the same provider. Generated rather than checked in: |
| 70 | /// a PEM private key in the repository is a thing to explain forever, and this |
| 71 | /// one signs nothing outside this process. |
| 72 | fn signing_key() -> &'static RsaPrivateKey { |
| 73 | static KEY: OnceLock<RsaPrivateKey> = OnceLock::new(); |
| 74 | KEY.get_or_init(|| RsaPrivateKey::new(&mut OsRng, 2048).expect("the system RNG yields a key")) |
| 75 | } |
| 76 | |
| 77 | const CLIENT_ID: &str = "anvil-test"; |
| 78 | const CLIENT_SECRET: &str = "s3cret"; |
| 79 | const ANVIL_URL: &str = "https://anvil.localhost"; |
| 80 | |
| 81 | // --- the stand-in provider -------------------------------------------------- |
| 82 | |
| 83 | /// What the provider will hand back for one authorization code. |
| 84 | #[derive(Clone)] |
| 85 | struct Grant { |
| 86 | claims: Value, |
| 87 | /// Return this token verbatim instead of signing `claims` — how the test |
| 88 | /// serves something the provider never would. |
| 89 | raw: Option<String>, |
| 90 | } |
| 91 | |
| 92 | struct Idp { |
| 93 | issuer: String, |
| 94 | key: RsaPrivateKey, |
| 95 | grants: Mutex<HashMap<String, Grant>>, |
| 96 | /// Every form the token endpoint received, for asserting on PKCE. |
| 97 | token_requests: Mutex<Vec<HashMap<String, String>>>, |
| 98 | } |
| 99 | |
| 100 | impl Idp { |
| 101 | /// Start the provider on a loopback port and return it with its issuer URL. |
| 102 | async fn start() -> Arc<Self> { |
| 103 | let key = signing_key().clone(); |
| 104 | |
| 105 | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 106 | let port = listener.local_addr().unwrap().port(); |
| 107 | let idp = Arc::new(Self { |
| 108 | issuer: format!("http://127.0.0.1:{port}"), |
| 109 | key, |
| 110 | grants: Mutex::new(HashMap::new()), |
| 111 | token_requests: Mutex::new(Vec::new()), |
| 112 | }); |
| 113 | |
| 114 | let router = Router::new() |
| 115 | .route( |
| 116 | "/.well-known/openid-configuration", |
| 117 | get(|State(idp): State<Arc<Idp>>| async move { |
| 118 | Json(json!({ |
| 119 | "issuer": idp.issuer, |
| 120 | "authorization_endpoint": format!("{}/authorize", idp.issuer), |
| 121 | "token_endpoint": format!("{}/token", idp.issuer), |
| 122 | "jwks_uri": format!("{}/.well-known/jwks.json", idp.issuer), |
| 123 | "end_session_endpoint": format!("{}/logout", idp.issuer), |
| 124 | })) |
| 125 | }), |
| 126 | ) |
| 127 | .route( |
| 128 | "/.well-known/jwks.json", |
| 129 | get(|State(idp): State<Arc<Idp>>| async move { Json(idp.jwks()) }), |
| 130 | ) |
| 131 | .route("/token", post(token)) |
| 132 | .with_state(idp.clone()); |
| 133 | |
| 134 | tokio::spawn(async move { |
| 135 | let _ = axum::serve(listener, router).await; |
| 136 | }); |
| 137 | idp |
| 138 | } |
| 139 | |
| 140 | fn jwks(&self) -> Value { |
| 141 | let n = URL_SAFE_NO_PAD.encode(self.key.n().to_bytes_be()); |
| 142 | let e = URL_SAFE_NO_PAD.encode(self.key.e().to_bytes_be()); |
| 143 | json!({"keys": [{"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test-1", "n": n, "e": e}]}) |
| 144 | } |
| 145 | |
| 146 | /// Register `code` as redeemable for an id token carrying `claims`. |
| 147 | fn grant(&self, code: &str, claims: Value) { |
| 148 | self.grants |
| 149 | .lock() |
| 150 | .unwrap() |
| 151 | .insert(code.to_string(), Grant { claims, raw: None }); |
| 152 | } |
| 153 | |
| 154 | /// Register `code` as redeemable for exactly this token, whatever it is. |
| 155 | fn grant_raw(&self, code: &str, token: String) { |
| 156 | self.grants.lock().unwrap().insert( |
| 157 | code.to_string(), |
| 158 | Grant { |
| 159 | claims: Value::Null, |
| 160 | raw: Some(token), |
| 161 | }, |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | /// The claims a happy-path login produces, before the test edits them. |
| 166 | fn claims(&self, nonce: &str) -> Value { |
| 167 | json!({ |
| 168 | "iss": self.issuer, |
| 169 | "aud": CLIENT_ID, |
| 170 | "sub": "sso-user-1", |
| 171 | "exp": now() + 300, |
| 172 | "iat": now(), |
| 173 | "nonce": nonce, |
| 174 | "email": "collin@example.com", |
| 175 | "email_verified": true, |
| 176 | "name": "Collin", |
| 177 | "preferred_username": "collin", |
| 178 | "role": "admin", |
| 179 | }) |
| 180 | } |
| 181 | |
| 182 | /// Sign `claims` into a compact RS256 JWS. |
| 183 | fn id_token(&self, claims: &Value) -> String { |
| 184 | let header = json!({"alg": "RS256", "typ": "JWT", "kid": "test-1"}); |
| 185 | let signing_input = format!( |
| 186 | "{}.{}", |
| 187 | URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()), |
| 188 | URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()) |
| 189 | ); |
| 190 | let signature = SigningKey::<Sha256>::new(self.key.clone()).sign(signing_input.as_bytes()); |
| 191 | format!( |
| 192 | "{signing_input}.{}", |
| 193 | URL_SAFE_NO_PAD.encode(signature.to_bytes()) |
| 194 | ) |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | /// `POST /token` — the provider's code exchange. |
| 199 | async fn token( |
| 200 | State(idp): State<Arc<Idp>>, |
| 201 | Form(form): Form<HashMap<String, String>>, |
| 202 | ) -> Result<Json<Value>, (StatusCode, Json<Value>)> { |
| 203 | idp.token_requests.lock().unwrap().push(form.clone()); |
| 204 | |
| 205 | let deny = |msg: &str| { |
| 206 | Err(( |
| 207 | StatusCode::BAD_REQUEST, |
| 208 | Json(json!({"error": "invalid_grant", "error_description": msg})), |
| 209 | )) |
| 210 | }; |
| 211 | if form.get("client_id").map(String::as_str) != Some(CLIENT_ID) |
| 212 | || form.get("client_secret").map(String::as_str) != Some(CLIENT_SECRET) |
| 213 | { |
| 214 | return deny("bad client credentials"); |
| 215 | } |
| 216 | if form.get("code_verifier").is_none_or(String::is_empty) { |
| 217 | return deny("no PKCE verifier"); |
| 218 | } |
| 219 | let Some(grant) = form |
| 220 | .get("code") |
| 221 | .and_then(|c| idp.grants.lock().unwrap().get(c).cloned()) |
| 222 | else { |
| 223 | return deny("unknown code"); |
| 224 | }; |
| 225 | let id_token = grant.raw.unwrap_or_else(|| idp.id_token(&grant.claims)); |
| 226 | Ok(Json(json!({ |
| 227 | "access_token": "at", |
| 228 | "token_type": "Bearer", |
| 229 | "id_token": id_token, |
| 230 | }))) |
| 231 | } |
| 232 | |
| 233 | fn now() -> i64 { |
| 234 | std::time::SystemTime::now() |
| 235 | .duration_since(std::time::UNIX_EPOCH) |
| 236 | .unwrap() |
| 237 | .as_secs() as i64 |
| 238 | } |
| 239 | |
| 240 | // --- anvil's side ----------------------------------------------------------- |
| 241 | |
| 242 | struct Harness { |
| 243 | app: App, |
| 244 | router: Router, |
| 245 | _dir: tempfile::TempDir, |
| 246 | } |
| 247 | |
| 248 | /// An anvil pointed at `issuer`, or at nothing when it is empty. |
| 249 | async fn harness(issuer: &str) -> Harness { |
| 250 | let dir = tempfile::tempdir().unwrap(); |
| 251 | let config = Config { |
| 252 | data_dir: dir.path().to_path_buf(), |
| 253 | http: anvil_core::config::HttpConfig { |
| 254 | base_url: ANVIL_URL.to_string(), |
| 255 | ..Default::default() |
| 256 | }, |
| 257 | oidc: anvil_core::config::OidcConfig { |
| 258 | issuer: issuer.to_string(), |
| 259 | client_id: CLIENT_ID.to_string(), |
| 260 | client_secret: CLIENT_SECRET.to_string(), |
| 261 | ..Default::default() |
| 262 | }, |
| 263 | ..Default::default() |
| 264 | }; |
| 265 | let app = App::bootstrap(config).await.unwrap(); |
| 266 | Harness { |
| 267 | router: anvil_web::router(app.clone()), |
| 268 | app, |
| 269 | _dir: dir, |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | impl Harness { |
| 274 | async fn get(&self, path: &str, cookie: Option<&str>) -> (StatusCode, HashMap<String, String>) { |
| 275 | let mut req = Request::get(path); |
| 276 | if let Some(cookie) = cookie { |
| 277 | req = req.header(header::COOKIE, cookie); |
| 278 | } |
| 279 | let response = self |
| 280 | .router |
| 281 | .clone() |
| 282 | .oneshot(req.body(Body::empty()).unwrap()) |
| 283 | .await |
| 284 | .unwrap(); |
| 285 | let status = response.status(); |
| 286 | let mut headers = HashMap::new(); |
| 287 | if let Some(location) = response.headers().get(header::LOCATION) { |
| 288 | headers.insert("location".into(), location.to_str().unwrap().to_string()); |
| 289 | } |
| 290 | // Only ever one cookie per response here, but keep them all by name. |
| 291 | for value in response.headers().get_all(header::SET_COOKIE) { |
| 292 | let raw = value.to_str().unwrap(); |
| 293 | let (name, _) = raw.split_once('=').unwrap(); |
| 294 | headers.insert(format!("cookie:{name}"), raw.to_string()); |
| 295 | } |
| 296 | (status, headers) |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | /// Start a login and pull out what the provider would have been sent, plus the |
| 301 | /// cookie the callback must present. |
| 302 | struct Started { |
| 303 | state: String, |
| 304 | nonce: String, |
| 305 | challenge: String, |
| 306 | cookie: String, |
| 307 | } |
| 308 | |
| 309 | async fn start_login(h: &Harness, next: Option<&str>) -> Started { |
| 310 | let path = match next { |
| 311 | Some(next) => format!("/-/oidc/login?next={next}"), |
| 312 | None => "/-/oidc/login".to_string(), |
| 313 | }; |
| 314 | let (status, headers) = h.get(&path, None).await; |
| 315 | assert_eq!(status, StatusCode::SEE_OTHER, "login redirects"); |
| 316 | |
| 317 | let location = headers.get("location").expect("redirects to the provider"); |
| 318 | let url = reqwest::Url::parse(location).unwrap(); |
| 319 | let param = |key: &str| { |
| 320 | url.query_pairs() |
| 321 | .find(|(k, _)| k == key) |
| 322 | .map(|(_, v)| v.to_string()) |
| 323 | .unwrap_or_default() |
| 324 | }; |
| 325 | assert_eq!(param("response_type"), "code"); |
| 326 | assert_eq!(param("client_id"), CLIENT_ID); |
| 327 | assert_eq!( |
| 328 | param("redirect_uri"), |
| 329 | format!("{ANVIL_URL}/-/oidc/callback"), |
| 330 | "the redirect URI must match what is registered at the provider" |
| 331 | ); |
| 332 | assert_eq!(param("code_challenge_method"), "S256"); |
| 333 | |
| 334 | let cookie = headers |
| 335 | .get("cookie:anvil_oidc") |
| 336 | .expect("stashes the pending login") |
| 337 | .split(';') |
| 338 | .next() |
| 339 | .unwrap() |
| 340 | .to_string(); |
| 341 | Started { |
| 342 | state: param("state"), |
| 343 | nonce: param("nonce"), |
| 344 | challenge: param("code_challenge"), |
| 345 | cookie, |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | /// Come back from the provider with `code`, carrying the pending cookie. |
| 350 | async fn callback( |
| 351 | h: &Harness, |
| 352 | started: &Started, |
| 353 | code: &str, |
| 354 | state: &str, |
| 355 | ) -> (StatusCode, HashMap<String, String>) { |
| 356 | h.get( |
| 357 | &format!("/-/oidc/callback?code={code}&state={state}"), |
| 358 | Some(&started.cookie), |
| 359 | ) |
| 360 | .await |
| 361 | } |
| 362 | |
| 363 | // --- the tests -------------------------------------------------------------- |
| 364 | |
| 365 | /// The whole hand-off: a login that ends with a session cookie and an account |
| 366 | /// that did not exist before. |
| 367 | #[tokio::test] |
| 368 | async fn a_first_sign_in_provisions_an_account_and_a_session() { |
| 369 | let idp = Idp::start().await; |
| 370 | let h = harness(&idp.issuer).await; |
| 371 | |
| 372 | let started = start_login(&h, Some("/collin/anvil")).await; |
| 373 | idp.grant("code-1", idp.claims(&started.nonce)); |
| 374 | let (status, headers) = callback(&h, &started, "code-1", &started.state).await; |
| 375 | |
| 376 | assert_eq!(status, StatusCode::SEE_OTHER); |
| 377 | assert_eq!( |
| 378 | headers.get("location").map(String::as_str), |
| 379 | Some("/collin/anvil"), |
| 380 | "returns to where the login started" |
| 381 | ); |
| 382 | let session = headers |
| 383 | .get("cookie:anvil_session") |
| 384 | .expect("sets a session cookie"); |
| 385 | assert!(session.contains("HttpOnly"), "{session}"); |
| 386 | |
| 387 | // PKCE: the verifier the token endpoint saw must hash to the challenge the |
| 388 | // authorization request carried. |
| 389 | let form = idp.token_requests.lock().unwrap().last().cloned().unwrap(); |
| 390 | let verifier = form.get("code_verifier").unwrap(); |
| 391 | let hashed = URL_SAFE_NO_PAD.encode(ring::digest::digest( |
| 392 | &ring::digest::SHA256, |
| 393 | verifier.as_bytes(), |
| 394 | )); |
| 395 | assert_eq!(hashed, started.challenge); |
| 396 | assert_eq!(form.get("grant_type").unwrap(), "authorization_code"); |
| 397 | |
| 398 | let user = users::find_by_sso_sub(&h.app.db, "sso-user-1") |
| 399 | .await |
| 400 | .unwrap() |
| 401 | .expect("the account was provisioned"); |
| 402 | assert_eq!(user.username, "collin"); |
| 403 | assert_eq!(user.email, "collin@example.com"); |
| 404 | assert!(user.is_admin, "the role claim makes an admin"); |
| 405 | assert!( |
| 406 | user.password_hash.is_empty(), |
| 407 | "no password is invented for an SSO account" |
| 408 | ); |
| 409 | |
| 410 | // Signing in again reuses that account rather than making a second one. |
| 411 | let started = start_login(&h, None).await; |
| 412 | idp.grant("code-2", idp.claims(&started.nonce)); |
| 413 | let (status, _) = callback(&h, &started, "code-2", &started.state).await; |
| 414 | assert_eq!(status, StatusCode::SEE_OTHER); |
| 415 | let again = users::find_by_sso_sub(&h.app.db, "sso-user-1") |
| 416 | .await |
| 417 | .unwrap() |
| 418 | .unwrap(); |
| 419 | assert_eq!(again.id, user.id); |
| 420 | } |
| 421 | |
| 422 | /// An account that predates single sign-on is adopted on a *verified* address, |
| 423 | /// and only then. |
| 424 | #[tokio::test] |
| 425 | async fn an_existing_account_is_adopted_only_on_a_verified_address() { |
| 426 | let idp = Idp::start().await; |
| 427 | let h = harness(&idp.issuer).await; |
| 428 | let existing = users::create(&h.app.db, "collin", "collin@example.com", "pw", false) |
| 429 | .await |
| 430 | .unwrap(); |
| 431 | |
| 432 | // Unverified: refused, with the password left as the way in. |
| 433 | let started = start_login(&h, None).await; |
| 434 | let mut claims = idp.claims(&started.nonce); |
| 435 | claims["email_verified"] = json!(false); |
| 436 | idp.grant("code-1", claims); |
| 437 | let (status, headers) = callback(&h, &started, "code-1", &started.state).await; |
| 438 | assert_eq!(status, StatusCode::FORBIDDEN); |
| 439 | assert!(!headers.contains_key("cookie:anvil_session")); |
| 440 | let untouched = users::find_by_id(&h.app.db, existing.id) |
| 441 | .await |
| 442 | .unwrap() |
| 443 | .unwrap(); |
| 444 | assert!(untouched.sso_sub.is_empty(), "not linked"); |
| 445 | |
| 446 | // Verified: the same row is adopted, not duplicated. |
| 447 | let started = start_login(&h, None).await; |
| 448 | idp.grant("code-2", idp.claims(&started.nonce)); |
| 449 | let (status, headers) = callback(&h, &started, "code-2", &started.state).await; |
| 450 | assert_eq!(status, StatusCode::SEE_OTHER); |
| 451 | assert!(headers.contains_key("cookie:anvil_session")); |
| 452 | |
| 453 | let linked = users::find_by_id(&h.app.db, existing.id) |
| 454 | .await |
| 455 | .unwrap() |
| 456 | .unwrap(); |
| 457 | assert_eq!(linked.sso_sub, "sso-user-1"); |
| 458 | assert!(linked.is_admin, "the role claim is applied on adoption"); |
| 459 | assert!( |
| 460 | !linked.password_hash.is_empty(), |
| 461 | "the existing password still works" |
| 462 | ); |
| 463 | } |
| 464 | |
| 465 | /// A `preferred_username` somebody already holds does not collide, and one that |
| 466 | /// could not be a username at all is replaced rather than rejected. |
| 467 | #[tokio::test] |
| 468 | async fn a_taken_or_unusable_username_is_allocated_around() { |
| 469 | let idp = Idp::start().await; |
| 470 | let h = harness(&idp.issuer).await; |
| 471 | users::create(&h.app.db, "collin", "someone@example.com", "pw", false) |
| 472 | .await |
| 473 | .unwrap(); |
| 474 | |
| 475 | let started = start_login(&h, None).await; |
| 476 | let mut claims = idp.claims(&started.nonce); |
| 477 | // A different person, whose preferred name is taken and whose address is |
| 478 | // theirs alone. |
| 479 | claims["sub"] = json!("sso-user-2"); |
| 480 | claims["email"] = json!("other@example.com"); |
| 481 | idp.grant("code-1", claims); |
| 482 | let (status, _) = callback(&h, &started, "code-1", &started.state).await; |
| 483 | assert_eq!(status, StatusCode::SEE_OTHER); |
| 484 | let user = users::find_by_sso_sub(&h.app.db, "sso-user-2") |
| 485 | .await |
| 486 | .unwrap() |
| 487 | .unwrap(); |
| 488 | assert_eq!(user.username, "collin-2"); |
| 489 | |
| 490 | // A reserved name, and one full of characters a URL path cannot carry. |
| 491 | let started = start_login(&h, None).await; |
| 492 | let mut claims = idp.claims(&started.nonce); |
| 493 | claims["sub"] = json!("sso-user-3"); |
| 494 | claims["preferred_username"] = json!("settings"); |
| 495 | claims["email"] = json!("third@example.com"); |
| 496 | idp.grant("code-2", claims); |
| 497 | let (status, _) = callback(&h, &started, "code-2", &started.state).await; |
| 498 | assert_eq!(status, StatusCode::SEE_OTHER); |
| 499 | let user = users::find_by_sso_sub(&h.app.db, "sso-user-3") |
| 500 | .await |
| 501 | .unwrap() |
| 502 | .unwrap(); |
| 503 | assert_eq!( |
| 504 | user.username, "third", |
| 505 | "a reserved name falls back to the address" |
| 506 | ); |
| 507 | } |
| 508 | |
| 509 | /// Every way a callback can be wrong must end without a session. |
| 510 | #[tokio::test] |
| 511 | async fn a_tampered_callback_never_yields_a_session() { |
| 512 | let idp = Idp::start().await; |
| 513 | let h = harness(&idp.issuer).await; |
| 514 | |
| 515 | // A state that is not the one we issued. |
| 516 | let started = start_login(&h, None).await; |
| 517 | idp.grant("code-1", idp.claims(&started.nonce)); |
| 518 | let (status, headers) = callback(&h, &started, "code-1", "not-the-state").await; |
| 519 | assert_eq!(status, StatusCode::BAD_REQUEST); |
| 520 | assert!(!headers.contains_key("cookie:anvil_session")); |
| 521 | |
| 522 | // No pending cookie at all (a callback arriving out of nowhere). |
| 523 | let (status, _) = h |
| 524 | .get( |
| 525 | &format!("/-/oidc/callback?code=code-1&state={}", started.state), |
| 526 | None, |
| 527 | ) |
| 528 | .await; |
| 529 | assert_eq!(status, StatusCode::BAD_REQUEST); |
| 530 | |
| 531 | // A token minted for a different client. |
| 532 | let started = start_login(&h, None).await; |
| 533 | let mut claims = idp.claims(&started.nonce); |
| 534 | claims["aud"] = json!("some-other-app"); |
| 535 | idp.grant("code-2", claims); |
| 536 | let (status, headers) = callback(&h, &started, "code-2", &started.state).await; |
| 537 | assert_eq!(status, StatusCode::BAD_GATEWAY); |
| 538 | assert!(!headers.contains_key("cookie:anvil_session")); |
| 539 | |
| 540 | // A token carrying another login's nonce — the replay the nonce exists for. |
| 541 | let started = start_login(&h, None).await; |
| 542 | let mut claims = idp.claims(&started.nonce); |
| 543 | claims["nonce"] = json!("a-nonce-from-some-other-login"); |
| 544 | idp.grant("code-3", claims); |
| 545 | let (status, headers) = callback(&h, &started, "code-3", &started.state).await; |
| 546 | assert_eq!(status, StatusCode::BAD_GATEWAY); |
| 547 | assert!(!headers.contains_key("cookie:anvil_session")); |
| 548 | |
| 549 | // An expired token. |
| 550 | let started = start_login(&h, None).await; |
| 551 | let mut claims = idp.claims(&started.nonce); |
| 552 | claims["exp"] = json!(now() - 3600); |
| 553 | idp.grant("code-4", claims); |
| 554 | let (status, headers) = callback(&h, &started, "code-4", &started.state).await; |
| 555 | assert_eq!(status, StatusCode::BAD_GATEWAY); |
| 556 | assert!(!headers.contains_key("cookie:anvil_session")); |
| 557 | |
| 558 | // The provider refusing outright. |
| 559 | let started = start_login(&h, None).await; |
| 560 | let (status, _) = h |
| 561 | .get( |
| 562 | "/-/oidc/callback?error=access_denied&error_description=no+grant+for+this+app", |
| 563 | Some(&started.cookie), |
| 564 | ) |
| 565 | .await; |
| 566 | assert_eq!(status, StatusCode::FORBIDDEN); |
| 567 | |
| 568 | assert!( |
| 569 | users::find_by_sso_sub(&h.app.db, "sso-user-1") |
| 570 | .await |
| 571 | .unwrap() |
| 572 | .is_none(), |
| 573 | "no account was provisioned by any of it" |
| 574 | ); |
| 575 | } |
| 576 | |
| 577 | /// A token whose signature does not verify is refused, however well-formed and |
| 578 | /// truthful the claims inside it are. This is the check that makes every other |
| 579 | /// claim worth reading. |
| 580 | #[tokio::test] |
| 581 | async fn a_bad_signature_is_refused() { |
| 582 | let idp = Idp::start().await; |
| 583 | let h = harness(&idp.issuer).await; |
| 584 | |
| 585 | // The real claims for this very login, with one bit flipped in the |
| 586 | // signature — what an attacker who could mint claims but not sign them |
| 587 | // would produce. |
| 588 | let started = start_login(&h, None).await; |
| 589 | let token = idp.id_token(&idp.claims(&started.nonce)); |
| 590 | let (rest, signature) = token.rsplit_once('.').unwrap(); |
| 591 | let mut bytes = URL_SAFE_NO_PAD.decode(signature).unwrap(); |
| 592 | bytes[0] ^= 0xff; |
| 593 | idp.grant_raw( |
| 594 | "code-1", |
| 595 | format!("{rest}.{}", URL_SAFE_NO_PAD.encode(&bytes)), |
| 596 | ); |
| 597 | |
| 598 | let (status, headers) = callback(&h, &started, "code-1", &started.state).await; |
| 599 | assert_eq!(status, StatusCode::BAD_GATEWAY); |
| 600 | assert!(!headers.contains_key("cookie:anvil_session")); |
| 601 | |
| 602 | // And an unsigned token that asks to be trusted on the strength of its |
| 603 | // `alg` header, which is the attack that check exists for. |
| 604 | let started = start_login(&h, None).await; |
| 605 | let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); |
| 606 | let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&idp.claims(&started.nonce)).unwrap()); |
| 607 | idp.grant_raw("code-2", format!("{header}.{payload}.")); |
| 608 | |
| 609 | let (status, headers) = callback(&h, &started, "code-2", &started.state).await; |
| 610 | assert_eq!(status, StatusCode::BAD_GATEWAY); |
| 611 | assert!(!headers.contains_key("cookie:anvil_session")); |
| 612 | |
| 613 | assert!( |
| 614 | users::find_by_sso_sub(&h.app.db, "sso-user-1") |
| 615 | .await |
| 616 | .unwrap() |
| 617 | .is_none() |
| 618 | ); |
| 619 | } |
| 620 | |
| 621 | /// With no issuer configured, the routes are simply not a way in. |
| 622 | #[tokio::test] |
| 623 | async fn an_unconfigured_instance_offers_nothing() { |
| 624 | let h = harness("").await; |
| 625 | |
| 626 | let (status, _) = h.get("/-/oidc/login", None).await; |
| 627 | assert_eq!(status, StatusCode::NOT_FOUND); |
| 628 | let (status, _) = h.get("/-/oidc/callback?code=x&state=y", None).await; |
| 629 | assert_eq!(status, StatusCode::NOT_FOUND); |
| 630 | } |