a (hacky, wip) multi-tenant oidc-terminating reverse proxy, written in anger on top of pingora
at main 596 lines 23 kB view raw
1//! # Background 2//! 3//! so! there exists oauth & oidc packages. they're what kanidm uses to implement oidc. 4//! unfortunately, they're poorly maintained on the client side (e.g. their reqwest bindings don't 5//! work with the latest version of reqwest), and rather clunkily implemented through callbacks 6//! instead of typestate, which mean they're tightly bound to exact implementation details. 7//! 8//! this is... annoying. so we have this instead 9//! 10//! # Overview 11//! 12//! this implementation is based on [oauth2.1]. this means, basically, it's [oauth2.0] with best 13//! practices applied. 14//! 15//! the oidc half is based on [oidc core 1.0 + errata 2][oidc1], but with... some of the more 16//! ill-advised ignorable parts duely ignored. 17//! 18//! [oauth2.1]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1 19//! [oauth2.0]: https://www.rfc-editor.org/rfc/rfc8414.html 20//! [oidc1]: https://openid.net/specs/openid-connect-core-1_0.html 21 22/// # [OAuth 2.0 Authorization Server Metadata][rfc:8414] and related helpers 23/// 24/// most enums here also have additional values specified in the [iana registry]. 25/// 26/// [iana registry]: https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml 27pub mod metadata { 28 use std::collections::HashSet; 29 30 use serde::Deserialize; 31 use url::Url; 32 33 /// [OAuth 2.0 Authorization Server Metadata][rfc:8414#2] 34 /// 35 /// see the rfc for field descriptions. 36 #[derive(Deserialize)] 37 pub struct AuthServerMetadata { 38 pub issuer: Url, 39 // > \[authorization_endpoint\] is REQUIRED unless no grant types are supported that use the 40 // > authorization endpoint 41 // 42 // we require the authorization flow, so we leave this as required 43 pub authorization_endpoint: Url, 44 // same here 45 pub token_endpoint: Url, 46 pub jwks_uri: Option<Url>, 47 pub response_types_supported: HashSet<ResponseType>, 48 pub response_modes_supported: Option<HashSet<ResponseMode>>, 49 pub grant_types_supported: Option<HashSet<GrantType>>, 50 // defaults to [`TokenEndpointAuthMethod::ClientSecretBasic`], but oauth 2.1 [adds in post 51 // too][rfc:draft-ietf-v2.1#10.1] 52 pub token_endpoint_auth_methods_supported: Option<HashSet<AuthMethod>>, 53 pub code_challenge_methods_supported: Option<HashSet<CodeChallengeMethod>>, 54 55 // per https://openid.net/specs/openid-connect-discovery-1_0.html 56 pub id_token_signing_alg_values_supported: Option<HashSet<SigningAlgValue>>, 57 // per the spec, extra fields are defined in [OIDC Discovery 1.0 with errata 58 // 2][oidc-discovery-1]. 59 // 60 // the rfc also contains a bunch of extra fields that we don't use, so aren't captured 61 // here 62 // 63 // [oidc-discovery-1]: https://openid.net/specs/openid-connect-discovery-1_0.html 64 } 65 impl AuthServerMetadata { 66 /// check if this metadata conforms to our expectations of a modern oauth v2.1 & oidc core 67 /// v1 server 68 pub fn generally_as_expected(&self) -> color_eyre::Result<()> { 69 use color_eyre::eyre::eyre; 70 if !self.response_types_supported.contains(&ResponseType::Code) { 71 return Err(eyre!("response type `code` not supported by auth server")); 72 } 73 // if this is missing, assume query is supported 74 if !ResponseMode::Query.is_supported_for(self.response_modes_supported.as_ref()) { 75 return Err(eyre!("response mode `query` not supported by auth server")); 76 } 77 if !GrantType::AuthorizationCode.is_supported_for(self.grant_types_supported.as_ref()) { 78 return Err(eyre!( 79 "grant type `authorization_code` not supported by auth server" 80 )); 81 } 82 if !AuthMethod::ClientSecretPost 83 .is_supported_for(self.token_endpoint_auth_methods_supported.as_ref()) 84 { 85 return Err(eyre!( 86 "client_secret_post auth method not supported, not a valid oauth 2.1 server, and honestly not a good oauth 2.0 server either" 87 )); 88 } 89 if self 90 .code_challenge_methods_supported 91 .as_ref() 92 .is_none_or(|m| !m.contains(&CodeChallengeMethod::S256)) 93 { 94 return Err(eyre!( 95 "auth server does not support pkce, or does not support S256 pkce" 96 )); 97 } 98 if self.authorization_endpoint.host() != self.issuer.host() { 99 return Err(eyre!( 100 "authorization endpoint not on issuer server: {} vs {}", 101 self.authorization_endpoint.as_str(), 102 self.issuer.as_str() 103 )); 104 } 105 if self.token_endpoint.host() != self.issuer.host() { 106 return Err(eyre!("token endpoint not on issuer server")); 107 } 108 if self 109 .jwks_uri 110 .as_ref() 111 .is_some_and(|jwks_uri| jwks_uri.host() != self.issuer.host()) 112 { 113 return Err(eyre!("jwks uri not on issuer server")); 114 } 115 // the rest need to be checked if we ever use them 116 117 // signing methods only checked if we want to actually verify the id tokens (see the 118 // config) 119 120 Ok(()) 121 } 122 } 123 124 /// [OAuth 2.0 Dynamic Client Registration response types][rfc:7591#2] 125 /// 126 /// as linked in the [`AuthServerMetadata::response_types_supported`] specification. 127 #[derive(Deserialize, Eq, PartialEq, Hash)] 128 #[serde(rename_all = "snake_case")] 129 pub enum ResponseType { 130 Code, 131 Token, 132 #[serde(untagged)] 133 Other(String), 134 } 135 136 /// per <rfc8414#2> this is a mix of [OAuth.Response], and [OAuth.Post] from the openid folks. 137 /// 138 /// 139 /// [OAuth.Response]: https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html 140 /// [OAuth.Post]: https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html 141 /// 142 /// as linked in the [`AuthServerMetadata::response_types_supported`] specification. 143 /// 144 /// defaults to [`ResponseMode::Query`] and [`ResponseMode::Fragment`] if missing 145 #[derive(Deserialize, Eq, PartialEq, Hash)] 146 #[serde(rename_all = "snake_case")] 147 pub enum ResponseMode { 148 Query, 149 Fragment, 150 FormPost, 151 #[serde(untagged)] 152 Other(String), 153 } 154 impl ResponseMode { 155 pub fn is_supported_for(&self, options: Option<&HashSet<Self>>) -> bool { 156 match options { 157 Some(opts) => opts.contains(self), 158 // per the field documentation (see [`Self`]) 159 None if *self == Self::Query => true, 160 None if *self == Self::Fragment => true, 161 None => false, 162 } 163 } 164 } 165 166 /// [OAuth 2.0 Dynamic Client Registration grant types][rfc:7591#2] 167 /// 168 /// as linked in the [`AuthServerMetadata::grant_types_supported`] specification. 169 /// 170 /// defaults to [`GrantType::AuthorizationCode`] and [`GrantType::Implicit`] if missing per 171 /// the rfc, but oauth 2.1 [removes the implict grant][rfc:draft-ietf-v2.1#10.1] 172 #[derive(Deserialize, Eq, PartialEq, Hash)] 173 #[serde(rename_all = "snake_case")] 174 pub enum GrantType { 175 AuthorizationCode, 176 Implicit, 177 Password, 178 ClientCredentials, 179 RefreshToken, 180 #[serde(rename = "urn:ietf:params:oauth:grant-type:jwt-bearer")] 181 UrnIetfParamsOauthGrantTypeJwtBearer, 182 #[serde(rename = "urn:ietf:params:oauth:grant-type:saml2-bearer")] 183 UrnIetfParamsOauthGrantTypeSaml2Bearer, 184 #[serde(untagged)] 185 Other(String), 186 } 187 impl GrantType { 188 pub fn is_supported_for(&self, options: Option<&HashSet<Self>>) -> bool { 189 match options { 190 Some(opts) => opts.contains(self), 191 // per the field documentation (see [`Self`]) 192 None if *self == Self::AuthorizationCode => true, 193 // NB: oauth 2.1 removes the implicit grant 194 // None if *self == Self::Implicit => true, 195 None => false, 196 } 197 } 198 } 199 /// [OAuth 2.0 Dynamic Client Registration token endpoint auth methods][rfc:7591#2] 200 /// 201 /// as linked in the [`AuthServerMetadata::token_endpoint_auth_methods_supported`] specification. 202 #[derive(Deserialize, Eq, PartialEq, Hash)] 203 #[serde(rename_all = "snake_case")] 204 pub enum AuthMethod { 205 None, 206 ClientSecretPost, 207 ClientSecretBasic, 208 #[serde(untagged)] 209 Other(String), 210 } 211 impl AuthMethod { 212 pub fn is_supported_for(&self, options: Option<&HashSet<Self>>) -> bool { 213 match options { 214 Some(opts) => opts.contains(self), 215 // per the field documentation (see [`Self`]), this MUST be supported 216 None if *self == Self::ClientSecretBasic => true, 217 // per <rfc:draft-ietf-oauth-v2.1#2.5>, servers MUST support this too 218 None if *self == Self::ClientSecretPost => true, 219 None => false, 220 } 221 } 222 } 223 /// JWT signing alg values 224 /// 225 /// as linked in the [`AuthServerMetadata::token_endpoint_auth_signing_alg_values_supported`] specification. 226 #[derive(Deserialize, Eq, PartialEq, Hash, Debug)] 227 pub enum SigningAlgValue { 228 /// NB: this must be rejected, but we capture it here to avoid it going into [`Self::Other`] 229 None, 230 RS256, 231 ES256, 232 #[serde(untagged)] 233 Other(String), 234 } 235 /// [PKCE challenge methods][rfc:7636#4.3] 236 /// 237 /// as linked in the [`AuthServerMetadata::code_challenge_methods_supported`] specification. 238 #[derive(Deserialize, Eq, PartialEq, Hash)] 239 pub enum CodeChallengeMethod { 240 /// should never be used, but we want to catch it so it doesn't go in Other 241 Plain, 242 S256, 243 #[serde(untagged)] 244 Other(String), 245 } 246 247 /// get the [oidc discovery 1.0+errata 2][oidc-discovery-1] well-known endpoint for a given base 248 /// url 249 /// 250 /// users are expected to perform (both) issuer-id transformations themselves, if need-be 251 /// (per [rfc:8414#5]) 252 /// 253 /// [oidc-discovery-1]: https://openid.net/specs/openid-connect-discovery-1_0.html 254 pub fn oidc_discovery_uri(base_url: &Url) -> color_eyre::Result<Url> { 255 Ok(base_url.join(".well-known/openid-configuration")?) 256 } 257} 258 259pub mod auth_code_flow { 260 //! [`metadata::GrantType::AuthorizationCode`] flow 261 //! 262 //! # [oauth 2.1 authorization code grant][rfc:draft-ietf-oauth-v2.1#4.1] 263 //! 264 //! 1. [`self::code_request::redirect_to_auth_server`] 265 //! 2. [`self::code_response::receive_redirect`] 266 //! 3. [`self::token_request::request_access_token`] 267 //! 4. deserialize response into either [`self::token_response::Valid`] or 268 //! [`self::token_response::Error`] 269 270 use std::borrow::Cow; 271 272 use serde::Deserialize; 273 274 /// auto-join/split authorization code scopes 275 #[derive(Deserialize)] 276 #[serde(try_from = "Cow<'_, str>")] 277 pub struct Scopes<'u>(Cow<'u, str>); 278 279 #[allow(clippy::infallible_try_from, reason = "required for serde")] 280 impl<'u> TryFrom<Cow<'u, str>> for Scopes<'u> { 281 type Error = std::convert::Infallible; 282 283 fn try_from(value: Cow<'u, str>) -> std::result::Result<Self, Self::Error> { 284 Ok(Self(value)) 285 } 286 } 287 impl<'u> Scopes<'u> { 288 pub fn base_scopes() -> Self { 289 Self(Cow::Borrowed("openid")) 290 } 291 pub fn add_scope(mut self, scope: impl AsRef<str>) -> Self { 292 match &mut self.0 { 293 Cow::Borrowed(b) => { 294 self.0 = format!("{b} {}", scope.as_ref()).into(); 295 } 296 Cow::Owned(v) => { 297 v.push(' '); 298 v.push_str(scope.as_ref()); 299 } 300 } 301 self 302 } 303 } 304 impl<'u, S: AsRef<str>> FromIterator<S> for Scopes<'u> { 305 fn from_iter<T: IntoIterator<Item = S>>(iter: T) -> Self { 306 Self( 307 iter.into_iter() 308 .fold(String::new(), |mut acc, elem| { 309 if !acc.is_empty() { 310 acc.push(' '); 311 } 312 acc.push_str(elem.as_ref()); 313 acc 314 }) 315 .into(), 316 ) 317 } 318 } 319 320 /// Step 1 321 pub mod code_request { 322 323 use color_eyre::Result; 324 use url::Url; 325 326 use super::super::metadata::AuthServerMetadata; 327 use super::Scopes; 328 329 /// data used to construct the initial authorization code browser redirect url 330 pub struct Data<'u> { 331 // owned cause it's unique every time 332 code_verifier: String, 333 client_id: &'u str, 334 scope: &'u Scopes<'u>, 335 state: uuid::Uuid, 336 redirect_uri: &'u Url, 337 } 338 impl<'u> Data<'u> { 339 pub fn new(client_id: &'u str, scope: &'u Scopes<'u>, redirect_uri: &'u Url) -> Self { 340 use base64::prelude::*; 341 use rand::Rng as _; 342 use sha2::Digest as _; 343 344 let mut rng = rand::rngs::OsRng; 345 Self { 346 code_verifier: BASE64_URL_SAFE_NO_PAD 347 .encode(sha2::Sha256::digest(rng.r#gen::<[u8; 32]>())), 348 client_id, 349 scope, 350 state: uuid::Uuid::new_v4(), 351 redirect_uri, 352 } 353 } 354 } 355 356 /// slice of [`super::metadata::AuthServerMetadata`] needed for the initial 357 /// [`redirect_to_auth_server`] call 358 pub struct Metadata<'u> { 359 authorization_endpoint: &'u Url, 360 } 361 impl<'u> From<&'u AuthServerMetadata> for Metadata<'u> { 362 fn from(orig: &'u AuthServerMetadata) -> Self { 363 Self { 364 authorization_endpoint: &orig.authorization_endpoint, 365 } 366 } 367 } 368 369 /// the information required to start the authorization code flow and redirect a browser 370 pub struct RedirectInfo { 371 /// the url to send to the browser 372 pub url: Url, 373 /// the code verifier, to use when submitting the token request 374 pub code_verifier: String, 375 /// the state, to use to associate the authorization server's response back to the code 376 /// verifier and such 377 pub state: uuid::Uuid, 378 } 379 380 /// construct the url used to redirect the user to the authorization server login 381 /// 382 /// take the returned state and use it to save the code verifier 383 pub fn redirect_to_auth_server( 384 meta: Metadata<'_>, 385 params: Data<'_>, 386 ) -> Result<RedirectInfo> { 387 let mut url = meta.authorization_endpoint.clone(); 388 let mut query = url.query_pairs_mut(); 389 query.append_pair("response_type", "code"); 390 query.append_pair("client_id", params.client_id); 391 { 392 use base64::prelude::*; 393 use sha2::Digest as _; 394 395 query.append_pair("code_challenge_method", "S256"); 396 let challenge = 397 BASE64_URL_SAFE.encode(sha2::Sha256::digest(params.code_verifier.as_bytes())); 398 query.append_pair("code_challenge", &challenge); 399 } 400 401 // NB: there's some optional oidc parameters here, but they're mostly worth skipping 402 // the main one that's useful is the nonce, but pkce takes the place of that and is 403 // more broadly standardized in oauth2 v2.1 404 405 query.append_pair("redirect_uri", params.redirect_uri.as_str()); 406 query.append_pair("scope", &params.scope.0); 407 query.append_pair("state", &params.state.to_string()); 408 drop(query); 409 410 Ok(RedirectInfo { 411 url, 412 code_verifier: params.code_verifier, 413 state: params.state, 414 }) 415 } 416 } 417 418 /// Step 2 419 pub mod code_response { 420 use std::borrow::Cow; 421 422 use color_eyre::Result; 423 use serde::Deserialize; 424 425 /// types of errors that a server can respond with, having failed the initial auth code request 426 #[derive(Deserialize)] 427 #[serde(rename_all = "snake_case")] 428 pub enum ErrorType<'u> { 429 // oauth 2.1 per [rfc:draft-ietf-oauth-v2-1#4.1.2.1] 430 InvalidRequest, 431 UnauthorizedClient, 432 AccessDenied, 433 UnsupportedResponseType, 434 InvalidScope, 435 ServerError, 436 TemporarilyUnavailable, 437 438 // TODO(on-oss): 439 // [oidc-core-1](https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.6) 440 // defines some, but we don't really care about those for now, so they can go in "other" 441 442 // anything else a server happens to randomly shove in there 443 #[serde(untagged)] 444 #[allow( 445 dead_code, 446 reason = "gonna make use of these shortly to surface errors better" 447 )] 448 Other(Cow<'u, str>), 449 } 450 /// an error that the server responds with when an authorization code request fails 451 #[allow( 452 dead_code, 453 reason = "gonna make use of these shortly to surface errors better" 454 )] 455 #[derive(Deserialize)] 456 pub struct Error<'u> { 457 pub error: ErrorType<'u>, 458 pub error_description: Option<Cow<'u, str>>, 459 pub error_uri: Option<Cow<'u, str>>, 460 pub state: uuid::Uuid, 461 pub iss: Option<Cow<'u, str>>, 462 } 463 464 /// the query parameters for a successful authorization code response 465 pub struct Response<'u> { 466 pub code: Cow<'u, str>, 467 pub state: uuid::Uuid, 468 } 469 470 pub fn receive_redirect<'u>( 471 query: &'u str, 472 issuer: &'u str, 473 ) -> Result<Result<Response<'u>, Error<'u>>> { 474 #[derive(Deserialize)] 475 #[serde(untagged)] 476 enum Params<'u> { 477 Valid { 478 code: Cow<'u, str>, 479 state: uuid::Uuid, 480 iss: Option<Cow<'u, str>>, 481 }, 482 Error(Error<'u>), 483 } 484 485 let params = 486 Params::deserialize(serde_html_form::Deserializer::from_bytes(query.as_bytes()))?; 487 let (code, state, iss) = match params { 488 Params::Valid { code, state, iss } => (code, state, iss), 489 Params::Error(err) => return Ok(Err(err)), 490 }; 491 if iss.as_ref().is_some_and(|iss| iss != issuer) { 492 // NB: it's unlikely to happen except via a misconfiguration, but technically this 493 // could cause us to leak our in_progress states 494 // per [rfc:draft-ietf-oauth-v2-1#7.14], we _must_ validate this if present 495 return Err(color_eyre::eyre::eyre!("issuer mismatch")); 496 } 497 498 Ok(Ok(Response { code, state })) 499 } 500 } 501 502 /// Step 3 503 pub mod token_request { 504 use color_eyre::Result; 505 use url::Url; 506 507 use super::super::metadata::AuthServerMetadata; 508 use super::code_response::Response; 509 510 /// slice of [`super::metadata::AuthServerMetadata`] needed for the initial 511 /// [`request_access_token`] call 512 pub struct Metadata<'u> { 513 token_endpoint: &'u Url, 514 } 515 impl<'u> From<&'u AuthServerMetadata> for Metadata<'u> { 516 fn from(orig: &'u AuthServerMetadata) -> Self { 517 Self { 518 token_endpoint: &orig.token_endpoint, 519 } 520 } 521 } 522 523 pub struct Data<'u> { 524 pub code: Response<'u>, // owned, is consumed 525 pub client_id: &'u str, 526 pub client_secret: &'u str, 527 // grant type is hardcoded for this 528 pub code_verifier: String, // owned, consumed 529 pub redirect_uri: &'u str, 530 } 531 532 pub struct Request<'u> { 533 pub url: &'u Url, 534 } 535 536 /// Step 4: request an access token 537 pub fn request_access_token<'u, 'm: 'u, BODY: form_urlencoded::Target>( 538 meta: Metadata<'m>, 539 data: Data<'u>, 540 body: BODY, 541 ) -> Result<Request<'u>> { 542 let mut body = form_urlencoded::Serializer::new(body); 543 body.append_pair("grant_type", "authorization_code"); 544 body.append_pair("code", &data.code.code); 545 body.append_pair("redirect_uri", data.redirect_uri); // oauth 2.0 only 546 body.append_pair("client_id", data.client_id); 547 body.append_pair("code_verifier", &data.code_verifier); 548 body.append_pair("client_secret", data.client_secret); 549 550 Ok(Request { 551 url: meta.token_endpoint, 552 }) 553 } 554 } 555 556 /// Step 4 557 pub mod token_response { 558 use serde::Deserialize; 559 use std::borrow::Cow; 560 561 #[derive(Deserialize)] 562 pub struct Valid<'u> { 563 /// required per 564 /// [oidc-core-1](https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.3.3) 565 /// when oidc is in play 566 pub id_token: Option<Cow<'u, str>>, 567 } 568 569 #[derive(Deserialize)] 570 #[serde(rename_all = "snake_case")] 571 pub enum ErrorType<'u> { 572 // oauth 2.1 per [rfc:draft-ietf-oauth-v2-1#4.3.2] 573 InvalidRequest, 574 InvalidClient, 575 InvalidGrant, 576 UnauthorizedClient, 577 UnsupportedGrantType, 578 InvalidScope, 579 580 // anything else a server happens to randomly shove in there 581 #[serde(untagged)] 582 #[allow(dead_code, reason = "deserialization purposes")] 583 Other(Cow<'u, str>), 584 } 585 #[derive(Deserialize)] 586 #[allow( 587 dead_code, 588 reason = "gonna make use of these shortly to surface errors better" 589 )] 590 pub struct Error<'u> { 591 pub error: ErrorType<'u>, 592 pub error_description: Option<Cow<'u, str>>, 593 pub error_uri: Option<Cow<'u, str>>, 594 } 595 } 596}