clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

main
devsite / crates / devsite-server / src / oidc.rs
oidc.rs 18.8K
1//! OIDC authorization-code + PKCE login adapter.2//!3//! Shoo is the zero-configuration default, not an application dependency. Another public4//! OIDC client can be selected entirely with `DEVSITE_OIDC_*` settings. This module ends5//! at `ExternalIdentity`; account and session policy remain in the application port.67use std::collections::HashMap;8use std::str::FromStr;9use std::sync::{Arc, Mutex, RwLock};10use std::time::{Duration, Instant};1112use anyhow::{bail, Context, Result};13use axum::extract::{Query, State};14use axum::http::{header, HeaderMap, HeaderValue, StatusCode};15use axum::response::{Html, IntoResponse, Redirect, Response};16use axum::routing::get;17use axum::Router;18use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, JwkSet, KeyOperations, PublicKeyUse};19use jsonwebtoken::{Algorithm, DecodingKey, Validation};20use serde::Deserialize;21use sha2::{Digest, Sha256};22use url::Url;2324use crate::api::{self, Shared};25use crate::auth::{self, ExternalIdentity};2627const SHOO_ISSUER: &str = "https://shoo.dev";28const LOGIN_ATTEMPT_TTL_SECS: u64 = 10 * 60;29const MAX_PENDING_LOGINS: usize = 4096;30const JWKS_TTL: Duration = Duration::from_secs(10 * 60);3132pub struct OidcConfig {33 issuer: String,34 authorization_endpoint: Url,35 token_endpoint: Url,36 jwks_uri: Url,37 client_id: String,38 scopes: String,39 algorithms: Vec<Algorithm>,40 callback_uri: String,41}4243impl OidcConfig {44 pub fn from_env(public_origin: &str) -> Result<Self> {45 let issuer = env_or("DEVSITE_OIDC_ISSUER", SHOO_ISSUER);46 let authorization_endpoint = endpoint_from_env(47 "DEVSITE_OIDC_AUTHORIZATION_ENDPOINT",48 &format!("{}/authorize", issuer.trim_end_matches('/')),49 )?;50 let token_endpoint = endpoint_from_env(51 "DEVSITE_OIDC_TOKEN_ENDPOINT",52 &format!("{}/token", issuer.trim_end_matches('/')),53 )?;54 let jwks_uri = endpoint_from_env(55 "DEVSITE_OIDC_JWKS_URI",56 &format!("{}/.well-known/jwks.json", issuer.trim_end_matches('/')),57 )?;58 let client_id = std::env::var("DEVSITE_OIDC_CLIENT_ID")59 .ok()60 .filter(|value| !value.trim().is_empty())61 .unwrap_or_else(|| format!("origin:{public_origin}"));62 let scopes = env_or("DEVSITE_OIDC_SCOPES", "openid");63 if !scopes.split_whitespace().any(|scope| scope == "openid") {64 bail!("DEVSITE_OIDC_SCOPES must include openid");65 }66 // Keep Shoo's existing ES256 pin as the safe default. A different provider must67 // explicitly name its asymmetric signing algorithm.68 let algorithms = parse_algorithms(&env_or("DEVSITE_OIDC_ALGORITHMS", "ES256"))?;6970 let issuer_url = Url::parse(&issuer).context("DEVSITE_OIDC_ISSUER is not a URL")?;71 validate_provider_url("DEVSITE_OIDC_ISSUER", &issuer_url)?;7273 Ok(Self {74 issuer,75 authorization_endpoint,76 token_endpoint,77 jwks_uri,78 client_id,79 scopes,80 algorithms,81 callback_uri: format!("{}/auth/callback", public_origin.trim_end_matches('/')),82 })83 }8485 pub fn issuer(&self) -> &str {86 &self.issuer87 }8889 pub fn client_id(&self) -> &str {90 &self.client_id91 }92}9394fn env_or(name: &str, default: &str) -> String {95 std::env::var(name)96 .ok()97 .filter(|value| !value.trim().is_empty())98 .unwrap_or_else(|| default.to_string())99}100101fn endpoint_from_env(name: &str, default: &str) -> Result<Url> {102 let value = env_or(name, default);103 let url = Url::parse(&value).with_context(|| format!("{name} is not a URL"))?;104 validate_provider_url(name, &url)?;105 Ok(url)106}107108fn validate_provider_url(name: &str, url: &Url) -> Result<()> {109 let loopback = url.host_str().is_some_and(|host| {110 host == "localhost"111 || host112 .parse::<std::net::IpAddr>()113 .is_ok_and(|ip| ip.is_loopback())114 });115 if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {116 bail!("{name} must use HTTPS (HTTP is allowed only for loopback development)");117 }118 Ok(())119}120121fn parse_algorithms(value: &str) -> Result<Vec<Algorithm>> {122 let mut algorithms = Vec::new();123 for name in value124 .split(',')125 .map(str::trim)126 .filter(|name| !name.is_empty())127 {128 let algorithm = Algorithm::from_str(name)129 .with_context(|| format!("unsupported OIDC signing algorithm {name}"))?;130 if matches!(131 algorithm,132 Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512133 ) {134 bail!("symmetric OIDC signing algorithm {name} is not accepted");135 }136 if !algorithms.contains(&algorithm) {137 algorithms.push(algorithm);138 }139 }140 if algorithms.is_empty() {141 bail!("DEVSITE_OIDC_ALGORITHMS must name at least one asymmetric algorithm");142 }143 Ok(algorithms)144}145146struct PendingLogin {147 verifier: String,148 return_to: String,149 expires_at: u64,150}151152struct OidcAdapter {153 config: OidcConfig,154 http: reqwest::Client,155 keys: RwLock<Option<(JwkSet, Instant)>>,156}157158impl OidcAdapter {159 fn new(config: OidcConfig) -> Result<Self> {160 Ok(Self {161 config,162 http: reqwest::Client::builder()163 .connect_timeout(Duration::from_secs(5))164 .timeout(Duration::from_secs(15))165 .build()166 .context("building OIDC HTTP client")?,167 keys: RwLock::new(None),168 })169 }170171 fn authorization_url(&self, state: &str, challenge: &str) -> Url {172 let mut url = self.config.authorization_endpoint.clone();173 url.query_pairs_mut()174 .append_pair("response_type", "code")175 .append_pair("client_id", &self.config.client_id)176 .append_pair("redirect_uri", &self.config.callback_uri)177 .append_pair("scope", &self.config.scopes)178 .append_pair("state", state)179 .append_pair("code_challenge", challenge)180 .append_pair("code_challenge_method", "S256");181 url182 }183184 async fn authenticate(&self, code: &str, verifier: &str) -> Result<ExternalIdentity> {185 let form = {186 let mut serializer = url::form_urlencoded::Serializer::new(String::new());187 serializer188 .append_pair("grant_type", "authorization_code")189 .append_pair("code", code)190 .append_pair("redirect_uri", &self.config.callback_uri)191 .append_pair("client_id", &self.config.client_id)192 .append_pair("code_verifier", verifier);193 serializer.finish()194 };195196 let response = self197 .http198 .post(self.config.token_endpoint.clone())199 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")200 .body(form)201 .send()202 .await203 .context("exchanging the OIDC authorization code")?204 .error_for_status()205 .context("the OIDC token endpoint rejected the authorization code")?206 .json::<TokenResponse>()207 .await208 .context("parsing the OIDC token response")?;209210 self.verify(&response.id_token).await211 }212213 async fn keys(&self) -> Result<JwkSet> {214 if let Some((keys, fetched)) = self.keys.read().unwrap().as_ref() {215 if fetched.elapsed() < JWKS_TTL {216 return Ok(keys.clone());217 }218 }219220 let keys = self221 .http222 .get(self.config.jwks_uri.clone())223 .send()224 .await225 .context("fetching OIDC JWKS")?226 .error_for_status()227 .context("OIDC JWKS endpoint returned an error status")?228 .json::<JwkSet>()229 .await230 .context("parsing OIDC JWKS")?;231 *self.keys.write().unwrap() = Some((keys.clone(), Instant::now()));232 Ok(keys)233 }234235 async fn verify(&self, id_token: &str) -> Result<ExternalIdentity> {236 let token_header =237 jsonwebtoken::decode_header(id_token).context("unreadable token header")?;238 if !self.config.algorithms.contains(&token_header.alg) {239 bail!("unexpected token algorithm {:?}", token_header.alg);240 }241 let kid = token_header.kid.context("token has no key id")?;242 let keys = self.keys().await?;243 let jwk = keys244 .find(&kid)245 .with_context(|| format!("no OIDC key matches kid {kid}"))?;246 validate_jwk(jwk, token_header.alg)?;247 let key = DecodingKey::from_jwk(jwk).context("OIDC key is not usable")?;248249 let mut validation = Validation::new(token_header.alg);250 validation.set_issuer(&[&self.config.issuer]);251 validation.set_audience(&[&self.config.client_id]);252 validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);253 let data = jsonwebtoken::decode::<IdTokenClaims>(id_token, &key, &validation)254 .context("id_token failed verification")?;255256 // Keep the two identity-defining checks explicit so a future Validation change257 // cannot silently widen which identities this adapter accepts.258 if data.claims.iss != self.config.issuer {259 bail!("unexpected token issuer {}", data.claims.iss);260 }261 if !data.claims.aud.contains(&self.config.client_id) {262 bail!("token was issued for a different client");263 }264 if data.claims.sub.is_empty() {265 bail!("token subject is empty");266 }267 if data.claims.sub.len() > 2048 {268 bail!("token subject is unreasonably large");269 }270271 Ok(ExternalIdentity {272 namespace: data.claims.iss,273 subject: data.claims.sub,274 })275 }276}277278fn validate_jwk(jwk: &Jwk, algorithm: Algorithm) -> Result<()> {279 if jwk280 .common281 .public_key_use282 .as_ref()283 .is_some_and(|key_use| key_use != &PublicKeyUse::Signature)284 {285 bail!("OIDC key is not marked for signatures");286 }287 if jwk288 .common289 .key_operations290 .as_ref()291 .is_some_and(|operations| !operations.contains(&KeyOperations::Verify))292 {293 bail!("OIDC key is not permitted to verify signatures");294 }295 if matches!(jwk.algorithm, AlgorithmParameters::OctetKey(_)) {296 bail!("symmetric OIDC keys are not accepted");297 }298 if jwk299 .common300 .key_algorithm301 .is_some_and(|declared| declared.to_string() != format!("{algorithm:?}"))302 {303 bail!("OIDC key algorithm does not match the token header");304 }305 Ok(())306}307308#[derive(Deserialize)]309struct TokenResponse {310 id_token: String,311}312313#[derive(Debug, Deserialize)]314struct IdTokenClaims {315 sub: String,316 iss: String,317 aud: Audience,318 // Its presence keeps expiry validation required even though the application does not319 // otherwise use it directly.320 #[allow(dead_code)]321 exp: u64,322}323324#[derive(Debug, Deserialize)]325#[serde(untagged)]326enum Audience {327 One(String),328 Many(Vec<String>),329}330331impl Audience {332 fn contains(&self, expected: &str) -> bool {333 match self {334 Self::One(value) => value == expected,335 Self::Many(values) => values.iter().any(|value| value == expected),336 }337 }338}339340struct OidcState {341 app: Shared,342 adapter: OidcAdapter,343 pending: Mutex<HashMap<String, PendingLogin>>,344}345346pub fn router(app: Shared, config: OidcConfig) -> Result<Router> {347 let state = Arc::new(OidcState {348 app,349 adapter: OidcAdapter::new(config)?,350 pending: Mutex::new(HashMap::new()),351 });352 Ok(Router::new()353 .route("/auth/start", get(start))354 .route("/auth/callback", get(callback))355 .with_state(state))356}357358#[derive(Deserialize)]359struct StartQuery {360 return_to: Option<String>,361}362363async fn start(State(state): State<Arc<OidcState>>, Query(query): Query<StartQuery>) -> Response {364 let now = api::now_secs();365 let verifier = auth::generate_session_token();366 let challenge = data_encoding::BASE64URL_NOPAD.encode(&Sha256::digest(verifier.as_bytes()));367 let login_state = auth::generate_session_token();368 let return_to = safe_return_to(query.return_to.as_deref());369370 {371 let mut pending = state.pending.lock().unwrap();372 pending.retain(|_, attempt| attempt.expires_at > now);373 if pending.len() >= MAX_PENDING_LOGINS {374 if let Some(oldest) = pending375 .iter()376 .min_by_key(|(_, attempt)| attempt.expires_at)377 .map(|(key, _)| key.clone())378 {379 pending.remove(&oldest);380 }381 }382 pending.insert(383 login_state.clone(),384 PendingLogin {385 verifier,386 return_to,387 expires_at: now + LOGIN_ATTEMPT_TTL_SECS,388 },389 );390 }391392 let mut response = Redirect::temporary(393 state394 .adapter395 .authorization_url(&login_state, &challenge)396 .as_str(),397 )398 .into_response();399 match HeaderValue::from_str(&login_state_cookie(&state.app, &login_state, false)) {400 Ok(cookie) => {401 response.headers_mut().append(header::SET_COOKIE, cookie);402 response403 }404 Err(err) => {405 tracing::error!("could not create OIDC state cookie: {err}");406 login_failed(StatusCode::INTERNAL_SERVER_ERROR)407 }408 }409}410411#[derive(Deserialize)]412struct CallbackQuery {413 code: Option<String>,414 state: Option<String>,415 error: Option<String>,416}417418async fn callback(419 State(state): State<Arc<OidcState>>,420 headers: HeaderMap,421 Query(query): Query<CallbackQuery>,422) -> Response {423 let mut response = complete_callback(&state, &headers, query).await;424 match HeaderValue::from_str(&login_state_cookie(&state.app, "", true)) {425 Ok(cookie) => {426 response.headers_mut().append(header::SET_COOKIE, cookie);427 response428 }429 Err(err) => {430 tracing::error!("could not clear OIDC state cookie: {err}");431 login_failed(StatusCode::INTERNAL_SERVER_ERROR)432 }433 }434}435436async fn complete_callback(437 state: &OidcState,438 headers: &HeaderMap,439 query: CallbackQuery,440) -> Response {441 let Some(login_state) = query.state.as_deref() else {442 return login_failed(StatusCode::BAD_REQUEST);443 };444 // State in the callback URL is visible to the browser that began the login. Requiring445 // the same value in an HttpOnly cookie prevents an attacker from forwarding their own446 // completed callback URL and logging another browser into the attacker's account.447 if login_state_cookie_value(headers) != Some(login_state) {448 return login_failed(StatusCode::BAD_REQUEST);449 }450 let attempt = {451 let mut pending = state.pending.lock().unwrap();452 pending.remove(login_state)453 };454 let Some(attempt) = attempt else {455 return login_failed(StatusCode::BAD_REQUEST);456 };457 if attempt.expires_at <= api::now_secs() || query.error.is_some() {458 return login_failed(StatusCode::UNAUTHORIZED);459 }460 let Some(code) = query.code.as_deref() else {461 return login_failed(StatusCode::BAD_REQUEST);462 };463464 let identity = match state.adapter.authenticate(code, &attempt.verifier).await {465 Ok(identity) => identity,466 Err(err) => {467 tracing::warn!("OIDC sign-in failed: {err:#}");468 return login_failed(StatusCode::UNAUTHORIZED);469 }470 };471 let session = match api::establish_browser_session(&state.app, identity) {472 Ok(session) => session,473 Err(_) => return login_failed(StatusCode::INTERNAL_SERVER_ERROR),474 };475476 let target = if session.handle.is_some() {477 attempt.return_to478 } else {479 "/".to_string()480 };481 let mut response = Redirect::to(&target).into_response();482 match HeaderValue::from_str(&api::browser_session_cookie(&state.app, &session.token)) {483 Ok(cookie) => {484 response.headers_mut().append(header::SET_COOKIE, cookie);485 response486 }487 Err(err) => {488 tracing::error!("could not create browser session cookie: {err}");489 login_failed(StatusCode::INTERNAL_SERVER_ERROR)490 }491 }492}493494fn login_state_cookie(app: &api::AppState, value: &str, clear: bool) -> String {495 format!(496 "devsite_login_state={value}; Path=/auth/callback; HttpOnly; SameSite=Lax; Max-Age={}{}",497 if clear { 0 } else { LOGIN_ATTEMPT_TTL_SECS },498 if app.public_origin.starts_with("https://") {499 "; Secure"500 } else {501 ""502 }503 )504}505506fn login_state_cookie_value(headers: &HeaderMap) -> Option<&str> {507 headers508 .get(header::COOKIE)?509 .to_str()510 .ok()?511 .split(';')512 .filter_map(|part| part.trim().split_once('='))513 .find(|(name, _)| *name == "devsite_login_state")514 .map(|(_, value)| value)515}516517fn safe_return_to(value: Option<&str>) -> String {518 value519 .filter(|value| {520 value.starts_with('/')521 && !value.starts_with("//")522 && value.bytes().all(|byte| byte.is_ascii_graphic())523 && !value.contains('\\')524 && !value.starts_with("/auth/")525 })526 .unwrap_or("/")527 .to_string()528}529530fn login_failed(status: StatusCode) -> Response {531 (532 status,533 Html(534 "<!doctype html><meta charset=utf-8><title>Sign-in failed</title>\535 <main><h1>Sign-in failed.</h1><p>The login could not be verified.</p>\536 <p><a href=/auth/start>Try again</a> - <a href=/>Return home</a></p></main>",537 ),538 )539 .into_response()540}541542#[cfg(test)]543mod tests {544 use super::*;545546 #[test]547 fn return_paths_cannot_escape_the_site_or_reenter_the_callback() {548 assert_eq!(safe_return_to(Some("/@alice")), "/@alice");549 for unsafe_path in [550 "https://evil.example",551 "//evil.example",552 "/auth/callback",553 "/x\r\n",554 "/x\0",555 "/snowman-☃",556 ] {557 assert_eq!(safe_return_to(Some(unsafe_path)), "/");558 }559 }560561 #[test]562 fn audiences_support_both_oidc_encodings() {563 assert!(Audience::One("client".into()).contains("client"));564 assert!(Audience::Many(vec!["other".into(), "client".into()]).contains("client"));565 assert!(!Audience::Many(vec!["other".into()]).contains("client"));566 }567568 #[test]569 fn symmetric_provider_algorithms_are_rejected() {570 assert_eq!(parse_algorithms("RS256, ES256").unwrap().len(), 2);571 assert!(parse_algorithms("HS256").is_err());572 assert!(parse_algorithms("").is_err());573 }574575 #[test]576 fn callback_state_is_read_only_from_its_bound_cookie() {577 let mut headers = HeaderMap::new();578 headers.insert(579 header::COOKIE,580 HeaderValue::from_static("other=x; devsite_login_state=state-123"),581 );582 assert_eq!(login_state_cookie_value(&headers), Some("state-123"));583 }584}