kanidm/server/testkit/tests/oauth2_device_flow.rs
Firstyear 25c1c1573e
20250213 patch used wrong acp ()
Migrations and server bootstrap are very interconnected processes
and in this we'll be addressing and improving both.

Server bootstrap was performed by creating base entries in phases,
eventually bringing up enough of the *oldest* supported server
minimum remigration level, to then allow triggering of migrations.

Migrations then applied "patches" effectively ontop of this minimum
level to update entries to what they should be in newer versions of
the server.

This scheme has it's pros and cons, but the major con was that to
remove a migration meant squashing it's content back into the
minimum remigration level, and this was a human process that was
quite error prone and difficult to automate. As well, this scheme
also led to cases where the patch migrations would sometimes *not*
reflect all the needed changes or content, or in one case was actually
undone by a patchlevel fix up that was required to address a bug.

Invariably this led to issues, and cases where a new server may have
different content to a migrated one - not exactly what we want!

This is a new migration scheme that addresses this fragility. However
what it trades is verbosity of the content.

Rather than having a base set of entries and patching/updating small
sections ontop, we have migration data folders that contain the full
set of entries as they should appear at that migration level. This
makes the bootstrap process easier as we can just apply the migration
level as a whole, and targetted to what precise version we want.

This also makes migrations more durable as the content is explicitly
copied and all entries fully applied, so there is no risk that a
migration or data change can be forgotten or applied incorrectly. We
are expressing the full state of what our builtin and provided entries
should be.

Finally this rips out a number of places where migration data was being
used as test case data. Not all of these have been replaced (notably
in authsession with Account), but the majority have and have been replaced
with clearer use of constants rather than building whole entries just to
access the name and throw them away for example.
2025-02-28 10:18:48 +10:00

303 lines
9.8 KiB
Rust

