anvilsign in

collin/anvil

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 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.
302struct Started {
303 state: String,
304 nonce: String,
305 challenge: String,
306 cookie: String,
307}
308
309async 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.
350async 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]
368async 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]
425async 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]
468async 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]
511async 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]
581async 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]
623async 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}