Allow OAuth2 loopback redirects if the path matches (#3252)

This commit is contained in:
James Hodgkinson 2024-11-30 15:40:05 +10:00 committed by William Brown
parent c5f8196666
commit dafc98b1db
5 changed files with 181 additions and 85 deletions

View file

@ -178,11 +178,9 @@ codespell:
--skip='*.svg' \ --skip='*.svg' \
--skip='*.br' \ --skip='*.br' \
--skip='./rlm_python/mods-available/eap' \ --skip='./rlm_python/mods-available/eap' \
--skip='./server/web_ui/static/external' \ --skip='./server/lib/src/constants/system_config.rs'
--skip='./server/web_ui/pkg/external' \ --skip='./pykanidm/site' \
--skip='./server/web_ui/shared/static/external' \ --skip='./server/lib/src/constants/*.json'
--skip='./server/web_ui/admin/static/external,./server/web_ui/admin/pkg/external' \
--skip='./server/lib/src/constants/system_config.rs,./pykanidm/site,./server/lib/src/constants/*.json'
.PHONY: test/pykanidm/pytest .PHONY: test/pykanidm/pytest
test/pykanidm/pytest: ## python library testing test/pykanidm/pytest: ## python library testing

View file

@ -45,6 +45,7 @@ introspection.
Kanidm will expose its OAuth2 APIs at the following URLs, substituting Kanidm will expose its OAuth2 APIs at the following URLs, substituting
`:client_id:` with an OAuth2 client ID. `:client_id:` with an OAuth2 client ID.
<!-- markdownlint-disable MD033 -->
<dl> <dl>
@ -59,7 +60,7 @@ URL **(recommended)**
`https://idm.example.com/oauth2/openid/:client_id:/.well-known/openid-configuration` `https://idm.example.com/oauth2/openid/:client_id:/.well-known/openid-configuration`
This document includes all the URLs and attributes an app needs to be able to This document includes all the URLs and attributes an app needs to be able to
authenticate using OIDC with Kanidm, *except* for the `client_id` and authenticate using OIDC with Kanidm, _except_ for the `client_id` and
`client_secret`. `client_secret`.
Use this document wherever possible, as it will allow you to easily build and/or Use this document wherever possible, as it will allow you to easily build and/or
@ -183,6 +184,8 @@ Token signing public key
</dl> </dl>
<!-- markdownlint-enable MD037 -->
## Configuration ## Configuration
### Create the Kanidm Configuration ### Create the Kanidm Configuration
@ -220,12 +223,12 @@ kanidm system oauth2 update-scope-map nextcloud nextcloud_users email profile op
> claims may be added to the authorisation token. It is not guaranteed that all of the associated > claims may be added to the authorisation token. It is not guaranteed that all of the associated
> claims will be added. > claims will be added.
> >
> - **profile** - name, family_name, given_name, middle_name, nickname, preferred_username, profile, > * **profile** - name, family_name, given_name, middle_name, nickname, preferred_username, profile,
> picture, website, gender, birthdate, zoneinfo, locale, and updated_at > picture, website, gender, birthdate, zoneinfo, locale, and updated_at
> - **email** - email, email_verified > * **email** - email, email_verified
> - **address** - address > * **address** - address
> - **phone** - phone_number, phone_number_verified > * **phone** - phone_number, phone_number_verified
> - **groups** - groups > * **groups** - groups
<!-- this is just to split the templates up --> <!-- this is just to split the templates up -->
@ -233,6 +236,7 @@ kanidm system oauth2 update-scope-map nextcloud nextcloud_users email profile op
> >
> If you are creating an OpenID Connect (OIDC) client you **MUST** provide a scope map containing > If you are creating an OpenID Connect (OIDC) client you **MUST** provide a scope map containing
> `openid`. Without this, OpenID Connect clients **WILL NOT WORK**! > `openid`. Without this, OpenID Connect clients **WILL NOT WORK**!
>
> ```bash > ```bash
> kanidm system oauth2 update-scope-map nextcloud nextcloud_users openid > kanidm system oauth2 update-scope-map nextcloud nextcloud_users openid
> ``` > ```
@ -315,10 +319,7 @@ applications that act as the OAuth2 client and its corresponding webserver is th
In this case, the SPA is unable to act as a confidential client since the basic secret would need to In this case, the SPA is unable to act as a confidential client since the basic secret would need to
be embedded in every client. be embedded in every client.
Another common example is native applications that use a redirect to localhost. These can't have a For this reason, public clients require PKCE to bind a specific browser session to its OAuth2
client secret embedded, so must act as public clients.
Public clients for this reason require PKCE to bind a specific browser session to its OAuth2
exchange. PKCE can not be disabled for public clients for this reason. exchange. PKCE can not be disabled for public clients for this reason.
To create an OAuth2 public client: To create an OAuth2 public client:
@ -328,7 +329,13 @@ kanidm system oauth2 create-public <name> <displayname> <origin>
kanidm system oauth2 create-public mywebapp "My Web App" https://webapp.example.com kanidm system oauth2 create-public mywebapp "My Web App" https://webapp.example.com
``` ```
To allow localhost redirection ## Native Applications
Some applications will run a local web server on the user's device which directs users to the IDP for
authentication, then back to the local server. [BCP212](https://www.rfc-editor.org/info/bcp212)
"OAuth 2.0 for Native Apps" specifies the rules for this.
First allow localhost redirects:
```bash ```bash
kanidm system oauth2 enable-localhost-redirects <name> kanidm system oauth2 enable-localhost-redirects <name>
@ -336,6 +343,10 @@ kanidm system oauth2 disable-localhost-redirects <name>
kanidm system oauth2 enable-localhost-redirects mywebapp kanidm system oauth2 enable-localhost-redirects mywebapp
``` ```
> [!WARNING]
>
> Kanidm only allows these to be enabled on public clients where PKCE is enforced.
## Alternate Redirect URLs ## Alternate Redirect URLs
> [!WARNING] > [!WARNING]
@ -378,8 +389,10 @@ To indicate your readiness for this transition, all OAuth2 clients must have the
`strict-redirect-url` enabled. Once enabled, the client will begin to enforce the 1.4.0 strict `strict-redirect-url` enabled. Once enabled, the client will begin to enforce the 1.4.0 strict
validation behaviour. validation behaviour.
If you have not enabled `strict-redirect-url` on all OAuth2 clients the upgrade to 1.4.0 will refuse > [!WARNING]
to proceed. >
> If you have not enabled `strict-redirect-url` on all OAuth2 clients the upgrade to 1.4.0 will refuse
> to proceed.
To enable or disable strict validation: To enable or disable strict validation:

BIN
build_rs_cov.profraw Normal file

Binary file not shown.

View file

@ -434,6 +434,8 @@ impl KanidmClient {
id: &str, id: &str,
origin: &Url, origin: &Url,
) -> Result<(), ClientError> { ) -> Result<(), ClientError> {
// TODO: should we normalise loopback origins, so when a user specifies `http://localhost/foo` we store it as `http://[::1]/foo`?
let url_to_add = &[origin.as_str()]; let url_to_add = &[origin.as_str()];
self.perform_post_request( self.perform_post_request(
format!("/v1/oauth2/{}/_attr/{}", id, ATTR_OAUTH2_RS_ORIGIN).as_str(), format!("/v1/oauth2/{}/_attr/{}", id, ATTR_OAUTH2_RS_ORIGIN).as_str(),

View file

@ -46,7 +46,7 @@ use serde_with::{formats, serde_as};
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing::trace; use tracing::trace;
use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT}; use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
use url::{Origin, Url}; use url::{Host, Origin, Url};
use crate::idm::account::Account; use crate::idm::account::Account;
use crate::idm::server::{ use crate::idm::server::{
@ -251,9 +251,21 @@ enum OauthRSType {
}, },
} }
impl OauthRSType {
/// We only allow localhost redirects if PKCE is enabled/required
fn allow_localhost_redirect(&self) -> bool {
match self {
OauthRSType::Basic { .. } => false,
OauthRSType::Public {
allow_localhost_redirect,
} => *allow_localhost_redirect,
}
}
}
impl std::fmt::Debug for OauthRSType { impl std::fmt::Debug for OauthRSType {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let mut ds = f.debug_struct("Oauth2RSType"); let mut ds = f.debug_struct("OauthRSType");
match self { match self {
OauthRSType::Basic { enable_pkce, .. } => { OauthRSType::Basic { enable_pkce, .. } => {
ds.field("type", &"basic").field("pkce", enable_pkce) ds.field("type", &"basic").field("pkce", enable_pkce)
@ -1819,67 +1831,51 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
Oauth2Error::InvalidClientId Oauth2Error::InvalidClientId
})?; })?;
let allow_localhost_redirect = match &o2rs.type_ { // redirect_uri must be part of the client_id origins, unless the client is public and then it MAY
OauthRSType::Basic { .. } => false, // be a loopback address exempting it from this check and enforcement and we can carry on safely.
OauthRSType::Public { if o2rs.type_.allow_localhost_redirect() && check_is_loopback(&auth_req.redirect_uri) {
allow_localhost_redirect, debug!("Loopback redirect_uri detected, allowing for localhost");
} => *allow_localhost_redirect, } else {
}; // The legacy origin match is in use.
let origin_uri_matched =
!o2rs.strict_redirect_uri && o2rs.origins.contains(&auth_req.redirect_uri.origin());
// Strict uri validation is in use.
let strict_redirect_uri_matched =
o2rs.strict_redirect_uri && o2rs.redirect_uris.contains(&auth_req.redirect_uri);
// Allow opaque origins such as app uris.
let opaque_origin_matched = o2rs.opaque_origins.contains(&auth_req.redirect_uri);
let localhost_redirect = auth_req // At least one of these conditions must hold true to proceed.
.redirect_uri if !(strict_redirect_uri_matched || origin_uri_matched || opaque_origin_matched) {
.domain() if o2rs.strict_redirect_uri {
.map(|domain| domain == "localhost") warn!(
.unwrap_or_default(); "Invalid OAuth2 redirect_uri (must be an exact match to a redirect-url) - got {}",
auth_req.redirect_uri.as_str()
// Strict uri validation is in use. );
let strict_redirect_uri_matched = } else {
o2rs.strict_redirect_uri && o2rs.redirect_uris.contains(&auth_req.redirect_uri); warn!(
// The legacy origin match is in use. "Invalid OAuth2 redirect_uri (must be related to origin) - got {:?}",
let origin_uri_matched = auth_req.redirect_uri.origin()
!o2rs.strict_redirect_uri && o2rs.origins.contains(&auth_req.redirect_uri.origin()); );
// Allow opaque origins such as app uris. }
let opaque_origin_matched = o2rs.opaque_origins.contains(&auth_req.redirect_uri); return Err(Oauth2Error::InvalidOrigin);
// redirect_uri must be part of the client_id origin, unless the client is public and then it MAY }
// be localhost exempting it from this check and enforcement. // We have to specifically match on http here because non-http origins may be exempt from this
let localhost_redirect_matched = allow_localhost_redirect && localhost_redirect; // enforcement.
if (o2rs.origin_https_required && auth_req.redirect_uri.scheme() != "https")
// At least one of these conditions must hold true to proceed. && !opaque_origin_matched
if !(strict_redirect_uri_matched {
|| origin_uri_matched admin_warn!(
|| opaque_origin_matched "Invalid OAuth2 redirect_uri scheme (must be https for secure origin) - got {}",
|| localhost_redirect_matched) auth_req.redirect_uri.to_string()
{ );
if o2rs.strict_redirect_uri { return Err(Oauth2Error::InvalidOrigin);
warn!(
"Invalid OAuth2 redirect_uri (must be an exact match to a redirect-url) - got {}",
auth_req.redirect_uri.as_str()
);
} else {
warn!(
"Invalid OAuth2 redirect_uri (must be related to origin) - got {:?}",
auth_req.redirect_uri.origin()
);
} }
return Err(Oauth2Error::InvalidOrigin);
}
// We have to specifically match on http here because non-http origins may be exempt from this
// enforcement.
if !localhost_redirect
&& o2rs.origin_https_required
&& auth_req.redirect_uri.scheme() == "http"
{
admin_warn!(
"Invalid OAuth2 redirect_uri (must be https for secure origin) - got {:?}",
auth_req.redirect_uri.scheme()
);
return Err(Oauth2Error::InvalidOrigin);
} }
let code_challenge = if let Some(pkce_request) = &auth_req.pkce_request { let code_challenge = if let Some(pkce_request) = &auth_req.pkce_request {
if !o2rs.require_pkce() { if !o2rs.require_pkce() {
security_info!(?o2rs.name, "Insecure rs configuration - pkce is not enforced, but rs is requesting it!"); security_info!(?o2rs.name, "Insecure OAuth2 client configuration - PKCE is not enforced, but client is requesting it!");
} }
// CodeChallengeMethod must be S256 // CodeChallengeMethod must be S256
if pkce_request.code_challenge_method != CodeChallengeMethod::S256 { if pkce_request.code_challenge_method != CodeChallengeMethod::S256 {
@ -1891,7 +1887,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
security_error!(?o2rs.name, "No PKCE code challenge was provided with client in enforced PKCE mode."); security_error!(?o2rs.name, "No PKCE code challenge was provided with client in enforced PKCE mode.");
return Err(Oauth2Error::InvalidRequest); return Err(Oauth2Error::InvalidRequest);
} else { } else {
security_info!(?o2rs.name, "Insecure client configuration - pkce is not enforced."); security_info!(?o2rs.name, "Insecure client configuration - PKCE is not enforced.");
None None
}; };
@ -1928,7 +1924,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
}; };
let Some(account_uuid) = ident.get_uuid() else { let Some(account_uuid) = ident.get_uuid() else {
error!("consent request ident does not have a valid uuid, unable to proceed"); error!("Consent request ident does not have a valid UUID, unable to proceed");
return Err(Oauth2Error::InvalidRequest); return Err(Oauth2Error::InvalidRequest);
}; };
@ -2888,6 +2884,23 @@ fn parse_user_code(val: &str) -> Result<u32, Oauth2Error> {
}) })
} }
/// Check if a host is local (loopback or localhost)
fn host_is_local(host: &Host<&str>) -> bool {
match host {
Host::Ipv4(ip) => ip.is_loopback(),
Host::Ipv6(ip) => ip.is_loopback(),
Host::Domain(domain) => *domain == "localhost",
}
}
/// Ensure that the redirect URI is a loopback/localhost address
fn check_is_loopback(redirect_uri: &Url) -> bool {
redirect_uri.host().map_or(false, |host| {
// Check if the host is a loopback/localhost address.
host_is_local(&host)
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use base64::{engine::general_purpose, Engine as _}; use base64::{engine::general_purpose, Engine as _};
@ -2906,7 +2919,7 @@ mod tests {
use openssl::sha; use openssl::sha;
use crate::idm::accountpolicy::ResolvedAccountPolicy; use crate::idm::accountpolicy::ResolvedAccountPolicy;
use crate::idm::oauth2::{AuthoriseResponse, Oauth2Error}; use crate::idm::oauth2::{host_is_local, AuthoriseResponse, Oauth2Error, OauthRSType};
use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::idm::server::{IdmServer, IdmServerTransaction};
use crate::prelude::*; use crate::prelude::*;
use crate::value::{AuthType, OauthClaimMapJoin, SessionState}; use crate::value::{AuthType, OauthClaimMapJoin, SessionState};
@ -6593,12 +6606,16 @@ mod tests {
let ct = Duration::from_secs(TEST_CURRENT_TIME); let ct = Duration::from_secs(TEST_CURRENT_TIME);
let (_uat, ident, oauth2_rs_uuid) = setup_oauth2_resource_server_public(idms, ct).await; let (_uat, ident, oauth2_rs_uuid) = setup_oauth2_resource_server_public(idms, ct).await;
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap(); let mut idms_prox_write: crate::idm::server::IdmServerProxyWriteTransaction<'_> =
idms.proxy_write(ct).await.unwrap();
let modlist = ModifyList::new_list(vec![Modify::Present( let redirect_uri = Url::parse("http://localhost:8765/oauth2/result")
Attribute::OAuth2AllowLocalhostRedirect, .expect("Failed to parse redirect URL");
Value::Bool(true),
)]); let modlist = ModifyList::new_list(vec![
Modify::Present(Attribute::OAuth2AllowLocalhostRedirect, Value::Bool(true)),
Modify::Present(Attribute::OAuth2RsOrigin, Value::Url(redirect_uri.clone())),
]);
assert!(idms_prox_write assert!(idms_prox_write
.qs_write .qs_write
@ -6623,7 +6640,7 @@ mod tests {
code_challenge, code_challenge,
code_challenge_method: CodeChallengeMethod::S256, code_challenge_method: CodeChallengeMethod::S256,
}), }),
redirect_uri: Url::parse("http://localhost:8765/oauth2/result").unwrap(), redirect_uri: redirect_uri.clone(),
scope: OAUTH2_SCOPE_OPENID.to_string(), scope: OAUTH2_SCOPE_OPENID.to_string(),
nonce: Some("abcdef".to_string()), nonce: Some("abcdef".to_string()),
oidc_ext: Default::default(), oidc_ext: Default::default(),
@ -6655,7 +6672,7 @@ mod tests {
let token_req = AccessTokenRequest { let token_req = AccessTokenRequest {
grant_type: GrantTypeReq::AuthorizationCode { grant_type: GrantTypeReq::AuthorizationCode {
code: permit_success.code, code: permit_success.code,
redirect_uri: Url::parse("http://localhost:8765/oauth2/result").unwrap(), redirect_uri,
// From the first step. // From the first step.
code_verifier, code_verifier,
}, },
@ -6873,4 +6890,70 @@ mod tests {
dbg!(&res); dbg!(&res);
assert!(res.is_err()); assert!(res.is_err());
} }
#[test]
fn test_url_localhost_domain() {
// ref #2390 - localhost with ports for OAuth2 redirect_uri
// ensure host_is_local isn't true for a non-local host
let example_is_not_local = "https://example.com/sdfsdf";
println!("Ensuring that {} is not local", example_is_not_local);
assert!(!host_is_local(
&Url::parse(example_is_not_local)
.expect("Failed to parse example.com as a host?")
.host()
.expect(&format!(
"Couldn't get a host from {}",
example_is_not_local
))
));
let test_urls = [
("http://localhost:8080/oauth2/callback", "/oauth2/callback"),
("https://localhost/foo/bar", "/foo/bar"),
("http://127.0.0.1:12345/foo", "/foo"),
("http://[::1]:12345/foo", "/foo"),
];
for (url, path) in test_urls.into_iter() {
println!("Testing URL: {}", url);
let url = Url::parse(url).expect("One of the test values failed!");
assert!(host_is_local(
&url.host().expect("Didn't parse a host out?")
));
assert_eq!(url.path(), path);
}
}
#[test]
fn test_oauth2_rs_type_allow_localhost_redirect() {
let test_cases = [
(
OauthRSType::Public {
allow_localhost_redirect: true,
},
true,
),
(
OauthRSType::Public {
allow_localhost_redirect: false,
},
false,
),
(
OauthRSType::Basic {
authz_secret: "supersecret".to_string(),
enable_pkce: false,
},
false,
),
];
assert!(test_cases.iter().all(|(rs_type, expected)| {
let actual = rs_type.allow_localhost_redirect();
println!("Testing {:?} -> {}", rs_type, expected);
actual == *expected
}));
}
} }