anvilsign in

collin/anvil

main / crates / anvil-web / tests / oidc_flow.rs
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
14use std::{
15 collections::HashMap,
16 sync::{
17 Arc,
18 Mutex,
19 OnceLock,
20 },
21};
22
23use anvil_core::{
24 App,
25 Config,
26 users,
27};
28use 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};
46use base64::{
47 Engine,
48 engine::general_purpose::URL_SAFE_NO_PAD,
49};
50use rsa::{
51 RsaPrivateKey,
52 pkcs1v15::SigningKey,
53 rand_core::OsRng,
54 signature::{
55 SignatureEncoding,
56 Signer,
57 },
58 traits::PublicKeyParts,
59};
60use serde_json::{
61 Value,
62 json,
63};
64use sha2::Sha256;
65use 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.
72fn 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
77const CLIENT_ID: &str = "anvil-test";
78const CLIENT_SECRET: &str = "s3cret";
79const 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)]
85struct 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
92struct 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
100impl 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.
199async 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
233fn 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
242struct 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.
249async 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
273impl Harness {
274 /// The rendered body of a page, for asserting on markup.
275 async fn get_body(&self, path: &str, cookie: Option<&str>) -> String {
276 let mut req = Request::get(path);
277 if let Some(cookie) = cookie {
278 req = req.header(header::COOKIE, cookie);
279 }
280 let response = self
281 .router
282 .clone()
283 .oneshot(req.body(Body::empty()).unwrap())
284 .await
285 .unwrap();
286 let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
287 .await
288 .unwrap();
289 String::from_utf8_lossy(&bytes).into_owned()
290 }
291
292 async fn get(&self, path: &str, cookie: Option<&str>) -> (StatusCode, HashMap<String, String>) {
293 let mut req = Request::get(path);
294 if let Some(cookie) = cookie {
295 req = req.header(header::COOKIE, cookie);
296 }
297 let response = self
298 .router
299 .clone()
300 .oneshot(req.body(Body::empty()).unwrap())
301 .await
302 .unwrap();
303 let status = response.status();
304 let mut headers = HashMap::new();
305 if let Some(location) = response.headers().get(header::LOCATION) {
306 headers.insert("location".into(), location.to_str().unwrap().to_string());
307 }
308 // Only ever one cookie per response here, but keep them all by name.
309 for value in response.headers().get_all(header::SET_COOKIE) {
310 let raw = value.to_str().unwrap();
311 let (name, _) = raw.split_once('=').unwrap();
312 headers.insert(format!("cookie:{name}"), raw.to_string());
313 }
314 (status, headers)
315 }
316}
317
318/// Start a login and pull out what the provider would have been sent, plus the
319/// cookie the callback must present.
320struct Started {
321 state: String,
322 nonce: String,
323 challenge: String,
324 cookie: String,
325}
326
327async fn start_login(h: &Harness, next: Option<&str>) -> Started {
328 let path = match next {
329 Some(next) => format!("/-/oidc/login?next={next}"),
330 None => "/-/oidc/login".to_string(),
331 };
332 let (status, headers) = h.get(&path, None).await;
333 assert_eq!(status, StatusCode::SEE_OTHER, "login redirects");
334
335 let location = headers.get("location").expect("redirects to the provider");
336 let url = reqwest::Url::parse(location).unwrap();
337 let param = |key: &str| {
338 url.query_pairs()
339 .find(|(k, _)| k == key)
340 .map(|(_, v)| v.to_string())
341 .unwrap_or_default()
342 };
343 assert_eq!(param("response_type"), "code");
344 assert_eq!(param("client_id"), CLIENT_ID);
345 assert_eq!(
346 param("redirect_uri"),
347 format!("{ANVIL_URL}/-/oidc/callback"),
348 "the redirect URI must match what is registered at the provider"
349 );
350 assert_eq!(param("code_challenge_method"), "S256");
351
352 let cookie = headers
353 .get("cookie:anvil_oidc")
354 .expect("stashes the pending login")
355 .split(';')
356 .next()
357 .unwrap()
358 .to_string();
359 Started {
360 state: param("state"),
361 nonce: param("nonce"),
362 challenge: param("code_challenge"),
363 cookie,
364 }
365}
366
367/// Come back from the provider with `code`, carrying the pending cookie.
368async fn callback(
369 h: &Harness,
370 started: &Started,
371 code: &str,
372 state: &str,
373) -> (StatusCode, HashMap<String, String>) {
374 h.get(
375 &format!("/-/oidc/callback?code={code}&state={state}"),
376 Some(&started.cookie),
377 )
378 .await
379}
380
381// --- the tests --------------------------------------------------------------
382
383/// The whole hand-off: a login that ends with a session cookie and an account
384/// that did not exist before.
385#[tokio::test]
386async fn a_first_sign_in_provisions_an_account_and_a_session() {
387 let idp = Idp::start().await;
388 let h = harness(&idp.issuer).await;
389
390 let started = start_login(&h, Some("/collin/anvil")).await;
391 idp.grant("code-1", idp.claims(&started.nonce));
392 let (status, headers) = callback(&h, &started, "code-1", &started.state).await;
393
394 assert_eq!(status, StatusCode::SEE_OTHER);
395 assert_eq!(
396 headers.get("location").map(String::as_str),
397 Some("/collin/anvil"),
398 "returns to where the login started"
399 );
400 let session = headers
401 .get("cookie:anvil_session")
402 .expect("sets a session cookie");
403 assert!(session.contains("HttpOnly"), "{session}");
404
405 // PKCE: the verifier the token endpoint saw must hash to the challenge the
406 // authorization request carried.
407 let form = idp.token_requests.lock().unwrap().last().cloned().unwrap();
408 let verifier = form.get("code_verifier").unwrap();
409 let hashed = URL_SAFE_NO_PAD.encode(ring::digest::digest(
410 &ring::digest::SHA256,
411 verifier.as_bytes(),
412 ));
413 assert_eq!(hashed, started.challenge);
414 assert_eq!(form.get("grant_type").unwrap(), "authorization_code");
415
416 let user = users::find_by_sso_sub(&h.app.db, "sso-user-1")
417 .await
418 .unwrap()
419 .expect("the account was provisioned");
420 assert_eq!(user.username, "collin");
421 assert_eq!(user.email, "collin@example.com");
422 assert!(user.is_admin, "the role claim makes an admin");
423 assert!(
424 user.password_hash.is_empty(),
425 "no password is invented for an SSO account"
426 );
427
428 // Signing in again reuses that account rather than making a second one.
429 let started = start_login(&h, None).await;
430 idp.grant("code-2", idp.claims(&started.nonce));
431 let (status, _) = callback(&h, &started, "code-2", &started.state).await;
432 assert_eq!(status, StatusCode::SEE_OTHER);
433 let again = users::find_by_sso_sub(&h.app.db, "sso-user-1")
434 .await
435 .unwrap()
436 .unwrap();
437 assert_eq!(again.id, user.id);
438
439 // Signing out of a linked account redirects to the provider, so the form
440 // must escape the layout's `hx-boost` — a boosted POST would follow that
441 // cross-origin redirect by XHR and be refused by CORS, leaving a button
442 // that quietly does nothing.
443 let session = session.split(';').next().unwrap();
444 let body = h.get_body("/", Some(session)).await;
445 let logout = body
446 .split_once(r#"action="/-/logout""#)
447 .map(|(before, after)| {
448 format!(
449 "{}{}",
450 &before[before.len().saturating_sub(120)..],
451 &after[..60.min(after.len())]
452 )
453 })
454 .expect("the nav offers a sign-out");
455 assert!(logout.contains(r#"hx-boost="false""#), "{logout}");
456}
457
458/// An account that predates single sign-on is adopted on a *verified* address,
459/// and only then.
460#[tokio::test]
461async fn an_existing_account_is_adopted_only_on_a_verified_address() {
462 let idp = Idp::start().await;
463 let h = harness(&idp.issuer).await;
464 let existing = users::create(&h.app.db, "collin", "collin@example.com", "pw", false)
465 .await
466 .unwrap();
467
468 // Unverified: refused, with the password left as the way in.
469 let started = start_login(&h, None).await;
470 let mut claims = idp.claims(&started.nonce);
471 claims["email_verified"] = json!(false);
472 idp.grant("code-1", claims);
473 let (status, headers) = callback(&h, &started, "code-1", &started.state).await;
474 assert_eq!(status, StatusCode::FORBIDDEN);
475 assert!(!headers.contains_key("cookie:anvil_session"));
476 let untouched = users::find_by_id(&h.app.db, existing.id)
477 .await
478 .unwrap()
479 .unwrap();
480 assert!(untouched.sso_sub.is_empty(), "not linked");
481
482 // Verified: the same row is adopted, not duplicated.
483 let started = start_login(&h, None).await;
484 idp.grant("code-2", idp.claims(&started.nonce));
485 let (status, headers) = callback(&h, &started, "code-2", &started.state).await;
486 assert_eq!(status, StatusCode::SEE_OTHER);
487 assert!(headers.contains_key("cookie:anvil_session"));
488
489 let linked = users::find_by_id(&h.app.db, existing.id)
490 .await
491 .unwrap()
492 .unwrap();
493 assert_eq!(linked.sso_sub, "sso-user-1");
494 assert!(linked.is_admin, "the role claim is applied on adoption");
495 assert!(
496 !linked.password_hash.is_empty(),
497 "the existing password still works"
498 );
499}
500
501/// A `preferred_username` somebody already holds does not collide, and one that
502/// could not be a username at all is replaced rather than rejected.
503#[tokio::test]
504async fn a_taken_or_unusable_username_is_allocated_around() {
505 let idp = Idp::start().await;
506 let h = harness(&idp.issuer).await;
507 users::create(&h.app.db, "collin", "someone@example.com", "pw", false)
508 .await
509 .unwrap();
510
511 let started = start_login(&h, None).await;
512 let mut claims = idp.claims(&started.nonce);
513 // A different person, whose preferred name is taken and whose address is
514 // theirs alone.
515 claims["sub"] = json!("sso-user-2");
516 claims["email"] = json!("other@example.com");
517 idp.grant("code-1", claims);
518 let (status, _) = callback(&h, &started, "code-1", &started.state).await;
519 assert_eq!(status, StatusCode::SEE_OTHER);
520 let user = users::find_by_sso_sub(&h.app.db, "sso-user-2")
521 .await
522 .unwrap()
523 .unwrap();
524 assert_eq!(user.username, "collin-2");
525
526 // A reserved name, and one full of characters a URL path cannot carry.
527 let started = start_login(&h, None).await;
528 let mut claims = idp.claims(&started.nonce);
529 claims["sub"] = json!("sso-user-3");
530 claims["preferred_username"] = json!("settings");
531 claims["email"] = json!("third@example.com");
532 idp.grant("code-2", claims);
533 let (status, _) = callback(&h, &started, "code-2", &started.state).await;
534 assert_eq!(status, StatusCode::SEE_OTHER);
535 let user = users::find_by_sso_sub(&h.app.db, "sso-user-3")
536 .await
537 .unwrap()
538 .unwrap();
539 assert_eq!(
540 user.username, "third",
541 "a reserved name falls back to the address"
542 );
543}
544
545/// Every way a callback can be wrong must end without a session.
546#[tokio::test]
547async fn a_tampered_callback_never_yields_a_session() {
548 let idp = Idp::start().await;
549 let h = harness(&idp.issuer).await;
550
551 // A state that is not the one we issued.
552 let started = start_login(&h, None).await;
553 idp.grant("code-1", idp.claims(&started.nonce));
554 let (status, headers) = callback(&h, &started, "code-1", "not-the-state").await;
555 assert_eq!(status, StatusCode::BAD_REQUEST);
556 assert!(!headers.contains_key("cookie:anvil_session"));
557
558 // No pending cookie at all (a callback arriving out of nowhere).
559 let (status, _) = h
560 .get(
561 &format!("/-/oidc/callback?code=code-1&state={}", started.state),
562 None,
563 )
564 .await;
565 assert_eq!(status, StatusCode::BAD_REQUEST);
566
567 // A token minted for a different client.
568 let started = start_login(&h, None).await;
569 let mut claims = idp.claims(&started.nonce);
570 claims["aud"] = json!("some-other-app");
571 idp.grant("code-2", claims);
572 let (status, headers) = callback(&h, &started, "code-2", &started.state).await;
573 assert_eq!(status, StatusCode::BAD_GATEWAY);
574 assert!(!headers.contains_key("cookie:anvil_session"));
575
576 // A token carrying another login's nonce — the replay the nonce exists for.
577 let started = start_login(&h, None).await;
578 let mut claims = idp.claims(&started.nonce);
579 claims["nonce"] = json!("a-nonce-from-some-other-login");
580 idp.grant("code-3", claims);
581 let (status, headers) = callback(&h, &started, "code-3", &started.state).await;
582 assert_eq!(status, StatusCode::BAD_GATEWAY);
583 assert!(!headers.contains_key("cookie:anvil_session"));
584
585 // An expired token.
586 let started = start_login(&h, None).await;
587 let mut claims = idp.claims(&started.nonce);
588 claims["exp"] = json!(now() - 3600);
589 idp.grant("code-4", claims);
590 let (status, headers) = callback(&h, &started, "code-4", &started.state).await;
591 assert_eq!(status, StatusCode::BAD_GATEWAY);
592 assert!(!headers.contains_key("cookie:anvil_session"));
593
594 // The provider refusing outright.
595 let started = start_login(&h, None).await;
596 let (status, _) = h
597 .get(
598 "/-/oidc/callback?error=access_denied&error_description=no+grant+for+this+app",
599 Some(&started.cookie),
600 )
601 .await;
602 assert_eq!(status, StatusCode::FORBIDDEN);
603
604 assert!(
605 users::find_by_sso_sub(&h.app.db, "sso-user-1")
606 .await
607 .unwrap()
608 .is_none(),
609 "no account was provisioned by any of it"
610 );
611}
612
613/// A token whose signature does not verify is refused, however well-formed and
614/// truthful the claims inside it are. This is the check that makes every other
615/// claim worth reading.
616#[tokio::test]
617async fn a_bad_signature_is_refused() {
618 let idp = Idp::start().await;
619 let h = harness(&idp.issuer).await;
620
621 // The real claims for this very login, with one bit flipped in the
622 // signature — what an attacker who could mint claims but not sign them
623 // would produce.
624 let started = start_login(&h, None).await;
625 let token = idp.id_token(&idp.claims(&started.nonce));
626 let (rest, signature) = token.rsplit_once('.').unwrap();
627 let mut bytes = URL_SAFE_NO_PAD.decode(signature).unwrap();
628 bytes[0] ^= 0xff;
629 idp.grant_raw(
630 "code-1",
631 format!("{rest}.{}", URL_SAFE_NO_PAD.encode(&bytes)),
632 );
633
634 let (status, headers) = callback(&h, &started, "code-1", &started.state).await;
635 assert_eq!(status, StatusCode::BAD_GATEWAY);
636 assert!(!headers.contains_key("cookie:anvil_session"));
637
638 // And an unsigned token that asks to be trusted on the strength of its
639 // `alg` header, which is the attack that check exists for.
640 let started = start_login(&h, None).await;
641 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
642 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&idp.claims(&started.nonce)).unwrap());
643 idp.grant_raw("code-2", format!("{header}.{payload}."));
644
645 let (status, headers) = callback(&h, &started, "code-2", &started.state).await;
646 assert_eq!(status, StatusCode::BAD_GATEWAY);
647 assert!(!headers.contains_key("cookie:anvil_session"));
648
649 assert!(
650 users::find_by_sso_sub(&h.app.db, "sso-user-1")
651 .await
652 .unwrap()
653 .is_none()
654 );
655}
656
657/// With no issuer configured, the routes are simply not a way in.
658#[tokio::test]
659async fn an_unconfigured_instance_offers_nothing() {
660 let h = harness("").await;
661
662 let (status, _) = h.get("/-/oidc/login", None).await;
663 assert_eq!(status, StatusCode::NOT_FOUND);
664 let (status, _) = h.get("/-/oidc/callback?code=x&state=y", None).await;
665 assert_eq!(status, StatusCode::NOT_FOUND);
666}