diff --git a/Cargo.lock b/Cargo.lock index 7c8f5ae2..a78e0b68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3414,6 +3414,7 @@ dependencies = [ "http-body", "hyper", "hyper-rustls", + "language-tags", "mas-http", "mas-iana", "mas-jose", diff --git a/crates/oauth2-types/src/oidc.rs b/crates/oauth2-types/src/oidc.rs index a472ea1b..36b90540 100644 --- a/crates/oauth2-types/src/oidc.rs +++ b/crates/oauth2-types/src/oidc.rs @@ -16,7 +16,7 @@ //! //! [OpenID Connect]: https://openid.net/connect/ -use std::{ops::Deref, str::FromStr}; +use std::{fmt, ops::Deref, str::FromStr}; use language_tags::LanguageTag; use mas_iana::{ @@ -25,7 +25,10 @@ use mas_iana::{ }; use parse_display::{Display, FromStr, ParseError}; use serde::{Deserialize, Serialize}; -use serde_with::{skip_serializing_none, DeserializeFromStr, SerializeDisplay}; +use serde_with::{ + formats::SpaceSeparator, serde_as, skip_serializing_none, DeserializeFromStr, SerializeDisplay, + StringWithSeparator, +}; use thiserror::Error; use url::Url; @@ -471,6 +474,11 @@ pub struct ProviderMetadata { /// /// [device authorization endpoint]: https://www.rfc-editor.org/rfc/rfc8628 pub device_authorization_endpoint: Option, + + /// URL of the authorization server's [RP-Initiated Logout endpoint]. + /// + /// [RP-Initiated Logout endpoint]: https://openid.net/specs/openid-connect-rpinitiated-1_0.html + pub end_session_endpoint: Option, } impl ProviderMetadata { @@ -593,6 +601,10 @@ impl ProviderMetadata { )?; } + if let Some(url) = &metadata.end_session_endpoint { + validate_url("end_session_endpoint", url, ExtraUrlRestrictions::None)?; + } + Ok(metadata) } @@ -1057,6 +1069,66 @@ fn validate_signing_alg_values_supported<'a>( Ok(()) } +/// The body of a request to the [RP-Initiated Logout Endpoint]. +/// +/// [RP-Initiated Logout Endpoint]: https://openid.net/specs/openid-connect-rpinitiated-1_0.html +#[skip_serializing_none] +#[serde_as] +#[derive(Default, Serialize, Deserialize, Clone)] +pub struct RpInitiatedLogoutRequest { + /// ID Token previously issued by the OP to the RP. + /// + /// Recommended, used as a hint about the End-User's current authenticated + /// session with the Client. + pub id_token_hint: Option, + + /// Hint to the Authorization Server about the End-User that is logging out. + /// + /// The value and meaning of this parameter is left up to the OP's + /// discretion. For instance, the value might contain an email address, + /// phone number, username, or session identifier pertaining to the RP's + /// session with the OP for the End-User. + pub logout_hint: Option, + + /// OAuth 2.0 Client Identifier valid at the Authorization Server. + /// + /// The most common use case for this parameter is to specify the Client + /// Identifier when `post_logout_redirect_uri` is used but `id_token_hint` + /// is not. Another use is for symmetrically encrypted ID Tokens used as + /// `id_token_hint` values that require the Client Identifier to be + /// specified by other means, so that the ID Tokens can be decrypted by + /// the OP. + pub client_id: Option, + + /// URI to which the RP is requesting that the End-User's User Agent be + /// redirected after a logout has been performed. + /// + /// The value MUST have been previously registered with the OP, using the + /// `post_logout_redirect_uris` registration parameter. + pub post_logout_redirect_uri: Option, + + /// Opaque value used by the RP to maintain state between the logout request + /// and the callback to the endpoint specified by the + /// `post_logout_redirect_uri` parameter. + pub state: Option, + + /// End-User's preferred languages and scripts for the user interface, + /// ordered by preference. + #[serde_as(as = "Option>")] + #[serde(default)] + pub ui_locales: Option>, +} + +impl fmt::Debug for RpInitiatedLogoutRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RpInitiatedLogoutRequest") + .field("logout_hint", &self.logout_hint) + .field("post_logout_redirect_uri", &self.post_logout_redirect_uri) + .field("ui_locales", &self.ui_locales) + .finish_non_exhaustive() + } +} + #[cfg(test)] mod tests { use assert_matches::assert_matches; diff --git a/crates/oauth2-types/src/registration/client_metadata_serde.rs b/crates/oauth2-types/src/registration/client_metadata_serde.rs index faafa2f8..18d762ab 100644 --- a/crates/oauth2-types/src/registration/client_metadata_serde.rs +++ b/crates/oauth2-types/src/registration/client_metadata_serde.rs @@ -124,6 +124,7 @@ pub struct ClientMetadataSerdeHelper { introspection_signed_response_alg: Option, introspection_encrypted_response_alg: Option, introspection_encrypted_response_enc: Option, + post_logout_redirect_uris: Option>, #[serde(flatten)] extra: ClientMetadataLocalizedFields, } @@ -168,6 +169,7 @@ impl From for ClientMetadataSerdeHelper { introspection_signed_response_alg, introspection_encrypted_response_alg, introspection_encrypted_response_enc, + post_logout_redirect_uris, }, } = metadata; @@ -202,6 +204,7 @@ impl From for ClientMetadataSerdeHelper { introspection_signed_response_alg, introspection_encrypted_response_alg, introspection_encrypted_response_enc, + post_logout_redirect_uris, extra: ClientMetadataLocalizedFields { client_name, logo_uri, @@ -246,6 +249,7 @@ impl From for ClientMetadata { introspection_signed_response_alg, introspection_encrypted_response_alg, introspection_encrypted_response_enc, + post_logout_redirect_uris, extra: ClientMetadataLocalizedFields { client_name, @@ -292,6 +296,7 @@ impl From for ClientMetadata { introspection_signed_response_alg, introspection_encrypted_response_alg, introspection_encrypted_response_enc, + post_logout_redirect_uris, } } } diff --git a/crates/oauth2-types/src/registration/mod.rs b/crates/oauth2-types/src/registration/mod.rs index 0d958996..6c4f006e 100644 --- a/crates/oauth2-types/src/registration/mod.rs +++ b/crates/oauth2-types/src/registration/mod.rs @@ -418,6 +418,12 @@ pub struct ClientMetadata { /// [JWE]: http://tools.ietf.org/html/draft-ietf-jose-json-web-encryption /// [introspection endpoint]: https://www.rfc-editor.org/info/rfc7662 pub introspection_encrypted_response_enc: Option, + + /// `post_logout_redirect_uri` values that are pre-registered by the client + /// for use at the provider's [RP-Initiated Logout endpoint]. + /// + /// [RP-Initiated Logout endpoint]: https://openid.net/specs/openid-connect-rpinitiated-1_0.html + pub post_logout_redirect_uris: Option>, } impl ClientMetadata { diff --git a/crates/oidc-client/Cargo.toml b/crates/oidc-client/Cargo.toml index 9749e675..7adb636c 100644 --- a/crates/oidc-client/Cargo.toml +++ b/crates/oidc-client/Cargo.toml @@ -26,6 +26,7 @@ futures = "0.3.28" futures-util = "0.3.28" headers = "0.3.8" http = "0.2.9" +language-tags = "0.3.2" once_cell = "1.18.0" mime = "0.3.17" rand = "0.8.5" diff --git a/crates/oidc-client/src/requests/mod.rs b/crates/oidc-client/src/requests/mod.rs index f0e1894a..e25793cc 100644 --- a/crates/oidc-client/src/requests/mod.rs +++ b/crates/oidc-client/src/requests/mod.rs @@ -22,5 +22,6 @@ pub mod jose; pub mod refresh_token; pub mod registration; pub mod revocation; +pub mod rp_initiated_logout; pub mod token; pub mod userinfo; diff --git a/crates/oidc-client/src/requests/rp_initiated_logout.rs b/crates/oidc-client/src/requests/rp_initiated_logout.rs new file mode 100644 index 00000000..c1936ecb --- /dev/null +++ b/crates/oidc-client/src/requests/rp_initiated_logout.rs @@ -0,0 +1,135 @@ +// Copyright 2023 Kévin Commaille. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Requests for [RP-Initiated Logout]. +//! +//! [RP-Initiated Logout]: https://openid.net/specs/openid-connect-rpinitiated-1_0.html + +use language_tags::LanguageTag; +use oauth2_types::oidc::RpInitiatedLogoutRequest; +use rand::{ + distributions::{Alphanumeric, DistString}, + Rng, +}; +use url::Url; + +/// The data necessary to build a logout request. +#[derive(Default, Clone)] +pub struct LogoutData { + /// ID Token previously issued by the OP to the RP. + /// + /// Recommended, used as a hint about the End-User's current authenticated + /// session with the Client. + pub id_token_hint: Option, + + /// Hint to the Authorization Server about the End-User that is logging out. + /// + /// The value and meaning of this parameter is left up to the OP's + /// discretion. For instance, the value might contain an email address, + /// phone number, username, or session identifier pertaining to the RP's + /// session with the OP for the End-User. + pub logout_hint: Option, + + /// OAuth 2.0 Client Identifier valid at the Authorization Server. + /// + /// The most common use case for this parameter is to specify the Client + /// Identifier when `post_logout_redirect_uri` is used but `id_token_hint` + /// is not. Another use is for symmetrically encrypted ID Tokens used as + /// `id_token_hint` values that require the Client Identifier to be + /// specified by other means, so that the ID Tokens can be decrypted by + /// the OP. + pub client_id: Option, + + /// URI to which the RP is requesting that the End-User's User Agent be + /// redirected after a logout has been performed. + /// + /// The value MUST have been previously registered with the OP, using the + /// `post_logout_redirect_uris` registration parameter. + pub post_logout_redirect_uri: Option, + + /// The End-User's preferred languages and scripts for the user interface, + /// ordered by preference. + pub ui_locales: Option>, +} + +/// Build the URL for initiating logout at the logout endpoint. +/// +/// # Arguments +/// +/// * `end_session_endpoint` - The URL of the issuer's logout endpoint. +/// +/// * `logout_data` - The data necessary to build the logout request. +/// +/// * `rng` - A random number generator. +/// +/// # Returns +/// +/// A URL to be opened in a web browser where the end-user will be able to +/// logout of their session, and an optional `state` string. +/// +/// The `state` will only be set if `post_logout_redirect_uri` is set. It should +/// be present in the query when the end user is redirected to the +/// `post_logout_redirect_uri`. +/// +/// # Errors +/// +/// Returns an error if preparing the URL fails. +/// +/// [`VerifiedClientMetadata`]: oauth2_types::registration::VerifiedClientMetadata +/// [`ClientErrorCode`]: oauth2_types::errors::ClientErrorCode +#[allow(clippy::too_many_lines)] +pub fn build_end_session_url( + mut end_session_endpoint: Url, + logout_data: LogoutData, + rng: &mut impl Rng, +) -> Result<(Url, Option), serde_urlencoded::ser::Error> { + let LogoutData { + id_token_hint, + logout_hint, + client_id, + post_logout_redirect_uri, + ui_locales, + } = logout_data; + + let state = if post_logout_redirect_uri.is_some() { + Some(Alphanumeric.sample_string(rng, 16)) + } else { + None + }; + + let logout_request = RpInitiatedLogoutRequest { + id_token_hint, + logout_hint, + client_id, + post_logout_redirect_uri, + state: state.clone(), + ui_locales, + }; + + let logout_query = serde_urlencoded::to_string(logout_request)?; + + // Add our parameters to the query, because the URL might already have one. + let mut full_query = end_session_endpoint + .query() + .map(ToOwned::to_owned) + .unwrap_or_default(); + if !full_query.is_empty() { + full_query.push('&'); + } + full_query.push_str(&logout_query); + + end_session_endpoint.set_query(Some(&full_query)); + + Ok((end_session_endpoint, state)) +}