OpenAPI/swagger docs autogen (#2175)

* always be clippyin'
* pulling oauth2 api things out into their own module
* starting openapi generation
This commit is contained in:
James Hodgkinson 2023-10-14 12:39:14 +10:00 committed by GitHub
parent 8bcf1935a5
commit f28d5cef22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 3540 additions and 1200 deletions

93
Cargo.lock generated
View file

@ -2952,6 +2952,7 @@ dependencies = [
"tracing",
"url",
"urlencoding",
"utoipa",
"uuid",
"webauthn-rs-proto",
]
@ -3081,7 +3082,10 @@ dependencies = [
"tracing-subscriber",
"url",
"urlencoding",
"utoipa",
"utoipa-swagger-ui",
"uuid",
"walkdir",
]
[[package]]
@ -3158,6 +3162,7 @@ dependencies = [
"compact_jwt",
"fantoccini",
"futures",
"http",
"hyper-tls",
"kanidm_build_profiles",
"kanidm_client",
@ -4544,6 +4549,41 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust-embed"
version = "6.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "6.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"shellexpand",
"syn 2.0.38",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "7.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74"
dependencies = [
"sha2 0.10.8",
"walkdir",
]
[[package]]
name = "rustc-demangle"
version = "0.1.23"
@ -5656,6 +5696,47 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
[[package]]
name = "utoipa"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d82b1bc5417102a73e8464c686eef947bdfb99fcdfc0a4f228e81afa9526470a"
dependencies = [
"indexmap 2.0.2",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05d96dcd6fc96f3df9b3280ef480770af1b7c5d14bc55192baa9b067976d920c"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"regex",
"syn 2.0.38",
]
[[package]]
name = "utoipa-swagger-ui"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84614caa239fb25b2bb373a52859ffd94605ceb256eeb1d63436325cf81e3653"
dependencies = [
"axum",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"utoipa",
"zip",
]
[[package]]
name = "uuid"
version = "1.4.1"
@ -6317,6 +6398,18 @@ dependencies = [
"syn 2.0.38",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
"flate2",
]
[[package]]
name = "zstd"
version = "0.12.4"

View file

@ -91,7 +91,6 @@ axum = { version = "0.6.20", features = [
"form",
"headers",
"http2",
"http2",
"json",
"macros",
"multipart",
@ -208,6 +207,7 @@ tss-esapi = "^7.3.0"
url = "^2.4.1"
urlencoding = "2.1.3"
users = "^0.11.0"
utoipa = "3.5.0"
uuid = "^1.4.1"
wasm-bindgen = "^0.2.86"

View file

@ -135,14 +135,14 @@ reauthenticate for short periods to access higher levels of privilege.
When using a user command that requires these privileges you will be warned:
```
```shell
kanidm person credential update william
# Privileges have expired for william@idm.example.com - you need to re-authenticate again.
```
To reauthenticate
```
```shell
kanidm reauth -D william
```

View file

@ -24,7 +24,7 @@ use std::os::unix::fs::MetadataExt;
use std::path::Path;
use std::time::Duration;
use kanidm_proto::constants::{APPLICATION_JSON, ATTR_NAME};
use kanidm_proto::constants::{APPLICATION_JSON, ATTR_NAME, KOPID, KSESSIONID, KVERSION};
use kanidm_proto::v1::*;
use reqwest::header::CONTENT_TYPE;
use reqwest::Response;
@ -47,10 +47,6 @@ mod service_account;
mod sync_account;
mod system;
pub const KOPID: &str = "X-KANIDM-OPID";
pub const KSESSIONID: &str = "X-KANIDM-AUTH-SESSION-ID";
const KVERSION: &str = "X-KANIDM-VERSION";
const EXPECT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug)]

View file

@ -14,7 +14,6 @@ repository = { workspace = true }
[features]
wasm = ["webauthn-rs-proto/wasm"]
[dependencies]
base32 = { workspace = true }
base64urlsafedata = { workspace = true }
@ -27,6 +26,6 @@ time = { workspace = true, features = ["serde", "std"] }
tracing = { workspace = true }
url = { workspace = true, features = ["serde"] }
urlencoding = { workspace = true }
utoipa = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
webauthn-rs-proto = { workspace = true }

View file

@ -191,3 +191,9 @@ pub const TEST_ATTR_TEST_ATTR: &str = "testattr";
pub const TEST_ATTR_EXTRA: &str = "extra";
pub const TEST_ATTR_NUMBER: &str = "testattrnumber";
pub const TEST_ATTR_NOTALLOWED: &str = "notallowed";
pub const KSESSIONID: &str = "X-KANIDM-AUTH-SESSION-ID";
pub const KOPID: &str = "X-KANIDM-OPID";
pub const KVERSION: &str = "X-KANIDM-VERSION";
pub const X_FORWARDED_FOR: &str = "x-forwarded-for";

View file

@ -1,6 +1,7 @@
use base64urlsafedata::Base64UrlSafeData;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use utoipa::ToSchema;
use uuid::Uuid;
pub use scim_proto::prelude::{ScimAttr, ScimComplexAttr, ScimEntry, ScimError, ScimSimpleAttr};
@ -12,7 +13,7 @@ use crate::constants::{
ATTR_NAME, ATTR_SSH_PUBLICKEY,
};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema)]
pub enum ScimSyncState {
Refresh,
Active { cookie: Base64UrlSafeData },

View file

@ -9,6 +9,7 @@ use std::fmt;
use std::str::FromStr;
use time::OffsetDateTime;
use url::Url;
use utoipa::ToSchema;
use uuid::Uuid;
use webauthn_rs_proto::{
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
@ -19,7 +20,7 @@ use crate::constants::{ATTR_GROUP, ATTR_LDAP_SSHPUBLICKEY};
// These proto implementations are here because they have public definitions
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, ToSchema)]
pub enum AccountType {
Person,
ServiceAccount,
@ -35,7 +36,7 @@ impl ToString for AccountType {
}
/* ===== errors ===== */
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SchemaError {
NotImplemented,
@ -52,7 +53,7 @@ pub enum SchemaError {
PhantomAttribute(String),
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum PluginError {
AttrUnique(String),
@ -62,7 +63,7 @@ pub enum PluginError {
Oauth2Secrets,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ConsistencyError {
Unknown,
@ -88,7 +89,7 @@ pub enum ConsistencyError {
DeniedName(Uuid),
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum PasswordFeedback {
// https://docs.rs/zxcvbn/latest/zxcvbn/feedback/enum.Suggestion.html
@ -217,7 +218,7 @@ impl fmt::Display for PasswordFeedback {
}
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum OperationError {
SessionExpired,
@ -295,7 +296,7 @@ impl PartialEq for OperationError {
// domain specific fields for the purposes of IDM, over the normal
// entry/ava/filter types. These related deeply to schema.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct Group {
pub spn: String,
pub uuid: String,
@ -308,7 +309,7 @@ impl fmt::Display for Group {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct Claim {
pub name: String,
pub uuid: String,
@ -380,7 +381,7 @@ impl fmt::Display for UatStatusState {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(rename_all = "lowercase")]
pub struct UatStatus {
pub account_id: Uuid,
@ -426,7 +427,7 @@ pub enum UatPurpose {
/// This structure and how it works will *very much* change over time from this
/// point onward! This means on updates, that sessions will invalidate in many
/// cases.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(rename_all = "lowercase")]
pub struct UserAuthToken {
pub session_id: Uuid,
@ -499,7 +500,7 @@ pub enum ApiTokenPurpose {
Synchronise,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(rename_all = "lowercase")]
pub struct ApiToken {
// The account this is associated with.
@ -546,7 +547,7 @@ impl PartialEq for ApiToken {
impl Eq for ApiToken {}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(rename_all = "lowercase")]
pub struct ApiTokenGenerate {
pub label: String,
@ -560,7 +561,7 @@ pub struct ApiTokenGenerate {
// This is similar to uat, but omits claims (they have no role in radius), and adds
// the radius secret field.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct RadiusAuthToken {
pub name: String,
pub displayname: String,
@ -581,7 +582,7 @@ impl fmt::Display for RadiusAuthToken {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct UnixGroupToken {
pub name: String,
pub spn: String,
@ -598,13 +599,13 @@ impl fmt::Display for UnixGroupToken {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct GroupUnixExtend {
pub gidnumber: Option<u32>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct UnixUserToken {
pub name: String,
pub spn: String,
@ -639,7 +640,7 @@ impl fmt::Display for UnixUserToken {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct AccountUnixExtend {
pub gidnumber: Option<u32>,
@ -665,7 +666,7 @@ pub enum CredentialDetailType {
PasswordMfa(Vec<String>, Vec<String>, usize),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct CredentialDetail {
pub uuid: Uuid,
pub type_: CredentialDetailType,
@ -729,13 +730,13 @@ impl fmt::Display for CredentialDetail {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct PasskeyDetail {
pub uuid: Uuid,
pub tag: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct CredentialStatus {
pub creds: Vec<CredentialDetail>,
}
@ -750,7 +751,7 @@ impl fmt::Display for CredentialStatus {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct BackupCodesView {
pub backup_codes: Vec<String>,
}
@ -776,7 +777,7 @@ impl fmt::Display for Entry {
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Ord, PartialOrd, Eq, PartialEq, Hash)]
#[derive(Debug, Serialize, Deserialize, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Filter {
// This is attr - value
@ -804,7 +805,7 @@ pub enum Modify {
Purged(String),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct ModifyList {
pub mods: Vec<Modify>,
}
@ -815,7 +816,7 @@ impl ModifyList {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SearchRequest {
pub filter: Filter,
}
@ -826,7 +827,7 @@ impl SearchRequest {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SearchResponse {
pub entries: Vec<Entry>,
}
@ -837,7 +838,7 @@ impl SearchResponse {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateRequest {
pub entries: Vec<Entry>,
}
@ -848,7 +849,7 @@ impl CreateRequest {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct DeleteRequest {
pub filter: Filter,
}
@ -859,7 +860,7 @@ impl DeleteRequest {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ModifyRequest {
// Probably needs a modlist?
pub filter: Filter,
@ -884,7 +885,7 @@ impl ModifyRequest {
//
// On loginSuccess, we send a cookie, and that allows the token to be
// generated. The cookie can be shared between servers.
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthCredential {
Anonymous,
@ -909,7 +910,7 @@ impl fmt::Debug for AuthCredential {
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialOrd, Ord)]
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialOrd, Ord, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthMech {
Anonymous,
@ -935,13 +936,13 @@ impl fmt::Display for AuthMech {
}
}
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
#[derive(Debug, Serialize, Deserialize, Copy, Clone, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthIssueSession {
Token,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthStep {
// name
@ -962,14 +963,14 @@ pub enum AuthStep {
}
// Request auth for identity X with roles Y?
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AuthRequest {
pub step: AuthStep,
}
// Respond with the list of auth types and nonce, etc.
// It can also contain a denied, or success.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthAllowed {
Anonymous,
@ -1032,7 +1033,7 @@ impl fmt::Display for AuthAllowed {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthState {
// You need to select how you want to talk to me.
@ -1050,7 +1051,7 @@ pub enum AuthState {
// SuccessCookie,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AuthResponse {
pub sessionid: Uuid,
pub state: AuthState,
@ -1072,7 +1073,7 @@ pub enum SetCredentialRequest {
}
*/
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum TotpAlgo {
Sha1,
@ -1090,7 +1091,7 @@ impl fmt::Display for TotpAlgo {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TotpSecret {
pub accountname: String,
/// User-facing name of the system, issuer of the TOTP
@ -1123,12 +1124,12 @@ impl TotpSecret {
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CUIntentToken {
pub token: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CUSessionToken {
pub token: String,
}
@ -1170,7 +1171,7 @@ impl fmt::Debug for CURequest {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum CURegState {
// Nothing in progress.
None,
@ -1181,14 +1182,14 @@ pub enum CURegState {
Passkey(CreationChallengeResponse),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub enum CUExtPortal {
None,
Hidden,
Some(Url),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CUStatus {
// Display values
pub spn: String,
@ -1204,7 +1205,7 @@ pub struct CUStatus {
pub passkeys_can_edit: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
pub struct WhoamiResponse {
// Should we just embed the entry? Or destructure it?
pub youare: Entry,
@ -1217,7 +1218,7 @@ impl WhoamiResponse {
}
// Simple string value provision.
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SingleStringRequest {
pub value: String,
}

View file

@ -26,7 +26,6 @@ filetime = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }
http = { workspace = true }
hyper = { workspace = true }
kanidm_proto = { workspace = true }
kanidm_utils_users = { workspace = true }
@ -63,6 +62,15 @@ urlencoding = { workspace = true }
tempfile = { workspace = true }
url = { workspace = true, features = ["serde"] }
uuid = { workspace = true, features = ["serde", "v4"] }
utoipa = { workspace = true, features = [
"axum_extras",
"openapi_extensions",
"preserve_order", # Preserve order of properties when serializing the schema for a component.
] }
utoipa-swagger-ui = { version = "3.1.5", features = ["axum"] }
[dev-dependencies]
walkdir = { workspace = true }
[build-dependencies]
kanidm_build_profiles = { workspace = true }

View file

@ -308,6 +308,12 @@ impl fmt::Display for Configuration {
impl Default for Configuration {
fn default() -> Self {
Self::new()
}
}
impl Configuration {
pub fn new() -> Self {
Configuration {
address: DEFAULT_SERVER_ADDRESS.to_string(),
ldapaddress: None,
@ -335,12 +341,6 @@ impl Default for Configuration {
integration_repl_config: None,
}
}
}
impl Configuration {
pub fn new() -> Self {
Self::default()
}
pub fn new_for_test() -> Self {
Configuration {

View file

@ -0,0 +1,249 @@
use axum::{response::Redirect, routing::get, Router};
use kanidm_proto::{scim_v1::ScimSyncState, v1};
use utoipa::{
openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
Modify, OpenApi,
};
use utoipa_swagger_ui::SwaggerUi;
use super::{errors::WebError, ServerState};
pub(crate) mod path_schema;
pub(crate) mod response_schema;
#[cfg(test)]
pub(crate) mod tests;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"token_jwt",
SecurityScheme::Http(
HttpBuilder::new()
.scheme(HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
)
}
}
}
// docs for the derive macro are here: <https://docs.rs/utoipa-gen/3.5.0/utoipa_gen/derive.OpenApi.html#info-attribute-syntax>
#[derive(OpenApi)]
#[openapi(
paths(
super::generic::status,
super::generic::robots_txt,
super::oauth2::oauth2_image_get,
super::v1::raw_create,
super::v1::raw_delete,
super::v1::raw_modify,
super::v1::raw_search,
super::v1_oauth2::oauth2_id_image_delete,
super::v1_oauth2::oauth2_id_image_post,
super::v1_oauth2::oauth2_get,
super::v1_oauth2::oauth2_basic_post,
super::v1_oauth2::oauth2_public_post,
super::v1_oauth2::oauth2_id_get,
super::v1_oauth2::oauth2_id_patch,
super::v1_oauth2::oauth2_id_delete,
super::v1_oauth2::oauth2_id_image_post,
super::v1_oauth2::oauth2_id_image_delete,
super::v1_oauth2::oauth2_id_get_basic_secret,
super::v1_oauth2::oauth2_id_scopemap_post,
super::v1_oauth2::oauth2_id_scopemap_delete,
super::v1_oauth2::oauth2_id_sup_scopemap_post,
super::v1_oauth2::oauth2_id_sup_scopemap_delete,
super::v1_scim::scim_sync_post,
super::v1_scim::scim_sync_get,
super::v1::schema_get,
super::v1::whoami,
super::v1::whoami_uat,
super::v1::applinks_get,
super::v1::schema_attributetype_get,
super::v1::schema_attributetype_get_id,
super::v1::schema_classtype_get,
super::v1::schema_classtype_get_id,
super::v1::person_get,
super::v1::person_post,
super::v1::service_account_credential_generate,
super::v1::service_account_api_token_delete,
super::v1::service_account_api_token_get,
super::v1::service_account_api_token_post,
super::v1::person_id_get,
super::v1::person_id_patch,
super::v1::person_id_delete,
super::v1::person_id_get_attr,
super::v1::person_id_put_attr,
super::v1::person_id_post_attr,
super::v1::person_id_delete_attr,
super::v1::person_get_id_credential_status,
super::v1::person_id_credential_update_get,
super::v1::person_id_credential_update_intent_get,
super::v1::person_id_credential_update_intent_ttl_get,
super::v1::service_account_id_ssh_pubkeys_get,
super::v1::service_account_id_ssh_pubkeys_post,
super::v1::person_id_ssh_pubkeys_get,
super::v1::person_id_ssh_pubkeys_post,
super::v1::person_id_ssh_pubkeys_tag_get,
super::v1::person_id_ssh_pubkeys_tag_delete,
super::v1::person_id_radius_get,
super::v1::person_id_radius_post,
super::v1::person_id_radius_delete,
super::v1::person_id_radius_token_get,
super::v1::account_id_ssh_pubkeys_get,
super::v1::account_id_radius_token_post,
super::v1::service_account_id_unix_post,
super::v1::person_id_unix_credential_put,
super::v1::person_id_unix_credential_delete,
super::v1::person_identify_user_post,
super::v1::service_account_get,
super::v1::service_account_post,
super::v1::service_account_get,
super::v1::service_account_post,
super::v1::service_account_id_get,
super::v1::service_account_id_delete,
super::v1::service_account_id_get_attr,
super::v1::service_account_id_put_attr,
super::v1::service_account_id_post_attr,
super::v1::service_account_id_delete_attr,
super::v1::service_account_into_person,
super::v1::service_account_api_token_post,
super::v1::service_account_api_token_get,
super::v1::service_account_api_token_delete,
super::v1::service_account_credential_generate,
super::v1::service_account_id_credential_status_get,
super::v1::service_account_id_ssh_pubkeys_tag_get,
super::v1::service_account_id_ssh_pubkeys_tag_delete,
super::v1::account_id_unix_post,
super::v1::account_id_unix_auth_post,
super::v1::account_id_unix_token,
super::v1::account_id_unix_token,
super::v1::account_id_radius_token_post,
super::v1::account_id_radius_token_get,
super::v1::account_id_ssh_pubkeys_get,
super::v1::account_id_ssh_pubkeys_tag_get,
super::v1::account_id_user_auth_token_get,
super::v1::account_user_auth_token_delete,
super::v1::credential_update_exchange_intent,
super::v1::credential_update_status,
super::v1::credential_update_update,
super::v1::credential_update_commit,
super::v1::credential_update_cancel,
super::v1::domain_get,
super::v1::domain_attr_get,
super::v1::domain_attr_put,
super::v1::domain_attr_delete,
super::v1::group_id_unix_token_get,
super::v1::group_id_unix_post,
super::v1::group_get,
super::v1::group_post,
super::v1::group_id_get,
super::v1::group_id_delete,
super::v1::group_id_attr_delete,
super::v1::group_id_attr_get,
super::v1::group_id_attr_put,
super::v1::group_id_attr_post,
super::v1::system_get,
super::v1::system_attr_get,
super::v1::system_attr_post,
super::v1::system_attr_put,
super::v1::system_attr_delete,
super::v1::recycle_bin_get,
super::v1::recycle_bin_id_get,
super::v1::recycle_bin_revive_id_post,
super::v1::auth,
super::v1::auth_valid,
super::v1::logout,
super::v1::reauth,
super::v1_scim::sync_account_get,
super::v1_scim::sync_account_post,
super::v1_scim::sync_account_id_get,
super::v1_scim::sync_account_id_patch,
super::v1_scim::sync_account_id_attr_get,
super::v1_scim::sync_account_id_attr_put,
super::v1_scim::sync_account_id_finalise_get,
super::v1_scim::sync_account_id_terminate_get,
super::v1_scim::sync_account_token_post,
super::v1_scim::sync_account_token_delete,
super::v1::debug_ipinfo,
),
components(
schemas(
// TODO: can't add Entry/ProtoEntry to schema as this was only recently supported utoipa v3.5.0 doesn't support it - ref <https://github.com/juhaku/utoipa/pull/756/files>
// v1::Entry,
v1::AccountUnixExtend,
v1::ApiToken,
v1::ApiTokenGenerate,
v1::AuthRequest,
v1::AuthResponse,
v1::AuthState,
v1::BackupCodesView,
v1::Claim,
v1::CreateRequest,
v1::CredentialDetail,
v1::CredentialStatus,
v1::CUIntentToken,
v1::CUSessionToken,
v1::CUStatus,
v1::DeleteRequest,
v1::Group,
v1::GroupUnixExtend,
v1::ModifyList,
v1::ModifyRequest,
v1::PasskeyDetail,
v1::RadiusAuthToken,
v1::SearchRequest,
v1::SearchResponse,
v1::SingleStringRequest,
v1::TotpSecret,
v1::TotpAlgo,
v1::UatStatus,
v1::UnixGroupToken,
v1::UnixUserToken,
v1::UserAuthToken,
v1::WhoamiResponse,
ScimSyncState,
WebError,
)
),
modifiers(&SecurityAddon),
tags(
(name = "kanidm", description = "Kanidm API")
),
info(
title = "Kanidm",
description = "API for interacting with the Kanidm system. This is a work in progress",
contact( // <https://docs.rs/utoipa-gen/3.5.0/utoipa_gen/derive.OpenApi.html#info-attribute-syntax>
name="Kanidm",
url="https://github.com/kanidm/kanidm",
)
)
)]
pub(crate) struct ApiDoc;
pub(crate) fn router() -> Router<ServerState> {
Router::new()
.route("/docs", get(Redirect::temporary("/docs/swagger-ui")))
.route("/docs/", get(Redirect::temporary("/docs/swagger-ui")))
.merge(
SwaggerUi::new("/docs/swagger-ui").url(
"/docs/v1/openapi.json",
<ApiDoc as utoipa::OpenApi>::openapi(),
)
)
}

View file

@ -0,0 +1,34 @@
//! Path schema objects for the API documentation.
use serde::{Deserialize, Serialize};
use utoipa::IntoParams;
#[derive(IntoParams, Serialize, Deserialize, Debug)]
pub(crate) struct UuidOrName {
id: String,
}
#[derive(IntoParams, Serialize, Deserialize, Debug)]
pub(crate) struct TokenId {
token_id: String,
}
#[derive(IntoParams, Serialize, Deserialize, Debug)]
pub(crate) struct Id {
id: String,
}
#[derive(IntoParams, Serialize, Deserialize, Debug)]
pub(crate) struct Attr {
attr: String,
}
#[derive(IntoParams, Serialize, Deserialize, Debug)]
pub(crate) struct RsName {
// The short name of the OAuth2 resource server to target
rs_name: String,
}
#[derive(IntoParams, Serialize, Deserialize, Debug)]
pub(crate) struct GroupName {
// The short name of the group to target
group: String,
}

View file

@ -0,0 +1,56 @@
//! This file contains the default response schemas for the API.
//!
//! These are used to generate the OpenAPI schema definitions.
//!
use kanidm_proto::constants::APPLICATION_JSON;
use std::collections::BTreeMap;
use utoipa::{
openapi::{Content, RefOr, Response, ResponseBuilder, ResponsesBuilder},
IntoResponses,
};
#[allow(dead_code)] // because this is used for the OpenAPI schema gen
/// An empty response with `application/json` content type - use [ApiResponseWithout200] if you want to do everything but a 200
pub(crate) enum DefaultApiResponse {
Ok,
InvalidRequest,
NeedsAuthorization,
NotAuthorized,
}
impl IntoResponses for DefaultApiResponse {
fn responses() -> BTreeMap<String, RefOr<Response>> {
ResponsesBuilder::new()
.response(
"200",
ResponseBuilder::new()
.content(APPLICATION_JSON, Content::default())
.description("Ok"),
)
.response("400", ResponseBuilder::new().description("Invalid Request"))
.response("401", ResponseBuilder::new().description("Authorization required"))
.response("403", ResponseBuilder::new().description("Not Authorized"))
.build()
.into()
}
}
#[allow(dead_code)] // because this is used for the OpenAPI schema gen
/// A response set without the 200 status so the "defaults" can be handled.
pub(crate) enum ApiResponseWithout200 {
InvalidRequest,
NeedsAuthorization,
NotAuthorized,
}
impl IntoResponses for ApiResponseWithout200 {
fn responses() -> BTreeMap<String, RefOr<Response>> {
ResponsesBuilder::new()
.response("400", ResponseBuilder::new().description("Invalid Request"))
.response("401", ResponseBuilder::new().description("Authorization required"))
.response("403", ResponseBuilder::new().description("Not Authorized"))
.build()
.into()
}
}

View file

@ -0,0 +1,113 @@
#[test]
/// This parses the source code trying to make sure we have API docs for every endpoint we publish.
///
/// It's not perfect, but it's a start!
fn figure_out_if_we_have_all_the_routes() {
use std::collections::HashMap;
// load this file
let module_filename = format!("{}/src/https/apidocs/mod.rs", env!("CARGO_MANIFEST_DIR"));
println!("trying to load apidocs source file: {}", module_filename);
let file = std::fs::read_to_string(&module_filename).unwrap();
// find all the lines that start with super::v1:: and end with a comma
let apidocs_function_finder = regex::Regex::new(r#"super::([a-zA-Z0-9_:]+),"#).unwrap();
let mut apidocs_routes: HashMap<String, Vec<(String, String)>> = HashMap::new();
for line in file.lines() {
if let Some(caps) = apidocs_function_finder.captures(line) {
let route = caps.get(1).unwrap().as_str();
println!("route: {}", route);
let mut splitter = route.split("::");
let module = splitter.next().unwrap();
let handler = splitter.next().unwrap();
if !apidocs_routes.contains_key(module) {
apidocs_routes.insert(module.to_string(), Vec::new());
}
apidocs_routes
.get_mut(module)
.unwrap()
.push((handler.to_string(), "unset".to_string()));
}
}
for (module, routes) in apidocs_routes.iter() {
println!("API Module: {}", module);
for route in routes {
println!(" - {} (method: {})", route.0, route.1);
}
}
// this looks for method(handler) axum things
let routedef_finder =
regex::Regex::new(r#"(any|delete|get|head|options|patch|post|put|trace)\(([a-z:_]+)\)"#)
.unwrap();
// work our way through the source files in this package looking for routedefs
let mut found_routes: HashMap<String, Vec<(String, String)>> = HashMap::new();
let walker = walkdir::WalkDir::new(format!("{}/src", env!("CARGO_MANIFEST_DIR")))
.follow_links(false)
.into_iter();
for entry in walker {
let entry = entry.unwrap();
if entry.path().is_dir() {
continue;
}
println!("checking {}", entry.path().display());
// because nobody wants to see their project dir all over the place
let relative_filename = entry
.path()
.display()
.to_string()
.replace(&format!("{}/", env!("CARGO_MANIFEST_DIR")), "");
let source_module = relative_filename.split("/").last().unwrap();
let source_module = source_module.split(".").next().unwrap();
let file = std::fs::read_to_string(&entry.path()).unwrap();
for line in file.lines() {
if line.contains("skip_route_check") {
println!("Skipping this line because it contains skip_route_check");
continue;
}
if let Some(caps) = routedef_finder.captures(line) {
let method = caps.get(1).unwrap().as_str();
let route = caps.get(2).unwrap().as_str();
if !found_routes.contains_key(source_module) {
found_routes.insert(source_module.to_string(), Vec::new());
}
let new_route = (route.to_string(), method.to_string());
println!("Found new route: {} {:?}", source_module, new_route);
found_routes.get_mut(source_module).unwrap().push(new_route);
}
}
}
// now we check the things
for (module, routes) in found_routes {
if ["ui"].contains(&module.as_str()) {
println!(
"We can skip checking {} because it's allow-listed for docs",
module
);
continue;
}
if !apidocs_routes.contains_key(&module) {
panic!("Module {} is missing from the API docs", module);
}
// we can't handle the method yet because that's in the derive
for (route, _method) in routes {
let mut found_route = false;
for (apiroute_handler, _method) in apidocs_routes[&module].iter() {
if &route == apiroute_handler {
found_route = true;
break;
}
}
if !found_route {
panic!("couldn't find apidocs route for {}::{}", module, route);
} else {
println!("Docs OK: {}::{}", module, route);
}
}
}
}

View file

@ -0,0 +1,69 @@
//! Where we hide the error handling widgets
//!
use axum::response::{IntoResponse, Response};
use http::header::ACCESS_CONTROL_ALLOW_ORIGIN;
use http::{HeaderValue, StatusCode};
use kanidm_proto::v1::OperationError;
use utoipa::ToSchema;
/// The web app's top level error type, this takes an `OperationError` and converts it into a HTTP response.
#[derive(Debug, ToSchema)]
pub enum WebError {
/// Something went wrong when doing things.
OperationError(OperationError),
InternalServerError(String),
}
impl From<OperationError> for WebError {
fn from(inner: OperationError) -> Self {
WebError::OperationError(inner)
}
}
impl WebError {
pub(crate) fn response_with_access_control_origin_header(self) -> Response {
let mut res = self.into_response();
res.headers_mut().insert(
ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_str("*").expect("Header generation failed, this is weird."),
);
res
}
}
impl IntoResponse for WebError {
fn into_response(self) -> Response {
match self {
WebError::InternalServerError(inner) => {
(StatusCode::INTERNAL_SERVER_ERROR, inner).into_response()
}
WebError::OperationError(inner) => {
let (response_code, headers) = match &inner {
OperationError::NotAuthenticated | OperationError::SessionExpired => {
// https://datatracker.ietf.org/doc/html/rfc7235#section-4.1
(
StatusCode::UNAUTHORIZED,
// Some([("WWW-Authenticate", "Bearer")]),
Some([("WWW-Authenticate", "Bearer"); 1]),
)
}
OperationError::SystemProtectedObject | OperationError::AccessDenied => {
(StatusCode::FORBIDDEN, None)
}
OperationError::NoMatchingEntries => (StatusCode::NOT_FOUND, None),
OperationError::PasswordQuality(_)
| OperationError::EmptyRequest
| OperationError::SchemaViolation(_) => (StatusCode::BAD_REQUEST, None),
_ => (StatusCode::INTERNAL_SERVER_ERROR, None),
};
let body =
serde_json::to_string(&inner).unwrap_or_else(|_err| format!("{:?}", inner));
match headers {
Some(headers) => (response_code, headers, body).into_response(),
None => (response_code, body).into_response(),
}
}
}
}
}

View file

@ -4,13 +4,14 @@ use axum::{
http::{header::HeaderName, request::Parts, StatusCode},
RequestPartsExt,
};
use kanidm_proto::constants::X_FORWARDED_FOR;
use std::net::{IpAddr, SocketAddr};
use crate::https::ServerState;
#[allow(clippy::declare_interior_mutable_const)]
const X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
const X_FORWARDED_FOR_HEADER: HeaderName = HeaderName::from_static(X_FORWARDED_FOR);
pub struct TrustedClientIp(pub IpAddr);
@ -24,7 +25,7 @@ impl FromRequestParts<ServerState> for TrustedClientIp {
state: &ServerState,
) -> Result<Self, Self::Rejection> {
if state.trust_x_forward_for {
if let Some(x_forward_for) = parts.headers.get(X_FORWARDED_FOR) {
if let Some(x_forward_for) = parts.headers.get(X_FORWARDED_FOR_HEADER) {
// X forward for may be comma separate.
let first = x_forward_for
.to_str()

View file

@ -1,26 +1,45 @@
use axum::extract::State;
use axum::response::{IntoResponse, Response};
use axum::Extension;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Extension, Router};
use http::header::CONTENT_TYPE;
use kanidmd_lib::status::StatusRequestEvent;
use super::middleware::KOpId;
use super::ServerState;
/// Status endpoint used for healthchecks
#[utoipa::path(
get,
path = "/status",
responses(
(status = 200, description = "Ok"),
),
tag = "system",
)]
/// Status endpoint used for health checks, returns true when the server is up.
pub async fn status(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse {
) -> String {
let r = state
.status_ref
.handle_request(StatusRequestEvent {
eventid: kopid.eventid,
})
.await;
Response::new(format!("{}", r))
format!("{}", r)
}
#[utoipa::path(
get,
path = "/robots.txt",
responses(
(status = 200, description = "Ok"),
),
tag = "ui",
)]
pub async fn robots_txt() -> impl IntoResponse {
(
[(CONTENT_TYPE, "text/plain;charset=utf-8")],
@ -31,3 +50,9 @@ pub async fn robots_txt() -> impl IntoResponse {
),
)
}
pub(crate) fn route_setup() -> Router<ServerState> {
Router::new()
.route("/robots.txt", get(robots_txt))
.route("/status", get(status))
}

View file

@ -1,4 +1,5 @@
use axum::{
headers::{CacheControl, HeaderMapExt},
http::{self, Request},
middleware::Next,
response::Response,
@ -18,3 +19,19 @@ pub async fn dont_cache_me<B>(request: Request<B>, next: Next<B>) -> Response {
response
}
/// Adds `no-cache max-age=0` to the response headers.
pub async fn cache_me<B>(request: Request<B>, next: Next<B>) -> Response {
let mut response = next.run(request).await;
let cache_header = CacheControl::new()
.with_max_age(std::time::Duration::from_secs(300))
.with_private();
response.headers_mut().typed_insert(cache_header);
response.headers_mut().insert(
http::header::PRAGMA,
http::HeaderValue::from_static("no-cache"),
);
response
}

View file

@ -6,8 +6,8 @@ use axum::{
TypedHeader,
};
use http::HeaderValue;
use kanidm_proto::constants::{KOPID, KVERSION};
use uuid::Uuid;
pub(crate) mod caching;
pub(crate) mod compression;
pub(crate) mod hsts_header;
@ -21,14 +21,16 @@ pub async fn version_middleware<B>(request: Request<B>, next: Next<B>) -> Respon
let mut response = next.run(request).await;
response
.headers_mut()
.insert("X-KANIDM-VERSION", HeaderValue::from_static(KANIDM_VERSION));
.insert(KVERSION, HeaderValue::from_static(KANIDM_VERSION));
response
}
#[derive(Clone, Debug)]
/// For holding onto the event ID and other handy request-based things
pub struct KOpId {
/// The event correlation ID
pub eventid: Uuid,
/// The User Access Token, if present
pub uat: Option<String>,
}
@ -45,7 +47,9 @@ pub async fn are_we_json_yet<B>(request: Request<B>, next: Next<B>) -> Response
assert!(headers.contains_key(http::header::CONTENT_TYPE));
assert!(
headers.get(http::header::CONTENT_TYPE)
== Some(&HeaderValue::from_static(crate::https::APPLICATION_JSON))
== Some(&HeaderValue::from_static(
kanidm_proto::constants::APPLICATION_JSON
))
);
}
@ -72,7 +76,7 @@ pub async fn kopid_middleware<B>(
// This conversion *should never* fail. If it does, rather than panic, we warn and
// just don't put the id in the response.
let _ = HeaderValue::from_str(&eventid.as_hyphenated().to_string())
.map(|hv| response.headers_mut().insert("X-KANIDM-OPID", hv))
.map(|hv| response.headers_mut().insert(KOPID, hv))
.map_err(|err| {
warn!(?err, "An invalid operation id was encountered");
});

View file

@ -1,3 +1,6 @@
mod apidocs;
pub(crate) mod errors;
mod extractors;
mod generic;
mod javascript;
@ -8,35 +11,31 @@ mod tests;
pub(crate) mod trace;
mod ui;
mod v1;
mod v1_oauth2;
mod v1_scim;
use self::generic::*;
use self::javascript::*;
use crate::actors::v1_read::QueryServerReadV1;
use crate::actors::v1_write::QueryServerWriteV1;
use crate::config::{Configuration, ServerRole, TlsConfiguration};
use axum::extract::connect_info::{IntoMakeServiceWithConnectInfo, ResponseFuture};
use axum::middleware::{from_fn, from_fn_with_state};
use axum::response::{IntoResponse, Redirect, Response};
use axum::response::Redirect;
use axum::routing::*;
use axum::Router;
use axum_csp::{CspDirectiveType, CspValue};
use axum_macros::FromRef;
use compact_jwt::{Jws, JwsSigner, JwsUnverified};
use http::header::{ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE};
use http::{HeaderMap, HeaderValue, StatusCode};
use http::{HeaderMap, HeaderValue};
use hyper::server::accept::Accept;
use hyper::server::conn::{AddrStream, Http};
use hyper::Body;
use kanidm_proto::constants::APPLICATION_JSON;
use kanidm_proto::v1::OperationError;
use kanidm_proto::constants::KSESSIONID;
use kanidmd_lib::status::StatusActor;
use openssl::ssl::{Ssl, SslAcceptor, SslFiletype, SslMethod};
use sketching::*;
use tokio_openssl::SslStream;
use futures_util::future::poll_fn;
use serde::Serialize;
use tokio::net::TcpListener;
use tracing::Level;
@ -82,7 +81,7 @@ impl ServerState {
fn get_current_auth_session_id(&self, headers: &HeaderMap) -> Option<Uuid> {
// We see if there is a signed header copy first.
headers
.get("X-KANIDM-AUTH-SESSION-ID")
.get(KSESSIONID)
.and_then(|hv| {
// Get the first header value.
hv.to_str().ok()
@ -98,32 +97,39 @@ pub fn get_js_files(role: ServerRole) -> Vec<JavaScriptFile> {
// let's set up the list of js module hashes
{
let filepath = "wasmloader.js";
#[allow(clippy::unwrap_used)]
js_files.push(JavaScriptFile {
match generate_integrity_hash(format!(
"{}/{}",
env!("KANIDM_WEB_UI_PKG_PATH").to_owned(),
filepath,
hash: generate_integrity_hash(format!(
"{}/{}",
env!("KANIDM_WEB_UI_PKG_PATH").to_owned(),
)) {
Ok(hash) => js_files.push(JavaScriptFile {
filepath,
))
.unwrap(),
filetype: Some("module".to_string()),
});
hash,
filetype: Some("module".to_string()),
}),
Err(err) => {
admin_error!(?err, "Failed to generate integrity hash for wasmloader.js")
}
};
}
// let's set up the list of non-module hashes
{
let filepath = "external/bootstrap.bundle.min.js";
#[allow(clippy::unwrap_used)]
js_files.push(JavaScriptFile {
match generate_integrity_hash(format!(
"{}/{}",
env!("KANIDM_WEB_UI_PKG_PATH").to_owned(),
filepath,
hash: generate_integrity_hash(format!(
"{}/{}",
env!("KANIDM_WEB_UI_PKG_PATH").to_owned(),
)) {
Ok(hash) =>
js_files.push(JavaScriptFile {
filepath,
))
.unwrap(),
filetype: None,
});
hash,
filetype: None,
}),
Err(err) => {
admin_error!(?err, "Failed to generate integrity hash for bootstrap.bundle.min.js")
}
}
}
};
js_files
@ -197,28 +203,25 @@ pub async fn create_https_server(
let static_routes = match config.role {
ServerRole::WriteReplica | ServerRole::ReadOnlyReplica => {
// Create a spa router that captures everything at ui without key extraction.
let spa_router = Router::new()
.route("/", get(crate::https::ui::ui_handler))
.fallback(crate::https::ui::ui_handler);
Router::new()
// direct users to the base app page. If a login is required,
// then views will take care of redirection. We shouldn't redir
// to login because that force clears previous sessions!
.route("/", get(|| async { Redirect::temporary("/ui") }))
.route("/manifest.webmanifest", get(manifest::manifest))
.nest("/ui", spa_router)
.route("/manifest.webmanifest", get(manifest::manifest)) // skip_route_check
.nest("/ui", ui::spa_router())
.layer(middleware::compression::new())
.route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get))
// skip_route_check
}
ServerRole::WriteReplicaNoUI => Router::new(),
};
let app = Router::new()
.route("/robots.txt", get(robots_txt))
.route("/status", get(status))
.merge(oauth2::oauth2_route_setup(state.clone()))
.merge(v1_scim::scim_route_setup())
.merge(v1::router(state.clone()));
.merge(generic::route_setup())
.merge(oauth2::route_setup(state.clone()))
.merge(v1_scim::route_setup())
.merge(v1::route_setup(state.clone()));
let app = match config.role {
ServerRole::WriteReplicaNoUI => app,
@ -265,6 +268,7 @@ pub async fn create_https_server(
// to be exited, and this middleware sets up ids' and other bits for for logging
// coherence to be maintained.
.layer(from_fn(middleware::kopid_middleware))
.merge(apidocs::router())
// this MUST be the last layer before with_state else the span never starts and everything breaks.
.layer(trace_layer)
.with_state(state)
@ -402,85 +406,3 @@ pub(crate) async fn handle_conn(
}
}
}
/// Convert any kind of Result<T, OperationError> into an axum response with a stable type
/// by JSON-encoding the body.
#[instrument(name = "to_axum_response", level = "debug")]
pub fn to_axum_response<T: Serialize + core::fmt::Debug>(
v: Result<T, OperationError>,
) -> Response<Body> {
match v {
Ok(iv) => {
let body = match serde_json::to_string(&iv) {
Ok(val) => val,
Err(err) => {
error!("Failed to serialise response: {:?}", err);
format!("{:?}", iv)
}
};
trace!("Response Body: {:?}", body);
#[allow(clippy::unwrap_used)]
Response::builder()
.header(CONTENT_TYPE, APPLICATION_JSON)
.body(Body::from(body))
.unwrap()
}
Err(e) => {
debug!("OperationError: {:?}", e);
let res = match &e {
OperationError::NotAuthenticated | OperationError::SessionExpired => {
// https://datatracker.ietf.org/doc/html/rfc7235#section-4.1
Response::builder()
.status(http::StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Bearer")
}
OperationError::SystemProtectedObject | OperationError::AccessDenied => {
Response::builder().status(http::StatusCode::FORBIDDEN)
}
OperationError::NoMatchingEntries => {
Response::builder().status(http::StatusCode::NOT_FOUND)
}
OperationError::PasswordQuality(_)
| OperationError::EmptyRequest
| OperationError::SchemaViolation(_) => {
Response::builder().status(http::StatusCode::BAD_REQUEST)
}
_ => Response::builder().status(http::StatusCode::INTERNAL_SERVER_ERROR),
};
match serde_json::to_string(&e) {
#[allow(clippy::expect_used)]
Ok(val) => res
.body(Body::from(val))
.expect("Failed to build response!"),
#[allow(clippy::expect_used)]
Err(_) => res
.body(Body::from(format!("{:?}", e)))
.expect("Failed to build response!"),
}
}
}
}
/// Wrapper for the externally-defined error type from the protocol
pub struct HttpOperationError(OperationError);
impl IntoResponse for HttpOperationError {
fn into_response(self) -> Response {
let HttpOperationError(error) = self;
let body = match serde_json::to_string(&error) {
Ok(val) => val,
Err(e) => {
admin_warn!("Failed to serialize error response: original_error=\"{:?}\" serialization_error=\"{:?}\"", error , e);
format!("{:?}", error)
}
};
#[allow(clippy::unwrap_used)]
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Body::from(body))
.unwrap()
.into_response()
}
}

View file

@ -1,12 +1,13 @@
use super::errors::WebError;
use super::middleware::KOpId;
use super::v1::{json_rest_event_get, json_rest_event_post};
use super::{to_axum_response, HttpOperationError, ServerState};
use super::ServerState;
use axum::extract::{Path, Query, State};
use axum::middleware::from_fn;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Extension, Form, Json, Router};
use axum_macros::debug_handler;
use compact_jwt::{JwkKeySet, OidcToken};
use http::header::{
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION, CONTENT_TYPE,
LOCATION, WWW_AUTHENTICATE,
@ -14,9 +15,7 @@ use http::header::{
use http::{HeaderMap, HeaderValue, StatusCode};
use hyper::Body;
use kanidm_proto::constants::APPLICATION_JSON;
use kanidm_proto::internal::{ImageType, ImageValue};
use kanidm_proto::oauth2::{AuthorisationResponse, OidcDiscoveryResponse};
use kanidm_proto::v1::Entry as ProtoEntry;
use kanidm_proto::oauth2::{AuthorisationResponse, OidcDiscoveryResponse, AccessTokenResponse};
use kanidmd_lib::idm::oauth2::{
AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess,
AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest,
@ -24,9 +23,9 @@ use kanidmd_lib::idm::oauth2::{
use kanidmd_lib::prelude::f_eq;
use kanidmd_lib::prelude::*;
use kanidmd_lib::value::PartialValue;
use kanidmd_lib::valueset::image::ImageValueThings;
use serde::{Deserialize, Serialize};
// TODO: merge this into a value in WebError later
pub struct HTTPOauth2Error(Oauth2Error);
impl IntoResponse for HTTPOauth2Error {
@ -34,17 +33,18 @@ impl IntoResponse for HTTPOauth2Error {
let HTTPOauth2Error(error) = self;
if let Oauth2Error::AuthenticationRequired = error {
#[allow(clippy::unwrap_used)]
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(WWW_AUTHENTICATE, "Bearer")
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Body::empty())
.unwrap()
(
StatusCode::UNAUTHORIZED,
[
(WWW_AUTHENTICATE, "Bearer"),
(ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
],
)
.into_response()
} else {
let err = ErrorResponse {
error: error.to_string(),
..Default::default()
..Default::default()
};
let body = match serde_json::to_string(&err) {
@ -54,299 +54,71 @@ impl IntoResponse for HTTPOauth2Error {
format!("{:?}", err)
}
};
#[allow(clippy::unwrap_used)]
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Body::from(body))
.unwrap()
(
StatusCode::BAD_REQUEST,
[(ACCESS_CONTROL_ALLOW_ORIGIN, "*")],
body,
)
.into_response()
}
.into_response()
}
}
// == Oauth2 Configuration Endpoints ==
/// List all the OAuth2 Resource Servers
pub async fn oauth2_get(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse {
let filter = filter_all!(f_eq(
Attribute::Class,
EntryClass::OAuth2ResourceServer.into()
));
json_rest_event_get(state, None, filter, kopid).await
}
pub async fn oauth2_basic_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>,
) -> impl IntoResponse {
let classes = vec![
EntryClass::OAuth2ResourceServer.to_string(),
EntryClass::OAuth2ResourceServerBasic.to_string(),
EntryClass::Object.to_string(),
];
json_rest_event_post(state, classes, obj, kopid).await
}
pub async fn oauth2_public_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>,
) -> impl IntoResponse {
let classes = vec![
EntryClass::OAuth2ResourceServer.to_string(),
EntryClass::OAuth2ResourceServerPublic.to_string(),
EntryClass::Object.to_string(),
];
json_rest_event_post(state, classes, obj, kopid).await
}
/// Get a filter matching a given OAuth2 Resource Server
fn oauth2_id(rs_name: &str) -> Filter<FilterInvalid> {
pub(crate) fn oauth2_id(rs_name: &str) -> Filter<FilterInvalid> {
filter_all!(f_and!([
f_eq(Attribute::Class, EntryClass::OAuth2ResourceServer.into()),
f_eq(Attribute::OAuth2RsName, PartialValue::new_iname(rs_name))
]))
}
pub async fn oauth2_id_get(
State(state): State<ServerState>,
Path(rs_name): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> Response<Body> {
let filter = oauth2_id(&rs_name);
let res = state
.qe_r_ref
.handle_internalsearch(kopid.uat, filter, None, kopid.eventid)
.await
.map(|mut r| r.pop());
to_axum_response(res)
}
#[instrument(level = "info", skip(state))]
pub async fn oauth2_id_get_basic_secret(
#[utoipa::path(
get,
path = "/ui/images/oauth2/{rs_name}",
params(
super::apidocs::path_schema::RsName
),
responses(
(status = 200, description = "Ok", body=&[u8]),
(status = 403, description = "Authorization refused"),
(status = 403, description = "Authorization refused"),
),
security(("token_jwt" = [])),
tag = "ui",
)]
/// This returns the image for the OAuth2 Resource Server if the user has permissions
///
pub(crate) async fn oauth2_image_get(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
) -> Response<Body> {
let filter = oauth2_id(&rs_name);
let res = state
.qe_r_ref
.handle_oauth2_basic_secret_read(kopid.uat, filter, kopid.eventid)
.await;
to_axum_response(res)
}
pub async fn oauth2_id_patch(
State(state): State<ServerState>,
Path(rs_name): Path<String>,
Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>,
) -> Response<Body> {
let filter = oauth2_id(&rs_name);
let res = state
.qe_w_ref
.handle_internalpatch(kopid.uat, filter, obj, kopid.eventid)
.await;
to_axum_response(res)
}
pub async fn oauth2_id_scopemap_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((rs_name, group)): Path<(String, String)>,
Json(scopes): Json<Vec<String>>,
) -> Response<Body> {
let filter = oauth2_id(&rs_name);
let res = state
.qe_w_ref
.handle_oauth2_scopemap_update(kopid.uat, group, scopes, filter, kopid.eventid)
.await;
to_axum_response(res)
}
pub async fn oauth2_id_scopemap_delete(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((rs_name, group)): Path<(String, String)>,
) -> Response<Body> {
let filter = oauth2_id(&rs_name);
let res = state
.qe_w_ref
.handle_oauth2_scopemap_delete(kopid.uat, group, filter, kopid.eventid)
.await;
to_axum_response(res)
}
pub async fn oauth2_id_sup_scopemap_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((rs_name, group)): Path<(String, String)>,
Json(scopes): Json<Vec<String>>,
) -> Response<Body> {
let filter = oauth2_id(&rs_name);
let res = state
.qe_w_ref
.handle_oauth2_sup_scopemap_update(kopid.uat, group, scopes, filter, kopid.eventid)
.await;
to_axum_response(res)
}
pub async fn oauth2_id_sup_scopemap_delete(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((rs_name, group)): Path<(String, String)>,
) -> Response<Body> {
let filter = oauth2_id(&rs_name);
let res = state
.qe_w_ref
.handle_oauth2_sup_scopemap_delete(kopid.uat, group, filter, kopid.eventid)
.await;
to_axum_response(res)
}
pub async fn oauth2_id_delete(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
) -> Response<Body> {
let filter = oauth2_id(&rs_name);
let res = state
.qe_w_ref
.handle_internaldelete(kopid.uat, filter, kopid.eventid)
.await;
to_axum_response(res)
}
/// this returns the image for the user if the user has permissions
pub async fn oauth2_image_get(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
) -> Response<Body> {
) -> Response {
let rs_filter = oauth2_id(&rs_name);
let res = state
.qe_r_ref
.handle_oauth2_rs_image_get_image(kopid.uat, rs_filter)
.await;
let image = match res {
Ok(image) => image,
Err(_err) => {
admin_error!(
"Unable to get image for oauth2 resource server: {}",
rs_name
match res {
Ok(image) => (
StatusCode::OK,
[(CONTENT_TYPE, image.filetype.as_content_type_str())],
image.contents,
)
.into_response(),
Err(err) => {
admin_debug!(
"Unable to get image for oauth2 resource server {}: {:?}",
rs_name,
err
);
#[allow(clippy::unwrap_used)]
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap();
// TODO: a 404 probably isn't perfect but it's not the worst
(StatusCode::NOT_FOUND, "").into_response()
}
};
#[allow(clippy::expect_used)]
Response::builder()
.header(CONTENT_TYPE, image.filetype.as_content_type_str())
.body(Body::from(image.contents))
.expect("Somehow failed to turn an image into a response!")
}
pub async fn oauth2_id_image_delete(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
) -> Response<Body> {
let rs_filter = oauth2_id(&rs_name);
let res = state
.qe_w_ref
.handle_oauth2_rs_image_delete(kopid.uat, rs_filter)
.await;
to_axum_response(res)
}
pub async fn oauth2_id_image_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
mut multipart: axum::extract::Multipart,
) -> Response<Body> {
// because we might not get an image
let mut image: Option<ImageValue> = None;
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let filename = field.file_name().map(|f| f.to_string()).clone();
if let Some(filename) = filename {
let content_type = field.content_type().map(|f| f.to_string()).clone();
let content_type = match content_type {
Some(val) => {
if VALID_IMAGE_UPLOAD_CONTENT_TYPES.contains(&val.as_str()) {
val
} else {
debug!("Invalid content type: {}", val);
let res =
to_axum_response::<String>(Err(OperationError::InvalidRequestState));
return res;
}
}
None => {
debug!("No content type header provided");
let res = to_axum_response::<String>(Err(OperationError::InvalidRequestState));
return res;
}
};
let data = match field.bytes().await {
Ok(val) => val,
Err(_e) => {
let res = to_axum_response::<String>(Err(OperationError::InvalidRequestState));
return res;
}
};
let filetype = match ImageType::try_from_content_type(&content_type) {
Ok(val) => val,
Err(_err) => {
let res = to_axum_response::<String>(Err(OperationError::InvalidRequestState));
return res;
}
};
image = Some(ImageValue {
filetype,
filename: filename.to_string(),
contents: data.to_vec(),
});
};
}
let res = match image {
Some(image) => {
let image_validation_result = image.validate_image();
if let Err(err) = image_validation_result {
admin_error!("Invalid image uploaded: {:?}", err);
return to_axum_response::<String>(Err(OperationError::InvalidRequestState));
}
let rs_name = oauth2_id(&rs_name);
state
.qe_w_ref
.handle_oauth2_rs_image_update(kopid.uat, rs_name, image)
.await
}
None => Err(OperationError::InvalidAttribute(
"No image included, did you mean to use the DELETE method?".to_string(),
)),
};
to_axum_response(res)
}
// == OAUTH2 PROTOCOL FLOW HANDLERS ==
@ -701,7 +473,7 @@ pub async fn oauth2_token_post(
Extension(kopid): Extension<KOpId>,
headers: HeaderMap,
Form(tok_req): Form<AccessTokenRequest>,
) -> Result<Json<kanidm_proto::oauth2::AccessTokenResponse>, HTTPOauth2Error> {
) -> Result<Json<AccessTokenResponse>, HTTPOauth2Error> {
// This is called directly by the resource server, where we then issue
// the token to the caller.
@ -731,7 +503,7 @@ pub async fn oauth2_openid_discovery_get(
State(state): State<ServerState>,
Path(client_id): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> Result<Json<OidcDiscoveryResponse>, HttpOperationError> {
) -> Result<Json<OidcDiscoveryResponse>, Response> {
// let client_id = req.get_url_param("client_id")?;
let res = state
@ -743,7 +515,7 @@ pub async fn oauth2_openid_discovery_get(
Ok(dsc) => Ok(Json(dsc)),
Err(e) => {
error!(err = ?e, "Unable to access discovery info");
Err(HttpOperationError(e))
Err(WebError::from(e).response_with_access_control_origin_header())
}
}
}
@ -753,7 +525,7 @@ pub async fn oauth2_openid_userinfo_get(
State(state): State<ServerState>,
Path(client_id): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse {
) -> Result<Json<OidcToken>, HTTPOauth2Error> {
// The token we want to inspect is in the authorisation header.
let client_token = match kopid.uat {
Some(val) => val,
@ -778,13 +550,13 @@ pub async fn oauth2_openid_publickey_get(
State(state): State<ServerState>,
Path(client_id): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> Response<Body> {
to_axum_response(
state
.qe_r_ref
.handle_oauth2_openid_publickey(client_id, kopid.eventid)
.await,
)
) -> Result<Json<JwkKeySet>, WebError> {
state
.qe_r_ref
.handle_oauth2_openid_publickey(client_id, kopid.eventid)
.await
.map(Json::from)
.map_err(WebError::from)
}
/// This is called directly by the resource server, where we then issue
@ -887,12 +659,12 @@ pub async fn oauth2_token_revoke_post(
Some(val) => val,
None =>
{
#[allow(clippy::unwrap_used)]
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Body::empty())
.unwrap()
return (
StatusCode::UNAUTHORIZED,
[(ACCESS_CONTROL_ALLOW_ORIGIN, "*")],
""
)
.into_response();
}
};
@ -906,21 +678,17 @@ pub async fn oauth2_token_revoke_post(
match res {
Ok(()) =>
{
#[allow(clippy::unwrap_used)]
Response::builder()
.status(StatusCode::OK)
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Body::empty())
.unwrap()
(StatusCode::OK,
[(ACCESS_CONTROL_ALLOW_ORIGIN, "*")],
""
).into_response()
}
Err(Oauth2Error::AuthenticationRequired) => {
// This will trigger our ui to auth and retry.
#[allow(clippy::unwrap_used)]
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Body::empty())
.unwrap()
(StatusCode::UNAUTHORIZED,
[(ACCESS_CONTROL_ALLOW_ORIGIN, "*"),],
""
).into_response()
}
Err(e) => {
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
@ -928,30 +696,27 @@ pub async fn oauth2_token_revoke_post(
error: e.to_string(),
..Default::default()
};
#[allow(clippy::unwrap_used)]
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Body::from(
serde_json::to_string(&err).unwrap_or("".to_string()),
))
.unwrap()
(StatusCode::BAD_REQUEST,
[(ACCESS_CONTROL_ALLOW_ORIGIN, "*"),],
serde_json::to_string(&err).unwrap_or("".to_string()),
).into_response()
}
}
}
// Some requests from browsers require preflight so that CORS works.
pub async fn oauth2_preflight_options() -> Response<Body> {
#[allow(clippy::unwrap_used)]
Response::builder()
.status(StatusCode::OK)
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header(ACCESS_CONTROL_ALLOW_HEADERS, "Authorization")
.body(Body::empty())
.unwrap()
pub async fn oauth2_preflight_options() -> Response {
(
StatusCode::OK,
[
(ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
(ACCESS_CONTROL_ALLOW_HEADERS, "Authorization"),
],
String::new(),
).into_response()
}
pub fn oauth2_route_setup(state: ServerState) -> Router<ServerState> {
pub fn route_setup(state: ServerState) -> Router<ServerState> {
// this has all the openid-related routes
let openid_router = Router::new()
// // ⚠️ ⚠️ WARNING ⚠️ ⚠️
@ -975,7 +740,7 @@ pub fn oauth2_route_setup(state: ServerState) -> Router<ServerState> {
.with_state(state.clone());
Router::new()
.route("/oauth2", get(oauth2_get))
.route("/oauth2", get(super::v1_oauth2::oauth2_get))
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
.route(

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>🚰 Sink!</title>
<meta name="theme-color" content="white" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href="/pkg/img/favicon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<pre>
___
.' _ '.
/ /` `\ \
| | [__]
| | {{
| | }}
_ | | _ {{
___________<_>_| |_<_>}}________d
.=======^=(___)=^={{====.
/ .----------------}}---. \
/ / {{ \ \
/ / }} \ \
( '=========================' )
'-----------------------------'
</pre>
</body>
</html>

View file

@ -1,13 +1,20 @@
use axum::extract::State;
use axum::http::HeaderValue;
use axum::response::Response;
use axum::Extension;
use axum::routing::get;
use axum::{Extension, Router};
use http::header::CONTENT_TYPE;
use super::middleware::KOpId;
use super::ServerState;
pub async fn ui_handler(
pub(crate) fn spa_router() -> Router<ServerState> {
Router::new()
.route("/", get(ui_handler))
.fallback(ui_handler)
}
pub(crate) async fn ui_handler(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
) -> Response<String> {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,429 @@
use super::apidocs::path_schema;
use super::apidocs::response_schema::{DefaultApiResponse, ApiResponseWithout200};
use super::errors::WebError;
use super::middleware::KOpId;
use super::oauth2::oauth2_id;
use super::v1::{json_rest_event_get, json_rest_event_post};
use super::ServerState;
use axum::extract::{Path, State};
use axum::{Extension, Json};
use kanidm_proto::internal::{ImageType, ImageValue};
use kanidm_proto::v1::Entry as ProtoEntry;
use kanidmd_lib::prelude::*;
use kanidmd_lib::valueset::image::ImageValueThings;
use sketching::admin_error;
#[utoipa::path(
get,
path = "/v1/oauth2",
responses(
(status = 200,content_type="application/json", body=Vec<ProtoEntry>),
ApiResponseWithout200,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
/// Lists all the OAuth2 Resource Servers
pub(crate) async fn oauth2_get(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
) -> Result<Json<Vec<ProtoEntry>>, WebError> {
let filter = filter_all!(f_eq(
Attribute::Class,
EntryClass::OAuth2ResourceServer.into()
));
json_rest_event_get(state, None, filter, kopid).await
}
#[utoipa::path(
post,
path = "/v1/oauth2/basic",
request_body=ProtoEntry,
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
// TODO: what does this actually do? :D
pub(crate) async fn oauth2_basic_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>,
) -> Result<Json<()>, WebError> {
let classes = vec![
EntryClass::OAuth2ResourceServer.to_string(),
EntryClass::OAuth2ResourceServerBasic.to_string(),
EntryClass::Object.to_string(),
];
json_rest_event_post(state, classes, obj, kopid).await
}
#[utoipa::path(
post,
path = "/v1/oauth2/_public",
request_body=ProtoEntry,
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
// TODO: what does this actually do? :D
pub(crate) async fn oauth2_public_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>,
) -> Result<Json<()>, WebError> {
let classes = vec![
EntryClass::OAuth2ResourceServer.to_string(),
EntryClass::OAuth2ResourceServerPublic.to_string(),
EntryClass::Object.to_string(),
];
json_rest_event_post(state, classes, obj, kopid).await
}
#[utoipa::path(
get,
path = "/v1/oauth2/{rs_name}",
params(
path_schema::RsName
),
responses(
(status = 200, /* TODO response=Option<ProtoEntry>*/),
ApiResponseWithout200,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
/// Get the details of a given OAuth2 Resource Server.
pub(crate) async fn oauth2_id_get(
State(state): State<ServerState>,
Path(rs_name): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> Result<Json<Option<ProtoEntry>>, WebError> {
let filter = oauth2_id(&rs_name);
state
.qe_r_ref
.handle_internalsearch(kopid.uat, filter, None, kopid.eventid)
.await
.map(|mut r| r.pop())
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
get,
path = "/v1/oauth2/{rs_name}/_basic_secret",
params(
path_schema::RsName,
),
responses(
(status = 200,content_type="application/json", body=Option<String>),
ApiResponseWithout200,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
/// Get the basic secret for a given OAuth2 Resource Server. This is used for authentication.
#[instrument(level = "info", skip(state))]
pub(crate) async fn oauth2_id_get_basic_secret(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
) -> Result<Json<Option<String>>, WebError> {
let filter = oauth2_id(&rs_name);
state
.qe_r_ref
.handle_oauth2_basic_secret_read(kopid.uat, filter, kopid.eventid)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
patch,
path = "/v1/oauth2/{rs_name}",
params(
path_schema::RsName,
),
request_body=ProtoEntry,
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
/// Modify an OAuth2 Resource Server
pub(crate) async fn oauth2_id_patch(
State(state): State<ServerState>,
Path(rs_name): Path<String>,
Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>,
) -> Result<Json<()>, WebError> {
let filter = oauth2_id(&rs_name);
state
.qe_w_ref
.handle_internalpatch(kopid.uat, filter, obj, kopid.eventid)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
patch,
path = "/v1/oauth2/{rs_name}/_scopemap/{group}",
params(
path_schema::RsName,
path_schema::GroupName,
),
request_body=Vec<String>,
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
/// Modify the scope map for a given OAuth2 Resource Server
pub(crate) async fn oauth2_id_scopemap_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((rs_name, group)): Path<(String, String)>,
Json(scopes): Json<Vec<String>>,
) -> Result<Json<()>, WebError> {
let filter = oauth2_id(&rs_name);
state
.qe_w_ref
.handle_oauth2_scopemap_update(kopid.uat, group, scopes, filter, kopid.eventid)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
delete,
path = "/v1/oauth2/{rs_name}/_scopemap/{group}",
params(
path_schema::RsName,
path_schema::GroupName,
),
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
// Delete a scope map for a given OAuth2 Resource Server
pub(crate) async fn oauth2_id_scopemap_delete(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((rs_name, group)): Path<(String, String)>,
) -> Result<Json<()>, WebError> {
let filter = oauth2_id(&rs_name);
state
.qe_w_ref
.handle_oauth2_scopemap_delete(kopid.uat, group, filter, kopid.eventid)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
post,
path = "/v1/oauth2/{rs_name}/_sup_scopemap/{group}",
params(
path_schema::RsName,
path_schema::GroupName,
),
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
/// Create a supplemental scope map for a given OAuth2 Resource Server
pub(crate) async fn oauth2_id_sup_scopemap_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((rs_name, group)): Path<(String, String)>,
Json(scopes): Json<Vec<String>>,
) -> Result<Json<()>, WebError> {
let filter = oauth2_id(&rs_name);
state
.qe_w_ref
.handle_oauth2_sup_scopemap_update(kopid.uat, group, scopes, filter, kopid.eventid)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
delete,
path = "/v1/oauth2/{rs_name}/_sup_scopemap/{group}",
params(
path_schema::RsName,
path_schema::GroupName,
),
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
// Delete a supplemental scope map configuration.
pub(crate) async fn oauth2_id_sup_scopemap_delete(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((rs_name, group)): Path<(String, String)>,
) -> Result<Json<()>, WebError> {
let filter = oauth2_id(&rs_name);
state
.qe_w_ref
.handle_oauth2_sup_scopemap_delete(kopid.uat, group, filter, kopid.eventid)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
delete,
path = "/v1/oauth2/{rs_name}",
params(
path_schema::RsName,
),
responses(
DefaultApiResponse,
(status = 404),
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
/// Delete an OAuth2 Resource Server
pub(crate) async fn oauth2_id_delete(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
) -> Result<Json<()>, WebError> {
let filter = oauth2_id(&rs_name);
state
.qe_w_ref
.handle_internaldelete(kopid.uat, filter, kopid.eventid)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
delete,
path = "/v1/oauth2/{rs_name}/_image",
params(
path_schema::RsName,
),
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
// API endpoint for deleting the image associated with an OAuth2 Resource Server.
pub(crate) async fn oauth2_id_image_delete(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
) -> Result<Json<()>, WebError> {
state
.qe_w_ref
.handle_oauth2_rs_image_delete(kopid.uat, oauth2_id(&rs_name))
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
post,
path = "/v1/oauth2/{rs_name}/_image",
params(
path_schema::RsName,
),
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/oauth2",
)]
/// API endpoint for creating/replacing the image associated with an OAuth2 Resource Server.
///
/// It requires a multipart form with the image file, and the content type must be one of the
/// [VALID_IMAGE_UPLOAD_CONTENT_TYPES].
pub(crate) async fn oauth2_id_image_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<()>, WebError> {
// because we might not get an image
let mut image: Option<ImageValue> = None;
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let filename = field.file_name().map(|f| f.to_string()).clone();
if let Some(filename) = filename {
let content_type = field.content_type().map(|f| f.to_string()).clone();
let content_type = match content_type {
Some(val) => {
if VALID_IMAGE_UPLOAD_CONTENT_TYPES.contains(&val.as_str()) {
val
} else {
debug!("Invalid content type: {}", val);
return Err(OperationError::InvalidRequestState.into());
}
}
None => {
debug!("No content type header provided");
return Err(OperationError::InvalidRequestState.into());
}
};
let data = match field.bytes().await {
Ok(val) => val,
Err(_e) => return Err(OperationError::InvalidRequestState.into()),
};
let filetype = match ImageType::try_from_content_type(&content_type) {
Ok(val) => val,
Err(_err) => return Err(OperationError::InvalidRequestState.into()),
};
image = Some(ImageValue {
filetype,
filename: filename.to_string(),
contents: data.to_vec(),
});
};
}
match image {
Some(image) => {
let image_validation_result = image.validate_image();
match image_validation_result {
Err(err) => {
admin_error!("Invalid image uploaded: {:?}", err);
Err(WebError::from(OperationError::InvalidRequestState))
}
Ok(_) => {
let rs_name = oauth2_id(&rs_name);
state
.qe_w_ref
.handle_oauth2_rs_image_update(kopid.uat, rs_name, image)
.await
.map(Json::from)
.map_err(WebError::from)
}
}
}
None => Err(WebError::from(OperationError::InvalidAttribute(
"No image included, did you mean to use the DELETE method?".to_string(),
))),
}
}

View file

@ -1,190 +1,315 @@
use super::apidocs::path_schema;
use super::apidocs::response_schema::{ApiResponseWithout200,DefaultApiResponse};
use super::errors::WebError;
use super::middleware::KOpId;
use super::{to_axum_response, ServerState};
use axum::extract::{Path, State};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use axum_auth::AuthBearer;
use kanidm_proto::scim_v1::ScimSyncRequest;
use kanidm_proto::v1::Entry as ProtoEntry;
use kanidmd_lib::prelude::*;
use super::v1::{
json_rest_event_get, json_rest_event_get_id, json_rest_event_get_id_attr, json_rest_event_post,
json_rest_event_put_id_attr,
};
use super::ServerState;
use axum::extract::{Path, State};
use axum::response::Html;
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use axum_auth::AuthBearer;
use kanidm_proto::scim_v1::{ScimSyncRequest, ScimSyncState};
use kanidm_proto::v1::Entry as ProtoEntry;
use kanidmd_lib::prelude::*;
#[utoipa::path(
get,
path = "/v1/sync_account",
responses(
(status = 200,content_type="application/json", body=Vec<ProtoEntry>),
ApiResponseWithout200,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
/// Get all? the sync accounts.
pub async fn sync_account_get(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse {
) -> Result<Json<Vec<ProtoEntry>>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
json_rest_event_get(state, None, filter, kopid).await
}
#[utoipa::path(
post,
path = "/v1/sync_account",
// request_body=ProtoEntry,
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
pub async fn sync_account_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>,
) -> impl IntoResponse {
) -> Result<Json<()>, WebError> {
let classes: Vec<String> = vec![EntryClass::SyncAccount.into(), EntryClass::Object.into()];
json_rest_event_post(state, classes, obj, kopid).await
}
#[utoipa::path(
get,
path = "/v1/sync_account/{id}",
params(
path_schema::Id
),
responses(
(status = 200,content_type="application/json", body=Option<ProtoEntry>),
ApiResponseWithout200,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
/// Get the details of a sync account
pub async fn sync_account_id_get(
State(state): State<ServerState>,
Path(id): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse {
) -> Result<Json<Option<ProtoEntry>>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
json_rest_event_get_id(state, id, filter, None, kopid).await
}
#[utoipa::path(
patch,
path = "/v1/sync_account/{id}",
params(
path_schema::UuidOrName
),
request_body=ProtoEntry,
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
/// Modify a sync account in-place
pub async fn sync_account_id_patch(
State(state): State<ServerState>,
Path(id): Path<String>,
Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>,
) -> impl IntoResponse {
) -> Result<Json<()>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
let filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str())));
let res = state
state
.qe_w_ref
.handle_internalpatch(kopid.uat, filter, obj, kopid.eventid)
.await;
to_axum_response(res)
.await
.map(Json::from)
.map_err(WebError::from)
}
pub async fn sync_account_id_get_finalise(
#[utoipa::path(
get,
path = "/v1/sync_account/{id}/_finalise",
params(
path_schema::UuidOrName
),
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
// TODO: why is this a get and not a post?
pub async fn sync_account_id_finalise_get(
State(state): State<ServerState>,
Path(id): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse {
let res = state
) -> Result<Json<()>, WebError> {
state
.qe_w_ref
.handle_sync_account_finalise(kopid.uat, id, kopid.eventid)
.await;
to_axum_response(res)
.await
.map(Json::from)
.map_err(WebError::from)
}
pub async fn sync_account_id_get_terminate(
#[utoipa::path(
get,
path = "/v1/sync_account/{id}/_terminate",
params(
path_schema::UuidOrName
),
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
// TODO: why is this a get if it's a terminate?
pub async fn sync_account_id_terminate_get(
State(state): State<ServerState>,
Path(id): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse {
let res = state
) -> Result<Json<()>, WebError> {
state
.qe_w_ref
.handle_sync_account_terminate(kopid.uat, id, kopid.eventid)
.await;
to_axum_response(res)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
post,
path = "/v1/sync_account/{id}/_sync_token",
params(
path_schema::UuidOrName
),
responses(
(status = 200), // TODO: response content
ApiResponseWithout200,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
pub async fn sync_account_token_post(
State(state): State<ServerState>,
Path(id): Path<String>,
Extension(kopid): Extension<KOpId>,
Json(label): Json<String>,
) -> impl IntoResponse {
let res = state
) -> Result<Json<String>, WebError> {
state
.qe_w_ref
.handle_sync_account_token_generate(kopid.uat, id, label, kopid.eventid)
.await;
to_axum_response(res)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
delete,
path = "/v1/sync_account/{id}/_sync_token",
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
pub async fn sync_account_token_delete(
State(state): State<ServerState>,
Path(id): Path<String>,
Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse {
let res = state
) -> Result<Json<()>, WebError> {
state
.qe_w_ref
.handle_sync_account_token_destroy(kopid.uat, id, kopid.eventid)
.await;
to_axum_response(res)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
post,
path = "/scim/v1/Sync",
request_body = ScimSyncRequest,
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "scim",
)]
async fn scim_sync_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
AuthBearer(bearer): AuthBearer,
Json(changes): Json<ScimSyncRequest>,
) -> impl IntoResponse {
let res = state
) -> Result<Json<()>, WebError> {
state
.qe_w_ref
.handle_scim_sync_apply(Some(bearer), changes, kopid.eventid)
.await;
to_axum_response(res)
.await
.map(Json::from)
.map_err(WebError::from)
}
#[utoipa::path(
get,
path = "/scim/v1/Sync",
responses(
(status = 200), // TODO: response content
ApiResponseWithout200,
),
security(("token_jwt" = [])),
tag = "scim",
)]
async fn scim_sync_get(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
AuthBearer(bearer): AuthBearer,
) -> impl IntoResponse {
) -> Result<Json<ScimSyncState>, WebError> {
// Given the token, what is it's connected sync state?
trace!(?bearer);
let res = state
state
.qe_r_ref
.handle_scim_sync_status(Some(bearer), kopid.eventid)
.await;
to_axum_response(res)
.await
.map(Json::from)
.map_err(WebError::from)
}
pub async fn sync_account_id_get_attr(
#[utoipa::path(
get,
path = "/v1/sync_account/{id}/_attr/{attr}",
params(
path_schema::UuidOrName,
path_schema::Attr,
),
responses(
(status = 200), // TODO: response content
ApiResponseWithout200,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
pub async fn sync_account_id_attr_get(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((id, attr)): Path<(String, String)>,
) -> impl IntoResponse {
) -> Result<Json<Option<Vec<String>>>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
json_rest_event_get_id_attr(state, id, attr, filter, kopid).await
}
pub async fn sync_account_id_put_attr(
#[utoipa::path(
post,
path = "/v1/sync_account/{id}/_attr/{attr}",
params(
path_schema::UuidOrName,
path_schema::Attr,
),
request_body=Vec<String>,
responses(
DefaultApiResponse,
),
security(("token_jwt" = [])),
tag = "v1/sync_account",
)]
pub async fn sync_account_id_attr_put(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
Path((id, attr)): Path<(String, String)>,
Json(values): Json<Vec<String>>,
) -> impl IntoResponse {
) -> Result<Json<()>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
json_rest_event_put_id_attr(state, id, attr, filter, values, kopid).await
}
async fn scim_sink_get() -> impl IntoResponse {
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="theme-color" content="white" />
<meta name="viewport" content="width=device-width" />
<title>Sink!</title>
<link rel="icon" href="/pkg/img/favicon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<pre>
___
.' _ '.
/ /` `\ \
| | [__]
| | {{
| | }}
_ | | _ {{
___________<_>_| |_<_>}}________
.=======^=(___)=^={{====.
/ .----------------}}---. \
/ / {{ \ \
/ / }} \ \
( '=========================' )
'-----------------------------'
</pre>
</body>
</html>"#
/// When you want the kitchen Sink
async fn scim_sink_get() -> Html<&'static str> {
Html::from(include_str!("scim/sink.html"))
}
pub fn scim_route_setup() -> Router<ServerState> {
pub fn route_setup() -> Router<ServerState> {
Router::new()
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.2
//
@ -254,6 +379,30 @@ pub fn scim_route_setup() -> Router<ServerState> {
//
// POST Send a sync update
//
.route(
"/v1/sync_account",
get(sync_account_get).post(sync_account_post),
)
.route(
"/v1/sync_account/:id",
get(sync_account_id_get).patch(sync_account_id_patch),
)
.route(
"/v1/sync_account/:id/_attr/:attr",
get(sync_account_id_attr_get).put(sync_account_id_attr_put),
)
.route(
"/v1/sync_account/:id/_finalise",
get(sync_account_id_finalise_get),
)
.route(
"/v1/sync_account/:id/_terminate",
get(sync_account_id_terminate_get),
)
.route(
"/v1/sync_account/:id/_sync_token",
post(sync_account_token_post).delete(sync_account_token_delete),
)
.route("/scim/v1/Sync", post(scim_sync_post).get(scim_sync_get))
.route("/scim/v1/Sink", get(scim_sink_get))
.route("/scim/v1/Sink", get(scim_sink_get)) // skip_route_check
}

View file

@ -373,6 +373,7 @@ struct ConsumerConnSettings {
replica_connect_timeout: Duration,
}
#[allow(clippy::too_many_arguments)]
async fn repl_task(
origin: Url,
client_key: PKey<Private>,
@ -827,7 +828,7 @@ async fn repl_acceptor(
}
if respond.send(success).is_err() {
warn!("Server certificate renewal was requested, but requsetor disconnected");
warn!("Server certificate renewal was requested, but requester disconnected!");
} else {
trace!("Sent server certificate renewal status via control channel");
}

View file

@ -278,11 +278,13 @@ impl fmt::Debug for FilterResolved {
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// A filter before it's gone through schema validation
pub struct FilterInvalid {
inner: FilterComp,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
/// A filter after it's gone through schema validation
pub struct FilterValid {
inner: FilterComp,
}

View file

@ -27,6 +27,7 @@ kanidmd_core = { workspace = true }
kanidmd_lib = { workspace = true }
# used for webdriver testing
hyper-tls = { workspace = true }
http = { workspace = true }
# used for webdriver testing
fantoccini = { version = "0.19.3", optional = true }
serde = { workspace = true }

View file

@ -649,31 +649,19 @@ async fn test_https_robots_txt(rsclient: KanidmClient) {
eprintln!(
"csp headers: {:#?}",
response.headers().get("content-security-policy")
response
.headers()
.get(http::header::CONTENT_SECURITY_POLICY)
);
assert_ne!(
response
.headers()
.get(http::header::CONTENT_SECURITY_POLICY),
None
);
assert_ne!(response.headers().get("content-security-policy"), None);
eprintln!("{}", response.text().await.unwrap());
}
// TODO: #1787 when the routemap comes back
// #[kanidmd_testkit::test]
// async fn test_https_routemap(rsclient: KanidmClient) {
// // We need to do manual reqwests here.
// let response = match reqwest::get(rsclient.make_url("/v1/routemap")).await {
// Ok(value) => value,
// Err(error) => {
// panic!("Failed to query {:?} : {:#?}", addr, error);
// }
// };
// eprintln!("response: {:#?}", response);
// assert_eq!(response.status(), 200);
// let body = response.text().await.unwrap();
// eprintln!("{}", body);
// assert!(body.contains("/scim/v1/Sync"));
// assert!(body.contains(r#""path": "/v1/routemap""#));
// }
/// This literally tests that the thing exists and responds in a way we expect, probably worth testing it better...
#[kanidmd_testkit::test]
async fn test_v1_raw_delete(rsclient: KanidmClient) {

View file

@ -20,6 +20,8 @@ async fn test_https_manifest(rsclient: KanidmClient) {
eprintln!(
"csp headers: {:#?}",
response.headers().get("content-security-policy")
response
.headers()
.get(http::header::CONTENT_SECURITY_POLICY)
);
}

View file

@ -4,6 +4,7 @@ use std::{
};
use kanidm_client::KanidmClient;
use kanidm_proto::constants::X_FORWARDED_FOR;
const DEFAULT_IP_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
@ -18,7 +19,7 @@ async fn dont_trust_xff_send_header(rsclient: KanidmClient) {
let res = client
.get(rsclient.make_url("/v1/debug/ipinfo"))
.header(
"X-Forwarded-For",
X_FORWARDED_FOR,
"An invalid header that will get through!!!",
)
.send()
@ -41,7 +42,7 @@ async fn dont_trust_xff_dont_send_header(rsclient: KanidmClient) {
let res = client
.get(rsclient.make_url("/v1/debug/ipinfo"))
.header(
"X-Forwarded-For",
X_FORWARDED_FOR,
"An invalid header that will get through!!!",
)
.send()
@ -69,7 +70,7 @@ async fn trust_xff_send_invalid_header_single_value(rsclient: KanidmClient) {
let res = client
.get(rsclient.make_url("/v1/debug/ipinfo"))
.header(
"X-Forwarded-For",
X_FORWARDED_FOR,
"An invalid header that will get through!!!",
)
.send()
@ -91,7 +92,7 @@ async fn trust_xff_send_invalid_header_multiple_values(rsclient: KanidmClient) {
let res = client
.get(rsclient.make_url("/v1/debug/ipinfo"))
.header(
"X-Forwarded-For",
X_FORWARDED_FOR,
"203.0.113.195_noooo_my_ip_address, 2001:db8:85a3:8d3:1319:8a2e:370:7348",
)
.send()
@ -111,7 +112,7 @@ async fn trust_xff_send_valid_header_single_ipv4_address(rsclient: KanidmClient)
.unwrap();
let res = client
.get(rsclient.make_url("/v1/debug/ipinfo"))
.header("X-Forwarded-For", ip_addr)
.header(X_FORWARDED_FOR, ip_addr)
.send()
.await
.unwrap();
@ -133,7 +134,7 @@ async fn trust_xff_send_valid_header_single_ipv6_address(rsclient: KanidmClient)
.unwrap();
let res = client
.get(rsclient.make_url("/v1/debug/ipinfo"))
.header("X-Forwarded-For", ip_addr)
.header(X_FORWARDED_FOR, ip_addr)
.send()
.await
.unwrap();
@ -155,7 +156,7 @@ async fn trust_xff_send_valid_header_multiple_address(rsclient: KanidmClient) {
.unwrap();
let res = client
.get(rsclient.make_url("/v1/debug/ipinfo"))
.header("X-Forwarded-For", first_ip_addr)
.header(X_FORWARDED_FOR, first_ip_addr)
.send()
.await
.unwrap();
@ -177,7 +178,7 @@ async fn trust_xff_send_valid_header_multiple_address(rsclient: KanidmClient) {
.unwrap();
let res = client
.get(rsclient.make_url("/v1/debug/ipinfo"))
.header("X-Forwarded-For", second_ip_addr)
.header(X_FORWARDED_FOR, second_ip_addr)
.send()
.await
.unwrap();

View file

@ -19,9 +19,16 @@ async fn test_https_middleware_headers(rsclient: KanidmClient) {
assert_eq!(response.status(), 200);
eprintln!(
"csp headers: {:#?}",
response.headers().get("content-security-policy")
response
.headers()
.get(http::header::CONTENT_SECURITY_POLICY)
);
assert_ne!(
response
.headers()
.get(http::header::CONTENT_SECURITY_POLICY),
None
);
assert_ne!(response.headers().get("content-security-policy"), None);
// here we test the /ui/login endpoint which should have the headers
let response = match reqwest::get(rsclient.make_url("/ui/login")).await {
@ -39,7 +46,14 @@ async fn test_https_middleware_headers(rsclient: KanidmClient) {
eprintln!(
"csp headers: {:#?}",
response.headers().get("content-security-policy")
response
.headers()
.get(http::header::CONTENT_SECURITY_POLICY)
);
assert_ne!(
response
.headers()
.get(http::header::CONTENT_SECURITY_POLICY),
None
);
assert_ne!(response.headers().get("content-security-policy"), None);
}

View file

@ -23,7 +23,7 @@ macro_rules! assert_no_cache {
// Check we have correct nocache headers.
let cache_header: &str = $response
.headers()
.get("cache-control")
.get(http::header::CACHE_CONTROL)
.expect("missing cache-control header")
.to_str()
.expect("invalid cache-control header");
@ -151,9 +151,10 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
.expect("Failed to send discovery preflight request.");
assert!(response.status() == reqwest::StatusCode::OK);
let cors_header: &str = response
.headers()
.get("access-control-allow-origin")
.get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
.expect("missing access-control-allow-origin header")
.to_str()
.expect("invalid access-control-allow-origin header");
@ -646,7 +647,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
assert!(response.status() == reqwest::StatusCode::OK);
let cors_header: &str = response
.headers()
.get("access-control-allow-origin")
.get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
.expect("missing access-control-allow-origin header")
.to_str()
.expect("invalid access-control-allow-origin header");

View file

@ -2,6 +2,7 @@
use std::path::Path;
use std::time::SystemTime;
use kanidm_proto::constants::KSESSIONID;
use kanidm_proto::internal::ImageValue;
use kanidm_proto::v1::{
ApiToken, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthResponse, AuthState,
@ -1826,7 +1827,7 @@ async fn start_password_session(
};
assert_eq!(res.status(), 200);
let session_id = res.headers().get("x-kanidm-auth-session-id").unwrap();
let session_id = res.headers().get(KSESSIONID).unwrap();
let authreq = AuthRequest {
step: AuthStep::Begin(AuthMech::Password),
@ -1836,7 +1837,7 @@ async fn start_password_session(
let res = match client
.post(rsclient.make_url("/v1/auth"))
.header("Content-Type", "application/json")
.header("x-kanidm-auth-session-id", session_id)
.header(KSESSIONID, session_id)
.body(authreq)
.send()
.await
@ -1854,7 +1855,7 @@ async fn start_password_session(
let res = match client
.post(rsclient.make_url("/v1/auth"))
.header("Content-Type", "application/json")
.header("x-kanidm-auth-session-id", session_id)
.header(KSESSIONID, session_id)
.body(authreq)
.send()
.await

View file

@ -105,6 +105,7 @@ async fn test_scim_sync_get(rsclient: KanidmClient) {
.default_headers(headers)
.build()
.unwrap();
// here we test the /ui/ endpoint which should have the headers
let response = match client.get(rsclient.make_url("/scim/v1/Sync")).send().await {
Ok(value) => value,
@ -117,12 +118,30 @@ async fn test_scim_sync_get(rsclient: KanidmClient) {
}
};
eprintln!("response: {:#?}", response);
// assert_eq!(response.status(), 200);
assert!(response.status().is_client_error());
// check that the CSP headers are coming back
eprintln!(
"csp headers: {:#?}",
response.headers().get(http::header::CONTENT_SECURITY_POLICY)
);
assert_ne!(response.headers().get(http::header::CONTENT_SECURITY_POLICY), None);
// test that the proper content type comes back
let url = rsclient.make_url("/scim/v1/Sink");
let response = match client.get(url.clone()).send().await {
Ok(value) => value,
Err(error) => {
panic!(
"Failed to query {:?} : {:#?}",
url,
error
);
}
};
assert!( response.status().is_success());
let content_type = response.headers().get(http::header::CONTENT_TYPE).unwrap();
assert!(content_type.to_str().unwrap().contains("text/html"));
assert!(response.text().await.unwrap().contains("Sink"));
// eprintln!(
// "csp headers: {:#?}",
// response.headers().get("content-security-policy")
// );
// assert_ne!(response.headers().get("content-security-policy"), None);
// eprintln!("{}", response.text().await.unwrap());
}

View file

@ -15,7 +15,7 @@
use error::FetchError;
use gloo::console;
use kanidm_proto::constants::APPLICATION_JSON;
use kanidm_proto::constants::{APPLICATION_JSON, KSESSIONID};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
@ -91,7 +91,7 @@ pub async fn do_request(
if let Some(sessionid) = models::pop_auth_session_id() {
request
.headers()
.set("x-kanidm-auth-session-id", &sessionid)
.set(KSESSIONID, &sessionid)
.expect_throw("failed to set auth session id header");
}
@ -108,7 +108,7 @@ pub async fn do_request(
let status = resp.status();
let headers: Headers = resp.headers();
if let Some(sessionid) = headers.get("x-kanidm-auth-session-id").ok().flatten() {
if let Some(sessionid) = headers.get(KSESSIONID).ok().flatten() {
models::push_auth_session_id(sessionid);
}