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", "tracing",
"url", "url",
"urlencoding", "urlencoding",
"utoipa",
"uuid", "uuid",
"webauthn-rs-proto", "webauthn-rs-proto",
] ]
@ -3081,7 +3082,10 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"urlencoding", "urlencoding",
"utoipa",
"utoipa-swagger-ui",
"uuid", "uuid",
"walkdir",
] ]
[[package]] [[package]]
@ -3158,6 +3162,7 @@ dependencies = [
"compact_jwt", "compact_jwt",
"fantoccini", "fantoccini",
"futures", "futures",
"http",
"hyper-tls", "hyper-tls",
"kanidm_build_profiles", "kanidm_build_profiles",
"kanidm_client", "kanidm_client",
@ -4544,6 +4549,41 @@ dependencies = [
"smallvec", "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]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.23" version = "0.1.23"
@ -5656,6 +5696,47 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 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]] [[package]]
name = "uuid" name = "uuid"
version = "1.4.1" version = "1.4.1"
@ -6317,6 +6398,18 @@ dependencies = [
"syn 2.0.38", "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]] [[package]]
name = "zstd" name = "zstd"
version = "0.12.4" version = "0.12.4"

View file

@ -91,7 +91,6 @@ axum = { version = "0.6.20", features = [
"form", "form",
"headers", "headers",
"http2", "http2",
"http2",
"json", "json",
"macros", "macros",
"multipart", "multipart",
@ -208,6 +207,7 @@ tss-esapi = "^7.3.0"
url = "^2.4.1" url = "^2.4.1"
urlencoding = "2.1.3" urlencoding = "2.1.3"
users = "^0.11.0" users = "^0.11.0"
utoipa = "3.5.0"
uuid = "^1.4.1" uuid = "^1.4.1"
wasm-bindgen = "^0.2.86" 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: When using a user command that requires these privileges you will be warned:
``` ```shell
kanidm person credential update william kanidm person credential update william
# Privileges have expired for william@idm.example.com - you need to re-authenticate again. # Privileges have expired for william@idm.example.com - you need to re-authenticate again.
``` ```
To reauthenticate To reauthenticate
``` ```shell
kanidm reauth -D william kanidm reauth -D william
``` ```

View file

@ -24,7 +24,7 @@ use std::os::unix::fs::MetadataExt;
use std::path::Path; use std::path::Path;
use std::time::Duration; 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 kanidm_proto::v1::*;
use reqwest::header::CONTENT_TYPE; use reqwest::header::CONTENT_TYPE;
use reqwest::Response; use reqwest::Response;
@ -47,10 +47,6 @@ mod service_account;
mod sync_account; mod sync_account;
mod system; 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"); const EXPECT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug)] #[derive(Debug)]

View file

@ -14,7 +14,6 @@ repository = { workspace = true }
[features] [features]
wasm = ["webauthn-rs-proto/wasm"] wasm = ["webauthn-rs-proto/wasm"]
[dependencies] [dependencies]
base32 = { workspace = true } base32 = { workspace = true }
base64urlsafedata = { workspace = true } base64urlsafedata = { workspace = true }
@ -27,6 +26,6 @@ time = { workspace = true, features = ["serde", "std"] }
tracing = { workspace = true } tracing = { workspace = true }
url = { workspace = true, features = ["serde"] } url = { workspace = true, features = ["serde"] }
urlencoding = { workspace = true } urlencoding = { workspace = true }
utoipa = { workspace = true }
uuid = { workspace = true, features = ["serde"] } uuid = { workspace = true, features = ["serde"] }
webauthn-rs-proto = { workspace = true } 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_EXTRA: &str = "extra";
pub const TEST_ATTR_NUMBER: &str = "testattrnumber"; pub const TEST_ATTR_NUMBER: &str = "testattrnumber";
pub const TEST_ATTR_NOTALLOWED: &str = "notallowed"; 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 base64urlsafedata::Base64UrlSafeData;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
pub use scim_proto::prelude::{ScimAttr, ScimComplexAttr, ScimEntry, ScimError, ScimSimpleAttr}; pub use scim_proto::prelude::{ScimAttr, ScimComplexAttr, ScimEntry, ScimError, ScimSimpleAttr};
@ -12,7 +13,7 @@ use crate::constants::{
ATTR_NAME, ATTR_SSH_PUBLICKEY, ATTR_NAME, ATTR_SSH_PUBLICKEY,
}; };
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema)]
pub enum ScimSyncState { pub enum ScimSyncState {
Refresh, Refresh,
Active { cookie: Base64UrlSafeData }, Active { cookie: Base64UrlSafeData },

View file

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

View file

@ -26,7 +26,6 @@ filetime = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
http = { workspace = true } http = { workspace = true }
hyper = { workspace = true } hyper = { workspace = true }
kanidm_proto = { workspace = true } kanidm_proto = { workspace = true }
kanidm_utils_users = { workspace = true } kanidm_utils_users = { workspace = true }
@ -63,6 +62,15 @@ urlencoding = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
url = { workspace = true, features = ["serde"] } url = { workspace = true, features = ["serde"] }
uuid = { workspace = true, features = ["serde", "v4"] } 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] [build-dependencies]
kanidm_build_profiles = { workspace = true } kanidm_build_profiles = { workspace = true }

View file

@ -308,6 +308,12 @@ impl fmt::Display for Configuration {
impl Default for Configuration { impl Default for Configuration {
fn default() -> Self { fn default() -> Self {
Self::new()
}
}
impl Configuration {
pub fn new() -> Self {
Configuration { Configuration {
address: DEFAULT_SERVER_ADDRESS.to_string(), address: DEFAULT_SERVER_ADDRESS.to_string(),
ldapaddress: None, ldapaddress: None,
@ -335,12 +341,6 @@ impl Default for Configuration {
integration_repl_config: None, integration_repl_config: None,
} }
} }
}
impl Configuration {
pub fn new() -> Self {
Self::default()
}
pub fn new_for_test() -> Self { pub fn new_for_test() -> Self {
Configuration { 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}, http::{header::HeaderName, request::Parts, StatusCode},
RequestPartsExt, RequestPartsExt,
}; };
use kanidm_proto::constants::X_FORWARDED_FOR;
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use crate::https::ServerState; use crate::https::ServerState;
#[allow(clippy::declare_interior_mutable_const)] #[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); pub struct TrustedClientIp(pub IpAddr);
@ -24,7 +25,7 @@ impl FromRequestParts<ServerState> for TrustedClientIp {
state: &ServerState, state: &ServerState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
if state.trust_x_forward_for { 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. // X forward for may be comma separate.
let first = x_forward_for let first = x_forward_for
.to_str() .to_str()

View file

@ -1,26 +1,45 @@
use axum::extract::State; use axum::extract::State;
use axum::response::{IntoResponse, Response}; use axum::response::IntoResponse;
use axum::Extension; use axum::routing::get;
use axum::{Extension, Router};
use http::header::CONTENT_TYPE; use http::header::CONTENT_TYPE;
use kanidmd_lib::status::StatusRequestEvent; use kanidmd_lib::status::StatusRequestEvent;
use super::middleware::KOpId; use super::middleware::KOpId;
use super::ServerState; 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( pub async fn status(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse { ) -> String {
let r = state let r = state
.status_ref .status_ref
.handle_request(StatusRequestEvent { .handle_request(StatusRequestEvent {
eventid: kopid.eventid, eventid: kopid.eventid,
}) })
.await; .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 { pub async fn robots_txt() -> impl IntoResponse {
( (
[(CONTENT_TYPE, "text/plain;charset=utf-8")], [(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::{ use axum::{
headers::{CacheControl, HeaderMapExt},
http::{self, Request}, http::{self, Request},
middleware::Next, middleware::Next,
response::Response, response::Response,
@ -18,3 +19,19 @@ pub async fn dont_cache_me<B>(request: Request<B>, next: Next<B>) -> Response {
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, TypedHeader,
}; };
use http::HeaderValue; use http::HeaderValue;
use kanidm_proto::constants::{KOPID, KVERSION};
use uuid::Uuid; use uuid::Uuid;
pub(crate) mod caching; pub(crate) mod caching;
pub(crate) mod compression; pub(crate) mod compression;
pub(crate) mod hsts_header; 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; let mut response = next.run(request).await;
response response
.headers_mut() .headers_mut()
.insert("X-KANIDM-VERSION", HeaderValue::from_static(KANIDM_VERSION)); .insert(KVERSION, HeaderValue::from_static(KANIDM_VERSION));
response response
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
/// For holding onto the event ID and other handy request-based things /// For holding onto the event ID and other handy request-based things
pub struct KOpId { pub struct KOpId {
/// The event correlation ID
pub eventid: Uuid, pub eventid: Uuid,
/// The User Access Token, if present
pub uat: Option<String>, 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.contains_key(http::header::CONTENT_TYPE));
assert!( assert!(
headers.get(http::header::CONTENT_TYPE) 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 // This conversion *should never* fail. If it does, rather than panic, we warn and
// just don't put the id in the response. // just don't put the id in the response.
let _ = HeaderValue::from_str(&eventid.as_hyphenated().to_string()) 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| { .map_err(|err| {
warn!(?err, "An invalid operation id was encountered"); warn!(?err, "An invalid operation id was encountered");
}); });

View file

@ -1,3 +1,6 @@
mod apidocs;
pub(crate) mod errors;
mod extractors; mod extractors;
mod generic; mod generic;
mod javascript; mod javascript;
@ -8,35 +11,31 @@ mod tests;
pub(crate) mod trace; pub(crate) mod trace;
mod ui; mod ui;
mod v1; mod v1;
mod v1_oauth2;
mod v1_scim; mod v1_scim;
use self::generic::*;
use self::javascript::*; use self::javascript::*;
use crate::actors::v1_read::QueryServerReadV1; use crate::actors::v1_read::QueryServerReadV1;
use crate::actors::v1_write::QueryServerWriteV1; use crate::actors::v1_write::QueryServerWriteV1;
use crate::config::{Configuration, ServerRole, TlsConfiguration}; use crate::config::{Configuration, ServerRole, TlsConfiguration};
use axum::extract::connect_info::{IntoMakeServiceWithConnectInfo, ResponseFuture}; use axum::extract::connect_info::{IntoMakeServiceWithConnectInfo, ResponseFuture};
use axum::middleware::{from_fn, from_fn_with_state}; use axum::middleware::{from_fn, from_fn_with_state};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::Redirect;
use axum::routing::*; use axum::routing::*;
use axum::Router; use axum::Router;
use axum_csp::{CspDirectiveType, CspValue}; use axum_csp::{CspDirectiveType, CspValue};
use axum_macros::FromRef; use axum_macros::FromRef;
use compact_jwt::{Jws, JwsSigner, JwsUnverified}; use compact_jwt::{Jws, JwsSigner, JwsUnverified};
use http::header::{ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE}; use http::{HeaderMap, HeaderValue};
use http::{HeaderMap, HeaderValue, StatusCode};
use hyper::server::accept::Accept; use hyper::server::accept::Accept;
use hyper::server::conn::{AddrStream, Http}; use hyper::server::conn::{AddrStream, Http};
use hyper::Body; use kanidm_proto::constants::KSESSIONID;
use kanidm_proto::constants::APPLICATION_JSON;
use kanidm_proto::v1::OperationError;
use kanidmd_lib::status::StatusActor; use kanidmd_lib::status::StatusActor;
use openssl::ssl::{Ssl, SslAcceptor, SslFiletype, SslMethod}; use openssl::ssl::{Ssl, SslAcceptor, SslFiletype, SslMethod};
use sketching::*; use sketching::*;
use tokio_openssl::SslStream; use tokio_openssl::SslStream;
use futures_util::future::poll_fn; use futures_util::future::poll_fn;
use serde::Serialize;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::Level; use tracing::Level;
@ -82,7 +81,7 @@ impl ServerState {
fn get_current_auth_session_id(&self, headers: &HeaderMap) -> Option<Uuid> { fn get_current_auth_session_id(&self, headers: &HeaderMap) -> Option<Uuid> {
// We see if there is a signed header copy first. // We see if there is a signed header copy first.
headers headers
.get("X-KANIDM-AUTH-SESSION-ID") .get(KSESSIONID)
.and_then(|hv| { .and_then(|hv| {
// Get the first header value. // Get the first header value.
hv.to_str().ok() 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's set up the list of js module hashes
{ {
let filepath = "wasmloader.js"; let filepath = "wasmloader.js";
#[allow(clippy::unwrap_used)] match generate_integrity_hash(format!(
js_files.push(JavaScriptFile { "{}/{}",
env!("KANIDM_WEB_UI_PKG_PATH").to_owned(),
filepath, filepath,
hash: generate_integrity_hash(format!( )) {
"{}/{}", Ok(hash) => js_files.push(JavaScriptFile {
env!("KANIDM_WEB_UI_PKG_PATH").to_owned(),
filepath, filepath,
)) hash,
.unwrap(), filetype: Some("module".to_string()),
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's set up the list of non-module hashes
{ {
let filepath = "external/bootstrap.bundle.min.js"; let filepath = "external/bootstrap.bundle.min.js";
#[allow(clippy::unwrap_used)] match generate_integrity_hash(format!(
js_files.push(JavaScriptFile { "{}/{}",
env!("KANIDM_WEB_UI_PKG_PATH").to_owned(),
filepath, filepath,
hash: generate_integrity_hash(format!( )) {
"{}/{}", Ok(hash) =>
env!("KANIDM_WEB_UI_PKG_PATH").to_owned(), js_files.push(JavaScriptFile {
filepath, filepath,
)) hash,
.unwrap(), filetype: None,
filetype: None, }),
}); Err(err) => {
admin_error!(?err, "Failed to generate integrity hash for bootstrap.bundle.min.js")
}
}
} }
}; };
js_files js_files
@ -197,28 +203,25 @@ pub async fn create_https_server(
let static_routes = match config.role { let static_routes = match config.role {
ServerRole::WriteReplica | ServerRole::ReadOnlyReplica => { ServerRole::WriteReplica | ServerRole::ReadOnlyReplica => {
// Create a spa router that captures everything at ui without key extraction. // 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() Router::new()
// direct users to the base app page. If a login is required, // direct users to the base app page. If a login is required,
// then views will take care of redirection. We shouldn't redir // then views will take care of redirection. We shouldn't redir
// to login because that force clears previous sessions! // to login because that force clears previous sessions!
.route("/", get(|| async { Redirect::temporary("/ui") })) .route("/", get(|| async { Redirect::temporary("/ui") }))
.route("/manifest.webmanifest", get(manifest::manifest)) .route("/manifest.webmanifest", get(manifest::manifest)) // skip_route_check
.nest("/ui", spa_router) .nest("/ui", ui::spa_router())
.layer(middleware::compression::new()) .layer(middleware::compression::new())
.route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get)) .route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get))
// skip_route_check
} }
ServerRole::WriteReplicaNoUI => Router::new(), ServerRole::WriteReplicaNoUI => Router::new(),
}; };
let app = Router::new() let app = Router::new()
.route("/robots.txt", get(robots_txt)) .merge(generic::route_setup())
.route("/status", get(status)) .merge(oauth2::route_setup(state.clone()))
.merge(oauth2::oauth2_route_setup(state.clone())) .merge(v1_scim::route_setup())
.merge(v1_scim::scim_route_setup()) .merge(v1::route_setup(state.clone()));
.merge(v1::router(state.clone()));
let app = match config.role { let app = match config.role {
ServerRole::WriteReplicaNoUI => app, 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 // to be exited, and this middleware sets up ids' and other bits for for logging
// coherence to be maintained. // coherence to be maintained.
.layer(from_fn(middleware::kopid_middleware)) .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. // this MUST be the last layer before with_state else the span never starts and everything breaks.
.layer(trace_layer) .layer(trace_layer)
.with_state(state) .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::middleware::KOpId;
use super::v1::{json_rest_event_get, json_rest_event_post}; use super::ServerState;
use super::{to_axum_response, HttpOperationError, ServerState};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::middleware::from_fn; use axum::middleware::from_fn;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Extension, Form, Json, Router}; use axum::{Extension, Form, Json, Router};
use axum_macros::debug_handler; use axum_macros::debug_handler;
use compact_jwt::{JwkKeySet, OidcToken};
use http::header::{ use http::header::{
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION, CONTENT_TYPE, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION, CONTENT_TYPE,
LOCATION, WWW_AUTHENTICATE, LOCATION, WWW_AUTHENTICATE,
@ -14,9 +15,7 @@ use http::header::{
use http::{HeaderMap, HeaderValue, StatusCode}; use http::{HeaderMap, HeaderValue, StatusCode};
use hyper::Body; use hyper::Body;
use kanidm_proto::constants::APPLICATION_JSON; use kanidm_proto::constants::APPLICATION_JSON;
use kanidm_proto::internal::{ImageType, ImageValue}; use kanidm_proto::oauth2::{AuthorisationResponse, OidcDiscoveryResponse, AccessTokenResponse};
use kanidm_proto::oauth2::{AuthorisationResponse, OidcDiscoveryResponse};
use kanidm_proto::v1::Entry as ProtoEntry;
use kanidmd_lib::idm::oauth2::{ use kanidmd_lib::idm::oauth2::{
AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess,
AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest, AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest,
@ -24,9 +23,9 @@ use kanidmd_lib::idm::oauth2::{
use kanidmd_lib::prelude::f_eq; use kanidmd_lib::prelude::f_eq;
use kanidmd_lib::prelude::*; use kanidmd_lib::prelude::*;
use kanidmd_lib::value::PartialValue; use kanidmd_lib::value::PartialValue;
use kanidmd_lib::valueset::image::ImageValueThings;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// TODO: merge this into a value in WebError later
pub struct HTTPOauth2Error(Oauth2Error); pub struct HTTPOauth2Error(Oauth2Error);
impl IntoResponse for HTTPOauth2Error { impl IntoResponse for HTTPOauth2Error {
@ -34,17 +33,18 @@ impl IntoResponse for HTTPOauth2Error {
let HTTPOauth2Error(error) = self; let HTTPOauth2Error(error) = self;
if let Oauth2Error::AuthenticationRequired = error { if let Oauth2Error::AuthenticationRequired = error {
#[allow(clippy::unwrap_used)] (
Response::builder() StatusCode::UNAUTHORIZED,
.status(StatusCode::UNAUTHORIZED) [
.header(WWW_AUTHENTICATE, "Bearer") (WWW_AUTHENTICATE, "Bearer"),
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") (ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
.body(Body::empty()) ],
.unwrap() )
.into_response()
} else { } else {
let err = ErrorResponse { let err = ErrorResponse {
error: error.to_string(), error: error.to_string(),
..Default::default() ..Default::default()
}; };
let body = match serde_json::to_string(&err) { let body = match serde_json::to_string(&err) {
@ -54,299 +54,71 @@ impl IntoResponse for HTTPOauth2Error {
format!("{:?}", err) 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 == // == 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 /// 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!([ filter_all!(f_and!([
f_eq(Attribute::Class, EntryClass::OAuth2ResourceServer.into()), f_eq(Attribute::Class, EntryClass::OAuth2ResourceServer.into()),
f_eq(Attribute::OAuth2RsName, PartialValue::new_iname(rs_name)) f_eq(Attribute::OAuth2RsName, PartialValue::new_iname(rs_name))
])) ]))
} }
pub async fn oauth2_id_get( #[utoipa::path(
State(state): State<ServerState>, get,
Path(rs_name): Path<String>, path = "/ui/images/oauth2/{rs_name}",
Extension(kopid): Extension<KOpId>, params(
) -> Response<Body> { super::apidocs::path_schema::RsName
let filter = oauth2_id(&rs_name); ),
responses(
let res = state (status = 200, description = "Ok", body=&[u8]),
.qe_r_ref (status = 403, description = "Authorization refused"),
.handle_internalsearch(kopid.uat, filter, None, kopid.eventid) (status = 403, description = "Authorization refused"),
.await ),
.map(|mut r| r.pop()); security(("token_jwt" = [])),
to_axum_response(res) tag = "ui",
} )]
/// This returns the image for the OAuth2 Resource Server if the user has permissions
#[instrument(level = "info", skip(state))] ///
pub async fn oauth2_id_get_basic_secret( pub(crate) async fn oauth2_image_get(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
Path(rs_name): Path<String>, Path(rs_name): Path<String>,
) -> Response<Body> { ) -> Response {
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> {
let rs_filter = oauth2_id(&rs_name); let rs_filter = oauth2_id(&rs_name);
let res = state let res = state
.qe_r_ref .qe_r_ref
.handle_oauth2_rs_image_get_image(kopid.uat, rs_filter) .handle_oauth2_rs_image_get_image(kopid.uat, rs_filter)
.await; .await;
let image = match res { match res {
Ok(image) => image, Ok(image) => (
Err(_err) => { StatusCode::OK,
admin_error!( [(CONTENT_TYPE, image.filetype.as_content_type_str())],
"Unable to get image for oauth2 resource server: {}", image.contents,
rs_name )
.into_response(),
Err(err) => {
admin_debug!(
"Unable to get image for oauth2 resource server {}: {:?}",
rs_name,
err
); );
#[allow(clippy::unwrap_used)] // TODO: a 404 probably isn't perfect but it's not the worst
return Response::builder() (StatusCode::NOT_FOUND, "").into_response()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap();
} }
};
#[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 == // == OAUTH2 PROTOCOL FLOW HANDLERS ==
@ -701,7 +473,7 @@ pub async fn oauth2_token_post(
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
headers: HeaderMap, headers: HeaderMap,
Form(tok_req): Form<AccessTokenRequest>, 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 // This is called directly by the resource server, where we then issue
// the token to the caller. // the token to the caller.
@ -731,7 +503,7 @@ pub async fn oauth2_openid_discovery_get(
State(state): State<ServerState>, State(state): State<ServerState>,
Path(client_id): Path<String>, Path(client_id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> Result<Json<OidcDiscoveryResponse>, HttpOperationError> { ) -> Result<Json<OidcDiscoveryResponse>, Response> {
// let client_id = req.get_url_param("client_id")?; // let client_id = req.get_url_param("client_id")?;
let res = state let res = state
@ -743,7 +515,7 @@ pub async fn oauth2_openid_discovery_get(
Ok(dsc) => Ok(Json(dsc)), Ok(dsc) => Ok(Json(dsc)),
Err(e) => { Err(e) => {
error!(err = ?e, "Unable to access discovery info"); 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>, State(state): State<ServerState>,
Path(client_id): Path<String>, Path(client_id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse { ) -> Result<Json<OidcToken>, HTTPOauth2Error> {
// The token we want to inspect is in the authorisation header. // The token we want to inspect is in the authorisation header.
let client_token = match kopid.uat { let client_token = match kopid.uat {
Some(val) => val, Some(val) => val,
@ -778,13 +550,13 @@ pub async fn oauth2_openid_publickey_get(
State(state): State<ServerState>, State(state): State<ServerState>,
Path(client_id): Path<String>, Path(client_id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> Response<Body> { ) -> Result<Json<JwkKeySet>, WebError> {
to_axum_response( state
state .qe_r_ref
.qe_r_ref .handle_oauth2_openid_publickey(client_id, kopid.eventid)
.handle_oauth2_openid_publickey(client_id, kopid.eventid) .await
.await, .map(Json::from)
) .map_err(WebError::from)
} }
/// This is called directly by the resource server, where we then issue /// 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, Some(val) => val,
None => None =>
{ {
#[allow(clippy::unwrap_used)] return (
return Response::builder() StatusCode::UNAUTHORIZED,
.status(StatusCode::UNAUTHORIZED) [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")],
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") ""
.body(Body::empty()) )
.unwrap() .into_response();
} }
}; };
@ -906,21 +678,17 @@ pub async fn oauth2_token_revoke_post(
match res { match res {
Ok(()) => Ok(()) =>
{ {
#[allow(clippy::unwrap_used)] (StatusCode::OK,
Response::builder() [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")],
.status(StatusCode::OK) ""
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") ).into_response()
.body(Body::empty())
.unwrap()
} }
Err(Oauth2Error::AuthenticationRequired) => { Err(Oauth2Error::AuthenticationRequired) => {
// This will trigger our ui to auth and retry. // This will trigger our ui to auth and retry.
#[allow(clippy::unwrap_used)] (StatusCode::UNAUTHORIZED,
Response::builder() [(ACCESS_CONTROL_ALLOW_ORIGIN, "*"),],
.status(StatusCode::UNAUTHORIZED) ""
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") ).into_response()
.body(Body::empty())
.unwrap()
} }
Err(e) => { Err(e) => {
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 // 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(), error: e.to_string(),
..Default::default() ..Default::default()
}; };
#[allow(clippy::unwrap_used)] (StatusCode::BAD_REQUEST,
Response::builder() [(ACCESS_CONTROL_ALLOW_ORIGIN, "*"),],
.status(StatusCode::BAD_REQUEST) serde_json::to_string(&err).unwrap_or("".to_string()),
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") ).into_response()
.body(Body::from(
serde_json::to_string(&err).unwrap_or("".to_string()),
))
.unwrap()
} }
} }
} }
// Some requests from browsers require preflight so that CORS works. // Some requests from browsers require preflight so that CORS works.
pub async fn oauth2_preflight_options() -> Response<Body> { pub async fn oauth2_preflight_options() -> Response {
#[allow(clippy::unwrap_used)] (
Response::builder() StatusCode::OK,
.status(StatusCode::OK) [
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") (ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
.header(ACCESS_CONTROL_ALLOW_HEADERS, "Authorization") (ACCESS_CONTROL_ALLOW_HEADERS, "Authorization"),
.body(Body::empty()) ],
.unwrap() 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 // this has all the openid-related routes
let openid_router = Router::new() let openid_router = Router::new()
// // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // // ⚠️ ⚠️ WARNING ⚠️ ⚠️
@ -975,7 +740,7 @@ pub fn oauth2_route_setup(state: ServerState) -> Router<ServerState> {
.with_state(state.clone()); .with_state(state.clone());
Router::new() Router::new()
.route("/oauth2", get(oauth2_get)) .route("/oauth2", get(super::v1_oauth2::oauth2_get))
// ⚠️ ⚠️ WARNING ⚠️ ⚠️ // ⚠️ ⚠️ WARNING ⚠️ ⚠️
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
.route( .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::extract::State;
use axum::http::HeaderValue; use axum::http::HeaderValue;
use axum::response::Response; use axum::response::Response;
use axum::Extension; use axum::routing::get;
use axum::{Extension, Router};
use http::header::CONTENT_TYPE; use http::header::CONTENT_TYPE;
use super::middleware::KOpId; use super::middleware::KOpId;
use super::ServerState; 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>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> Response<String> { ) -> 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::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::{ 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_get, json_rest_event_get_id, json_rest_event_get_id_attr, json_rest_event_post,
json_rest_event_put_id_attr, 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( pub async fn sync_account_get(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse { ) -> Result<Json<Vec<ProtoEntry>>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
json_rest_event_get(state, None, filter, kopid).await 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( pub async fn sync_account_post(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>, Json(obj): Json<ProtoEntry>,
) -> impl IntoResponse { ) -> Result<Json<()>, WebError> {
let classes: Vec<String> = vec![EntryClass::SyncAccount.into(), EntryClass::Object.into()]; let classes: Vec<String> = vec![EntryClass::SyncAccount.into(), EntryClass::Object.into()];
json_rest_event_post(state, classes, obj, kopid).await 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( pub async fn sync_account_id_get(
State(state): State<ServerState>, State(state): State<ServerState>,
Path(id): Path<String>, Path(id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse { ) -> Result<Json<Option<ProtoEntry>>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
json_rest_event_get_id(state, id, filter, None, kopid).await 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( pub async fn sync_account_id_patch(
State(state): State<ServerState>, State(state): State<ServerState>,
Path(id): Path<String>, Path(id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
Json(obj): Json<ProtoEntry>, Json(obj): Json<ProtoEntry>,
) -> impl IntoResponse { ) -> Result<Json<()>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); 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 filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str())));
let res = state state
.qe_w_ref .qe_w_ref
.handle_internalpatch(kopid.uat, filter, obj, kopid.eventid) .handle_internalpatch(kopid.uat, filter, obj, kopid.eventid)
.await; .await
to_axum_response(res) .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>, State(state): State<ServerState>,
Path(id): Path<String>, Path(id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse { ) -> Result<Json<()>, WebError> {
let res = state state
.qe_w_ref .qe_w_ref
.handle_sync_account_finalise(kopid.uat, id, kopid.eventid) .handle_sync_account_finalise(kopid.uat, id, kopid.eventid)
.await; .await
to_axum_response(res) .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>, State(state): State<ServerState>,
Path(id): Path<String>, Path(id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse { ) -> Result<Json<()>, WebError> {
let res = state state
.qe_w_ref .qe_w_ref
.handle_sync_account_terminate(kopid.uat, id, kopid.eventid) .handle_sync_account_terminate(kopid.uat, id, kopid.eventid)
.await; .await
to_axum_response(res) .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( pub async fn sync_account_token_post(
State(state): State<ServerState>, State(state): State<ServerState>,
Path(id): Path<String>, Path(id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
Json(label): Json<String>, Json(label): Json<String>,
) -> impl IntoResponse { ) -> Result<Json<String>, WebError> {
let res = state state
.qe_w_ref .qe_w_ref
.handle_sync_account_token_generate(kopid.uat, id, label, kopid.eventid) .handle_sync_account_token_generate(kopid.uat, id, label, kopid.eventid)
.await; .await
to_axum_response(res) .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( pub async fn sync_account_token_delete(
State(state): State<ServerState>, State(state): State<ServerState>,
Path(id): Path<String>, Path(id): Path<String>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
) -> impl IntoResponse { ) -> Result<Json<()>, WebError> {
let res = state state
.qe_w_ref .qe_w_ref
.handle_sync_account_token_destroy(kopid.uat, id, kopid.eventid) .handle_sync_account_token_destroy(kopid.uat, id, kopid.eventid)
.await; .await
to_axum_response(res) .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( async fn scim_sync_post(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
AuthBearer(bearer): AuthBearer, AuthBearer(bearer): AuthBearer,
Json(changes): Json<ScimSyncRequest>, Json(changes): Json<ScimSyncRequest>,
) -> impl IntoResponse { ) -> Result<Json<()>, WebError> {
let res = state state
.qe_w_ref .qe_w_ref
.handle_scim_sync_apply(Some(bearer), changes, kopid.eventid) .handle_scim_sync_apply(Some(bearer), changes, kopid.eventid)
.await; .await
to_axum_response(res) .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( async fn scim_sync_get(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
AuthBearer(bearer): AuthBearer, AuthBearer(bearer): AuthBearer,
) -> impl IntoResponse { ) -> Result<Json<ScimSyncState>, WebError> {
// Given the token, what is it's connected sync state? // Given the token, what is it's connected sync state?
trace!(?bearer); trace!(?bearer);
let res = state state
.qe_r_ref .qe_r_ref
.handle_scim_sync_status(Some(bearer), kopid.eventid) .handle_scim_sync_status(Some(bearer), kopid.eventid)
.await; .await
to_axum_response(res) .map(Json::from)
.map_err(WebError::from)
} }
#[utoipa::path(
pub async fn sync_account_id_get_attr( 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>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
Path((id, attr)): Path<(String, String)>, 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())); let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
json_rest_event_get_id_attr(state, id, attr, filter, kopid).await 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>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
Path((id, attr)): Path<(String, String)>, Path((id, attr)): Path<(String, String)>,
Json(values): Json<Vec<String>>, Json(values): Json<Vec<String>>,
) -> impl IntoResponse { ) -> Result<Json<()>, WebError> {
let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into()));
json_rest_event_put_id_attr(state, id, attr, filter, values, kopid).await json_rest_event_put_id_attr(state, id, attr, filter, values, kopid).await
} }
async fn scim_sink_get() -> impl IntoResponse { /// When you want the kitchen Sink
r#" async fn scim_sink_get() -> Html<&'static str> {
<!DOCTYPE html> Html::from(include_str!("scim/sink.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>"#
} }
pub fn scim_route_setup() -> Router<ServerState> { pub fn route_setup() -> Router<ServerState> {
Router::new() Router::new()
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.2 // 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 // 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/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, replica_connect_timeout: Duration,
} }
#[allow(clippy::too_many_arguments)]
async fn repl_task( async fn repl_task(
origin: Url, origin: Url,
client_key: PKey<Private>, client_key: PKey<Private>,
@ -827,7 +828,7 @@ async fn repl_acceptor(
} }
if respond.send(success).is_err() { 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 { } else {
trace!("Sent server certificate renewal status via control channel"); 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)] #[derive(Debug, Clone, PartialEq, Eq)]
/// A filter before it's gone through schema validation
pub struct FilterInvalid { pub struct FilterInvalid {
inner: FilterComp, inner: FilterComp,
} }
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
/// A filter after it's gone through schema validation
pub struct FilterValid { pub struct FilterValid {
inner: FilterComp, inner: FilterComp,
} }

View file

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

View file

@ -649,31 +649,19 @@ async fn test_https_robots_txt(rsclient: KanidmClient) {
eprintln!( eprintln!(
"csp headers: {:#?}", "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()); 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... /// This literally tests that the thing exists and responds in a way we expect, probably worth testing it better...
#[kanidmd_testkit::test] #[kanidmd_testkit::test]
async fn test_v1_raw_delete(rsclient: KanidmClient) { async fn test_v1_raw_delete(rsclient: KanidmClient) {

View file

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

View file

@ -19,9 +19,16 @@ async fn test_https_middleware_headers(rsclient: KanidmClient) {
assert_eq!(response.status(), 200); assert_eq!(response.status(), 200);
eprintln!( eprintln!(
"csp headers: {:#?}", "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 // here we test the /ui/login endpoint which should have the headers
let response = match reqwest::get(rsclient.make_url("/ui/login")).await { let response = match reqwest::get(rsclient.make_url("/ui/login")).await {
@ -39,7 +46,14 @@ async fn test_https_middleware_headers(rsclient: KanidmClient) {
eprintln!( eprintln!(
"csp headers: {:#?}", "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. // Check we have correct nocache headers.
let cache_header: &str = $response let cache_header: &str = $response
.headers() .headers()
.get("cache-control") .get(http::header::CACHE_CONTROL)
.expect("missing cache-control header") .expect("missing cache-control header")
.to_str() .to_str()
.expect("invalid cache-control header"); .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."); .expect("Failed to send discovery preflight request.");
assert!(response.status() == reqwest::StatusCode::OK); assert!(response.status() == reqwest::StatusCode::OK);
let cors_header: &str = response let cors_header: &str = response
.headers() .headers()
.get("access-control-allow-origin") .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
.expect("missing access-control-allow-origin header") .expect("missing access-control-allow-origin header")
.to_str() .to_str()
.expect("invalid access-control-allow-origin header"); .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); assert!(response.status() == reqwest::StatusCode::OK);
let cors_header: &str = response let cors_header: &str = response
.headers() .headers()
.get("access-control-allow-origin") .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)
.expect("missing access-control-allow-origin header") .expect("missing access-control-allow-origin header")
.to_str() .to_str()
.expect("invalid access-control-allow-origin header"); .expect("invalid access-control-allow-origin header");

View file

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

View file

@ -105,6 +105,7 @@ async fn test_scim_sync_get(rsclient: KanidmClient) {
.default_headers(headers) .default_headers(headers)
.build() .build()
.unwrap(); .unwrap();
// here we test the /ui/ endpoint which should have the headers // here we test the /ui/ endpoint which should have the headers
let response = match client.get(rsclient.make_url("/scim/v1/Sync")).send().await { let response = match client.get(rsclient.make_url("/scim/v1/Sync")).send().await {
Ok(value) => value, Ok(value) => value,
@ -117,12 +118,30 @@ async fn test_scim_sync_get(rsclient: KanidmClient) {
} }
}; };
eprintln!("response: {:#?}", response); 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 error::FetchError;
use gloo::console; use gloo::console;
use kanidm_proto::constants::APPLICATION_JSON; use kanidm_proto::constants::{APPLICATION_JSON, KSESSIONID};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
@ -91,7 +91,7 @@ pub async fn do_request(
if let Some(sessionid) = models::pop_auth_session_id() { if let Some(sessionid) = models::pop_auth_session_id() {
request request
.headers() .headers()
.set("x-kanidm-auth-session-id", &sessionid) .set(KSESSIONID, &sessionid)
.expect_throw("failed to set auth session id header"); .expect_throw("failed to set auth session id header");
} }
@ -108,7 +108,7 @@ pub async fn do_request(
let status = resp.status(); let status = resp.status();
let headers: Headers = resp.headers(); 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); models::push_auth_session_id(sessionid);
} }