| 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 | |
| 22 | use std::{ |
| 23 | collections::HashMap, |
| 24 | sync::{ |
| 25 | Mutex, |
| 26 | OnceLock, |
| 27 | }, |
| 28 | }; |
| 29 | |
| 30 | use anvil_core::{ |
| 31 | App, |
| 32 | User, |
| 33 | config::{ |
| 34 | OIDC_CALLBACK_PATH, |
| 35 | OidcConfig, |
| 36 | }, |
| 37 | sessions, |
| 38 | users, |
| 39 | }; |
| 40 | use 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 | }; |
| 54 | use axum_extra::extract::cookie::{ |
| 55 | Cookie, |
| 56 | CookieJar, |
| 57 | SameSite, |
| 58 | }; |
| 59 | use base64::{ |
| 60 | Engine, |
| 61 | engine::general_purpose::URL_SAFE_NO_PAD, |
| 62 | }; |
| 63 | use maud::{ |
| 64 | Markup, |
| 65 | html, |
| 66 | }; |
| 67 | use ring::{ |
| 68 | digest, |
| 69 | rand::{ |
| 70 | SecureRandom, |
| 71 | SystemRandom, |
| 72 | }, |
| 73 | signature, |
| 74 | }; |
| 75 | use serde::Deserialize; |
| 76 | |
| 77 | use 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. |
| 85 | const PENDING_COOKIE: &str = "anvil_oidc"; |
| 86 | |
| 87 | /// How long a login has to complete. Only has to survive one round trip. |
| 88 | const PENDING_TTL_SECS: i64 = 600; |
| 89 | |
| 90 | /// Clock skew tolerated when checking an id token's `exp`. |
| 91 | const CLOCK_SKEW_SECS: i64 = 60; |
| 92 | |
| 93 | pub 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)] |
| 103 | struct 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)] |
| 114 | struct 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. |
| 130 | static 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. |
| 134 | static JWKS: OnceLock<Mutex<HashMap<String, Vec<Jwk>>>> = OnceLock::new(); |
| 135 | |
| 136 | fn 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). |
| 146 | fn 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. |
| 164 | async 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. |
| 194 | async 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)] |
| 222 | struct Pending { |
| 223 | state: String, |
| 224 | nonce: String, |
| 225 | verifier: String, |
| 226 | next: String, |
| 227 | expires_at: i64, |
| 228 | } |
| 229 | |
| 230 | impl 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. |
| 244 | fn 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. |
| 264 | fn 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. |
| 275 | fn 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 | |
| 286 | fn 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)] |
| 296 | struct LoginQuery { |
| 297 | next: Option<String>, |
| 298 | } |
| 299 | |
| 300 | /// `GET /-/oidc/login` — send the browser to the provider. |
| 301 | async 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)] |
| 348 | struct 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. |
| 356 | async 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. |
| 439 | async 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)] |
| 493 | struct 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 | |
| 510 | impl 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)] |
| 519 | struct 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. |
| 527 | async 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. |
| 611 | fn 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. |
| 621 | async 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. |
| 678 | pub(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. |
| 703 | pub(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 | // `hx-boost="false"`, because the layout boosts every link and this |
| 714 | // one must be a *browser* navigation. Boosted, htmx fetches |
| 715 | // `/-/oidc/login` by XHR, follows the redirect to the provider as |
| 716 | // an XHR too, and the provider — rightly — sends no CORS header, so |
| 717 | // the sign-in dies in the console instead of opening. A hand-off to |
| 718 | // another origin is a navigation, never a fetch. |
| 719 | a.btn href=(href) hx-boost="false" style="display:block;text-align:center" { |
| 720 | "Sign in with " (cfg.label()) |
| 721 | } |
| 722 | p.muted style="margin:16px 0 4px;font-size:12px" { "or use an anvil password" } |
| 723 | } |
| 724 | } |
| 725 | } |
| 726 | |
| 727 | /// Percent-encode a path for use in a query parameter, keeping `/` readable. |
| 728 | /// `reqwest::Url` would want a base URL we do not have here. |
| 729 | fn percent_encode(path: &str) -> String { |
| 730 | path.bytes() |
| 731 | .map(|b| match b { |
| 732 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { |
| 733 | (b as char).to_string() |
| 734 | } |
| 735 | _ => format!("%{b:02X}"), |
| 736 | }) |
| 737 | .collect() |
| 738 | } |
| 739 | |
| 740 | /// A failed sign-in, rendered as a page rather than swallowed: every one of |
| 741 | /// these is either a misconfiguration or an attack, and both want saying out |
| 742 | /// loud. |
| 743 | pub(crate) struct OidcError { |
| 744 | status: StatusCode, |
| 745 | message: String, |
| 746 | } |
| 747 | |
| 748 | impl OidcError { |
| 749 | fn new(status: StatusCode, message: impl Into<String>) -> Self { |
| 750 | Self { |
| 751 | status, |
| 752 | message: message.into(), |
| 753 | } |
| 754 | } |
| 755 | |
| 756 | /// The provider is unreachable, misconfigured, or answering nonsense. |
| 757 | fn provider(message: impl Into<String>) -> Self { |
| 758 | Self::new(StatusCode::BAD_GATEWAY, message) |
| 759 | } |
| 760 | |
| 761 | fn disabled() -> Self { |
| 762 | Self::new( |
| 763 | StatusCode::NOT_FOUND, |
| 764 | "Single sign-on is not configured on this instance.", |
| 765 | ) |
| 766 | } |
| 767 | } |
| 768 | |
| 769 | impl IntoResponse for OidcError { |
| 770 | fn into_response(self) -> Response { |
| 771 | tracing::warn!("sso sign-in failed: {}", self.message); |
| 772 | ( |
| 773 | self.status, |
| 774 | layout( |
| 775 | "Sign-in failed", |
| 776 | None, |
| 777 | html! { |
| 778 | h1 { "Sign-in failed" } |
| 779 | p.error-msg { (self.message) } |
| 780 | p { a href="/-/login" { "Back to sign in" } } |
| 781 | }, |
| 782 | ), |
| 783 | ) |
| 784 | .into_response() |
| 785 | } |
| 786 | } |
| 787 | |
| 788 | #[cfg(test)] |
| 789 | mod tests { |
| 790 | use super::*; |
| 791 | |
| 792 | #[test] |
| 793 | fn next_must_stay_on_this_site() { |
| 794 | assert_eq!(safe_next(Some("/collin/anvil")), "/collin/anvil"); |
| 795 | assert_eq!(safe_next(None), "/"); |
| 796 | assert_eq!(safe_next(Some("")), "/"); |
| 797 | // Protocol-relative and absolute URLs are the open redirect this guards. |
| 798 | assert_eq!(safe_next(Some("//evil.example")), "/"); |
| 799 | assert_eq!(safe_next(Some("/\\evil.example")), "/"); |
| 800 | assert_eq!(safe_next(Some("https://evil.example")), "/"); |
| 801 | } |
| 802 | |
| 803 | #[test] |
| 804 | fn a_pending_login_survives_the_cookie_round_trip() { |
| 805 | let pending = Pending { |
| 806 | state: random_token(16), |
| 807 | nonce: random_token(16), |
| 808 | verifier: random_token(32), |
| 809 | next: "/collin/anvil".into(), |
| 810 | expires_at: 1234, |
| 811 | }; |
| 812 | let decoded = Pending::decode(&pending.encode()).expect("round trips"); |
| 813 | assert_eq!(decoded.state, pending.state); |
| 814 | assert_eq!(decoded.verifier, pending.verifier); |
| 815 | assert_eq!(decoded.next, "/collin/anvil"); |
| 816 | assert!(Pending::decode("not base64").is_none()); |
| 817 | } |
| 818 | |
| 819 | /// The sign-in button is the one thing an unconfigured instance must not |
| 820 | /// grow, and a `next` on it must survive into the query string. |
| 821 | #[test] |
| 822 | fn the_sign_in_button_appears_only_when_configured() { |
| 823 | let off = OidcConfig::default(); |
| 824 | assert_eq!(sign_in_button(&off, None).into_string(), ""); |
| 825 | |
| 826 | let on = OidcConfig { |
| 827 | issuer: "https://login.localhost".into(), |
| 828 | ..OidcConfig::default() |
| 829 | }; |
| 830 | let markup = sign_in_button(&on, Some("/collin/anvil?tab=ci")).into_string(); |
| 831 | assert!(markup.contains("Sign in with login.localhost"), "{markup}"); |
| 832 | // Without this the layout's `hx-boost` fetches the hand-off by XHR and |
| 833 | // it dies on the provider's CORS policy — a failure nothing but a |
| 834 | // browser console reveals. |
| 835 | assert!(markup.contains(r#"hx-boost="false""#), "{markup}"); |
| 836 | assert!( |
| 837 | markup.contains("/-/oidc/login?next=/collin/anvil%3Ftab%3Dci"), |
| 838 | "{markup}" |
| 839 | ); |
| 840 | // An absolute `next` is dropped rather than carried into the redirect. |
| 841 | let markup = sign_in_button(&on, Some("https://evil.example")).into_string(); |
| 842 | assert!(markup.contains(r#"href="/-/oidc/login""#), "{markup}"); |
| 843 | } |
| 844 | } |