#![allow(unused_imports)]
use std::collections::BTreeMap;
use std::str::FromStr;
use compact_jwt::{JwkKeySet, JwsEs256Verifier, JwsVerifier, OidcToken, OidcUnverified};
use kanidm_client::KanidmClient;
use kanidm_proto::constants::uri::{OAUTH2_AUTHORISE, OAUTH2_AUTHORISE_PERMIT};
use kanidm_proto::internal::Oauth2ClaimMapJoin;
use kanidm_proto::oauth2::{
AccessTokenRequest, AccessTokenResponse, AuthorisationResponse, GrantTypeReq,
};
use kanidmd_lib::constants::NAME_IDM_ALL_ACCOUNTS;
use kanidmd_lib::prelude::uri::{OAUTH2_AUTHORISE_DEVICE, OAUTH2_TOKEN_ENDPOINT};
use kanidmd_lib::prelude::{Attribute, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID, OAUTH2_SCOPE_READ};
use kanidmd_testkit::{
assert_no_cache, ADMIN_TEST_PASSWORD, ADMIN_TEST_USER, IDM_ADMIN_TEST_PASSWORD,
IDM_ADMIN_TEST_USER, NOT_ADMIN_TEST_EMAIL, NOT_ADMIN_TEST_PASSWORD, NOT_ADMIN_TEST_USERNAME,
TEST_INTEGRATION_RS_DISPLAY, TEST_INTEGRATION_RS_GROUP_ALL, TEST_INTEGRATION_RS_ID,
TEST_INTEGRATION_RS_REDIRECT_URL, TEST_INTEGRATION_RS_URL,
};
use oauth2_ext::basic::BasicClient;
use oauth2_ext::http::StatusCode;
use oauth2_ext::{
AuthUrl, ClientId, DeviceAuthorizationUrl, HttpRequest, HttpResponse, PkceCodeChallenge,
RequestTokenError, Scope, StandardDeviceAuthorizationResponse, StandardErrorResponse, TokenUrl,
};
use reqwest::Client;
use tracing::{debug, error, info};
use url::Url;
#[cfg(feature = "dev-oauth2-device-flow")]
async fn http_client(
request: HttpRequest,
) -> Result<HttpResponse, oauth2_ext::reqwest::Error<reqwest::Error>> {
// let ca_contents = std::fs::read("/tmp/kanidm/ca.pem")
// .map_err(|err| oauth2::reqwest::Error::Other(err.to_string()))?;
let client = Client::builder()
.danger_accept_invalid_certs(true)
// Following redirects opens the client up to SSRF vulnerabilities.
.redirect(reqwest::redirect::Policy::none())
// reqwest::Certificate::from_der(&ca_contents)
// .map_err(oauth2::reqwest::Error::Reqwest)?,
// )
.build()
.map_err(oauth2_ext::reqwest::Error::Reqwest)?;
let method = reqwest::Method::from_str(request.method.as_str())
.map_err(|err| oauth2_ext::reqwest::Error::Other(err.to_string()))?;
let mut request_builder = client
.request(method, request.url.as_str())
.body(request.body);
for (name, value) in &request.headers {
request_builder = request_builder.header(name.as_str(), value.as_bytes());
}
let response = client
.execute(request_builder.build().map_err(|err| {
error!("Failed to build request... {:?}", err);
oauth2_ext::reqwest::Error::Reqwest(err)
})?)
.await
.map_err(|err| {
error!("Failed to query url {} error={:?}", request.url, err);
oauth2_ext::reqwest::Error::Reqwest(err)
})?;
let status_code = StatusCode::from_u16(response.status().as_u16())
.map_err(|err| oauth2_ext::reqwest::Error::Other(err.to_string()))?;
let headers = response
.headers()
.into_iter()
.map(|(k, v)| {
debug!("header key={:?} value={:?}", k, v);
(
oauth2_ext::http::HeaderName::from_str(k.as_str()).expect("Failed to parse header"),
oauth2_ext::http::HeaderValue::from_str(
v.to_str().expect("Failed to parse header value"),
)
.expect("Failed to parse header value"),
)
})
.collect();
let body = response.bytes().await.map_err(|err| {
error!("Failed to parse body...? {:?}", err);
oauth2_ext::reqwest::Error::Reqwest(err)
})?;
info!("Response body: {:?}", String::from_utf8(body.to_vec()));
Ok(HttpResponse {
status_code,
headers,
body: body.to_vec(),
})
}
#[cfg(feature = "dev-oauth2-device-flow")]
#[kanidmd_testkit::test]
async fn oauth2_device_flow(rsclient: KanidmClient) {
let res = rsclient
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
// Create an oauth2 application integration.
rsclient
.idm_oauth2_rs_public_create(
TEST_INTEGRATION_RS_ID,
TEST_INTEGRATION_RS_DISPLAY,
TEST_INTEGRATION_RS_URL,
)
.await
.expect("Failed to create oauth2 config");
rsclient
.idm_oauth2_client_add_origin(
TEST_INTEGRATION_RS_ID,
&Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"),
)
.await
.expect("Failed to update oauth2 config");
// Extend the admin account with extended details for openid claims.
rsclient
.idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME)
.await
.expect("Failed to create account details");
rsclient
.idm_person_account_set_attr(
NOT_ADMIN_TEST_USERNAME,
Attribute::Mail.as_ref(),
&[NOT_ADMIN_TEST_EMAIL],
)
.await
.expect("Failed to create account mail");
rsclient
.idm_person_account_primary_credential_set_password(
NOT_ADMIN_TEST_USERNAME,
NOT_ADMIN_TEST_PASSWORD,
)
.await
.expect("Failed to configure account password");
rsclient
.idm_oauth2_rs_update(TEST_INTEGRATION_RS_ID, None, None, None, true, true, true)
.await
.expect("Failed to update oauth2 config");
rsclient
.idm_oauth2_rs_update_scope_map(
TEST_INTEGRATION_RS_ID,
NAME_IDM_ALL_ACCOUNTS,
vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID],
)
.await
.expect("Failed to update oauth2 scopes");
rsclient
.idm_oauth2_rs_update_sup_scope_map(
TEST_INTEGRATION_RS_ID,
NAME_IDM_ALL_ACCOUNTS,
vec![ADMIN_TEST_USER],
)
.await
.expect("Failed to update oauth2 scopes");
// Add a custom claim map.
rsclient
.idm_oauth2_rs_update_claim_map(
TEST_INTEGRATION_RS_ID,
"test_claim",
NAME_IDM_ALL_ACCOUNTS,
&["claim_a".to_string(), "claim_b".to_string()],
)
.await
.expect("Failed to update oauth2 claims");
// Set an alternate join
rsclient
.idm_oauth2_rs_update_claim_map_join(
TEST_INTEGRATION_RS_ID,
"test_claim",
Oauth2ClaimMapJoin::Ssv,
)
.await
.expect("Failed to update oauth2 claims");
// Get our admin's auth token for our new client.
// We have to re-auth to update the mail field.
let res = rsclient
.auth_simple_password(IDM_ADMIN_TEST_USER, IDM_ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
// set up the device flow values
let rsdata = rsclient
.idm_oauth2_rs_get(TEST_INTEGRATION_RS_ID)
.await
.expect("failed to query rs")
.expect("failed to get rsdata");
dbg!(&rsdata);
assert!(
!rsdata
.attrs
.contains_key(Attribute::OAuth2DeviceFlowEnable.as_str()),
"Found device flow enable attribute, shouldn't be there yet!"
);
rsclient
.idm_oauth2_client_device_flow_update(TEST_INTEGRATION_RS_ID, false)
.await
.expect("Failed to update oauth2 config to disable device flow");
rsclient
.idm_oauth2_client_device_flow_update(TEST_INTEGRATION_RS_ID, true)
.await
.expect("Failed to update oauth2 config to enable device flow");
let rsdata = rsclient
.idm_oauth2_rs_get(TEST_INTEGRATION_RS_ID)
.await
.expect("failed to query rs")
.expect("failed to get rsdata");
dbg!(&rsdata);
assert!(
rsdata
.attrs
.contains_key(Attribute::OAuth2DeviceFlowEnable.as_str()),
"Couldn't find device flow enable attribute"
);
assert_eq!(
rsdata
.attrs
.get(Attribute::OAuth2DeviceFlowEnable.as_str())
.expect("Couldn't find device flow enable attribute"),
&vec!["true".to_string()],
"Device flow enable attribute not set to true"
);
// ok we've checked that adding the thing works.
// now we need to test the device flow itself.
// first we need to get the device code.
// kanidm system oauth2 create-public device_flow device_flow 'https://deviceauth'
let client = BasicClient::new(
ClientId::new(TEST_INTEGRATION_RS_ID.to_string()),
None,
AuthUrl::new(rsclient.make_url(OAUTH2_AUTHORISE).to_string())
.expect("Failed to build authurl"),
Some(
TokenUrl::new(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT).to_string())
.expect("Failed to build token url"),
),
)
.set_device_authorization_url(
DeviceAuthorizationUrl::new(rsclient.make_url(OAUTH2_AUTHORISE_DEVICE).to_string())
.expect("Failed to build DeviceAuthorizationUrl"),
);
let details: StandardDeviceAuthorizationResponse = client
.exchange_device_code()
.expect("Failed to exchange device code")
.add_scope(Scope::new("read".to_string()))
.request_async(http_client)
.await
.expect("Failed to get device code!");
debug!("{:?}", details);
dbg!(&details.device_code().secret());
assert!(details.device_code().secret().len() == 24);
// now take that device code and get the token... glhf!
let result = client
.exchange_device_access_token(&details)
.request_async(
http_client,
tokio::time::sleep,
Some(std::time::Duration::from_secs(1)),
)
.await;
assert!(result.is_err());
let err = result.err().expect("Failed to get error");
dbg!(&err.to_string());
assert!(err.to_string().contains("Server returned error response"));
}