//! # Background //! //! so! there exists oauth & oidc packages. they're what kanidm uses to implement oidc. //! unfortunately, they're poorly maintained on the client side (e.g. their reqwest bindings don't //! work with the latest version of reqwest), and rather clunkily implemented through callbacks //! instead of typestate, which mean they're tightly bound to exact implementation details. //! //! this is... annoying. so we have this instead //! //! # Overview //! //! this implementation is based on [oauth2.1]. this means, basically, it's [oauth2.0] with best //! practices applied. //! //! the oidc half is based on [oidc core 1.0 + errata 2][oidc1], but with... some of the more //! ill-advised ignorable parts duely ignored. //! //! [oauth2.1]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1 //! [oauth2.0]: https://www.rfc-editor.org/rfc/rfc8414.html //! [oidc1]: https://openid.net/specs/openid-connect-core-1_0.html /// # [OAuth 2.0 Authorization Server Metadata][rfc:8414] and related helpers /// /// most enums here also have additional values specified in the [iana registry]. /// /// [iana registry]: https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml pub mod metadata { use std::collections::HashSet; use serde::Deserialize; use url::Url; /// [OAuth 2.0 Authorization Server Metadata][rfc:8414#2] /// /// see the rfc for field descriptions. #[derive(Deserialize)] pub struct AuthServerMetadata { pub issuer: Url, // > \[authorization_endpoint\] is REQUIRED unless no grant types are supported that use the // > authorization endpoint // // we require the authorization flow, so we leave this as required pub authorization_endpoint: Url, // same here pub token_endpoint: Url, pub jwks_uri: Option, pub response_types_supported: HashSet, pub response_modes_supported: Option>, pub grant_types_supported: Option>, // defaults to [`TokenEndpointAuthMethod::ClientSecretBasic`], but oauth 2.1 [adds in post // too][rfc:draft-ietf-v2.1#10.1] pub token_endpoint_auth_methods_supported: Option>, pub code_challenge_methods_supported: Option>, // per https://openid.net/specs/openid-connect-discovery-1_0.html pub id_token_signing_alg_values_supported: Option>, // per the spec, extra fields are defined in [OIDC Discovery 1.0 with errata // 2][oidc-discovery-1]. // // the rfc also contains a bunch of extra fields that we don't use, so aren't captured // here // // [oidc-discovery-1]: https://openid.net/specs/openid-connect-discovery-1_0.html } impl AuthServerMetadata { /// check if this metadata conforms to our expectations of a modern oauth v2.1 & oidc core /// v1 server pub fn generally_as_expected(&self) -> color_eyre::Result<()> { use color_eyre::eyre::eyre; if !self.response_types_supported.contains(&ResponseType::Code) { return Err(eyre!("response type `code` not supported by auth server")); } // if this is missing, assume query is supported if !ResponseMode::Query.is_supported_for(self.response_modes_supported.as_ref()) { return Err(eyre!("response mode `query` not supported by auth server")); } if !GrantType::AuthorizationCode.is_supported_for(self.grant_types_supported.as_ref()) { return Err(eyre!( "grant type `authorization_code` not supported by auth server" )); } if !AuthMethod::ClientSecretPost .is_supported_for(self.token_endpoint_auth_methods_supported.as_ref()) { return Err(eyre!( "client_secret_post auth method not supported, not a valid oauth 2.1 server, and honestly not a good oauth 2.0 server either" )); } if self .code_challenge_methods_supported .as_ref() .is_none_or(|m| !m.contains(&CodeChallengeMethod::S256)) { return Err(eyre!( "auth server does not support pkce, or does not support S256 pkce" )); } if self.authorization_endpoint.host() != self.issuer.host() { return Err(eyre!( "authorization endpoint not on issuer server: {} vs {}", self.authorization_endpoint.as_str(), self.issuer.as_str() )); } if self.token_endpoint.host() != self.issuer.host() { return Err(eyre!("token endpoint not on issuer server")); } if self .jwks_uri .as_ref() .is_some_and(|jwks_uri| jwks_uri.host() != self.issuer.host()) { return Err(eyre!("jwks uri not on issuer server")); } // the rest need to be checked if we ever use them // signing methods only checked if we want to actually verify the id tokens (see the // config) Ok(()) } } /// [OAuth 2.0 Dynamic Client Registration response types][rfc:7591#2] /// /// as linked in the [`AuthServerMetadata::response_types_supported`] specification. #[derive(Deserialize, Eq, PartialEq, Hash)] #[serde(rename_all = "snake_case")] pub enum ResponseType { Code, Token, #[serde(untagged)] Other(String), } /// per this is a mix of [OAuth.Response], and [OAuth.Post] from the openid folks. /// /// /// [OAuth.Response]: https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html /// [OAuth.Post]: https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html /// /// as linked in the [`AuthServerMetadata::response_types_supported`] specification. /// /// defaults to [`ResponseMode::Query`] and [`ResponseMode::Fragment`] if missing #[derive(Deserialize, Eq, PartialEq, Hash)] #[serde(rename_all = "snake_case")] pub enum ResponseMode { Query, Fragment, FormPost, #[serde(untagged)] Other(String), } impl ResponseMode { pub fn is_supported_for(&self, options: Option<&HashSet>) -> bool { match options { Some(opts) => opts.contains(self), // per the field documentation (see [`Self`]) None if *self == Self::Query => true, None if *self == Self::Fragment => true, None => false, } } } /// [OAuth 2.0 Dynamic Client Registration grant types][rfc:7591#2] /// /// as linked in the [`AuthServerMetadata::grant_types_supported`] specification. /// /// defaults to [`GrantType::AuthorizationCode`] and [`GrantType::Implicit`] if missing per /// the rfc, but oauth 2.1 [removes the implict grant][rfc:draft-ietf-v2.1#10.1] #[derive(Deserialize, Eq, PartialEq, Hash)] #[serde(rename_all = "snake_case")] pub enum GrantType { AuthorizationCode, Implicit, Password, ClientCredentials, RefreshToken, #[serde(rename = "urn:ietf:params:oauth:grant-type:jwt-bearer")] UrnIetfParamsOauthGrantTypeJwtBearer, #[serde(rename = "urn:ietf:params:oauth:grant-type:saml2-bearer")] UrnIetfParamsOauthGrantTypeSaml2Bearer, #[serde(untagged)] Other(String), } impl GrantType { pub fn is_supported_for(&self, options: Option<&HashSet>) -> bool { match options { Some(opts) => opts.contains(self), // per the field documentation (see [`Self`]) None if *self == Self::AuthorizationCode => true, // NB: oauth 2.1 removes the implicit grant // None if *self == Self::Implicit => true, None => false, } } } /// [OAuth 2.0 Dynamic Client Registration token endpoint auth methods][rfc:7591#2] /// /// as linked in the [`AuthServerMetadata::token_endpoint_auth_methods_supported`] specification. #[derive(Deserialize, Eq, PartialEq, Hash)] #[serde(rename_all = "snake_case")] pub enum AuthMethod { None, ClientSecretPost, ClientSecretBasic, #[serde(untagged)] Other(String), } impl AuthMethod { pub fn is_supported_for(&self, options: Option<&HashSet>) -> bool { match options { Some(opts) => opts.contains(self), // per the field documentation (see [`Self`]), this MUST be supported None if *self == Self::ClientSecretBasic => true, // per , servers MUST support this too None if *self == Self::ClientSecretPost => true, None => false, } } } /// JWT signing alg values /// /// as linked in the [`AuthServerMetadata::token_endpoint_auth_signing_alg_values_supported`] specification. #[derive(Deserialize, Eq, PartialEq, Hash, Debug)] pub enum SigningAlgValue { /// NB: this must be rejected, but we capture it here to avoid it going into [`Self::Other`] None, RS256, ES256, #[serde(untagged)] Other(String), } /// [PKCE challenge methods][rfc:7636#4.3] /// /// as linked in the [`AuthServerMetadata::code_challenge_methods_supported`] specification. #[derive(Deserialize, Eq, PartialEq, Hash)] pub enum CodeChallengeMethod { /// should never be used, but we want to catch it so it doesn't go in Other Plain, S256, #[serde(untagged)] Other(String), } /// get the [oidc discovery 1.0+errata 2][oidc-discovery-1] well-known endpoint for a given base /// url /// /// users are expected to perform (both) issuer-id transformations themselves, if need-be /// (per [rfc:8414#5]) /// /// [oidc-discovery-1]: https://openid.net/specs/openid-connect-discovery-1_0.html pub fn oidc_discovery_uri(base_url: &Url) -> color_eyre::Result { Ok(base_url.join(".well-known/openid-configuration")?) } } pub mod auth_code_flow { //! [`metadata::GrantType::AuthorizationCode`] flow //! //! # [oauth 2.1 authorization code grant][rfc:draft-ietf-oauth-v2.1#4.1] //! //! 1. [`self::code_request::redirect_to_auth_server`] //! 2. [`self::code_response::receive_redirect`] //! 3. [`self::token_request::request_access_token`] //! 4. deserialize response into either [`self::token_response::Valid`] or //! [`self::token_response::Error`] use std::borrow::Cow; use serde::Deserialize; /// auto-join/split authorization code scopes #[derive(Deserialize)] #[serde(try_from = "Cow<'_, str>")] pub struct Scopes<'u>(Cow<'u, str>); #[allow(clippy::infallible_try_from, reason = "required for serde")] impl<'u> TryFrom> for Scopes<'u> { type Error = std::convert::Infallible; fn try_from(value: Cow<'u, str>) -> std::result::Result { Ok(Self(value)) } } impl<'u> Scopes<'u> { pub fn base_scopes() -> Self { Self(Cow::Borrowed("openid")) } pub fn add_scope(mut self, scope: impl AsRef) -> Self { match &mut self.0 { Cow::Borrowed(b) => { self.0 = format!("{b} {}", scope.as_ref()).into(); } Cow::Owned(v) => { v.push(' '); v.push_str(scope.as_ref()); } } self } } impl<'u, S: AsRef> FromIterator for Scopes<'u> { fn from_iter>(iter: T) -> Self { Self( iter.into_iter() .fold(String::new(), |mut acc, elem| { if !acc.is_empty() { acc.push(' '); } acc.push_str(elem.as_ref()); acc }) .into(), ) } } /// Step 1 pub mod code_request { use color_eyre::Result; use url::Url; use super::super::metadata::AuthServerMetadata; use super::Scopes; /// data used to construct the initial authorization code browser redirect url pub struct Data<'u> { // owned cause it's unique every time code_verifier: String, client_id: &'u str, scope: &'u Scopes<'u>, state: uuid::Uuid, redirect_uri: &'u Url, } impl<'u> Data<'u> { pub fn new(client_id: &'u str, scope: &'u Scopes<'u>, redirect_uri: &'u Url) -> Self { use base64::prelude::*; use rand::Rng as _; use sha2::Digest as _; let mut rng = rand::rngs::OsRng; Self { code_verifier: BASE64_URL_SAFE_NO_PAD .encode(sha2::Sha256::digest(rng.r#gen::<[u8; 32]>())), client_id, scope, state: uuid::Uuid::new_v4(), redirect_uri, } } } /// slice of [`super::metadata::AuthServerMetadata`] needed for the initial /// [`redirect_to_auth_server`] call pub struct Metadata<'u> { authorization_endpoint: &'u Url, } impl<'u> From<&'u AuthServerMetadata> for Metadata<'u> { fn from(orig: &'u AuthServerMetadata) -> Self { Self { authorization_endpoint: &orig.authorization_endpoint, } } } /// the information required to start the authorization code flow and redirect a browser pub struct RedirectInfo { /// the url to send to the browser pub url: Url, /// the code verifier, to use when submitting the token request pub code_verifier: String, /// the state, to use to associate the authorization server's response back to the code /// verifier and such pub state: uuid::Uuid, } /// construct the url used to redirect the user to the authorization server login /// /// take the returned state and use it to save the code verifier pub fn redirect_to_auth_server( meta: Metadata<'_>, params: Data<'_>, ) -> Result { let mut url = meta.authorization_endpoint.clone(); let mut query = url.query_pairs_mut(); query.append_pair("response_type", "code"); query.append_pair("client_id", params.client_id); { use base64::prelude::*; use sha2::Digest as _; query.append_pair("code_challenge_method", "S256"); let challenge = BASE64_URL_SAFE.encode(sha2::Sha256::digest(params.code_verifier.as_bytes())); query.append_pair("code_challenge", &challenge); } // NB: there's some optional oidc parameters here, but they're mostly worth skipping // the main one that's useful is the nonce, but pkce takes the place of that and is // more broadly standardized in oauth2 v2.1 query.append_pair("redirect_uri", params.redirect_uri.as_str()); query.append_pair("scope", ¶ms.scope.0); query.append_pair("state", ¶ms.state.to_string()); drop(query); Ok(RedirectInfo { url, code_verifier: params.code_verifier, state: params.state, }) } } /// Step 2 pub mod code_response { use std::borrow::Cow; use color_eyre::Result; use serde::Deserialize; /// types of errors that a server can respond with, having failed the initial auth code request #[derive(Deserialize)] #[serde(rename_all = "snake_case")] pub enum ErrorType<'u> { // oauth 2.1 per [rfc:draft-ietf-oauth-v2-1#4.1.2.1] InvalidRequest, UnauthorizedClient, AccessDenied, UnsupportedResponseType, InvalidScope, ServerError, TemporarilyUnavailable, // TODO(on-oss): // [oidc-core-1](https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.6) // defines some, but we don't really care about those for now, so they can go in "other" // anything else a server happens to randomly shove in there #[serde(untagged)] #[allow( dead_code, reason = "gonna make use of these shortly to surface errors better" )] Other(Cow<'u, str>), } /// an error that the server responds with when an authorization code request fails #[allow( dead_code, reason = "gonna make use of these shortly to surface errors better" )] #[derive(Deserialize)] pub struct Error<'u> { pub error: ErrorType<'u>, pub error_description: Option>, pub error_uri: Option>, pub state: uuid::Uuid, pub iss: Option>, } /// the query parameters for a successful authorization code response pub struct Response<'u> { pub code: Cow<'u, str>, pub state: uuid::Uuid, } pub fn receive_redirect<'u>( query: &'u str, issuer: &'u str, ) -> Result, Error<'u>>> { #[derive(Deserialize)] #[serde(untagged)] enum Params<'u> { Valid { code: Cow<'u, str>, state: uuid::Uuid, iss: Option>, }, Error(Error<'u>), } let params = Params::deserialize(serde_html_form::Deserializer::from_bytes(query.as_bytes()))?; let (code, state, iss) = match params { Params::Valid { code, state, iss } => (code, state, iss), Params::Error(err) => return Ok(Err(err)), }; if iss.as_ref().is_some_and(|iss| iss != issuer) { // NB: it's unlikely to happen except via a misconfiguration, but technically this // could cause us to leak our in_progress states // per [rfc:draft-ietf-oauth-v2-1#7.14], we _must_ validate this if present return Err(color_eyre::eyre::eyre!("issuer mismatch")); } Ok(Ok(Response { code, state })) } } /// Step 3 pub mod token_request { use color_eyre::Result; use url::Url; use super::super::metadata::AuthServerMetadata; use super::code_response::Response; /// slice of [`super::metadata::AuthServerMetadata`] needed for the initial /// [`request_access_token`] call pub struct Metadata<'u> { token_endpoint: &'u Url, } impl<'u> From<&'u AuthServerMetadata> for Metadata<'u> { fn from(orig: &'u AuthServerMetadata) -> Self { Self { token_endpoint: &orig.token_endpoint, } } } pub struct Data<'u> { pub code: Response<'u>, // owned, is consumed pub client_id: &'u str, pub client_secret: &'u str, // grant type is hardcoded for this pub code_verifier: String, // owned, consumed pub redirect_uri: &'u str, } pub struct Request<'u> { pub url: &'u Url, } /// Step 4: request an access token pub fn request_access_token<'u, 'm: 'u, BODY: form_urlencoded::Target>( meta: Metadata<'m>, data: Data<'u>, body: BODY, ) -> Result> { let mut body = form_urlencoded::Serializer::new(body); body.append_pair("grant_type", "authorization_code"); body.append_pair("code", &data.code.code); body.append_pair("redirect_uri", data.redirect_uri); // oauth 2.0 only body.append_pair("client_id", data.client_id); body.append_pair("code_verifier", &data.code_verifier); body.append_pair("client_secret", data.client_secret); Ok(Request { url: meta.token_endpoint, }) } } /// Step 4 pub mod token_response { use serde::Deserialize; use std::borrow::Cow; #[derive(Deserialize)] pub struct Valid<'u> { /// required per /// [oidc-core-1](https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.3.3) /// when oidc is in play pub id_token: Option>, } #[derive(Deserialize)] #[serde(rename_all = "snake_case")] pub enum ErrorType<'u> { // oauth 2.1 per [rfc:draft-ietf-oauth-v2-1#4.3.2] InvalidRequest, InvalidClient, InvalidGrant, UnauthorizedClient, UnsupportedGrantType, InvalidScope, // anything else a server happens to randomly shove in there #[serde(untagged)] #[allow(dead_code, reason = "deserialization purposes")] Other(Cow<'u, str>), } #[derive(Deserialize)] #[allow( dead_code, reason = "gonna make use of these shortly to surface errors better" )] pub struct Error<'u> { pub error: ErrorType<'u>, pub error_description: Option>, pub error_uri: Option>, } } }