anvilsign in

collin/anvil

1//! Single sign-on against an OpenID Connect provider (authorization code flow
2//! with PKCE) — see `docs/oidc.md`.
3//!
4//! Off unless `[oidc] issuer` is configured, so an unconfigured instance
5//! behaves exactly as it did before: local passwords only. When it is on it is
6//! *additional* rather than a replacement — existing accounts keep their
7//! passwords, and the two are reconciled on the `sub` claim, which the provider
8//! promises never changes, rather than on email, which does.
9//!
10//! Three routes make up the hand-off:
11//!
12//! | | |
13//! |---|---|
14//! | `GET /-/oidc/login?next=/path` | start the flow; stash state/nonce/PKCE in a ten-minute cookie |
15//! | `GET /-/oidc/callback` | exchange the code, provision or link the account, set the session |
16//! | `POST /-/logout` | (in [`crate::auth`]) end the provider's session too, when asked to |
17//!
18//! Everything the provider hands back is verified here: the `state` against the
19//! cookie, the id token's signature against the published JWKS, and its `iss`,
20//! `aud`, `exp` and `nonce` against what we asked for.
21
22use std::{
23 collections::HashMap,
24 sync::{
25 Mutex,
26 OnceLock,
27 },
28};
29
30use anvil_core::{
31 App,
32 User,
33 config::{
34 OIDC_CALLBACK_PATH,
35 OidcConfig,
36 },
37 sessions,
38 users,
39};
40use axum::{
41 Router,
42 extract::{
43 Query,
44 State,
45 },
46 http::StatusCode,
47 response::{
48 IntoResponse,
49 Redirect,
50 Response,
51 },
52 routing::get,
53};
54use axum_extra::extract::cookie::{
55 Cookie,
56 CookieJar,
57 SameSite,
58};
59use base64::{
60 Engine,
61 engine::general_purpose::URL_SAFE_NO_PAD,
62};
63use maud::{
64 Markup,
65 html,
66};
67use ring::{
68 digest,
69 rand::{
70 SecureRandom,
71 SystemRandom,
72 },
73 signature,
74};
75use serde::Deserialize;
76
77use crate::{
78 auth::session_cookie,
79 ui::layout,
80};
81
82/// Where the in-flight login's state, nonce and PKCE verifier live between the
83/// redirect out and the redirect back. Scoped to `/-/oidc` so it rides along on
84/// the callback and nothing else.
85const PENDING_COOKIE: &str = "anvil_oidc";
86
87/// How long a login has to complete. Only has to survive one round trip.
88const PENDING_TTL_SECS: i64 = 600;
89
90/// Clock skew tolerated when checking an id token's `exp`.
91const CLOCK_SKEW_SECS: i64 = 60;
92
93pub fn routes(router: Router<App>) -> Router<App> {
94 router
95 .route("/-/oidc/login", get(login))
96 .route(OIDC_CALLBACK_PATH, get(callback))
97}
98
99// --- the provider's metadata ------------------------------------------------
100
101/// The subset of the discovery document we act on.
102#[derive(Clone, Debug, Deserialize)]
103struct Discovery {
104 issuer: String,
105 authorization_endpoint: String,
106 token_endpoint: String,
107 jwks_uri: String,
108 #[serde(default)]
109 end_session_endpoint: String,
110}
111
112/// One RSA signing key from the provider's JWKS.
113#[derive(Clone, Debug, Deserialize)]
114struct Jwk {
115 #[serde(default)]
116 kty: String,
117 #[serde(default)]
118 kid: String,
119 /// Base64url big-endian modulus.
120 #[serde(default)]
121 n: String,
122 /// Base64url big-endian public exponent.
123 #[serde(default)]
124 e: String,
125}
126
127/// Discovery documents, keyed by issuer. Endpoints do not move under a running
128/// server, so this is a process-lifetime cache: a provider that relocates one
129/// wants a restart here anyway.
130static DISCOVERY: OnceLock<Mutex<HashMap<String, Discovery>>> = OnceLock::new();
131
132/// Signing keys, keyed by `jwks_uri`. Refetched when a token arrives under a
133/// `kid` we have not seen, which is how a key rotation propagates.
134static JWKS: OnceLock<Mutex<HashMap<String, Vec<Jwk>>>> = OnceLock::new();
135
136fn cache<T: 'static>(
137 slot: &'static OnceLock<Mutex<HashMap<String, T>>>,
138) -> &'static Mutex<HashMap<String, T>> {
139 slot.get_or_init(|| Mutex::new(HashMap::new()))
140}
141
142/// The HTTP client for back-channel calls. Native (system) roots so a
143/// `.localhost` provider fronted by portless's CA is trusted without extra
144/// configuration; the ring provider because that is what this workspace builds
145/// rustls with (see the manifest).
146fn http() -> reqwest::Client {
147 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
148 CLIENT
149 .get_or_init(|| {
150 let _ = rustls::crypto::ring::default_provider().install_default();
151 reqwest::Client::builder()
152 .timeout(std::time::Duration::from_secs(15))
153 .build()
154 .expect("client with default settings builds")
155 })
156 .clone()
157}
158
159/// Fetch (once) the provider's discovery document.
160///
161/// The document's own `issuer` must equal the one we were configured with —
162/// otherwise a redirect or a DNS takeover could point us at somebody else's
163/// tokens while every later `iss` check still passed.
164async fn discovery(issuer: &str) -> Result<Discovery, OidcError> {
165 if let Some(hit) = cache(&DISCOVERY).lock().unwrap().get(issuer).cloned() {
166 return Ok(hit);
167 }
168 let url = format!("{issuer}/.well-known/openid-configuration");
169 let doc: Discovery = http()
170 .get(&url)
171 .send()
172 .await
173 .and_then(|r| r.error_for_status())
174 .map_err(|e| OidcError::provider(format!("could not reach {url}: {e}")))?
175 .json()
176 .await
177 .map_err(|e| OidcError::provider(format!("{url} is not a discovery document: {e}")))?;
178
179 if doc.issuer.trim_end_matches('/') != issuer {
180 return Err(OidcError::provider(format!(
181 "{url} claims to be {}, not {issuer}",
182 doc.issuer
183 )));
184 }
185 cache(&DISCOVERY)
186 .lock()
187 .unwrap()
188 .insert(issuer.to_string(), doc.clone());
189 Ok(doc)
190}
191
192/// The provider's signing keys. `refresh` skips the cache, which is what a
193/// token under an unknown `kid` asks for.
194async fn jwks(uri: &str, refresh: bool) -> Result<Vec<Jwk>, OidcError> {
195 if !refresh && let Some(hit) = cache(&JWKS).lock().unwrap().get(uri).cloned() {
196 return Ok(hit);
197 }
198 #[derive(Deserialize)]
199 struct KeySet {
200 keys: Vec<Jwk>,
201 }
202 let set: KeySet = http()
203 .get(uri)
204 .send()
205 .await
206 .and_then(|r| r.error_for_status())
207 .map_err(|e| OidcError::provider(format!("could not reach {uri}: {e}")))?
208 .json()
209 .await
210 .map_err(|e| OidcError::provider(format!("{uri} is not a JWK set: {e}")))?;
211 cache(&JWKS)
212 .lock()
213 .unwrap()
214 .insert(uri.to_string(), set.keys.clone());
215 Ok(set.keys)
216}
217
218// --- the in-flight login ----------------------------------------------------
219
220/// What the callback needs to remember from the request that started it.
221#[derive(Debug, Deserialize, serde::Serialize)]
222struct Pending {
223 state: String,
224 nonce: String,
225 verifier: String,
226 next: String,
227 expires_at: i64,
228}
229
230impl Pending {
231 /// Serialize for the cookie. Base64 rather than raw JSON: cookie values
232 /// have their own grammar, and this sidesteps every quoting question.
233 fn encode(&self) -> String {
234 URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).expect("Pending serializes"))
235 }
236
237 fn decode(raw: &str) -> Option<Self> {
238 let bytes = URL_SAFE_NO_PAD.decode(raw).ok()?;
239 serde_json::from_slice(&bytes).ok()
240 }
241}
242
243/// A cookie carrying (or, when `value` is empty, clearing) the pending login.
244fn pending_cookie(app: &App, value: String) -> Cookie<'static> {
245 let max_age = if value.is_empty() {
246 time::Duration::ZERO
247 } else {
248 time::Duration::seconds(PENDING_TTL_SECS)
249 };
250 Cookie::build((PENDING_COOKIE, value))
251 // Not `/`: the callback is the only route that ever reads this.
252 .path("/-/oidc")
253 .http_only(true)
254 .secure(app.config.secure_cookies())
255 // Strict would be withheld on the redirect back from the provider,
256 // which is precisely the hop this exists for.
257 .same_site(SameSite::Lax)
258 .max_age(max_age)
259 .build()
260}
261
262/// `n` random bytes, base64url. Used for `state`, `nonce`, and the PKCE
263/// verifier — all of which only need to be unguessable.
264fn random_token(n: usize) -> String {
265 let mut bytes = vec![0u8; n];
266 SystemRandom::new()
267 .fill(&mut bytes)
268 .expect("the system RNG works");
269 URL_SAFE_NO_PAD.encode(bytes)
270}
271
272/// Where to go after a successful login. Only a path on this site is allowed:
273/// `next` arrives as a query parameter, so anything absolute would make the
274/// callback an open redirect.
275fn safe_next(raw: Option<&str>) -> String {
276 let raw = raw.unwrap_or("/");
277 // A leading `//` or `/\` is protocol-relative and leaves the site.
278 let relative = raw.starts_with('/') && !raw[1..].starts_with(['/', '\\']);
279 if relative {
280 raw.to_string()
281 } else {
282 "/".to_string()
283 }
284}
285
286fn now() -> i64 {
287 std::time::SystemTime::now()
288 .duration_since(std::time::UNIX_EPOCH)
289 .map(|d| d.as_secs() as i64)
290 .unwrap_or_default()
291}
292
293// --- handlers ---------------------------------------------------------------
294
295#[derive(Deserialize)]
296struct LoginQuery {
297 next: Option<String>,
298}
299
300/// `GET /-/oidc/login` — send the browser to the provider.
301async fn login(State(app): State<App>, jar: CookieJar, Query(q): Query<LoginQuery>) -> Response {
302 let cfg = &app.config.oidc;
303 if !cfg.enabled() {
304 return OidcError::disabled().into_response();
305 }
306 let disco = match discovery(cfg.issuer()).await {
307 Ok(d) => d,
308 Err(e) => return e.into_response(),
309 };
310
311 let verifier = random_token(32);
312 let pending = Pending {
313 state: random_token(16),
314 nonce: random_token(16),
315 // PKCE S256: the provider stores this hash with the code and only
316 // honours an exchange that presents the preimage, so a code stolen in
317 // transit is not redeemable.
318 verifier: verifier.clone(),
319 next: safe_next(q.next.as_deref()),
320 expires_at: now() + PENDING_TTL_SECS,
321 };
322 let challenge = URL_SAFE_NO_PAD.encode(digest::digest(&digest::SHA256, verifier.as_bytes()));
323
324 let mut url = match reqwest::Url::parse(&disco.authorization_endpoint) {
325 Ok(url) => url,
326 Err(e) => {
327 return OidcError::provider(format!("bad authorization endpoint: {e}")).into_response();
328 }
329 };
330 url.query_pairs_mut()
331 .append_pair("response_type", "code")
332 .append_pair("client_id", &cfg.client_id)
333 .append_pair("redirect_uri", &app.config.oidc_redirect_uri())
334 .append_pair("scope", "openid profile email")
335 .append_pair("state", &pending.state)
336 .append_pair("nonce", &pending.nonce)
337 .append_pair("code_challenge", &challenge)
338 .append_pair("code_challenge_method", "S256");
339
340 (
341 jar.add(pending_cookie(&app, pending.encode())),
342 Redirect::to(url.as_str()),
343 )
344 .into_response()
345}
346
347#[derive(Deserialize)]
348struct CallbackQuery {
349 code: Option<String>,
350 state: Option<String>,
351 error: Option<String>,
352 error_description: Option<String>,
353}
354
355/// `GET /-/oidc/callback` — finish the flow and sign the user in.
356async fn callback(
357 State(app): State<App>,
358 jar: CookieJar,
359 Query(q): Query<CallbackQuery>,
360) -> Response {
361 let cfg = &app.config.oidc;
362 if !cfg.enabled() {
363 return OidcError::disabled().into_response();
364 }
365 // Read it before clearing it: adding the removal cookie replaces the entry
366 // in the jar, and the value would be gone by the time we looked.
367 let pending = jar
368 .get(PENDING_COOKIE)
369 .and_then(|c| Pending::decode(c.value()));
370 // Whatever happens next, this login is over.
371 let jar = jar.add(pending_cookie(&app, String::new()));
372
373 if let Some(error) = q.error {
374 let detail = q.error_description.unwrap_or_else(|| error.clone());
375 let status = match error.as_str() {
376 "access_denied" => StatusCode::FORBIDDEN,
377 _ => StatusCode::BAD_REQUEST,
378 };
379 return (jar, OidcError::new(status, detail)).into_response();
380 }
381
382 let pending = match pending {
383 Some(p) if p.expires_at > now() => p,
384 _ => {
385 return (
386 jar,
387 OidcError::new(
388 StatusCode::BAD_REQUEST,
389 "This sign-in took too long, or was started in another browser. Try again.",
390 ),
391 )
392 .into_response();
393 }
394 };
395
396 // Binds the response to the request we started. Constant-time because the
397 // state is the one secret in the callback URL.
398 if !crate::auth::constant_time_eq(
399 pending.state.as_bytes(),
400 q.state.unwrap_or_default().as_bytes(),
401 ) {
402 return (
403 jar,
404 OidcError::new(StatusCode::BAD_REQUEST, "The sign-in state did not match."),
405 )
406 .into_response();
407 }
408 let Some(code) = q.code.filter(|c| !c.is_empty()) else {
409 return (
410 jar,
411 OidcError::new(StatusCode::BAD_REQUEST, "The provider returned no code."),
412 )
413 .into_response();
414 };
415
416 let claims = match exchange(&app, &code, &pending).await {
417 Ok(claims) => claims,
418 Err(e) => return (jar, e).into_response(),
419 };
420 let user = match resolve_local_user(&app, &claims).await {
421 Ok(user) => user,
422 Err(e) => return (jar, e).into_response(),
423 };
424
425 match sessions::create(&app.db, user.id).await {
426 Ok(session) => (
427 jar.add(session_cookie(&app, session.token)),
428 Redirect::to(&pending.next),
429 )
430 .into_response(),
431 Err(e) => {
432 tracing::error!("session create failed after sso login: {e}");
433 (jar, OidcError::provider("Could not start a session.")).into_response()
434 }
435 }
436}
437
438/// Trade the authorization code for an id token, and verify it.
439async fn exchange(app: &App, code: &str, pending: &Pending) -> Result<Claims, OidcError> {
440 let cfg = &app.config.oidc;
441 let disco = discovery(cfg.issuer()).await?;
442 let redirect_uri = app.config.oidc_redirect_uri();
443
444 let mut form = vec![
445 ("grant_type", "authorization_code"),
446 ("code", code),
447 ("redirect_uri", redirect_uri.as_str()),
448 ("code_verifier", pending.verifier.as_str()),
449 ("client_id", cfg.client_id.as_str()),
450 ];
451 if !cfg.client_secret.is_empty() {
452 form.push(("client_secret", cfg.client_secret.as_str()));
453 }
454
455 let response = http()
456 .post(&disco.token_endpoint)
457 .form(&form)
458 .send()
459 .await
460 .map_err(|e| OidcError::provider(format!("token endpoint unreachable: {e}")))?;
461
462 let status = response.status();
463 let body = response.text().await.unwrap_or_default();
464 if !status.is_success() {
465 // The body is the provider's own `{error, error_description}`; report
466 // the description when there is one, the status otherwise.
467 let detail = serde_json::from_str::<serde_json::Value>(&body)
468 .ok()
469 .and_then(|v| {
470 v.get("error_description")
471 .or_else(|| v.get("error"))
472 .and_then(|d| d.as_str().map(str::to_string))
473 })
474 .unwrap_or_else(|| format!("token endpoint returned {status}"));
475 return Err(OidcError::new(StatusCode::BAD_GATEWAY, detail));
476 }
477
478 #[derive(Deserialize)]
479 struct Tokens {
480 id_token: String,
481 }
482 let tokens: Tokens = serde_json::from_str(&body)
483 .map_err(|e| OidcError::provider(format!("token response has no id_token: {e}")))?;
484
485 verify_id_token(&tokens.id_token, cfg, &disco, &pending.nonce).await
486}
487
488// --- id token verification --------------------------------------------------
489
490/// The claims anvil acts on. `sub` identifies the account; `role` is this
491/// user's role *for this app*, as granted at the provider.
492#[derive(Debug, Deserialize)]
493struct Claims {
494 sub: String,
495 iss: String,
496 aud: serde_json::Value,
497 exp: i64,
498 #[serde(default)]
499 nonce: String,
500 #[serde(default)]
501 email: String,
502 #[serde(default)]
503 email_verified: bool,
504 #[serde(default)]
505 preferred_username: String,
506 #[serde(default)]
507 role: String,
508}
509
510impl Claims {
511 /// Whether the provider says this account administers anvil. `None` when
512 /// it says nothing, which leaves the local flag alone.
513 fn is_admin(&self) -> Option<bool> {
514 (!self.role.is_empty()).then(|| self.role == "admin")
515 }
516}
517
518#[derive(Deserialize)]
519struct JwtHeader {
520 alg: String,
521 #[serde(default)]
522 kid: String,
523}
524
525/// Verify an id token's signature and every claim that binds it to *this*
526/// login: the issuer, the audience, its expiry, and the nonce we generated.
527async fn verify_id_token(
528 token: &str,
529 cfg: &OidcConfig,
530 disco: &Discovery,
531 nonce: &str,
532) -> Result<Claims, OidcError> {
533 let bad = |msg: &str| OidcError::new(StatusCode::BAD_GATEWAY, format!("id token {msg}"));
534
535 let mut parts = token.split('.');
536 let (Some(header_b64), Some(payload_b64), Some(sig_b64), None) =
537 (parts.next(), parts.next(), parts.next(), parts.next())
538 else {
539 return Err(bad("is not a three-part JWS"));
540 };
541 let decode = |part: &str| {
542 URL_SAFE_NO_PAD
543 .decode(part)
544 .map_err(|_| bad("is not base64url"))
545 };
546 let header: JwtHeader = serde_json::from_slice(&decode(header_b64)?)
547 .map_err(|_| bad("has an unreadable header"))?;
548 // RS256 only. Accepting whatever `alg` says is how `none` and
549 // algorithm-confusion attacks get in; the provider signs RS256 and that is
550 // the only thing we verify.
551 if header.alg != "RS256" {
552 return Err(bad(&format!("is signed with {}, not RS256", header.alg)));
553 }
554
555 let signing_input = format!("{header_b64}.{payload_b64}");
556 let signature_bytes = decode(sig_b64)?;
557 let mut keys = jwks(&disco.jwks_uri, false).await?;
558 if !keys.iter().any(|k| matches(k, &header.kid)) {
559 // An unknown key id means a rotation since we last looked.
560 keys = jwks(&disco.jwks_uri, true).await?;
561 }
562 let key = keys
563 .iter()
564 .find(|k| matches(k, &header.kid))
565 .ok_or_else(|| bad("was signed by a key the provider does not publish"))?;
566
567 let n = URL_SAFE_NO_PAD
568 .decode(&key.n)
569 .map_err(|_| bad("key modulus is not base64url"))?;
570 let e = URL_SAFE_NO_PAD
571 .decode(&key.e)
572 .map_err(|_| bad("key exponent is not base64url"))?;
573 signature::RsaPublicKeyComponents { n: &n, e: &e }
574 .verify(
575 &signature::RSA_PKCS1_2048_8192_SHA256,
576 signing_input.as_bytes(),
577 &signature_bytes,
578 )
579 .map_err(|_| bad("signature does not verify"))?;
580
581 let claims: Claims =
582 serde_json::from_slice(&decode(payload_b64)?).map_err(|_| bad("has unreadable claims"))?;
583
584 if claims.iss.trim_end_matches('/') != cfg.issuer() {
585 return Err(bad("came from a different issuer"));
586 }
587 let audience_matches = match &claims.aud {
588 serde_json::Value::String(one) => one == &cfg.client_id,
589 serde_json::Value::Array(many) => many.iter().any(|a| a.as_str() == Some(&cfg.client_id)),
590 _ => false,
591 };
592 if !audience_matches {
593 return Err(bad("was issued for a different client"));
594 }
595 if claims.exp + CLOCK_SKEW_SECS < now() {
596 return Err(bad("has expired"));
597 }
598 // The nonce is what stops a token captured in one login from being
599 // replayed into another.
600 if !crate::auth::constant_time_eq(claims.nonce.as_bytes(), nonce.as_bytes()) {
601 return Err(bad("nonce does not match this login"));
602 }
603 if claims.sub.is_empty() {
604 return Err(bad("has no subject"));
605 }
606 Ok(claims)
607}
608
609/// Whether a JWK is the one a token's `kid` names. A key set with exactly one
610/// key needs no `kid` on either side to be unambiguous.
611fn matches(key: &Jwk, kid: &str) -> bool {
612 key.kty == "RSA" && (key.kid == kid || (kid.is_empty() && key.kid.is_empty()))
613}
614
615// --- mapping an identity onto a local account -------------------------------
616
617/// Find or create the local account for a verified identity.
618///
619/// The provider has already decided this person may use anvil, so there is no
620/// invite list to consult here — only the question of *which* row is theirs.
621async fn resolve_local_user(app: &App, claims: &Claims) -> Result<User, OidcError> {
622 let db = &app.db;
623 let email = claims.email.trim().to_ascii_lowercase();
624 let failed = |e: anvil_core::Error| OidcError::provider(e.to_string());
625
626 // 1. Seen before. Every login after the first lands here.
627 if let Some(user) = users::find_by_sso_sub(db, &claims.sub)
628 .await
629 .map_err(failed)?
630 {
631 return users::sync_from_sso(db, user.id, &email, claims.is_admin())
632 .await
633 .map_err(failed);
634 }
635
636 // 2. An account that predates single sign-on. Adopt it by email once, and
637 // only on an address the provider says it verified — linking on an
638 // unverified one is how one account takes over another.
639 if let Some(existing) = users::find_by_email(db, &email).await.map_err(failed)? {
640 if !claims.email_verified {
641 return Err(OidcError::new(
642 StatusCode::FORBIDDEN,
643 format!(
644 "An anvil account already exists for {email}, but {} has not verified that \
645 address. Sign in with your password instead.",
646 app.config.oidc.label()
647 ),
648 ));
649 }
650 let user = users::link_sso_sub(db, existing.id, &claims.sub)
651 .await
652 .map_err(failed)?;
653 return users::sync_from_sso(db, user.id, &email, claims.is_admin())
654 .await
655 .map_err(failed);
656 }
657
658 // 3. Brand new.
659 users::create_from_sso(
660 db,
661 &claims.preferred_username,
662 &email,
663 claims.is_admin().unwrap_or(false),
664 &claims.sub,
665 )
666 .await
667 .map_err(failed)
668}
669
670// --- logout -----------------------------------------------------------------
671
672/// Where to send a browser that has just signed out locally, when the account
673/// came from the provider and `sso_logout` is on: the provider's own logout,
674/// which is what makes "sign out" mean everywhere rather than just here.
675///
676/// `None` whenever that does not apply, or the provider advertises no
677/// `end_session_endpoint` — a local sign-out is still a sign-out.
678pub(crate) async fn end_session_url(app: &App, user: Option<&User>) -> Option<String> {
679 let cfg = &app.config.oidc;
680 if !cfg.enabled() || !cfg.sso_logout || user.is_none_or(|u| u.sso_sub.is_empty()) {
681 return None;
682 }
683 let disco = discovery(cfg.issuer()).await.ok()?;
684 if disco.end_session_endpoint.is_empty() {
685 return None;
686 }
687 let mut url = reqwest::Url::parse(&disco.end_session_endpoint).ok()?;
688 // Round-tripped through `Url` so a bare origin carries the trailing slash:
689 // the provider stores the *normalized* form of what was registered and
690 // compares it character for character, and an unmatched URI is ignored —
691 // leaving the user on the provider's page instead of back here.
692 let home = reqwest::Url::parse(&app.config.http.base_url).ok()?;
693 url.query_pairs_mut()
694 .append_pair("client_id", &cfg.client_id)
695 .append_pair("post_logout_redirect_uri", home.as_str());
696 Some(url.to_string())
697}
698
699// --- presentation -----------------------------------------------------------
700
701/// The sign-in button for the login page. Empty when no provider is
702/// configured, which is what keeps the page unchanged for everyone else.
703pub(crate) fn sign_in_button(cfg: &OidcConfig, next: Option<&str>) -> Markup {
704 if !cfg.enabled() {
705 return html! {};
706 }
707 let href = match safe_next(next) {
708 next if next != "/" => format!("/-/oidc/login?next={}", percent_encode(&next)),
709 _ => "/-/oidc/login".to_string(),
710 };
711 html! {
712 div style="max-width:320px" {
713 a.btn href=(href) style="display:block;text-align:center" {
714 "Sign in with " (cfg.label())
715 }
716 p.muted style="margin:16px 0 4px;font-size:12px" { "or use an anvil password" }
717 }
718 }
719}
720
721/// Percent-encode a path for use in a query parameter, keeping `/` readable.
722/// `reqwest::Url` would want a base URL we do not have here.
723fn percent_encode(path: &str) -> String {
724 path.bytes()
725 .map(|b| match b {
726 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
727 (b as char).to_string()
728 }
729 _ => format!("%{b:02X}"),
730 })
731 .collect()
732}
733
734/// A failed sign-in, rendered as a page rather than swallowed: every one of
735/// these is either a misconfiguration or an attack, and both want saying out
736/// loud.
737pub(crate) struct OidcError {
738 status: StatusCode,
739 message: String,
740}
741
742impl OidcError {
743 fn new(status: StatusCode, message: impl Into<String>) -> Self {
744 Self {
745 status,
746 message: message.into(),
747 }
748 }
749
750 /// The provider is unreachable, misconfigured, or answering nonsense.
751 fn provider(message: impl Into<String>) -> Self {
752 Self::new(StatusCode::BAD_GATEWAY, message)
753 }
754
755 fn disabled() -> Self {
756 Self::new(
757 StatusCode::NOT_FOUND,
758 "Single sign-on is not configured on this instance.",
759 )
760 }
761}
762
763impl IntoResponse for OidcError {
764 fn into_response(self) -> Response {
765 tracing::warn!("sso sign-in failed: {}", self.message);
766 (
767 self.status,
768 layout(
769 "Sign-in failed",
770 None,
771 html! {
772 h1 { "Sign-in failed" }
773 p.error-msg { (self.message) }
774 p { a href="/-/login" { "Back to sign in" } }
775 },
776 ),
777 )
778 .into_response()
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785
786 #[test]
787 fn next_must_stay_on_this_site() {
788 assert_eq!(safe_next(Some("/collin/anvil")), "/collin/anvil");
789 assert_eq!(safe_next(None), "/");
790 assert_eq!(safe_next(Some("")), "/");
791 // Protocol-relative and absolute URLs are the open redirect this guards.
792 assert_eq!(safe_next(Some("//evil.example")), "/");
793 assert_eq!(safe_next(Some("/\\evil.example")), "/");
794 assert_eq!(safe_next(Some("https://evil.example")), "/");
795 }
796
797 #[test]
798 fn a_pending_login_survives_the_cookie_round_trip() {
799 let pending = Pending {
800 state: random_token(16),
801 nonce: random_token(16),
802 verifier: random_token(32),
803 next: "/collin/anvil".into(),
804 expires_at: 1234,
805 };
806 let decoded = Pending::decode(&pending.encode()).expect("round trips");
807 assert_eq!(decoded.state, pending.state);
808 assert_eq!(decoded.verifier, pending.verifier);
809 assert_eq!(decoded.next, "/collin/anvil");
810 assert!(Pending::decode("not base64").is_none());
811 }
812
813 /// The sign-in button is the one thing an unconfigured instance must not
814 /// grow, and a `next` on it must survive into the query string.
815 #[test]
816 fn the_sign_in_button_appears_only_when_configured() {
817 let off = OidcConfig::default();
818 assert_eq!(sign_in_button(&off, None).into_string(), "");
819
820 let on = OidcConfig {
821 issuer: "https://login.localhost".into(),
822 ..OidcConfig::default()
823 };
824 let markup = sign_in_button(&on, Some("/collin/anvil?tab=ci")).into_string();
825 assert!(markup.contains("Sign in with login.localhost"), "{markup}");
826 assert!(
827 markup.contains("/-/oidc/login?next=/collin/anvil%3Ftab%3Dci"),
828 "{markup}"
829 );
830 // An absolute `next` is dropped rather than carried into the redirect.
831 let markup = sign_in_button(&on, Some("https://evil.example")).into_string();
832 assert!(markup.contains(r#"href="/-/oidc/login""#), "{markup}");
833 }
834}