a (hacky, wip) multi-tenant oidc-terminating reverse proxy, written in anger on top of pingora
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", ¶ms.scope.0);
407 query.append_pair("state", ¶ms.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}