From b1e7cb13a54c8865154123bf3c7f90d51bf03cec Mon Sep 17 00:00:00 2001 From: Firstyear Date: Fri, 19 Jan 2024 14:14:52 +1000 Subject: [PATCH] Add rfc8414 metadata (#2434) --- book/src/integrations/oauth2.md | 6 + proto/src/oauth2.rs | 51 ++++++++ server/core/src/actors/v1_read.rs | 17 ++- server/core/src/https/oauth2.rs | 32 ++++- server/lib/src/idm/oauth2.rs | 197 +++++++++++++++++++++++++++++- 5 files changed, 298 insertions(+), 5 deletions(-) diff --git a/book/src/integrations/oauth2.md b/book/src/integrations/oauth2.md index 4aa78aff6..2cb871191 100644 --- a/book/src/integrations/oauth2.md +++ b/book/src/integrations/oauth2.md @@ -60,6 +60,12 @@ Kanidm will expose its OAuth2 APIs at the following URLs: - rfc7662 token introspection url: `https://idm.example.com/oauth2/token/introspect` - rfc7009 token revoke url: `https://idm.example.com/oauth2/token/revoke` +Oauth2 Server Metadata - you need to substitute your OAuth2 `:client_id:` in the following urls: + +- Oauth2 issuer uri: `https://idm.example.com/oauth2/openid/:client_id:/` +- Oauth2 rfc8414 discovery: + `https://idm.example.com/oauth2/openid/:client_id:/.well-known/oauth-authorization-server` + OpenID Connect discovery - you need to substitute your OAuth2 `:client_id:` in the following urls: - OpenID connect issuer uri: `https://idm.example.com/oauth2/openid/:client_id:/` diff --git a/proto/src/oauth2.rs b/proto/src/oauth2.rs index 5373efecc..8f79e4b96 100644 --- a/proto/src/oauth2.rs +++ b/proto/src/oauth2.rs @@ -224,6 +224,11 @@ pub enum SubjectType { Public, } +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum PkceAlg { + S256, +} + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "UPPERCASE")] // WE REFUSE TO SUPPORT NONE. DONT EVEN ASK. IT WON'T HAPPEN. @@ -343,6 +348,52 @@ pub struct OidcDiscoveryResponse { pub require_request_uri_registration: bool, } +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug)] +// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata +pub struct Oauth2Rfc8414MetadataResponse { + pub issuer: Url, + pub authorization_endpoint: Url, + pub token_endpoint: Url, + + pub jwks_uri: Option, + + // rfc7591 reg endpoint. + pub registration_endpoint: Option, + + pub scopes_supported: Option>, + + // For Oauth2 should be Code, Token. + pub response_types_supported: Vec, + #[serde(default = "response_modes_supported_default")] + pub response_modes_supported: Vec, + #[serde(default = "grant_types_supported_default")] + pub grant_types_supported: Vec, + + #[serde(default = "token_endpoint_auth_methods_supported_default")] + pub token_endpoint_auth_methods_supported: Vec, + + pub token_endpoint_auth_signing_alg_values_supported: Option>, + + pub service_documentation: Option, + pub ui_locales_supported: Option>, + + pub op_policy_uri: Option, + pub op_tos_uri: Option, + + // rfc7009 + pub revocation_endpoint: Option, + pub revocation_endpoint_auth_methods_supported: Vec, + + // rfc7662 + pub introspection_endpoint: Option, + pub introspection_endpoint_auth_methods_supported: Vec, + pub introspection_endpoint_auth_signing_alg_values_supported: Option>, + + // RFC7636 + pub code_challenge_methods_supported: Vec, +} + #[skip_serializing_none] #[derive(Serialize, Deserialize, Debug, Default)] pub struct ErrorResponse { diff --git a/server/core/src/actors/v1_read.rs b/server/core/src/actors/v1_read.rs index e58e646bc..e72951994 100644 --- a/server/core/src/actors/v1_read.rs +++ b/server/core/src/actors/v1_read.rs @@ -32,7 +32,8 @@ use kanidmd_lib::{ idm::ldap::{LdapBoundToken, LdapResponseState, LdapServer}, idm::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AuthorisationRequest, - AuthoriseResponse, JwkKeySet, Oauth2Error, OidcDiscoveryResponse, OidcToken, + AuthoriseResponse, JwkKeySet, Oauth2Error, Oauth2Rfc8414MetadataResponse, + OidcDiscoveryResponse, OidcToken, }, idm::server::{IdmServer, IdmServerTransaction}, idm::serviceaccount::ListApiTokenEvent, @@ -1432,6 +1433,20 @@ impl QueryServerReadV1 { idms_prox_read.oauth2_openid_discovery(&client_id) } + #[instrument( + level = "info", + skip_all, + fields(uuid = ?eventid) + )] + pub async fn handle_oauth2_rfc8414_metadata( + &self, + client_id: String, + eventid: Uuid, + ) -> Result { + let idms_prox_read = self.idms.proxy_read().await; + idms_prox_read.oauth2_rfc8414_metadata(&client_id) + } + #[instrument( level = "info", skip_all, diff --git a/server/core/src/https/oauth2.rs b/server/core/src/https/oauth2.rs index 7feb07a8b..40d2b822e 100644 --- a/server/core/src/https/oauth2.rs +++ b/server/core/src/https/oauth2.rs @@ -519,8 +519,6 @@ pub async fn oauth2_openid_discovery_get( Path(client_id): Path, Extension(kopid): Extension, ) -> impl IntoResponse { - // let client_id = req.get_url_param("client_id")?; - let res = state .qe_r_ref .handle_oauth2_openid_discovery(client_id, kopid.eventid) @@ -540,6 +538,30 @@ pub async fn oauth2_openid_discovery_get( } } +pub async fn oauth2_rfc8414_metadata_get( + State(state): State, + Path(client_id): Path, + Extension(kopid): Extension, +) -> impl IntoResponse { + let res = state + .qe_r_ref + .handle_oauth2_rfc8414_metadata(client_id, kopid.eventid) + .await; + + match res { + Ok(dsc) => ( + StatusCode::OK, + [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], + Json(dsc), + ) + .into_response(), + Err(e) => { + error!(err = ?e, "Unable to access discovery info"); + WebError::from(e).response_with_access_control_origin_header() + } + } +} + #[debug_handler] pub async fn oauth2_openid_userinfo_get( State(state): State, @@ -758,6 +780,12 @@ pub fn route_setup(state: ServerState) -> Router { "/oauth2/openid/:client_id/public_key.jwk", get(oauth2_openid_publickey_get), ) + // // ⚠️ ⚠️ WARNING ⚠️ ⚠️ + // // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OAUTH2 DISCOVERY URLS + .route( + "/oauth2/openid/:client_id/.well-known/oauth-authorization-server", + get(oauth2_rfc8414_metadata_get).options(oauth2_preflight_options), + ) .with_state(state.clone()); Router::new() diff --git a/server/lib/src/idm/oauth2.rs b/server/lib/src/idm/oauth2.rs index 268589848..5b779502e 100644 --- a/server/lib/src/idm/oauth2.rs +++ b/server/lib/src/idm/oauth2.rs @@ -24,7 +24,7 @@ use kanidm_proto::constants::*; pub use kanidm_proto::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse, GrantTypeReq, - OidcDiscoveryResponse, TokenRevokeRequest, + Oauth2Rfc8414MetadataResponse, OidcDiscoveryResponse, PkceAlg, TokenRevokeRequest, }; use kanidm_proto::oauth2::{ ClaimType, DisplayValue, GrantType, IdTokenSignAlg, ResponseMode, ResponseType, SubjectType, @@ -283,6 +283,8 @@ pub struct Oauth2RS { // For discovery we need to build and keep a number of values. authorization_endpoint: Url, token_endpoint: Url, + revocation_endpoint: Url, + introspection_endpoint: Url, userinfo_endpoint: Url, jwks_uri: Url, scopes_supported: BTreeSet, @@ -533,6 +535,12 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { let mut token_endpoint = self.inner.origin.clone(); token_endpoint.set_path("/oauth2/token"); + let mut revocation_endpoint = self.inner.origin.clone(); + revocation_endpoint.set_path("/oauth2/token/revoke"); + + let mut introspection_endpoint = self.inner.origin.clone(); + introspection_endpoint.set_path("/oauth2/token/introspect"); + let mut userinfo_endpoint = self.inner.origin.clone(); userinfo_endpoint.set_path(&format!("/oauth2/openid/{name}/userinfo")); @@ -571,6 +579,8 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { iss, authorization_endpoint, token_endpoint, + revocation_endpoint, + introspection_endpoint, userinfo_endpoint, jwks_uri, scopes_supported, @@ -1936,6 +1946,82 @@ impl<'a> IdmServerProxyReadTransaction<'a> { } } + #[instrument(level = "debug", skip_all)] + pub fn oauth2_rfc8414_metadata( + &self, + client_id: &str, + ) -> Result { + let o2rs = self.oauth2rs.inner.rs_set.get(client_id).ok_or_else(|| { + admin_warn!( + "Invalid OAuth2 client_id (have you configured the OAuth2 resource server?)" + ); + OperationError::NoMatchingEntries + })?; + + let issuer = o2rs.iss.clone(); + let authorization_endpoint = o2rs.authorization_endpoint.clone(); + let token_endpoint = o2rs.token_endpoint.clone(); + let revocation_endpoint = Some(o2rs.revocation_endpoint.clone()); + let introspection_endpoint = Some(o2rs.introspection_endpoint.clone()); + let jwks_uri = Some(o2rs.jwks_uri.clone()); + let scopes_supported = Some(o2rs.scopes_supported.iter().cloned().collect()); + let response_types_supported = vec![ResponseType::Code]; + let response_modes_supported = vec![ResponseMode::Query]; + let grant_types_supported = vec![GrantType::AuthorisationCode]; + + let token_endpoint_auth_methods_supported = vec![ + TokenEndpointAuthMethod::ClientSecretBasic, + TokenEndpointAuthMethod::ClientSecretPost, + ]; + + let revocation_endpoint_auth_methods_supported = vec![ + TokenEndpointAuthMethod::ClientSecretBasic, + TokenEndpointAuthMethod::ClientSecretPost, + ]; + + let introspection_endpoint_auth_methods_supported = vec![ + TokenEndpointAuthMethod::ClientSecretBasic, + TokenEndpointAuthMethod::ClientSecretPost, + ]; + + let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone()); + + let require_pkce = match &o2rs.type_ { + OauthRSType::Basic { enable_pkce, .. } => *enable_pkce, + OauthRSType::Public { .. } => true, + }; + + let code_challenge_methods_supported = if require_pkce { + vec![PkceAlg::S256] + } else { + Vec::with_capacity(0) + }; + + Ok(Oauth2Rfc8414MetadataResponse { + issuer, + authorization_endpoint, + token_endpoint, + jwks_uri, + registration_endpoint: None, + scopes_supported, + response_types_supported, + response_modes_supported, + grant_types_supported, + token_endpoint_auth_methods_supported, + token_endpoint_auth_signing_alg_values_supported: None, + service_documentation, + ui_locales_supported: None, + op_policy_uri: None, + op_tos_uri: None, + revocation_endpoint, + revocation_endpoint_auth_methods_supported, + introspection_endpoint, + introspection_endpoint_auth_methods_supported, + introspection_endpoint_auth_signing_alg_values_supported: None, + code_challenge_methods_supported, + }) + } + #[instrument(level = "debug", skip_all)] pub fn oauth2_openid_discovery( &self, @@ -3527,6 +3613,114 @@ mod tests { ); } + #[idm_test] + async fn test_idm_oauth2_rfc8414_metadata( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (_secret, _uat, _ident, _) = + setup_oauth2_resource_server_basic(idms, ct, true, false, false).await; + + let idms_prox_read = idms.proxy_read().await; + + // check the discovery end point works as we expect + assert!( + idms_prox_read + .oauth2_rfc8414_metadata("nosuchclient") + .unwrap_err() + == OperationError::NoMatchingEntries + ); + + let discovery = idms_prox_read + .oauth2_rfc8414_metadata("test_resource_server") + .expect("Failed to get discovery"); + + assert!( + discovery.issuer + == Url::parse("https://idm.example.com/oauth2/openid/test_resource_server") + .unwrap() + ); + + assert!( + discovery.authorization_endpoint + == Url::parse("https://idm.example.com/ui/oauth2").unwrap() + ); + + assert!( + discovery.token_endpoint == Url::parse("https://idm.example.com/oauth2/token").unwrap() + ); + + assert!( + discovery.jwks_uri + == Some( + Url::parse( + "https://idm.example.com/oauth2/openid/test_resource_server/public_key.jwk" + ) + .unwrap() + ) + ); + + assert!(discovery.registration_endpoint.is_none()); + + assert!( + discovery.scopes_supported + == Some(vec![ + "groups".to_string(), + OAUTH2_SCOPE_OPENID.to_string(), + "supplement".to_string(), + ]) + ); + + assert!(discovery.response_types_supported == vec![ResponseType::Code]); + assert!(discovery.response_modes_supported == vec![ResponseMode::Query]); + assert!(discovery.grant_types_supported == vec![GrantType::AuthorisationCode]); + assert!( + discovery.token_endpoint_auth_methods_supported + == vec![ + TokenEndpointAuthMethod::ClientSecretBasic, + TokenEndpointAuthMethod::ClientSecretPost + ] + ); + assert!(discovery.service_documentation.is_some()); + + assert!(discovery.ui_locales_supported.is_none()); + assert!(discovery.op_policy_uri.is_none()); + assert!(discovery.op_tos_uri.is_none()); + + assert!( + discovery.revocation_endpoint + == Some(Url::parse("https://idm.example.com/oauth2/token/revoke").unwrap()) + ); + assert!( + discovery.revocation_endpoint_auth_methods_supported + == vec![ + TokenEndpointAuthMethod::ClientSecretBasic, + TokenEndpointAuthMethod::ClientSecretPost + ] + ); + + assert!( + discovery.introspection_endpoint + == Some(Url::parse("https://idm.example.com/oauth2/token/introspect").unwrap()) + ); + assert!( + discovery.introspection_endpoint_auth_methods_supported + == vec![ + TokenEndpointAuthMethod::ClientSecretBasic, + TokenEndpointAuthMethod::ClientSecretPost + ] + ); + assert!(discovery + .introspection_endpoint_auth_signing_alg_values_supported + .is_none()); + + assert_eq!( + discovery.code_challenge_methods_supported, + vec![PkceAlg::S256] + ) + } + #[idm_test] async fn test_idm_oauth2_openid_discovery( idms: &IdmServer, @@ -3611,7 +3805,6 @@ mod tests { .unwrap() ); - eprintln!("{:?}", discovery.scopes_supported); assert!( discovery.scopes_supported == Some(vec![