diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..134ee4f6d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,31 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/rust +{ + "name": "Rust", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/rust:1-1-bookworm", + "features": { + }, + + // Use 'mounts' to make the cargo cache persistent in a Docker Volume. + "mounts": [ + { + "source": "devcontainer-cargo-cache-${devcontainerId}", + "target": "/usr/local/cargo", + "type": "volume" + } + ], + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [8443], + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "rustup update && rustup default stable && rustup component add rustfmt clippy && sudo apt-get update && sudo apt-get install -y sccache ripgrep libssl-dev pkg-config jq libpam0g-dev libudev-dev cmake build-essential && cargo install cargo-audit mdbook-mermaid mdbook && cargo install mdbook-alerts --version 0.6.4 && cargo install deno --locked" + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/Cargo.lock b/Cargo.lock index d519921a1..d5e4b10ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] @@ -69,9 +69,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.15" +version = "0.6.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" dependencies = [ "anstyle", "anstyle-parse", @@ -84,43 +84,43 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" +checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" [[package]] name = "anstyle-parse" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "anyhow" -version = "1.0.90" +version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37bf3594c4c988a53154954629820791dde498571819ae4ca50ca811e060cc95" +checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" [[package]] name = "anymap2" @@ -184,7 +184,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -259,9 +259,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.12" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec134f64e2bc57411226dfc4e52dec859ddfc7e711fc5e07b612584f000e4aa" +checksum = "0cb8f1d480b0ea3783ab015936d2a55c87e219676f0c0b7dec61494043f21857" dependencies = [ "flate2", "futures-core", @@ -272,9 +272,9 @@ dependencies = [ [[package]] name = "async-stream" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", @@ -283,13 +283,13 @@ dependencies = [ [[package]] name = "async-stream-impl" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -300,7 +300,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -339,9 +339,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" @@ -356,7 +356,7 @@ dependencies = [ "futures-util", "http 0.2.12", "http-body 0.4.6", - "hyper 0.14.30", + "hyper 0.14.31", "itoa", "matchit", "memchr", @@ -504,7 +504,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -627,37 +627,34 @@ dependencies = [ "lazycell", "log", "peeking_take_while", - "prettyplease 0.2.22", + "prettyplease 0.2.25", "proc-macro2", "quote", "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.82", + "syn 2.0.85", "which", ] [[package]] name = "bindgen" -version = "0.69.4" +version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", + "itertools 0.13.0", "log", - "prettyplease 0.2.22", + "prettyplease 0.2.25", "proc-macro2", "quote", "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.82", - "which", + "syn 2.0.85", ] [[package]] @@ -748,9 +745,9 @@ checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" [[package]] name = "bytemuck" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" [[package]] name = "byteorder" @@ -760,9 +757,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" [[package]] name = "cast" @@ -772,9 +769,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.1.18" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ac837cdb5cb22e10a256099b4fc502b1dfe560cb282963a974d7abd80e476" +checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" dependencies = [ "shlex", ] @@ -883,9 +880,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.33" +version = "4.5.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9646e2e245bf62f45d39a0f3f36f1171ad1ea0d6967fd114bca72cb02a8fcdfb" +checksum = "07a13ab5b8cb13dbe35e68b83f6c12f9293b2f601797b71bc9f23befdb329feb" dependencies = [ "clap", ] @@ -899,7 +896,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -922,9 +919,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "compact_jwt" @@ -1236,7 +1233,7 @@ dependencies = [ "kanidmd_core", "mimalloc", "prctl", - "reqwest", + "reqwest 0.12.8", "sd-notify", "serde", "serde_json", @@ -1294,7 +1291,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -1316,7 +1313,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core 0.20.10", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -1360,7 +1357,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -1487,7 +1484,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -1522,9 +1519,9 @@ checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" [[package]] name = "encoding_rs" -version = "0.8.34" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] @@ -1546,7 +1543,7 @@ checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -1566,7 +1563,7 @@ checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -1715,9 +1712,9 @@ checksum = "b3ea1ec5f8307826a5b71094dd91fc04d4ae75d5709b20ad351c7fb4815c86ec" [[package]] name = "flate2" -version = "1.0.33" +version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "miniz_oxide", @@ -1725,9 +1722,9 @@ dependencies = [ [[package]] name = "fluent-uri" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7bd399b64ddd63a83cf40512c96007dafe9ac26cfc8c89c820a247c6f7d2376" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" dependencies = [ "borrow-or-share", "ref-cast", @@ -1866,7 +1863,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -1944,9 +1941,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "gix" @@ -1998,14 +1995,14 @@ dependencies = [ "gix-utils", "itoa", "thiserror", - "winnow 0.6.18", + "winnow 0.6.20", ] [[package]] name = "gix-chunk" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c8751169961ba7640b513c3b24af61aa962c967aaf04116734975cd5af0c52" +checksum = "6c28b58ba04f0c004722344390af9dbc85888fbb84be1981afb934da4114d4cf" dependencies = [ "thiserror", ] @@ -2042,14 +2039,14 @@ dependencies = [ "smallvec", "thiserror", "unicode-bom", - "winnow 0.6.18", + "winnow 0.6.20", ] [[package]] name = "gix-config-value" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03f76169faa0dec598eac60f83d7fcdd739ec16596eca8fb144c88973dbe6f8c" +checksum = "f3de3fdca9c75fa4b83a76583d265fa49b1de6b088ebcd210749c24ceeb74660" dependencies = [ "bitflags 2.6.0", "bstr", @@ -2180,7 +2177,7 @@ checksum = "999ce923619f88194171a67fb3e6d613653b8d4d6078b529b15a765da0edcc17" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -2199,7 +2196,7 @@ dependencies = [ "itoa", "smallvec", "thiserror", - "winnow 0.6.18", + "winnow 0.6.20", ] [[package]] @@ -2242,9 +2239,9 @@ dependencies = [ [[package]] name = "gix-path" -version = "0.10.11" +version = "0.10.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebfc4febd088abdcbc9f1246896e57e37b7a34f6909840045a1767c6dafac7af" +checksum = "c04e5a94fdb56b1e91eb7df2658ad16832428b8eeda24ff1a0f0288de2bce554" dependencies = [ "bstr", "gix-trace", @@ -2255,9 +2252,9 @@ dependencies = [ [[package]] name = "gix-quote" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbff4f9b9ea3fa7a25a70ee62f545143abef624ac6aa5884344e70c8b0a1d9ff" +checksum = "f89f9a1525dcfd9639e282ea939f5ab0d09d93cf2b90c1fc6104f1b9582a8e49" dependencies = [ "bstr", "gix-utils", @@ -2282,7 +2279,7 @@ dependencies = [ "gix-validate", "memmap2", "thiserror", - "winnow 0.6.18", + "winnow 0.6.20", ] [[package]] @@ -2330,9 +2327,9 @@ dependencies = [ [[package]] name = "gix-sec" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe4d52f30a737bbece5276fab5d3a8b276dc2650df963e293d0673be34e7a5f" +checksum = "a2007538eda296445c07949cf04f4a767307d887184d6b3e83e2d636533ddc6e" dependencies = [ "bitflags 2.6.0", "gix-path", @@ -2355,9 +2352,9 @@ dependencies = [ [[package]] name = "gix-trace" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cae0e8661c3ff92688ce1c8b8058b3efb312aba9492bbe93661a21705ab431b" +checksum = "04bdde120c29f1fc23a24d3e115aeeea3d60d8e65bab92cc5f9d90d9302eb952" [[package]] name = "gix-traverse" @@ -2392,9 +2389,9 @@ dependencies = [ [[package]] name = "gix-utils" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35192df7fd0fa112263bad8021e2df7167df4cc2a6e6d15892e1e55621d3d4dc" +checksum = "ba427e3e9599508ed98a6ddf8ed05493db114564e338e41f6a996d2e4790335f" dependencies = [ "fastrand", "unicode-normalization", @@ -2620,7 +2617,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.5.0", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -2639,7 +2636,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.1.0", - "indexmap 2.5.0", + "indexmap 2.6.0", "slab", "tokio", "tokio-util", @@ -2802,9 +2799,9 @@ checksum = "08a397c49fec283e3d6211adbe480be95aae5f304cfb923e9970e08956d5168a" [[package]] name = "httparse" -version = "1.9.4" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "httpdate" @@ -2823,9 +2820,9 @@ dependencies = [ [[package]] name = "hyper" -version = "0.14.30" +version = "0.14.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" dependencies = [ "bytes", "futures-channel", @@ -2866,6 +2863,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.31", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.3" @@ -2876,12 +2887,13 @@ dependencies = [ "http 1.1.0", "hyper 1.5.0", "hyper-util", - "rustls", + "rustls 0.23.15", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "tower-service", + "webpki-roots 0.26.6", ] [[package]] @@ -2890,7 +2902,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" dependencies = [ - "hyper 0.14.30", + "hyper 0.14.31", "pin-project-lite", "tokio", "tokio-io-timeout", @@ -2933,9 +2945,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -3041,12 +3053,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.14.5", + "hashbrown 0.15.0", "serde", ] @@ -3081,9 +3093,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "is-terminal" @@ -3120,15 +3132,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -3179,7 +3182,7 @@ dependencies = [ "percent-encoding", "referencing", "regex", - "reqwest", + "reqwest 0.12.8", "serde", "serde_json", "time", @@ -3273,7 +3276,7 @@ dependencies = [ "hyper 1.5.0", "kanidm_lib_file_permissions", "kanidm_proto", - "reqwest", + "reqwest 0.12.8", "serde", "serde_json", "serde_urlencoded", @@ -3286,6 +3289,21 @@ dependencies = [ "webauthn-rs-proto", ] +[[package]] +name = "kanidm_device_flow" +version = "1.4.0-dev" +dependencies = [ + "anyhow", + "base64 0.22.1", + "kanidm_proto", + "oauth2", + "reqwest 0.12.8", + "sketching", + "tokio", + "tracing", + "url", +] + [[package]] name = "kanidm_lib_crypto" version = "1.4.0-dev" @@ -3320,6 +3338,7 @@ name = "kanidm_proto" version = "1.4.0-dev" dependencies = [ "base32", + "base64 0.22.1", "clap", "enum-iterator", "num_enum", @@ -3566,7 +3585,7 @@ version = "1.4.0-dev" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -3592,7 +3611,7 @@ dependencies = [ "openssl", "petgraph", "regex", - "reqwest", + "reqwest 0.12.8", "serde", "serde_json", "sketching", @@ -3827,7 +3846,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.6.0", "libc", - "redox_syscall 0.5.4", + "redox_syscall 0.5.7", ] [[package]] @@ -3942,9 +3961,9 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memmap2" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" +checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" dependencies = [ "libc", ] @@ -3985,9 +4004,9 @@ dependencies = [ [[package]] name = "minicov" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c71e683cd655513b99affab7d317deb690528255a0d5f717f1024093c12b169" +checksum = "def6d99771d7c499c26ad4d40eb6645eafd3a1553b35fc26ea5a489a45e82d9a" dependencies = [ "cc", "walkdir", @@ -4314,6 +4333,7 @@ dependencies = [ "getrandom", "http 0.2.12", "rand", + "reqwest 0.11.27", "serde", "serde_json", "serde_path_to_error", @@ -4324,9 +4344,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] @@ -4351,9 +4371,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "oorandom" @@ -4384,7 +4404,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -4612,7 +4632,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.4", + "redox_syscall 0.5.7", "smallvec", "windows-targets 0.52.6", ] @@ -4689,7 +4709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.5.0", + "indexmap 2.6.0", "serde", "serde_derive", ] @@ -4731,29 +4751,29 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.5" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" [[package]] name = "pin-utils" @@ -4870,12 +4890,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.22" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479cf940fbbb3426c32c5d5176f62ad57549a0bb84773423ba8be9d089f5faba" +checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ "proc-macro2", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -4914,9 +4934,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.88" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" dependencies = [ "unicode-ident", ] @@ -5010,7 +5030,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.0.0", - "rustls", + "rustls 0.23.15", "socket2", "thiserror", "tokio", @@ -5027,7 +5047,7 @@ dependencies = [ "rand", "ring", "rustc-hash 2.0.0", - "rustls", + "rustls 0.23.15", "slab", "thiserror", "tinyvec", @@ -5117,9 +5137,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.4" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ "bitflags 2.6.0", ] @@ -5152,14 +5172,14 @@ checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] name = "reference-counted-singleton" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "242f841f006fa4f35979f74147f6d0be4402c19ca25b62b1c8e4c02e28288cb9" +checksum = "5daffa8f5ca827e146485577fa9dba9bd9c6921e06e954ab8f6408c10f753086" [[package]] name = "referencing" @@ -5176,9 +5196,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -5218,6 +5238,47 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.31", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.8" @@ -5237,7 +5298,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.5.0", - "hyper-rustls", + "hyper-rustls 0.27.3", "hyper-util", "ipnet", "js-sys", @@ -5248,22 +5309,23 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.15", "rustls-native-certs", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.1", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 0.26.6", "windows-registry", ] @@ -5368,7 +5430,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.82", + "syn 2.0.85", "walkdir", ] @@ -5424,14 +5486,26 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.13" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993" dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.102.8", "subtle", "zeroize", ] @@ -5443,7 +5517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcaf18a4f2be7326cd874a5fa579fae794320a0f388d365dca7e480e55f83f8a" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "rustls-pki-types", "schannel", "security-framework", @@ -5451,19 +5525,37 @@ dependencies = [ [[package]] name = "rustls-pemfile" -version = "2.1.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ - "base64 0.22.1", "rustls-pki-types", ] [[package]] name = "rustls-pki-types" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] [[package]] name = "rustls-webpki" @@ -5478,9 +5570,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "ryu" @@ -5499,9 +5591,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" +checksum = "01227be5826fa0690321a2ba6c5cd57a19cf3f6a09e76973b58e61de6ab9d1c1" dependencies = [ "windows-sys 0.59.0", ] @@ -5535,6 +5627,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sd-notify" version = "0.4.3" @@ -5556,9 +5658,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.1" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" dependencies = [ "core-foundation-sys", "libc", @@ -5580,11 +5682,11 @@ dependencies = [ [[package]] name = "selinux-sys" -version = "0.6.10" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1803f5aa3982540a3c0ae411fce872f34dcbf43bd552e8f1e790fa5255d97" +checksum = "8d557667087c5b4791e180b80979cd1a92fdb9bfd92cfd4b9ab199c4d7402423" dependencies = [ - "bindgen 0.69.4", + "bindgen 0.70.1", "cc", "dunce", "walkdir", @@ -5598,9 +5700,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" dependencies = [ "serde_derive", ] @@ -5658,13 +5760,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.213" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -5711,7 +5813,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.5.0", + "indexmap 2.6.0", "serde", "serde_derive", "serde_json", @@ -5728,7 +5830,7 @@ dependencies = [ "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -5961,9 +6063,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.82" +version = "2.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" dependencies = [ "proc-macro2", "quote", @@ -5997,6 +6099,27 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -6028,27 +6151,27 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -6143,19 +6266,20 @@ checksum = "8d9ef545650e79f30233c0003bcc2504d7efac6dad25fca40744de773fe2049c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] name = "tokio" -version = "1.40.0" +version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" dependencies = [ "backtrace", "bytes", "libc", "mio 1.0.2", + "parking_lot 0.12.3", "pin-project-lite", "signal-hook-registry", "socket2", @@ -6181,7 +6305,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -6205,13 +6329,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls", + "rustls 0.23.15", "rustls-pki-types", "tokio", ] @@ -6262,7 +6396,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.5.0", + "indexmap 2.6.0", "toml_datetime", "winnow 0.5.40", ] @@ -6282,7 +6416,7 @@ dependencies = [ "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", - "hyper 0.14.30", + "hyper 0.14.31", "hyper-timeout", "percent-encoding", "pin-project", @@ -6392,7 +6526,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -6539,18 +6673,15 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "unicase" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" -dependencies = [ - "version_check", -] +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] name = "unicode-bom" @@ -6566,30 +6697,30 @@ checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-xid" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229730647fbc343e3a80e463c1db7f78f3855d3f3739bee0dda773c9a037c90a" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" @@ -6627,7 +6758,7 @@ version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" dependencies = [ - "indexmap 2.5.0", + "indexmap 2.6.0", "serde", "serde_json", "utoipa-gen", @@ -6635,15 +6766,15 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "4.3.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bf0e16c02bc4bf5322ab65f10ab1149bdbcaa782cba66dc7057370a3f8190be" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" dependencies = [ "proc-macro-error", "proc-macro2", "quote", "regex", - "syn 2.0.82", + "syn 2.0.85", "url", "uuid", ] @@ -6773,7 +6904,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", "wasm-bindgen-shared", ] @@ -6807,7 +6938,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -6841,7 +6972,7 @@ checksum = "c97b2ef2c8d627381e51c071c2ab328eac606d3f69dd82bcbca20a9e389d95f0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -6994,6 +7125,21 @@ dependencies = [ "url", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.8" @@ -7018,7 +7164,7 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" dependencies = [ - "redox_syscall 0.5.4", + "redox_syscall 0.5.7", "wasite", "web-sys", ] @@ -7309,13 +7455,23 @@ dependencies = [ [[package]] name = "winnow" -version = "0.6.18" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "x509-cert" version = "0.2.5" @@ -7433,7 +7589,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] @@ -7453,7 +7609,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.82", + "syn 2.0.85", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 569b09c35..69bc5d9de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ resolver = "2" members = [ "proto", "tools/cli", + "tools/device_flow", "tools/iam_migrations/freeipa", "tools/iam_migrations/ldap", "tools/orca", diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 4130f87fe..8fe5299dc 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -1,3 +1,4 @@ + # Kanidm - [Introduction to Kanidm](introduction_to_kanidm.md) @@ -83,6 +84,7 @@ - [Cryptography Key Domains (2024)](developers/designs/cryptography_key_domains.md) - [Domain Join - Machine Accounts](developers/designs/domain_join_machine_accounts.md) - [Elevated Priv Mode](developers/designs/elevated_priv_mode.md) + - [OAuth2 Device Flow](developers/designs/oauth2_device_flow.md) - [OAuth2 Refresh Tokens](developers/designs/oauth2_refresh_tokens.md) - [Replication Coordinator](developers/designs/replication_coordinator.md) - [Replication Design and Notes](developers/designs/replication_design_and_notes.md) diff --git a/book/src/developers/designs/oauth2_device_flow.md b/book/src/developers/designs/oauth2_device_flow.md new file mode 100644 index 000000000..d5785d90e --- /dev/null +++ b/book/src/developers/designs/oauth2_device_flow.md @@ -0,0 +1,35 @@ +# OAuth2 Device Flow + +The general idea is that there's two flows. + +## Device/Backend + +- Start an auth flow +- Prompt the user with the link +- On an interval, check the status + - Still pending? Wait. + - Otherwise, handle the result. + +## User + +- Go to the "check user code" page +- Ensure user is authenticated +- Confirm that the user's happy for this auth session to happen + - This last step is the usual OAuth2 permissions/scope prompt + +```mermaid +flowchart TD + DeviceStatus -->|Pending| DeviceStatus + D[Device] -->|Start Backend flow| BackendFlowStart(Prompt User with details) + BackendFlowStart -->|User Clicks Link| DeviceGet + BackendFlowStart -->|Check Status| DeviceStatus + DeviceStatus -->|Result - error or success| End + + + DeviceGet -->|Not Logged in, Valid Token| LoginFlow(Login Flow) + DeviceGet -->|Invalid Token, Reprompt| DeviceGet + LoginFlow --> DeviceGet + DeviceGet -->|Logged in, Valid Token| ConfirmAccess(User Prompted to authorize) + ConfirmAccess -->|Confirmed| End(Done!) + +``` diff --git a/libs/client/src/oauth.rs b/libs/client/src/oauth.rs index 9e0257b63..4603ed952 100644 --- a/libs/client/src/oauth.rs +++ b/libs/client/src/oauth.rs @@ -1,4 +1,5 @@ use crate::{ClientError, KanidmClient}; +use kanidm_proto::attribute::Attribute; use kanidm_proto::constants::{ ATTR_DISPLAYNAME, ATTR_ES256_PRIVATE_KEY_DER, ATTR_NAME, ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE, ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT, @@ -453,4 +454,32 @@ impl KanidmClient { ) .await } + + pub async fn idm_oauth2_client_device_flow_update( + &self, + id: &str, + value: bool, + ) -> Result<(), ClientError> { + match value { + true => { + let mut update_oauth2_rs = Entry { + attrs: BTreeMap::new(), + }; + update_oauth2_rs.attrs.insert( + Attribute::OAuth2DeviceFlowEnable.into(), + vec![value.to_string()], + ); + self.perform_patch_request(format!("/v1/oauth2/{}", id).as_str(), update_oauth2_rs) + .await + } + false => { + self.perform_delete_request(&format!( + "/v1/oauth2/{}/_attr/{}", + id, + Attribute::OAuth2DeviceFlowEnable.as_str() + )) + .await + } + } + } } diff --git a/proto/Cargo.toml b/proto/Cargo.toml index 2cd08c13c..7bc548d72 100644 --- a/proto/Cargo.toml +++ b/proto/Cargo.toml @@ -16,11 +16,15 @@ test = true doctest = true [features] +# default = ["dev-oauth2-device-flow"] wasm = ["webauthn-rs-proto/wasm"] test = [] +dev-oauth2-device-flow = [] + [dependencies] base32 = { workspace = true } +base64 = { workspace = true } clap = { workspace = true } num_enum = { workspace = true } scim_proto = { workspace = true } diff --git a/proto/src/attribute.rs b/proto/src/attribute.rs index e3f7aa19a..2e9556e35 100644 --- a/proto/src/attribute.rs +++ b/proto/src/attribute.rs @@ -112,6 +112,7 @@ pub enum Attribute { OAuth2AllowInsecureClientDisablePkce, OAuth2AllowLocalhostRedirect, OAuth2ConsentScopeMap, + OAuth2DeviceFlowEnable, OAuth2JwtLegacyCryptoEnable, OAuth2PreferShortUsername, OAuth2RsBasicSecret, @@ -338,6 +339,7 @@ impl Attribute { } Attribute::OAuth2AllowLocalhostRedirect => ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT, Attribute::OAuth2ConsentScopeMap => ATTR_OAUTH2_CONSENT_SCOPE_MAP, + Attribute::OAuth2DeviceFlowEnable => ATTR_OAUTH2_DEVICE_FLOW_ENABLE, Attribute::OAuth2JwtLegacyCryptoEnable => ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE, Attribute::OAuth2PreferShortUsername => ATTR_OAUTH2_PREFER_SHORT_USERNAME, Attribute::OAuth2RsBasicSecret => ATTR_OAUTH2_RS_BASIC_SECRET, @@ -518,6 +520,7 @@ impl Attribute { } ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT => Attribute::OAuth2AllowLocalhostRedirect, ATTR_OAUTH2_CONSENT_SCOPE_MAP => Attribute::OAuth2ConsentScopeMap, + ATTR_OAUTH2_DEVICE_FLOW_ENABLE => Attribute::OAuth2DeviceFlowEnable, ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE => Attribute::OAuth2JwtLegacyCryptoEnable, ATTR_OAUTH2_PREFER_SHORT_USERNAME => Attribute::OAuth2PreferShortUsername, ATTR_OAUTH2_RS_BASIC_SECRET => Attribute::OAuth2RsBasicSecret, @@ -596,7 +599,10 @@ impl Attribute { #[allow(clippy::unreachable)] #[cfg(test)] _ => { - unreachable!() + unreachable!( + "Check that you've implemented the Attribute conversion for {:?}", + value + ); } } } @@ -608,6 +614,12 @@ impl fmt::Display for Attribute { } } +impl From for String { + fn from(attr: Attribute) -> String { + attr.to_string() + } +} + #[cfg(test)] mod test { use super::Attribute; @@ -632,7 +644,12 @@ mod test { let the_list = all::().collect::>(); for attr in the_list { let attr2 = Attribute::from(attr.as_str()); - assert!(attr == attr2); + assert!( + attr == attr2, + "Round-trip failed for {} <=> {} check you've implemented a from and to string", + attr, + attr2 + ); } } } diff --git a/proto/src/constants.rs b/proto/src/constants.rs index 7afc9d9c2..b77e7126d 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -149,6 +149,7 @@ pub const ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE: &str = "oauth2_allow_insecure_client_disable_pkce"; pub const ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT: &str = "oauth2_allow_localhost_redirect"; pub const ATTR_OAUTH2_CONSENT_SCOPE_MAP: &str = "oauth2_consent_scope_map"; +pub const ATTR_OAUTH2_DEVICE_FLOW_ENABLE: &str = "oauth2_device_flow_enable"; pub const ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE: &str = "oauth2_jwt_legacy_crypto_enable"; pub const ATTR_OAUTH2_PREFER_SHORT_USERNAME: &str = "oauth2_prefer_short_username"; pub const ATTR_OAUTH2_RS_BASIC_SECRET: &str = "oauth2_rs_basic_secret"; @@ -258,6 +259,7 @@ pub const KVERSION: &str = "X-KANIDM-VERSION"; pub const X_FORWARDED_FOR: &str = "x-forwarded-for"; // OAuth +pub const OAUTH2_DEVICE_CODE_SESSION: &str = "oauth2_device_code_session"; pub const OAUTH2_RESOURCE_SERVER: &str = "oauth2_resource_server"; pub const OAUTH2_RESOURCE_SERVER_BASIC: &str = "oauth2_resource_server_basic"; pub const OAUTH2_RESOURCE_SERVER_PUBLIC: &str = "oauth2_resource_server_public"; diff --git a/proto/src/constants/uri.rs b/proto/src/constants/uri.rs index 3474d4323..f17a3ef88 100644 --- a/proto/src/constants/uri.rs +++ b/proto/src/constants/uri.rs @@ -12,5 +12,16 @@ pub const OAUTH2_AUTHORISE: &str = "/oauth2/authorise"; pub const OAUTH2_AUTHORISE_PERMIT: &str = "/oauth2/authorise/permit"; /// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️ pub const OAUTH2_AUTHORISE_REJECT: &str = "/oauth2/authorise/reject"; +/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️ +pub const OAUTH2_AUTHORISE_DEVICE: &str = "/oauth2/device"; +/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️ +pub const OAUTH2_TOKEN_ENDPOINT: &str = "/oauth2/token"; +/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️ +pub const OAUTH2_TOKEN_INTROSPECT_ENDPOINT: &str = "/oauth2/token/introspect"; +/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️ +pub const OAUTH2_TOKEN_REVOKE_ENDPOINT: &str = "/oauth2/token/revoke"; + +/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️ +pub const OAUTH2_DEVICE_LOGIN: &str = "/oauth2/device"; // starts with /ui pub const V1_AUTH_VALID: &str = "/v1/auth/valid"; diff --git a/proto/src/oauth2.rs b/proto/src/oauth2.rs index 1d074ed46..0fc27c7cf 100644 --- a/proto/src/oauth2.rs +++ b/proto/src/oauth2.rs @@ -2,12 +2,19 @@ use std::collections::{BTreeMap, BTreeSet}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; use serde::{Deserialize, Serialize}; +use serde_with::base64::{Base64, UrlSafe}; use serde_with::formats::SpaceSeparator; -use serde_with::{base64, formats, serde_as, skip_serializing_none, StringWithSeparator}; +use serde_with::{formats, serde_as, skip_serializing_none, StringWithSeparator}; use url::Url; use uuid::Uuid; +/// How many seconds a device code is valid for. +pub const OAUTH2_DEVICE_CODE_EXPIRY_SECONDS: u64 = 300; +/// How often a client device can query the status of the token +pub const OAUTH2_DEVICE_CODE_INTERVAL_SECONDS: u64 = 5; + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)] pub enum CodeChallengeMethod { // default to plain if not requested as S256. Reject the auth? @@ -19,7 +26,7 @@ pub enum CodeChallengeMethod { #[serde_as] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PkceRequest { - #[serde_as(as = "base64::Base64")] + #[serde_as(as = "Base64")] pub code_challenge: Vec, pub code_challenge_method: CodeChallengeMethod, } @@ -102,6 +109,13 @@ pub enum GrantTypeReq { #[serde_as(as = "Option>")] scope: Option>, }, + /// ref + #[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")] + DeviceCode { + device_code: String, + // #[serde_as(as = "Option>")] + scope: Option>, + }, } /// An Access Token request. This requires a set of grant-type parameters to satisfy the request. @@ -448,6 +462,9 @@ pub struct OidcDiscoveryResponse { pub introspection_endpoint: Option, pub introspection_endpoint_auth_methods_supported: Vec, pub introspection_endpoint_auth_signing_alg_values_supported: Option>, + + /// Ref + pub device_authorization_endpoint: Option, } /// The response to an OAuth2 rfc8414 metadata request @@ -504,6 +521,39 @@ pub struct ErrorResponse { pub error_uri: Option, } +#[derive(Debug, Serialize, Deserialize)] +/// Ref +pub struct DeviceAuthorizationResponse { + /// Base64-encoded bundle of 16 bytes + device_code: String, + /// xxx-yyy-zzz where x/y/z are digits. Stored internally as a u32 because we'll drop the dashes and parse as a number. + user_code: String, + verification_uri: Url, + verification_uri_complete: Url, + expires_in: u64, + interval: u64, +} + +impl DeviceAuthorizationResponse { + pub fn new(verification_uri: Url, device_code: [u8; 16], user_code: String) -> Self { + let mut verification_uri_complete = verification_uri.clone(); + verification_uri_complete + .query_pairs_mut() + .append_pair("user_code", &user_code); + + let device_code = STANDARD.encode(device_code); + + Self { + verification_uri_complete, + device_code, + user_code, + verification_uri, + expires_in: OAUTH2_DEVICE_CODE_EXPIRY_SECONDS, + interval: OAUTH2_DEVICE_CODE_INTERVAL_SECONDS, + } + } +} + #[cfg(test)] mod tests { use super::{AccessTokenRequest, GrantTypeReq}; diff --git a/server/core/Cargo.toml b/server/core/Cargo.toml index f565a5317..a71a2af43 100644 --- a/server/core/Cargo.toml +++ b/server/core/Cargo.toml @@ -18,10 +18,11 @@ doctest = false [features] default = ["ui_htmx"] ui_htmx = [] +dev-oauth2-device-flow = [] [dependencies] async-trait = { workspace = true } -askama = { workspace = true } +askama = { workspace = true, features = ["with-axum"] } askama_axum = { workspace = true } axum = { workspace = true } axum-htmx = { workspace = true } diff --git a/server/core/src/actors/v1_write.rs b/server/core/src/actors/v1_write.rs index 15056d1e6..bbf33db79 100644 --- a/server/core/src/actors/v1_write.rs +++ b/server/core/src/actors/v1_write.rs @@ -12,6 +12,7 @@ use time::OffsetDateTime; use tracing::{info, instrument, trace}; use uuid::Uuid; + use kanidmd_lib::{ event::{CreateEvent, DeleteEvent, ModifyEvent, ReviveRecycledEvent}, filter::{Filter, FilterInvalid}, @@ -33,6 +34,9 @@ use kanidmd_lib::{ use kanidmd_lib::prelude::*; +#[cfg(feature = "dev-oauth2-device-flow")] +use std::collections::BTreeSet; + use super::QueryServerWriteV1; impl QueryServerWriteV1 { @@ -1701,4 +1705,26 @@ impl QueryServerWriteV1 { .oauth2_token_revoke(&client_auth_info, &intr_req, ct) .and_then(|()| idms_prox_write.commit().map_err(Oauth2Error::ServerError)) } + + #[cfg(feature = "dev-oauth2-device-flow")] + pub async fn handle_oauth2_device_flow_start( + &self, + client_auth_info: ClientAuthInfo, + client_id: &str, + scope: &Option>, + eventid: Uuid, + ) -> Result { + let ct = duration_from_epoch_now(); + let mut idms_prox_write = self + .idms + .proxy_write(ct) + .await + .map_err(Oauth2Error::ServerError)?; + idms_prox_write + .handle_oauth2_start_device_flow(client_auth_info, client_id, scope, eventid) + .and_then(|res| { + idms_prox_write.commit().map_err(Oauth2Error::ServerError)?; + Ok(res) + }) + } } diff --git a/server/core/src/https/oauth2.rs b/server/core/src/https/oauth2.rs index 699760498..4f3e98fbc 100644 --- a/server/core/src/https/oauth2.rs +++ b/server/core/src/https/oauth2.rs @@ -1,3 +1,5 @@ +use std::collections::{BTreeMap, BTreeSet}; + use super::errors::WebError; use super::middleware::KOpId; use super::ServerState; @@ -5,11 +7,13 @@ use crate::https::extractors::VerifiedClientInformation; use axum::{ body::Body, extract::{Path, Query, State}, - http::header::{ - ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE, LOCATION, - WWW_AUTHENTICATE, + http::{ + header::{ + ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE, LOCATION, + WWW_AUTHENTICATE, + }, + HeaderValue, StatusCode, }, - http::{HeaderValue, StatusCode}, middleware::from_fn, response::{IntoResponse, Response}, routing::{get, post}, @@ -22,6 +26,9 @@ use kanidm_proto::constants::uri::{ }; use kanidm_proto::constants::APPLICATION_JSON; use kanidm_proto::oauth2::AuthorisationResponse; + +#[cfg(feature = "dev-oauth2-device-flow")] +use kanidm_proto::oauth2::DeviceAuthorizationResponse; use kanidmd_lib::idm::oauth2::{ AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest, @@ -30,6 +37,12 @@ use kanidmd_lib::prelude::f_eq; use kanidmd_lib::prelude::*; use kanidmd_lib::value::PartialValue; use serde::{Deserialize, Serialize}; +use serde_with::formats::CommaSeparator; +use serde_with::{serde_as, StringWithSeparator}; + +#[cfg(feature = "dev-oauth2-device-flow")] +use uri::OAUTH2_AUTHORISE_DEVICE; +use uri::{OAUTH2_TOKEN_ENDPOINT, OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT}; // TODO: merge this into a value in WebError later pub struct HTTPOauth2Error(Oauth2Error); @@ -724,6 +737,38 @@ pub async fn oauth2_preflight_options() -> Response { .into_response() } +#[serde_as] +#[derive(Deserialize, Debug, Serialize)] +pub(crate) struct DeviceFlowForm { + client_id: String, + #[serde_as(as = "Option>")] + scope: Option>, + #[serde(flatten)] + extra: BTreeMap, // catches any extra nonsense that gets sent through +} + +/// Device flow! [RFC8628](https://datatracker.ietf.org/doc/html/rfc8628) +#[cfg(feature = "dev-oauth2-device-flow")] +#[instrument(level = "info", skip(state, kopid, client_auth_info))] +pub(crate) async fn oauth2_authorise_device_post( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + Form(form): Form, +) -> Result, HTTPOauth2Error> { + state + .qe_w_ref + .handle_oauth2_device_flow_start( + client_auth_info, + &form.client_id, + &form.scope, + kopid.eventid, + ) + .await + .map(Json::from) + .map_err(HTTPOauth2Error) +} + pub fn route_setup(state: ServerState) -> Router { // this has all the openid-related routes let openid_router = Router::new() @@ -753,7 +798,7 @@ pub fn route_setup(state: ServerState) -> Router { ) .with_state(state.clone()); - Router::new() + let mut router = Router::new() .route("/oauth2", get(super::v1_oauth2::oauth2_get)) // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS @@ -772,21 +817,30 @@ pub fn route_setup(state: ServerState) -> Router { .route( OAUTH2_AUTHORISE_REJECT, post(oauth2_authorise_reject_post).get(oauth2_authorise_reject_get), - ) - // ⚠️ ⚠️ WARNING ⚠️ ⚠️ - // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS + ); + // ⚠️ ⚠️ WARNING ⚠️ ⚠️ + // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS + #[cfg(feature = "dev-oauth2-device-flow")] + { + router = router.route(OAUTH2_AUTHORISE_DEVICE, post(oauth2_authorise_device_post)) + } + // ⚠️ ⚠️ WARNING ⚠️ ⚠️ + // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS + router = router .route( - "/oauth2/token", + OAUTH2_TOKEN_ENDPOINT, post(oauth2_token_post).options(oauth2_preflight_options), ) // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS .route( - "/oauth2/token/introspect", + OAUTH2_TOKEN_INTROSPECT_ENDPOINT, post(oauth2_token_introspect_post), ) - .route("/oauth2/token/revoke", post(oauth2_token_revoke_post)) + .route(OAUTH2_TOKEN_REVOKE_ENDPOINT, post(oauth2_token_revoke_post)) .merge(openid_router) .with_state(state) - .layer(from_fn(super::middleware::caching::dont_cache_me)) + .layer(from_fn(super::middleware::caching::dont_cache_me)); + + router } diff --git a/server/core/src/https/views/mod.rs b/server/core/src/https/views/mod.rs index 9c6058a6c..e1b5a186e 100644 --- a/server/core/src/https/views/mod.rs +++ b/server/core/src/https/views/mod.rs @@ -11,10 +11,7 @@ use axum_htmx::HxRequestGuardLayer; use constants::Urls; use kanidmd_lib::prelude::{OperationError, Uuid}; -use crate::https::{ - // extractors::VerifiedClientInformation, middleware::KOpId, v1::SessionId, - ServerState, -}; +use crate::https::ServerState; mod apps; mod constants; @@ -33,7 +30,7 @@ struct UnrecoverableErrorView { } pub fn view_router() -> Router { - let unguarded_router = Router::new() + let mut unguarded_router = Router::new() .route( "/", get(|| async { Redirect::permanent(Urls::Login.as_ref()) }), @@ -44,7 +41,16 @@ pub fn view_router() -> Router { .route("/profile", get(profile::view_profile_get)) .route("/profile/unlock", get(profile::view_profile_unlock_get)) .route("/logout", get(login::view_logout_get)) - .route("/oauth2", get(oauth2::view_index_get)) + .route("/oauth2", get(oauth2::view_index_get)); + + #[cfg(feature = "dev-oauth2-device-flow")] + { + unguarded_router = unguarded_router.route( + kanidmd_lib::prelude::uri::OAUTH2_DEVICE_LOGIN, + get(oauth2::view_device_get).post(oauth2::view_device_post), + ); + } + unguarded_router = unguarded_router .route("/oauth2/resume", get(oauth2::view_resume_get)) .route("/oauth2/consent", post(oauth2::view_consent_post)) // The login routes are htmx-free to make them simpler, which means diff --git a/server/core/src/https/views/oauth2.rs b/server/core/src/https/views/oauth2.rs index cc2168dd3..51af8fd4d 100644 --- a/server/core/src/https/views/oauth2.rs +++ b/server/core/src/https/views/oauth2.rs @@ -1,16 +1,17 @@ +use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; use kanidmd_lib::idm::oauth2::{ AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, Oauth2Error, }; use kanidmd_lib::prelude::*; -use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; - use kanidm_proto::internal::COOKIE_OAUTH2_REQ; use std::collections::BTreeSet; use askama::Template; +#[cfg(feature = "dev-oauth2-device-flow")] +use axum::http::StatusCode; use axum::{ extract::{Query, State}, http::header::ACCESS_CONTROL_ALLOW_ORIGIN, @@ -31,6 +32,7 @@ struct ConsentRequestView { // scopes: BTreeSet, pii_scopes: BTreeSet, consent_token: String, + redirect: Option, } #[derive(Template)] @@ -54,19 +56,18 @@ pub async fn view_resume_get( Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, jar: CookieJar, -) -> Response { +) -> Result { let maybe_auth_req = cookies::get_signed::(&state, &jar, COOKIE_OAUTH2_REQ); if let Some(auth_req) = maybe_auth_req { - oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await + Ok(oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await) } else { error!("unable to resume session, no auth_req was found in the cookie"); - UnrecoverableErrorView { + Err(UnrecoverableErrorView { err_code: OperationError::InvalidState, operation_id: kopid.eventid, - } - .into_response() + }) } } @@ -128,6 +129,7 @@ async fn oauth2_auth_req( // scopes, pii_scopes, consent_token, + redirect: None, } .into_response() } @@ -185,6 +187,9 @@ async fn oauth2_auth_req( #[derive(Debug, Clone, Deserialize)] pub struct ConsentForm { consent_token: String, + #[serde(default)] + #[allow(dead_code)] // TODO: do smoething with this + redirect: Option, } pub async fn view_consent_post( @@ -193,7 +198,7 @@ pub async fn view_consent_post( VerifiedClientInformation(client_auth_info): VerifiedClientInformation, jar: CookieJar, Form(consent_form): Form, -) -> Response { +) -> Result { let res = state .qe_w_ref .handle_oauth2_authorise_permit(client_auth_info, consent_form.consent_token, kopid.eventid) @@ -207,23 +212,38 @@ pub async fn view_consent_post( }) => { let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ); - redirect_uri - .query_pairs_mut() - .clear() - .append_pair("state", &state) - .append_pair("code", &code); - ( - jar, - [ - (HX_REDIRECT, redirect_uri.as_str().to_string()), - ( - ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), - redirect_uri.origin().ascii_serialization(), - ), - ], - Redirect::to(redirect_uri.as_str()), - ) - .into_response() + if let Some(redirect) = consent_form.redirect { + Ok(( + jar, + [ + (HX_REDIRECT, redirect_uri.as_str().to_string()), + ( + ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), + redirect_uri.origin().ascii_serialization(), + ), + ], + Redirect::to(&redirect), + ) + .into_response()) + } else { + redirect_uri + .query_pairs_mut() + .clear() + .append_pair("state", &state) + .append_pair("code", &code); + Ok(( + jar, + [ + (HX_REDIRECT, redirect_uri.as_str().to_string()), + ( + ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), + redirect_uri.origin().ascii_serialization(), + ), + ], + Redirect::to(redirect_uri.as_str()), + ) + .into_response()) + } } Err(err_code) => { error!( @@ -232,11 +252,64 @@ pub async fn view_consent_post( &err_code.to_string() ); - UnrecoverableErrorView { + Err(UnrecoverableErrorView { err_code: OperationError::InvalidState, operation_id: kopid.eventid, - } - .into_response() + }) } } } + +#[derive(Template, Debug, Clone)] +#[cfg(feature = "dev-oauth2-device-flow")] +#[template(path = "oauth2_device_login.html")] +pub struct Oauth2DeviceLoginView { + domain_custom_image: bool, + title: String, + user_code: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[cfg(feature = "dev-oauth2-device-flow")] +pub(crate) struct QueryUserCode { + pub user_code: Option, +} + +#[axum::debug_handler] +#[cfg(feature = "dev-oauth2-device-flow")] +pub async fn view_device_get( + State(state): State, + Extension(_kopid): Extension, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + Query(user_code): Query, +) -> Result { + // TODO: if we have a valid auth session and the user code is valid, prompt the user to allow the session to start + Ok(Oauth2DeviceLoginView { + domain_custom_image: state.qe_r_ref.domain_info_read().has_custom_image(), + title: "Device Login".to_string(), + user_code: user_code.user_code.unwrap_or("".to_string()), + }) +} + +#[derive(Deserialize)] +#[cfg(feature = "dev-oauth2-device-flow")] +pub struct Oauth2DeviceLoginForm { + user_code: String, + confirm_login: bool, +} + +#[cfg(feature = "dev-oauth2-device-flow")] +#[axum::debug_handler] +pub async fn view_device_post( + State(_state): State, + Extension(_kopid): Extension, + VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, + Form(form): Form, +) -> Result { + debug!("User code: {}", form.user_code); + debug!("User confirmed: {}", form.confirm_login); + + // TODO: when the user POST's this form we need to check the user code and see if it's valid + // then start a login flow which ends up authorizing the token at the end. + Err((StatusCode::NOT_IMPLEMENTED, "Not implemented yet")) +} diff --git a/server/core/src/https/views/profile.rs b/server/core/src/https/views/profile.rs index 7a02bfde0..4ceccd0ff 100644 --- a/server/core/src/https/views/profile.rs +++ b/server/core/src/https/views/profile.rs @@ -56,7 +56,6 @@ pub(crate) async fn view_profile_get( }) } -// #[axum::debug_handler] pub(crate) async fn view_profile_unlock_get( State(state): State, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, diff --git a/server/core/templates/oauth2_consent_request.html b/server/core/templates/oauth2_consent_request.html index d87e167a9..112fc910d 100644 --- a/server/core/templates/oauth2_consent_request.html +++ b/server/core/templates/oauth2_consent_request.html @@ -25,6 +25,9 @@ (% endif %)
+ (% if let Some(redirect) = redirect %) + + (% endif %)
diff --git a/server/core/templates/oauth2_device_login.html b/server/core/templates/oauth2_device_login.html new file mode 100644 index 000000000..d6830e10d --- /dev/null +++ b/server/core/templates/oauth2_device_login.html @@ -0,0 +1,35 @@ +(% extends "base.html" %) + +(% block body %) +
+
+ (% if domain_custom_image %) + + (% else %) + + (% endif %) +
+ +
+
+ + +
+
+ +
+
+
+
+(% endblock %) diff --git a/server/lib/Cargo.toml b/server/lib/Cargo.toml index 35e509d76..b8aebc0d8 100644 --- a/server/lib/Cargo.toml +++ b/server/lib/Cargo.toml @@ -26,9 +26,10 @@ name = "image_benches" harness = false [features] -# default = [ "libsqlite3-sys/bundled", "openssl/vendored" ] +default = [] dhat-heap = ["dep:dhat"] dhat-ad-hoc = ["dep:dhat"] +dev-oauth2-device-flow = [] # still-in-development oauth2 device flow support [dependencies] base64 = { workspace = true } @@ -105,7 +106,11 @@ svg = { workspace = true } whoami = { workspace = true } [dev-dependencies] -compact_jwt = { workspace = true, features = ["openssl", "hsm-crypto", "unsafe_release_without_verify"] } +compact_jwt = { workspace = true, features = [ + "openssl", + "hsm-crypto", + "unsafe_release_without_verify", +] } criterion = { workspace = true, features = ["html_reports"] } futures = { workspace = true } kanidmd_lib_macros = { workspace = true } diff --git a/server/lib/src/constants/acp.rs b/server/lib/src/constants/acp.rs index 347200625..be1836345 100644 --- a/server/lib/src/constants/acp.rs +++ b/server/lib/src/constants/acp.rs @@ -908,6 +908,117 @@ lazy_static! { }; } +lazy_static! { + pub static ref IDM_ACP_OAUTH2_MANAGE_DL9: BuiltinAcp = BuiltinAcp { + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlCreate, + EntryClass::AccessControlDelete, + EntryClass::AccessControlModify, + EntryClass::AccessControlSearch + ], + name: "idm_acp_hp_oauth2_manage_priv", + uuid: UUID_IDM_ACP_OAUTH2_MANAGE_V1, + description: "Builtin IDM Control for managing OAuth2 resource server integrations.", + receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_OAUTH2_ADMINS]), + target: BuiltinAcpTarget::Filter(ProtoFilter::And(vec![ + match_class_filter!(EntryClass::OAuth2ResourceServer), + FILTER_ANDNOT_TOMBSTONE_OR_RECYCLED.clone(), + ])), + search_attrs: vec![ + Attribute::Class, + Attribute::Description, + Attribute::DisplayName, + Attribute::Name, + Attribute::Spn, + Attribute::OAuth2Session, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsBasicSecret, + Attribute::OAuth2RsTokenKey, + Attribute::Es256PrivateKeyDer, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::Rs256PrivateKeyDer, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + Attribute::OAuth2StrictRedirectUri, + Attribute::OAuth2DeviceFlowEnable, + ], + modify_removed_attrs: vec![ + Attribute::Description, + Attribute::DisplayName, + Attribute::Name, + Attribute::OAuth2Session, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsBasicSecret, + Attribute::OAuth2RsTokenKey, + Attribute::Es256PrivateKeyDer, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::Rs256PrivateKeyDer, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + Attribute::OAuth2StrictRedirectUri, + Attribute::OAuth2DeviceFlowEnable, + ], + modify_present_attrs: vec![ + Attribute::Description, + Attribute::DisplayName, + Attribute::Name, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + Attribute::OAuth2StrictRedirectUri, + Attribute::OAuth2DeviceFlowEnable, + ], + create_attrs: vec![ + Attribute::Class, + Attribute::Description, + Attribute::Name, + Attribute::DisplayName, + Attribute::OAuth2RsName, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + Attribute::OAuth2StrictRedirectUri, + Attribute::OAuth2DeviceFlowEnable, + ], + create_classes: vec![ + EntryClass::Object, + EntryClass::Account, + EntryClass::OAuth2ResourceServer, + EntryClass::OAuth2ResourceServerBasic, + EntryClass::OAuth2ResourceServerPublic, + ], + ..Default::default() + }; +} + lazy_static! { pub static ref IDM_ACP_DOMAIN_ADMIN_DL6: BuiltinAcp = BuiltinAcp { classes: vec![ diff --git a/server/lib/src/constants/entries.rs b/server/lib/src/constants/entries.rs index 2239ee1b0..9c0f9994c 100644 --- a/server/lib/src/constants/entries.rs +++ b/server/lib/src/constants/entries.rs @@ -52,6 +52,7 @@ pub enum EntryClass { OAuth2ResourceServer, OAuth2ResourceServerBasic, OAuth2ResourceServerPublic, + OAuth2DeviceCodeSession, Object, OrgPerson, Person, @@ -102,6 +103,7 @@ impl From for &'static str { EntryClass::KeyObjectJweA128GCM => ENTRYCLASS_KEY_OBJECT_JWE_A128GCM, EntryClass::KeyObjectInternal => ENTRYCLASS_KEY_OBJECT_INTERNAL, EntryClass::MemberOf => ENTRYCLASS_MEMBER_OF, + EntryClass::OAuth2DeviceCodeSession => OAUTH2_DEVICE_CODE_SESSION, EntryClass::OAuth2ResourceServer => OAUTH2_RESOURCE_SERVER, EntryClass::OAuth2ResourceServerBasic => OAUTH2_RESOURCE_SERVER_BASIC, EntryClass::OAuth2ResourceServerPublic => OAUTH2_RESOURCE_SERVER_PUBLIC, diff --git a/server/lib/src/constants/mod.rs b/server/lib/src/constants/mod.rs index 8d4063ff3..4eff6b8e3 100644 --- a/server/lib/src/constants/mod.rs +++ b/server/lib/src/constants/mod.rs @@ -54,24 +54,24 @@ pub type DomainVersion = u32; /// previously. pub const DOMAIN_LEVEL_0: DomainVersion = 0; -/// Deprcated as of 1.3.0 +/// Deprecated as of 1.3.0 pub const DOMAIN_LEVEL_5: DomainVersion = 5; /// Domain Level introduced with 1.2.0. -/// Deprcated as of 1.4.0 +/// Deprecated as of 1.4.0 pub const DOMAIN_LEVEL_6: DomainVersion = 6; pub const PATCH_LEVEL_1: u32 = 1; /// Domain Level introduced with 1.3.0. -/// Deprcated as of 1.5.0 +/// Deprecated as of 1.5.0 pub const DOMAIN_LEVEL_7: DomainVersion = 7; /// Domain Level introduced with 1.4.0. -/// Deprcated as of 1.6.0 +/// Deprecated as of 1.6.0 pub const DOMAIN_LEVEL_8: DomainVersion = 8; /// Domain Level introduced with 1.5.0. -/// Deprcated as of 1.7.0 +/// Deprecated as of 1.7.0 pub const DOMAIN_LEVEL_9: DomainVersion = 9; // The minimum level that we can re-migrate from. diff --git a/server/lib/src/constants/schema.rs b/server/lib/src/constants/schema.rs index e7e4a8a01..2ad8b5560 100644 --- a/server/lib/src/constants/schema.rs +++ b/server/lib/src/constants/schema.rs @@ -468,6 +468,16 @@ pub static ref SCHEMA_ATTR_OAUTH2_STRICT_REDIRECT_URI_DL7: SchemaAttribute = Sch ..Default::default() }; + +pub static ref SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE_DL9: SchemaAttribute = SchemaAttribute { + uuid: UUID_SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE, + name: Attribute::OAuth2DeviceFlowEnable, + description: "Represents if OAuth2 Device Flow is permitted on this client.".to_string(), + + syntax: SyntaxType::Boolean, + ..Default::default() +}; + pub static ref SCHEMA_ATTR_ES256_PRIVATE_KEY_DER: SchemaAttribute = SchemaAttribute { uuid: UUID_SCHEMA_ATTR_ES256_PRIVATE_KEY_DER, name: Attribute::Es256PrivateKeyDer, @@ -1280,6 +1290,33 @@ pub static ref SCHEMA_CLASS_OAUTH2_RS_DL7: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_OAUTH2_RS_DL9: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_OAUTH2_RS, + name: EntryClass::OAuth2ResourceServer.into(), + description: "The class representing a configured OAuth2 Client".to_string(), + + systemmay: vec![ + Attribute::Description, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2RsSupScopeMap, + Attribute::Rs256PrivateKeyDer, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::Image, + Attribute::OAuth2RsClaimMap, + Attribute::OAuth2Session, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2StrictRedirectUri, + Attribute::OAuth2DeviceFlowEnable, + ], + systemmust: vec![ + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsTokenKey, + Attribute::Es256PrivateKeyDer, + ], + ..Default::default() +}; + pub static ref SCHEMA_CLASS_OAUTH2_RS_BASIC_DL5: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC, name: EntryClass::OAuth2ResourceServerBasic.into(), diff --git a/server/lib/src/constants/uuids.rs b/server/lib/src/constants/uuids.rs index 5fa0a7b60..f92e39389 100644 --- a/server/lib/src/constants/uuids.rs +++ b/server/lib/src/constants/uuids.rs @@ -441,6 +441,8 @@ pub const UUID_IDM_ACP_APPLICATION_ENTRY_MANAGER: Uuid = uuid!("00000000-0000-0000-0000-ffffff000072"); pub const UUID_IDM_ACP_APPLICATION_MANAGE: Uuid = uuid!("00000000-0000-0000-0000-ffffff000073"); pub const UUID_IDM_ACP_MAIL_SERVERS: Uuid = uuid!("00000000-0000-0000-0000-ffffff000074"); +pub const UUID_SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE: Uuid = + uuid!("00000000-0000-0000-0000-ffffff000075"); // End of system ranges pub const UUID_DOES_NOT_EXIST: Uuid = uuid!("00000000-0000-0000-0000-fffffffffffe"); diff --git a/server/lib/src/idm/oauth2.rs b/server/lib/src/idm/oauth2.rs index a27503e68..340538666 100644 --- a/server/lib/src/idm/oauth2.rs +++ b/server/lib/src/idm/oauth2.rs @@ -12,10 +12,9 @@ use std::str::FromStr; use std::sync::Arc; use std::time::Duration; +use base64::{engine::general_purpose, Engine as _}; use hashbrown::HashSet; -use ::base64::{engine::general_purpose, Engine as _}; - pub use compact_jwt::{compact::JwkKeySet, OidcToken}; use compact_jwt::{ crypto::JwsRs256Signer, jws::JwsBuilder, JwsCompact, JwsEs256Signer, JwsSigner, @@ -26,21 +25,27 @@ use fernet::Fernet; use hashbrown::HashMap; use kanidm_proto::constants::*; +// #[cfg(feature = "dev-oauth2-device-flow")] +// use kanidm_proto::oauth2::OAUTH2_DEVICE_CODE_EXPIRY_SECONDS; + pub use kanidm_proto::oauth2::{ AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse, GrantTypeReq, OAuth2RFC9068Token, OAuth2RFC9068TokenExtensions, Oauth2Rfc8414MetadataResponse, OidcDiscoveryResponse, PkceAlg, TokenRevokeRequest, }; + use kanidm_proto::oauth2::{ - AccessTokenType, ClaimType, DisplayValue, GrantType, IdTokenSignAlg, ResponseMode, - ResponseType, SubjectType, TokenEndpointAuthMethod, + AccessTokenType, ClaimType, DeviceAuthorizationResponse, DisplayValue, GrantType, + IdTokenSignAlg, ResponseMode, ResponseType, SubjectType, TokenEndpointAuthMethod, }; use openssl::sha; + use serde::{Deserialize, Serialize}; -use serde_with::{base64, formats, serde_as}; +use serde_with::{formats, serde_as}; use time::OffsetDateTime; use tracing::trace; +use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT}; use url::{Origin, Url}; use crate::idm::account::Account; @@ -72,6 +77,25 @@ pub enum Oauth2Error { InsufficientScope, // from https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1 UnsupportedTokenType, + /// A variant of "authorization_pending", the authorization request is + /// still pending and polling should continue, but the interval MUST + /// be increased by 5 seconds for this and all subsequent requests. + SlowDown, + /// The authorization request is still pending as the end user hasn't + /// yet completed the user-interaction steps (Section 3.3). The + /// client SHOULD repeat the access token request to the token + /// endpoint (a process known as polling). Before each new request, + /// the client MUST wait at least the number of seconds specified by + /// the "interval" parameter of the device authorization response (see + /// Section 3.2), or 5 seconds if none was provided, and respect any + /// increase in the polling interval required by the "slow_down" + /// error. + AuthorizationPending, + /// The "device_code" has expired, and the device authorization + /// session has concluded. The client MAY commence a new device + /// authorization request but SHOULD wait for user interaction before + /// restarting to avoid unnecessary polling. + ExpiredToken, } impl std::fmt::Display for Oauth2Error { @@ -91,6 +115,9 @@ impl std::fmt::Display for Oauth2Error { Oauth2Error::InvalidToken => "invalid_token", Oauth2Error::InsufficientScope => "insufficient_scope", Oauth2Error::UnsupportedTokenType => "unsupported_token_type", + Oauth2Error::SlowDown => "slow_down", + Oauth2Error::AuthorizationPending => "authorization_pending", + Oauth2Error::ExpiredToken => "expired_token", }) } } @@ -108,7 +135,9 @@ struct ConsentToken { // CSRF pub state: String, // The S256 code challenge. - #[serde_as(as = "Option>")] + #[serde_as( + as = "Option>" + )] pub code_challenge: Option>, // Where the RS wants us to go back to. pub redirect_uri: Url, @@ -128,7 +157,9 @@ struct TokenExchangeCode { pub session_id: Uuid, // The S256 code challenge. - #[serde_as(as = "Option>")] + #[serde_as( + as = "Option>" + )] pub code_challenge: Option>, // The original redirect uri pub redirect_uri: Url, @@ -305,6 +336,37 @@ pub struct Oauth2RS { type_: OauthRSType, /// Does the RS have a custom image set? If not, we use the default. has_custom_image: bool, + + device_authorization_endpoint: Option, +} + +impl Oauth2RS { + pub fn is_basic(&self) -> bool { + match self.type_ { + OauthRSType::Basic { .. } => true, + OauthRSType::Public { .. } => false, + } + } + + pub fn is_pkce(&self) -> bool { + match self.type_ { + OauthRSType::Basic { .. } => false, + OauthRSType::Public { .. } => true, + } + } + + /// Does this client require PKCE? + pub fn require_pkce(&self) -> bool { + match &self.type_ { + OauthRSType::Basic { enable_pkce, .. } => *enable_pkce, + OauthRSType::Public { .. } => true, + } + } + + /// Does this RS have device flow enabled? + pub fn device_flow_enabled(&self) -> bool { + self.device_authorization_endpoint.is_some() + } } impl std::fmt::Debug for Oauth2RS { @@ -628,13 +690,13 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { authorization_endpoint.set_path("/ui/oauth2"); let mut token_endpoint = self.inner.origin.clone(); - token_endpoint.set_path("/oauth2/token"); + token_endpoint.set_path(uri::OAUTH2_TOKEN_ENDPOINT); let mut revocation_endpoint = self.inner.origin.clone(); - revocation_endpoint.set_path("/oauth2/token/revoke"); + revocation_endpoint.set_path(OAUTH2_TOKEN_REVOKE_ENDPOINT); let mut introspection_endpoint = self.inner.origin.clone(); - introspection_endpoint.set_path("/oauth2/token/introspect"); + introspection_endpoint.set_path(OAUTH2_TOKEN_INTROSPECT_ENDPOINT); let mut userinfo_endpoint = self.inner.origin.clone(); userinfo_endpoint.set_path(&format!("/oauth2/openid/{name}/userinfo")); @@ -659,6 +721,20 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { .cloned() .collect(); + + let device_authorization_endpoint: Option = match cfg!(feature="dev-oauth2-device-flow") { + true => { + match ent.get_ava_single_bool(Attribute::OAuth2DeviceFlowEnable).unwrap_or(false) { + true => { + let mut device_authorization_endpoint = self.inner.origin.clone(); + device_authorization_endpoint.set_path(uri::OAUTH2_AUTHORISE_DEVICE); + Some(device_authorization_endpoint) + }, + false => None + } + }, + false => {None} + }; let client_id = name.clone(); let rscfg = Oauth2RS { name, @@ -687,6 +763,7 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { prefer_short_username, type_, has_custom_image, + device_authorization_endpoint, }; Ok((client_id, rscfg)) @@ -819,7 +896,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { // submit the Modify::Remove. This way it's inserted into the entry changelog // and when replication converges the session is actually removed. - let modlist = ModifyList::new_list(vec![Modify::Removed( + let modlist: ModifyList = ModifyList::new_list(vec![Modify::Removed( Attribute::OAuth2Session, PartialValue::Refer(session_id), )]); @@ -860,19 +937,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } }; - // DANGER: Why do we have to do this? During the use of qs for internal search - // and other operations we need qs to be mut. But when we borrow oauth2rs here we - // cause multiple borrows to occur on struct members that freaks rust out. This *IS* - // safe however because no element of the search or write process calls the oauth2rs - // excepting for this idm layer within a single thread, meaning that stripping the - // lifetime here is safe since we are the sole accessor. - let o2rs: &Oauth2RS = unsafe { - let s = self.oauth2rs.inner.rs_set.get(&client_id).ok_or_else(|| { - admin_warn!("Invalid OAuth2 client_id"); - Oauth2Error::AuthenticationRequired - })?; - &*(s as *const _) - }; + let o2rs = self.get_client(&client_id)?; // check the secret. let client_authentication_valid = match &o2rs.type_ { @@ -906,7 +971,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { redirect_uri, code_verifier, } => self.check_oauth2_token_exchange_authorization_code( - o2rs, + &o2rs, code, redirect_uri, code_verifier.as_deref(), @@ -914,7 +979,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { ), GrantTypeReq::ClientCredentials { scope } => { if client_authentication_valid { - self.check_oauth2_token_client_credentials(o2rs, scope.as_ref(), ct) + self.check_oauth2_token_client_credentials(&o2rs, scope.as_ref(), ct) } else { security_info!( "Unable to proceed with client credentials grant unless client authentication is provided and valid" @@ -925,10 +990,92 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { GrantTypeReq::RefreshToken { refresh_token, scope, - } => self.check_oauth2_token_refresh(o2rs, refresh_token, scope.as_ref(), ct), + } => self.check_oauth2_token_refresh(&o2rs, refresh_token, scope.as_ref(), ct), + GrantTypeReq::DeviceCode { device_code, scope } => { + self.check_oauth2_device_code_status(device_code, scope) + } } } + fn get_client(&self, client_id: &str) -> Result { + let s = self + .oauth2rs + .inner + .rs_set + .get(client_id) + .ok_or_else(|| { + admin_warn!("Invalid OAuth2 client_id {}", client_id); + Oauth2Error::AuthenticationRequired + })? + .clone(); + Ok(s) + } + + #[instrument(level = "info", skip(self))] + pub fn handle_oauth2_start_device_flow( + &mut self, + _client_auth_info: ClientAuthInfo, + _client_id: &str, + _scope: &Option>, + _eventid: Uuid, + ) -> Result { + // let o2rs = self.get_client(client_id)?; + + // info!("Got Client: {:?}", o2rs); + + // // TODO: change this to checking if it's got device flow enabled + // if !o2rs.require_pkce() { + // security_info!("Device flow is only available for PKCE-enabled clients"); + // return Err(Oauth2Error::InvalidRequest); + // } + + // info!( + // "Starting device flow for client_id={} scopes={} source={:?}", + // client_id, + // scope + // .as_ref() + // .map(|s| s.iter().cloned().collect::>().into_iter().join(",")) + // .unwrap_or("[]".to_string()), + // client_auth_info.source + // ); + + // let mut verification_uri = self.oauth2rs.inner.origin.clone(); + // verification_uri.set_path(uri::OAUTH2_DEVICE_LOGIN); + + // let (user_code_string, _user_code) = gen_user_code(); + // let expiry = + // Duration::from_secs(OAUTH2_DEVICE_CODE_EXPIRY_SECONDS) + duration_from_epoch_now(); + // let device_code = gen_device_code() + // .inspect_err(|err| error!("Failed to generate a device code! {:?}", err))?; + + Err(Oauth2Error::InvalidGrant) + + // TODO: store user_code / expiry / client_id / device_code in the backend, needs to be checked on the token exchange. + // Ok(DeviceAuthorizationResponse::new( + // verification_uri, + // device_code, + // user_code_string, + // )) + } + + #[instrument(level = "info", skip(self))] + fn check_oauth2_device_code_status( + &mut self, + device_code: &str, + scope: &Option>, + ) -> Result { + // TODO: check the device code is valid, do the needful + + error!( + "haven't done the device grant yet! Got device_code={} scope={:?}", + device_code, scope + ); + Err(Oauth2Error::AuthorizationPending) + + // if it's an expired code, then just delete it from the db and return an error. + // Err(Oauth2Error::ExpiredToken) + } + #[instrument(level = "debug", skip_all)] pub fn check_oauth2_authorise_permit( &mut self, @@ -1053,11 +1200,6 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { }) })?; - let require_pkce = match &o2rs.type_ { - OauthRSType::Basic { enable_pkce, .. } => *enable_pkce, - OauthRSType::Public { .. } => true, - }; - // If we have a verifier present, we MUST assert that a code challenge is present! // It is worth noting here that code_xchg is *server issued* and encrypted, with // a short validity period. The client controlled value is in token_req.code_verifier @@ -1078,7 +1220,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { ); return Err(Oauth2Error::InvalidRequest); } - } else if require_pkce { + } else if o2rs.require_pkce() { security_info!( "PKCE code verification failed - no code challenge present in PKCE enforced mode" ); @@ -1607,16 +1749,12 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { })?; // check the secret. - match &o2rs.type_ { - OauthRSType::Basic { authz_secret, .. } => { - if authz_secret != &secret { - security_info!("Invalid OAuth2 client_id secret"); - return Err(OperationError::InvalidSessionState); - } + if let OauthRSType::Basic { authz_secret, .. } = &o2rs.type_ { + if o2rs.is_basic() && authz_secret != &secret { + security_info!("Invalid OAuth2 secret for client_id={}", client_id); + return Err(OperationError::InvalidSessionState); } - // Relies on the token to be valid. - OauthRSType::Public { .. } => {} - }; + } o2rs.token_fernet .decrypt(token) @@ -1733,13 +1871,8 @@ impl<'a> IdmServerProxyReadTransaction<'a> { return Err(Oauth2Error::InvalidOrigin); } - let require_pkce = match &o2rs.type_ { - OauthRSType::Basic { enable_pkce, .. } => *enable_pkce, - OauthRSType::Public { .. } => true, - }; - let code_challenge = if let Some(pkce_request) = &auth_req.pkce_request { - if !require_pkce { + if !o2rs.require_pkce() { security_info!(?o2rs.name, "Insecure rs configuration - pkce is not enforced, but rs is requesting it!"); } // CodeChallengeMethod must be S256 @@ -1748,7 +1881,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { return Err(Oauth2Error::InvalidRequest); } Some(pkce_request.code_challenge.clone()) - } else if require_pkce { + } else if o2rs.require_pkce() { security_error!(?o2rs.name, "No PKCE code challenge was provided with client in enforced PKCE mode."); return Err(Oauth2Error::InvalidRequest); } else { @@ -2387,12 +2520,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone()); - let require_pkce = match &o2rs.type_ { - OauthRSType::Basic { enable_pkce, .. } => *enable_pkce, - OauthRSType::Public { .. } => true, - }; - - let code_challenge_methods_supported = if require_pkce { + let code_challenge_methods_supported = if o2rs.require_pkce() { vec![PkceAlg::S256] } else { Vec::with_capacity(0) @@ -2444,7 +2572,11 @@ impl<'a> IdmServerProxyReadTransaction<'a> { let scopes_supported = Some(o2rs.scopes_supported.iter().cloned().collect()); let response_types_supported = vec![ResponseType::Code]; let response_modes_supported = vec![ResponseMode::Query]; + + // TODO: add device code if the rs supports it per + // `urn:ietf:params:oauth:grant-type:device_code` let grant_types_supported = vec![GrantType::AuthorisationCode]; + let subject_types_supported = vec![SubjectType::Public]; let id_token_signing_alg_values_supported = match &o2rs.jws_signer { @@ -2463,12 +2595,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { let claims_supported = None; let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone()); - let require_pkce = match &o2rs.type_ { - OauthRSType::Basic { enable_pkce, .. } => *enable_pkce, - OauthRSType::Public { .. } => true, - }; - - let code_challenge_methods_supported = if require_pkce { + let code_challenge_methods_supported = if o2rs.require_pkce() { vec![PkceAlg::S256] } else { Vec::with_capacity(0) @@ -2534,6 +2661,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { introspection_endpoint, introspection_endpoint_auth_methods_supported, introspection_endpoint_auth_signing_alg_values_supported: None, + device_authorization_endpoint: o2rs.device_authorization_endpoint.clone(), }) } @@ -2701,12 +2829,55 @@ fn validate_scopes(req_scopes: &BTreeSet) -> Result<(), Oauth2Error> { Ok(()) } +/// device code is a random bucket of bytes used in the device flow +#[inline] +#[cfg(any(feature = "dev-oauth2-device-flow", test))] +#[allow(dead_code)] +fn gen_device_code() -> Result<[u8; 16], Oauth2Error> { + let mut rng = rand::thread_rng(); + let mut result = [0u8; 16]; + // doing it here because of feature-shenanigans. + use rand::Rng; + if let Err(err) = rng.try_fill(&mut result) { + error!("Failed to generate device code! {:?}", err); + return Err(Oauth2Error::ServerError(OperationError::Backend)); + } + Ok(result) +} + +#[inline] +#[cfg(any(feature = "dev-oauth2-device-flow", test))] +#[allow(dead_code)] +/// Returns (xxx-yyy-zzz, digits) where one's the human-facing code, the other is what we store in the DB. +fn gen_user_code() -> (String, u32) { + use rand::Rng; + let mut rng = rand::thread_rng(); + let num: u32 = rng.gen_range(0..=999999999); + let result = format!("{:09}", num); + ( + format!("{}-{}-{}", &result[0..3], &result[3..6], &result[6..9]), + num, + ) +} + +/// Take the supplied user code and check it's a valid u32 +#[allow(dead_code)] +fn parse_user_code(val: &str) -> Result { + let mut val = val.to_string(); + val.retain(|c| c.is_ascii_digit()); + val.parse().map_err(|err| { + debug!("Failed to parse value={} as u32: {:?}", val, err); + Oauth2Error::InvalidRequest + }) +} + #[cfg(test)] mod tests { use base64::{engine::general_purpose, Engine as _}; use std::convert::TryFrom; use std::str::FromStr; use std::time::Duration; + use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT}; use compact_jwt::{ compact::JwkUse, crypto::JwsRs256Verifier, dangernoverify::JwsDangerReleaseWithoutVerify, @@ -4271,7 +4442,12 @@ mod tests { ); assert!( - discovery.token_endpoint == Url::parse("https://idm.example.com/oauth2/token").unwrap() + discovery.token_endpoint + == Url::parse(&format!( + "https://idm.example.com{}", + uri::OAUTH2_TOKEN_ENDPOINT + )) + .unwrap() ); assert!( @@ -4319,7 +4495,13 @@ mod tests { assert!( discovery.revocation_endpoint - == Some(Url::parse("https://idm.example.com/oauth2/token/revoke").unwrap()) + == Some( + Url::parse(&format!( + "https://idm.example.com{}", + OAUTH2_TOKEN_REVOKE_ENDPOINT + )) + .unwrap() + ) ); assert!( discovery.revocation_endpoint_auth_methods_supported @@ -4331,7 +4513,13 @@ mod tests { assert!( discovery.introspection_endpoint - == Some(Url::parse("https://idm.example.com/oauth2/token/introspect").unwrap()) + == Some( + Url::parse(&format!( + "https://idm.example.com{}", + kanidm_proto::constants::uri::OAUTH2_TOKEN_INTROSPECT_ENDPOINT + )) + .unwrap() + ) ); assert!( discovery.introspection_endpoint_auth_methods_supported @@ -4507,7 +4695,13 @@ mod tests { // Extensions assert!( discovery.revocation_endpoint - == Some(Url::parse("https://idm.example.com/oauth2/token/revoke").unwrap()) + == Some( + Url::parse(&format!( + "https://idm.example.com{}", + OAUTH2_TOKEN_REVOKE_ENDPOINT + )) + .unwrap() + ) ); assert!( discovery.revocation_endpoint_auth_methods_supported @@ -4519,7 +4713,13 @@ mod tests { assert!( discovery.introspection_endpoint - == Some(Url::parse("https://idm.example.com/oauth2/token/introspect").unwrap()) + == Some( + Url::parse(&format!( + "https://idm.example.com{}", + OAUTH2_TOKEN_INTROSPECT_ENDPOINT + )) + .unwrap() + ) ); assert!( discovery.introspection_endpoint_auth_methods_supported @@ -6571,4 +6771,49 @@ mod tests { assert!(idms_prox_write.commit().is_ok()); } + + #[test] + fn test_get_code() { + use super::{gen_device_code, gen_user_code, parse_user_code}; + + assert!(gen_device_code().is_ok()); + + let (res_string, res_value) = gen_user_code(); + + assert!(res_string.split('-').count() == 3); + + let res_string_clean = res_string.replace("-", ""); + let res_string_as_num = res_string_clean + .parse::() + .expect("Failed to parse as number"); + assert_eq!(res_string_as_num, res_value); + + assert_eq!( + parse_user_code(&res_string).expect("Failed to parse code"), + res_value + ); + } + + #[idm_test] + async fn handle_oauth2_start_device_flow( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let ct = duration_from_epoch_now(); + + let client_auth_info = ClientAuthInfo::from(Source::Https( + "127.0.0.1" + .parse() + .expect("Failed to parse 127.0.0.1 as an IP!"), + )); + let eventid = Uuid::new_v4(); + + let res = idms + .proxy_write(ct) + .await + .expect("Failed to get idmspwt") + .handle_oauth2_start_device_flow(client_auth_info, "test_rs_id", &None, eventid); + dbg!(&res); + assert!(res.is_err()); + } } diff --git a/server/lib/src/schema.rs b/server/lib/src/schema.rs index 916af55c5..56ad624d4 100644 --- a/server/lib/src/schema.rs +++ b/server/lib/src/schema.rs @@ -83,12 +83,20 @@ pub struct SchemaAttribute { pub uuid: Uuid, // Perhaps later add aliases? pub description: String, + /// This is a vec, not a single value pub multivalue: bool, + /// If the attribute must be unique amongst all other values of this attribute? Maybe? pub unique: bool, + /// TODO: What does this do? pub phantom: bool, + /// TODO: What does this do? pub sync_allowed: bool, + + /// If the value of this attribute get replicated to other servers pub replicated: bool, + /// TODO: What does this do? pub index: Vec, + /// THe type of data that this attribute may hold. pub syntax: SyntaxType, } @@ -1934,6 +1942,22 @@ impl<'a> SchemaWriteTransaction<'a> { }, ); + self.attributes.insert( + Attribute::OAuth2DeviceFlowEnable, + SchemaAttribute { + name: Attribute::OAuth2DeviceFlowEnable, + uuid: UUID_SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE, + description: String::from("Enable the OAuth2 Device Flow for this client."), + multivalue: false, + unique: true, + phantom: false, + sync_allowed: false, + replicated: true, + index: vec![], + syntax: SyntaxType::Boolean, + }, + ); + self.classes.insert( EntryClass::AttributeType.into(), SchemaClass { diff --git a/server/lib/src/server/migrations.rs b/server/lib/src/server/migrations.rs index 1075be870..daade31e5 100644 --- a/server/lib/src/server/migrations.rs +++ b/server/lib/src/server/migrations.rs @@ -634,6 +634,34 @@ impl<'a> QueryServerWriteTransaction<'a> { // =========== Apply changes ============== + // Now update schema + let idm_schema_changes = [ + SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE_DL9.clone().into(), + SCHEMA_CLASS_OAUTH2_RS_DL9.clone().into(), + ]; + + idm_schema_changes + .into_iter() + .try_for_each(|entry| self.internal_migrate_or_create(entry)) + .map_err(|err| { + error!(?err, "migrate_domain_8_to_9 -> Error"); + err + })?; + + self.reload()?; + + let idm_data = [IDM_ACP_OAUTH2_MANAGE_DL9.clone().into()]; + + idm_data + .into_iter() + .try_for_each(|entry| self.internal_migrate_or_create(entry)) + .map_err(|err| { + error!(?err, "migrate_domain_8_to_9 -> Error"); + err + })?; + + self.reload()?; + Ok(()) } diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index 0aa61ba5b..2f4763b21 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -95,6 +95,10 @@ impl DomainInfo { pub fn image(&self) -> Option<&ImageValue> { self.d_image.as_ref() } + + pub fn has_custom_image(&self) -> bool { + self.d_image.is_some() + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] diff --git a/server/testkit/Cargo.toml b/server/testkit/Cargo.toml index dde0905dc..1f3778937 100644 --- a/server/testkit/Cargo.toml +++ b/server/testkit/Cargo.toml @@ -18,10 +18,12 @@ test = true doctest = false [features] -default = [] +# default = ["dev-oauth2-device-flow"] # Enables webdriver tests, you need to be running a webdriver server webdriver = [] +dev-oauth2-device-flow = [] + [dependencies] hyper-tls = { workspace = true } http = { workspace = true } @@ -57,7 +59,9 @@ escargot = "0.5.12" # used for webdriver testing fantoccini = { version = "0.21.2" } futures = { workspace = true } -oauth2_ext = { workspace = true, default-features = false } +oauth2_ext = { workspace = true, default-features = false, features = [ + "reqwest", +] } openssl = { workspace = true } petgraph = { version = "0.6.4", features = ["serde", "serde-1"] } serde_json = { workspace = true } diff --git a/server/testkit/src/lib.rs b/server/testkit/src/lib.rs index ac448a5e4..56bca39b4 100644 --- a/server/testkit/src/lib.rs +++ b/server/testkit/src/lib.rs @@ -28,9 +28,16 @@ pub const IDM_ADMIN_TEST_PASSWORD: &str = "integration idm admin password"; pub const NOT_ADMIN_TEST_USERNAME: &str = "krab_test_user"; pub const NOT_ADMIN_TEST_PASSWORD: &str = "eicieY7ahchaoCh0eeTa"; +pub const NOT_ADMIN_TEST_EMAIL: &str = "krab_test@example.com"; pub static PORT_ALLOC: AtomicU16 = AtomicU16::new(18080); +pub const TEST_INTEGRATION_RS_ID: &str = "test_integration"; +pub const TEST_INTEGRATION_RS_GROUP_ALL: &str = "idm_all_accounts"; +pub const TEST_INTEGRATION_RS_DISPLAY: &str = "Test Integration"; +pub const TEST_INTEGRATION_RS_URL: &str = "https://demo.example.com"; +pub const TEST_INTEGRATION_RS_REDIRECT_URL: &str = "https://demo.example.com/oauth2/flow"; + pub use testkit_macros::test; use tracing::trace; @@ -382,3 +389,28 @@ pub async fn login_put_admin_idm_admins(rsclient: &KanidmClient) { .await .expect("Failed to add admin user to idm_admins") } + +#[macro_export] +macro_rules! assert_no_cache { + ($response:expr) => {{ + // Check we have correct nocache headers. + let cache_header: &str = $response + .headers() + .get(http::header::CACHE_CONTROL) + .expect("missing cache-control header") + .to_str() + .expect("invalid cache-control header"); + + assert!(cache_header.contains("no-store")); + assert!(cache_header.contains("max-age=0")); + + let pragma_header: &str = $response + .headers() + .get("pragma") + .expect("missing cache-control header") + .to_str() + .expect("invalid cache-control header"); + + assert!(pragma_header.contains("no-cache")); + }}; +} diff --git a/server/testkit/tests/oauth2_device_flow.rs b/server/testkit/tests/oauth2_device_flow.rs new file mode 100644 index 000000000..d9e035d52 --- /dev/null +++ b/server/testkit/tests/oauth2_device_flow.rs @@ -0,0 +1,303 @@ +#![allow(unused_imports)] +use std::collections::BTreeMap; +use std::str::FromStr; + +use compact_jwt::{JwkKeySet, JwsEs256Verifier, JwsVerifier, OidcToken, OidcUnverified}; +use kanidm_client::KanidmClient; + +use kanidm_proto::constants::uri::{OAUTH2_AUTHORISE, OAUTH2_AUTHORISE_PERMIT}; + +use kanidm_proto::internal::Oauth2ClaimMapJoin; +use kanidm_proto::oauth2::{ + AccessTokenRequest, AccessTokenResponse, AuthorisationResponse, GrantTypeReq, +}; + +use kanidmd_lib::prelude::uri::{OAUTH2_AUTHORISE_DEVICE, OAUTH2_TOKEN_ENDPOINT}; +use kanidmd_lib::prelude::{ + Attribute, IDM_ALL_ACCOUNTS, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID, OAUTH2_SCOPE_READ, +}; +use kanidmd_testkit::{ + assert_no_cache, ADMIN_TEST_PASSWORD, ADMIN_TEST_USER, IDM_ADMIN_TEST_PASSWORD, + IDM_ADMIN_TEST_USER, NOT_ADMIN_TEST_EMAIL, NOT_ADMIN_TEST_PASSWORD, NOT_ADMIN_TEST_USERNAME, + TEST_INTEGRATION_RS_DISPLAY, TEST_INTEGRATION_RS_GROUP_ALL, TEST_INTEGRATION_RS_ID, + TEST_INTEGRATION_RS_REDIRECT_URL, TEST_INTEGRATION_RS_URL, +}; + +use oauth2_ext::basic::BasicClient; +use oauth2_ext::http::StatusCode; +use oauth2_ext::{ + AuthUrl, ClientId, DeviceAuthorizationUrl, HttpRequest, HttpResponse, PkceCodeChallenge, + RequestTokenError, Scope, StandardDeviceAuthorizationResponse, StandardErrorResponse, TokenUrl, +}; +use reqwest::Client; +use tracing::{debug, error, info}; +use url::Url; + +#[cfg(feature = "dev-oauth2-device-flow")] +async fn http_client( + request: HttpRequest, +) -> Result> { + // let ca_contents = std::fs::read("/tmp/kanidm/ca.pem") + // .map_err(|err| oauth2::reqwest::Error::Other(err.to_string()))?; + + let client = Client::builder() + .danger_accept_invalid_certs(true) + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(reqwest::redirect::Policy::none()) + // reqwest::Certificate::from_der(&ca_contents) + // .map_err(oauth2::reqwest::Error::Reqwest)?, + // ) + .build() + .map_err(oauth2_ext::reqwest::Error::Reqwest)?; + + let method = reqwest::Method::from_str(request.method.as_str()) + .map_err(|err| oauth2_ext::reqwest::Error::Other(err.to_string()))?; + + let mut request_builder = client + .request(method, request.url.as_str()) + .body(request.body); + + for (name, value) in &request.headers { + request_builder = request_builder.header(name.as_str(), value.as_bytes()); + } + + let response = client + .execute(request_builder.build().map_err(|err| { + error!("Failed to build request... {:?}", err); + oauth2_ext::reqwest::Error::Reqwest(err) + })?) + .await + .map_err(|err| { + error!("Failed to query url {} error={:?}", request.url, err); + oauth2_ext::reqwest::Error::Reqwest(err) + })?; + + let status_code = StatusCode::from_u16(response.status().as_u16()) + .map_err(|err| oauth2_ext::reqwest::Error::Other(err.to_string()))?; + let headers = response + .headers() + .into_iter() + .map(|(k, v)| { + debug!("header key={:?} value={:?}", k, v); + ( + oauth2_ext::http::HeaderName::from_str(k.as_str()).expect("Failed to parse header"), + oauth2_ext::http::HeaderValue::from_str( + v.to_str().expect("Failed to parse header value"), + ) + .expect("Failed to parse header value"), + ) + }) + .collect(); + + let body = response.bytes().await.map_err(|err| { + error!("Failed to parse body...? {:?}", err); + oauth2_ext::reqwest::Error::Reqwest(err) + })?; + info!("Response body: {:?}", String::from_utf8(body.to_vec())); + + Ok(HttpResponse { + status_code, + headers, + body: body.to_vec(), + }) +} + +#[cfg(feature = "dev-oauth2-device-flow")] +#[kanidmd_testkit::test] +async fn oauth2_device_flow(rsclient: KanidmClient) { + let res = rsclient + .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD) + .await; + assert!(res.is_ok()); + + // Create an oauth2 application integration. + rsclient + .idm_oauth2_rs_public_create( + TEST_INTEGRATION_RS_ID, + TEST_INTEGRATION_RS_DISPLAY, + TEST_INTEGRATION_RS_URL, + ) + .await + .expect("Failed to create oauth2 config"); + + rsclient + .idm_oauth2_client_add_origin( + TEST_INTEGRATION_RS_ID, + &Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"), + ) + .await + .expect("Failed to update oauth2 config"); + + // Extend the admin account with extended details for openid claims. + rsclient + .idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME) + .await + .expect("Failed to create account details"); + + rsclient + .idm_person_account_set_attr( + NOT_ADMIN_TEST_USERNAME, + Attribute::Mail.as_ref(), + &[NOT_ADMIN_TEST_EMAIL], + ) + .await + .expect("Failed to create account mail"); + + rsclient + .idm_person_account_primary_credential_set_password( + NOT_ADMIN_TEST_USERNAME, + NOT_ADMIN_TEST_PASSWORD, + ) + .await + .expect("Failed to configure account password"); + + rsclient + .idm_oauth2_rs_update(TEST_INTEGRATION_RS_ID, None, None, None, true, true, true) + .await + .expect("Failed to update oauth2 config"); + + rsclient + .idm_oauth2_rs_update_scope_map( + TEST_INTEGRATION_RS_ID, + IDM_ALL_ACCOUNTS.name, + vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID], + ) + .await + .expect("Failed to update oauth2 scopes"); + + rsclient + .idm_oauth2_rs_update_sup_scope_map( + TEST_INTEGRATION_RS_ID, + IDM_ALL_ACCOUNTS.name, + vec![ADMIN_TEST_USER], + ) + .await + .expect("Failed to update oauth2 scopes"); + + // Add a custom claim map. + rsclient + .idm_oauth2_rs_update_claim_map( + TEST_INTEGRATION_RS_ID, + "test_claim", + IDM_ALL_ACCOUNTS.name, + &["claim_a".to_string(), "claim_b".to_string()], + ) + .await + .expect("Failed to update oauth2 claims"); + + // Set an alternate join + rsclient + .idm_oauth2_rs_update_claim_map_join( + TEST_INTEGRATION_RS_ID, + "test_claim", + Oauth2ClaimMapJoin::Ssv, + ) + .await + .expect("Failed to update oauth2 claims"); + + // Get our admin's auth token for our new client. + // We have to re-auth to update the mail field. + let res = rsclient + .auth_simple_password(IDM_ADMIN_TEST_USER, IDM_ADMIN_TEST_PASSWORD) + .await; + assert!(res.is_ok()); + + // set up the device flow values + + let rsdata = rsclient + .idm_oauth2_rs_get(TEST_INTEGRATION_RS_ID) + .await + .expect("failed to query rs") + .expect("failed to get rsdata"); + + dbg!(&rsdata); + + assert!( + !rsdata + .attrs + .contains_key(Attribute::OAuth2DeviceFlowEnable.as_str()), + "Found device flow enable attribute, shouldn't be there yet!" + ); + + rsclient + .idm_oauth2_client_device_flow_update(TEST_INTEGRATION_RS_ID, false) + .await + .expect("Failed to update oauth2 config to disable device flow"); + + rsclient + .idm_oauth2_client_device_flow_update(TEST_INTEGRATION_RS_ID, true) + .await + .expect("Failed to update oauth2 config to enable device flow"); + + let rsdata = rsclient + .idm_oauth2_rs_get(TEST_INTEGRATION_RS_ID) + .await + .expect("failed to query rs") + .expect("failed to get rsdata"); + + dbg!(&rsdata); + + assert!( + rsdata + .attrs + .contains_key(Attribute::OAuth2DeviceFlowEnable.as_str()), + "Couldn't find device flow enable attribute" + ); + assert_eq!( + rsdata + .attrs + .get(Attribute::OAuth2DeviceFlowEnable.as_str()) + .expect("Couldn't find device flow enable attribute"), + &vec!["true".to_string()], + "Device flow enable attribute not set to true" + ); + + // ok we've checked that adding the thing works. + // now we need to test the device flow itself. + + // first we need to get the device code. + + // kanidm system oauth2 create-public device_flow device_flow 'https://deviceauth' + let client = BasicClient::new( + ClientId::new(TEST_INTEGRATION_RS_ID.to_string()), + None, + AuthUrl::new(rsclient.make_url(OAUTH2_AUTHORISE).to_string()) + .expect("Failed to build authurl"), + Some( + TokenUrl::new(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT).to_string()) + .expect("Failed to build token url"), + ), + ) + .set_device_authorization_url( + DeviceAuthorizationUrl::new(rsclient.make_url(OAUTH2_AUTHORISE_DEVICE).to_string()) + .expect("Failed to build DeviceAuthorizationUrl"), + ); + + let details: StandardDeviceAuthorizationResponse = client + .exchange_device_code() + .expect("Failed to exchange device code") + .add_scope(Scope::new("read".to_string())) + .request_async(http_client) + .await + .expect("Failed to get device code!"); + + debug!("{:?}", details); + dbg!(&details.device_code().secret()); + assert!(details.device_code().secret().len() == 24); + + // now take that device code and get the token... glhf! + + let result = client + .exchange_device_access_token(&details) + .request_async( + http_client, + tokio::time::sleep, + Some(std::time::Duration::from_secs(1)), + ) + .await; + + assert!(result.is_err()); + let err = result.err().expect("Failed to get error"); + dbg!(&err.to_string()); + assert!(err.to_string().contains("Server returned error response")); +} diff --git a/server/testkit/tests/oauth2_test.rs b/server/testkit/tests/oauth2_test.rs index fbda48b62..74169a7a8 100644 --- a/server/testkit/tests/oauth2_test.rs +++ b/server/testkit/tests/oauth2_test.rs @@ -16,45 +16,21 @@ use kanidmd_lib::prelude::{Attribute, IDM_ALL_ACCOUNTS}; use oauth2_ext::PkceCodeChallenge; use reqwest::header::{HeaderValue, CONTENT_TYPE}; use reqwest::StatusCode; +use uri::{OAUTH2_TOKEN_ENDPOINT, OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT}; use url::Url; use kanidm_client::KanidmClient; -use kanidmd_testkit::ADMIN_TEST_PASSWORD; - -macro_rules! assert_no_cache { - ($response:expr) => {{ - // Check we have correct nocache headers. - let cache_header: &str = $response - .headers() - .get(http::header::CACHE_CONTROL) - .expect("missing cache-control header") - .to_str() - .expect("invalid cache-control header"); - - assert!(cache_header.contains("no-store")); - assert!(cache_header.contains("max-age=0")); - - let pragma_header: &str = $response - .headers() - .get("pragma") - .expect("missing cache-control header") - .to_str() - .expect("invalid cache-control header"); - - assert!(pragma_header.contains("no-cache")); - }}; -} - -const TEST_INTEGRATION_RS_ID: &str = "test_integration"; -const TEST_INTEGRATION_RS_GROUP_ALL: &str = "idm_all_accounts"; -const TEST_INTEGRATION_RS_DISPLAY: &str = "Test Integration"; -const TEST_INTEGRATION_RS_URL: &str = "https://demo.example.com"; -const TEST_INTEGRATION_REDIRECT_URL: &str = "https://demo.example.com/oauth2/flow"; +use kanidmd_testkit::{ + assert_no_cache, ADMIN_TEST_PASSWORD, ADMIN_TEST_USER, NOT_ADMIN_TEST_EMAIL, + NOT_ADMIN_TEST_PASSWORD, NOT_ADMIN_TEST_USERNAME, TEST_INTEGRATION_RS_DISPLAY, + TEST_INTEGRATION_RS_GROUP_ALL, TEST_INTEGRATION_RS_ID, TEST_INTEGRATION_RS_REDIRECT_URL, + TEST_INTEGRATION_RS_URL, +}; #[kanidmd_testkit::test] async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { let res = rsclient - .auth_simple_password("admin", ADMIN_TEST_PASSWORD) + .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD) .await; assert!(res.is_ok()); @@ -71,39 +47,42 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { rsclient .idm_oauth2_client_add_origin( TEST_INTEGRATION_RS_ID, - &Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"), + &Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"), ) .await .expect("Failed to update oauth2 config"); // Extend the admin account with extended details for openid claims. rsclient - .idm_person_account_create("oauth_test", "oauth_test") + .idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME) .await .expect("Failed to create account details"); rsclient .idm_person_account_set_attr( - "oauth_test", + NOT_ADMIN_TEST_USERNAME, Attribute::Mail.as_ref(), - &["oauth_test@localhost"], + &[NOT_ADMIN_TEST_EMAIL], ) .await .expect("Failed to create account mail"); rsclient - .idm_person_account_primary_credential_set_password("oauth_test", ADMIN_TEST_PASSWORD) + .idm_person_account_primary_credential_set_password( + NOT_ADMIN_TEST_USERNAME, + NOT_ADMIN_TEST_PASSWORD, + ) .await .expect("Failed to configure account password"); rsclient - .idm_oauth2_rs_update("test_integration", None, None, None, true, true, true) + .idm_oauth2_rs_update(TEST_INTEGRATION_RS_ID, None, None, None, true, true, true) .await .expect("Failed to update oauth2 config"); rsclient .idm_oauth2_rs_update_scope_map( - "test_integration", + TEST_INTEGRATION_RS_ID, IDM_ALL_ACCOUNTS.name, vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID], ) @@ -112,15 +91,15 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { rsclient .idm_oauth2_rs_update_sup_scope_map( - "test_integration", + TEST_INTEGRATION_RS_ID, IDM_ALL_ACCOUNTS.name, - vec!["admin"], + vec![ADMIN_TEST_USER], ) .await .expect("Failed to update oauth2 scopes"); let client_secret = rsclient - .idm_oauth2_rs_get_basic_secret("test_integration") + .idm_oauth2_rs_get_basic_secret(TEST_INTEGRATION_RS_ID) .await .ok() .flatten() @@ -129,7 +108,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { // Get our admin's auth token for our new client. // We have to re-auth to update the mail field. let res = rsclient - .auth_simple_password("oauth_test", ADMIN_TEST_PASSWORD) + .auth_simple_password(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_PASSWORD) .await; assert!(res.is_ok()); let oauth_test_uat = rsclient @@ -205,7 +184,10 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { rsclient.make_url("/ui/oauth2") ); - assert_eq!(discovery.token_endpoint, rsclient.make_url("/oauth2/token")); + assert_eq!( + discovery.token_endpoint, + rsclient.make_url(OAUTH2_TOKEN_ENDPOINT) + ); assert!( discovery.userinfo_endpoint @@ -248,11 +230,11 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { .bearer_auth(oauth_test_uat.clone()) .query(&[ ("response_type", "code"), - ("client_id", "test_integration"), + ("client_id", TEST_INTEGRATION_RS_ID), ("state", "YWJjZGVm"), ("code_challenge", pkce_code_challenge.as_str()), ("code_challenge_method", "S256"), - ("redirect_uri", TEST_INTEGRATION_REDIRECT_URL), + ("redirect_uri", TEST_INTEGRATION_RS_REDIRECT_URL), ("scope", "email read openid"), ]) .send() @@ -274,6 +256,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { } = consent_req { // Note the supplemental scope here (admin) + dbg!(&scopes); assert!(scopes.contains("admin")); consent_token } else { @@ -319,14 +302,14 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { let form_req: AccessTokenRequest = GrantTypeReq::AuthorizationCode { code: code.to_string(), - redirect_uri: Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"), + redirect_uri: Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"), code_verifier: Some(pkce_code_verifier.secret().clone()), } .into(); let response = client - .post(rsclient.make_url("/oauth2/token")) - .basic_auth("test_integration", Some(client_secret.clone())) + .post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT)) + .basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone())) .form(&form_req) .send() .await @@ -361,8 +344,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { }; let response = client - .post(rsclient.make_url("/oauth2/token/introspect")) - .basic_auth("test_integration", Some(client_secret.clone())) + .post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT)) + .basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone())) .form(&intr_request) .send() .await @@ -382,14 +365,17 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { assert!(tir.active); assert!(tir.scope.is_some()); - assert_eq!(tir.client_id.as_deref(), Some("test_integration")); - assert_eq!(tir.username.as_deref(), Some("oauth_test@localhost")); + assert_eq!(tir.client_id.as_deref(), Some(TEST_INTEGRATION_RS_ID)); + assert_eq!( + tir.username.as_deref(), + Some(format!("{}@localhost", NOT_ADMIN_TEST_USERNAME).as_str()) + ); assert_eq!(tir.token_type, Some(AccessTokenType::Bearer)); assert!(tir.exp.is_some()); assert!(tir.iat.is_some()); assert!(tir.nbf.is_some()); assert!(tir.sub.is_some()); - assert_eq!(tir.aud.as_deref(), Some("test_integration")); + assert_eq!(tir.aud.as_deref(), Some(TEST_INTEGRATION_RS_ID)); assert!(tir.iss.is_none()); assert!(tir.jti.is_none()); @@ -410,7 +396,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { rsclient.make_url("/oauth2/openid/test_integration") ); eprintln!("{:?}", oidc.s_claims.email); - assert_eq!(oidc.s_claims.email.as_deref(), Some("oauth_test@localhost")); + assert_eq!(oidc.s_claims.email.as_deref(), Some(NOT_ADMIN_TEST_EMAIL)); assert_eq!(oidc.s_claims.email_verified, Some(true)); let response = client @@ -446,8 +432,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { .into(); let response = client - .post(rsclient.make_url("/oauth2/token")) - .basic_auth("test_integration", Some(client_secret.clone())) + .post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT)) + .basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone())) .form(&form_req) .send() .await @@ -467,8 +453,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { }; let response = client - .post(rsclient.make_url("/oauth2/token/introspect")) - .basic_auth("test_integration", Some(client_secret)) + .post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT)) + .basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret)) .form(&intr_request) .send() .await @@ -483,17 +469,17 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { assert!(tir.active); assert!(tir.scope.is_some()); - assert_eq!(tir.client_id.as_deref(), Some("test_integration")); + assert_eq!(tir.client_id.as_deref(), Some(TEST_INTEGRATION_RS_ID)); assert_eq!(tir.username.as_deref(), Some("test_integration@localhost")); assert_eq!(tir.token_type, Some(AccessTokenType::Bearer)); // auth back with admin so we can test deleting things let res = rsclient - .auth_simple_password("admin", ADMIN_TEST_PASSWORD) + .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD) .await; assert!(res.is_ok()); rsclient - .idm_oauth2_rs_delete_sup_scope_map("test_integration", TEST_INTEGRATION_RS_GROUP_ALL) + .idm_oauth2_rs_delete_sup_scope_map(TEST_INTEGRATION_RS_ID, TEST_INTEGRATION_RS_GROUP_ALL) .await .expect("Failed to update oauth2 scopes"); } @@ -501,7 +487,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { #[kanidmd_testkit::test] async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { let res = rsclient - .auth_simple_password("admin", ADMIN_TEST_PASSWORD) + .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD) .await; assert!(res.is_ok()); @@ -518,39 +504,42 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { rsclient .idm_oauth2_client_add_origin( TEST_INTEGRATION_RS_ID, - &Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"), + &Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"), ) .await .expect("Failed to update oauth2 config"); // Extend the admin account with extended details for openid claims. rsclient - .idm_person_account_create("oauth_test", "oauth_test") + .idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME) .await .expect("Failed to create account details"); rsclient .idm_person_account_set_attr( - "oauth_test", + NOT_ADMIN_TEST_USERNAME, Attribute::Mail.as_ref(), - &["oauth_test@localhost"], + &[NOT_ADMIN_TEST_EMAIL], ) .await .expect("Failed to create account mail"); rsclient - .idm_person_account_primary_credential_set_password("oauth_test", ADMIN_TEST_PASSWORD) + .idm_person_account_primary_credential_set_password( + NOT_ADMIN_TEST_USERNAME, + ADMIN_TEST_PASSWORD, + ) .await .expect("Failed to configure account password"); rsclient - .idm_oauth2_rs_update("test_integration", None, None, None, true, true, true) + .idm_oauth2_rs_update(TEST_INTEGRATION_RS_ID, None, None, None, true, true, true) .await .expect("Failed to update oauth2 config"); rsclient .idm_oauth2_rs_update_scope_map( - "test_integration", + TEST_INTEGRATION_RS_ID, IDM_ALL_ACCOUNTS.name, vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID], ) @@ -559,9 +548,9 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { rsclient .idm_oauth2_rs_update_sup_scope_map( - "test_integration", + TEST_INTEGRATION_RS_ID, IDM_ALL_ACCOUNTS.name, - vec!["admin"], + vec![ADMIN_TEST_USER], ) .await .expect("Failed to update oauth2 scopes"); @@ -569,7 +558,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { // Add a custom claim map. rsclient .idm_oauth2_rs_update_claim_map( - "test_integration", + TEST_INTEGRATION_RS_ID, "test_claim", IDM_ALL_ACCOUNTS.name, &["claim_a".to_string(), "claim_b".to_string()], @@ -580,7 +569,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { // Set an alternate join rsclient .idm_oauth2_rs_update_claim_map_join( - "test_integration", + TEST_INTEGRATION_RS_ID, "test_claim", Oauth2ClaimMapJoin::Ssv, ) @@ -590,7 +579,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { // Get our admin's auth token for our new client. // We have to re-auth to update the mail field. let res = rsclient - .auth_simple_password("oauth_test", ADMIN_TEST_PASSWORD) + .auth_simple_password(NOT_ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD) .await; assert!(res.is_ok()); let oauth_test_uat = rsclient @@ -639,11 +628,11 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { .bearer_auth(oauth_test_uat.clone()) .query(&[ ("response_type", "code"), - ("client_id", "test_integration"), + ("client_id", TEST_INTEGRATION_RS_ID), ("state", "YWJjZGVm"), ("code_challenge", pkce_code_challenge.as_str()), ("code_challenge_method", "S256"), - ("redirect_uri", TEST_INTEGRATION_REDIRECT_URL), + ("redirect_uri", TEST_INTEGRATION_RS_REDIRECT_URL), ("scope", "email read openid"), ]) .send() @@ -665,7 +654,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { } = consent_req { // Note the supplemental scope here (admin) - assert!(scopes.contains("admin")); + assert!(scopes.contains(ADMIN_TEST_USER)); consent_token } else { unreachable!(); @@ -710,15 +699,15 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { let form_req = AccessTokenRequest { grant_type: GrantTypeReq::AuthorizationCode { code: code.to_string(), - redirect_uri: Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"), + redirect_uri: Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"), code_verifier: Some(pkce_code_verifier.secret().clone()), }, - client_id: Some("test_integration".to_string()), + client_id: Some(TEST_INTEGRATION_RS_ID.to_string()), client_secret: None, }; let response = client - .post(rsclient.make_url("/oauth2/token")) + .post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT)) .form(&form_req) .send() .await @@ -750,7 +739,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { rsclient.make_url("/oauth2/openid/test_integration") ); eprintln!("{:?}", oidc.s_claims.email); - assert_eq!(oidc.s_claims.email.as_deref(), Some("oauth_test@localhost")); + assert_eq!(oidc.s_claims.email.as_deref(), Some(NOT_ADMIN_TEST_EMAIL)); assert_eq!(oidc.s_claims.email_verified, Some(true)); eprintln!("{:?}", oidc.claims); @@ -797,11 +786,11 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { // auth back with admin so we can test deleting things let res = rsclient - .auth_simple_password("admin", ADMIN_TEST_PASSWORD) + .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD) .await; assert!(res.is_ok()); rsclient - .idm_oauth2_rs_delete_sup_scope_map("test_integration", TEST_INTEGRATION_RS_GROUP_ALL) + .idm_oauth2_rs_delete_sup_scope_map(TEST_INTEGRATION_RS_ID, TEST_INTEGRATION_RS_GROUP_ALL) .await .expect("Failed to update oauth2 scopes"); } @@ -809,7 +798,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { #[kanidmd_testkit::test] async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) { let res = rsclient - .auth_simple_password("admin", ADMIN_TEST_PASSWORD) + .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD) .await; assert!(res.is_ok()); @@ -821,7 +810,7 @@ async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) { // test for a bad-body request on token let response = client - .post(rsclient.make_url("/oauth2/token")) + .post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT)) .form(&serde_json::json!({})) // .bearer_auth(atr.access_token.clone()) .send() @@ -832,7 +821,7 @@ async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) { // test for a bad-auth request let response = client - .post(rsclient.make_url("/oauth2/token/introspect")) + .post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT)) .form(&serde_json::json!({ "token": "lol" })) .send() .await @@ -844,7 +833,7 @@ async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) { #[kanidmd_testkit::test] async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) { let res = rsclient - .auth_simple_password("admin", ADMIN_TEST_PASSWORD) + .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD) .await; assert!(res.is_ok()); @@ -856,7 +845,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) { // test for a bad-body request on token let response = client - .post(rsclient.make_url("/oauth2/token/revoke")) + .post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT)) .form(&serde_json::json!({})) .bearer_auth("lolol") .send() @@ -867,7 +856,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) { // test for a invalid format request on token let response = client - .post(rsclient.make_url("/oauth2/token/revoke")) + .post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT)) .json("") .bearer_auth("lolol") .send() @@ -879,7 +868,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) { // test for a bad-body request on token let response = client - .post(rsclient.make_url("/oauth2/token/revoke")) + .post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT)) .form(&serde_json::json!({})) .bearer_auth("Basic lolol") .send() @@ -890,7 +879,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) { // test for a bad-body request on token let response = client - .post(rsclient.make_url("/oauth2/token/revoke")) + .post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT)) .body(serde_json::json!({}).to_string()) .bearer_auth("Basic lolol") .send() diff --git a/tools/cli/Cargo.toml b/tools/cli/Cargo.toml index 0f56dbb63..aa0489885 100644 --- a/tools/cli/Cargo.toml +++ b/tools/cli/Cargo.toml @@ -12,8 +12,12 @@ homepage = { workspace = true } repository = { workspace = true } [features] -default = ["unix"] +default = [ + "unix", + # "dev-oauth2-device-flow" +] unix = [] +dev-oauth2-device-flow = [] [lib] name = "kanidm_cli" diff --git a/tools/cli/src/cli/oauth2.rs b/tools/cli/src/cli/oauth2.rs index edb1816e6..d1064efe3 100644 --- a/tools/cli/src/cli/oauth2.rs +++ b/tools/cli/src/cli/oauth2.rs @@ -31,6 +31,12 @@ impl Oauth2Opt { Oauth2Opt::DisableLegacyCrypto(nopt) => nopt.copt.debug, Oauth2Opt::PreferShortUsername(nopt) => nopt.copt.debug, Oauth2Opt::PreferSPNUsername(nopt) => nopt.copt.debug, + + #[cfg(feature = "dev-oauth2-device-flow")] + Oauth2Opt::DeviceFlowDisable(nopt) => nopt.copt.debug, + + #[cfg(feature = "dev-oauth2-device-flow")] + Oauth2Opt::DeviceFlowEnable(nopt) => nopt.copt.debug, Oauth2Opt::CreateBasic { copt, .. } | Oauth2Opt::CreatePublic { copt, .. } | Oauth2Opt::UpdateClaimMap { copt, .. } @@ -47,6 +53,30 @@ impl Oauth2Opt { pub async fn exec(&self) { match self { + #[cfg(feature = "dev-oauth2-device-flow")] + Oauth2Opt::DeviceFlowDisable(nopt) => { + // TODO: finish the CLI bits for DeviceFlowDisable + let client = nopt.copt.to_client(OpType::Write).await; + match client + .idm_oauth2_client_device_flow_update(&nopt.name, true) + .await + { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, nopt.copt.output_mode), + } + } + #[cfg(feature = "dev-oauth2-device-flow")] + Oauth2Opt::DeviceFlowEnable(nopt) => { + // TODO: finish the CLI bits for DeviceFlowEnable + let client = nopt.copt.to_client(OpType::Write).await; + match client + .idm_oauth2_client_device_flow_update(&nopt.name, true) + .await + { + Ok(_) => println!("Success"), + Err(e) => handle_client_error(e, nopt.copt.output_mode), + } + } Oauth2Opt::List(copt) => { let client = copt.to_client(OpType::Read).await; match client.idm_oauth2_rs_list().await { diff --git a/tools/cli/src/opt/kanidm.rs b/tools/cli/src/opt/kanidm.rs index 97d88a6f8..eb47614f2 100644 --- a/tools/cli/src/opt/kanidm.rs +++ b/tools/cli/src/opt/kanidm.rs @@ -1188,6 +1188,12 @@ pub enum Oauth2Opt { /// Use the 'spn' attribute instead of 'name' for the preferred_username #[clap(name = "prefer-spn-username")] PreferSPNUsername(Named), + #[cfg(feature = "dev-oauth2-device-flow")] + /// Enable OAuth2 Device Flow authentication + DeviceFlowEnable(Named), + #[cfg(feature = "dev-oauth2-device-flow")] + /// Disable OAuth2 Device Flow authentication + DeviceFlowDisable(Named), } #[derive(Args, Debug)] diff --git a/tools/device_flow/Cargo.toml b/tools/device_flow/Cargo.toml new file mode 100644 index 000000000..55713c01b --- /dev/null +++ b/tools/device_flow/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "kanidm_device_flow" +description = "Kanidm Device Flow Client" +documentation = "https://kanidm.github.io/kanidm/stable/" +version = { workspace = true } +authors = { workspace = true } +rust-version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } + + +[lib] +test = false +doctest = false + +[features] + +[dependencies] +kanidm_proto = { workspace = true } +anyhow = { workspace = true } +oauth2 = "4.4.2" +reqwest = { version = "0.12.8", default-features = false, features = [ + "rustls-tls", +] } + +tokio = { workspace = true, features = ["full"] } +url = { workspace = true } +tracing = { workspace = true } +sketching = { workspace = true } +base64.workspace = true diff --git a/tools/device_flow/examples/device_flow.rs b/tools/device_flow/examples/device_flow.rs new file mode 100644 index 000000000..68c2e518b --- /dev/null +++ b/tools/device_flow/examples/device_flow.rs @@ -0,0 +1,136 @@ +use std::str::FromStr; + +use kanidm_proto::constants::uri::{ + OAUTH2_AUTHORISE, OAUTH2_AUTHORISE_DEVICE, OAUTH2_TOKEN_ENDPOINT, +}; +use oauth2::basic::BasicClient; +use oauth2::devicecode::StandardDeviceAuthorizationResponse; +use oauth2::http::StatusCode; +use oauth2::{ + AuthUrl, ClientId, DeviceAuthorizationUrl, HttpRequest, HttpResponse, Scope, TokenUrl, +}; +use reqwest::Client; +use sketching::tracing_subscriber::layer::SubscriberExt; +use sketching::tracing_subscriber::util::SubscriberInitExt; +use sketching::tracing_subscriber::{fmt, EnvFilter}; +use tracing::level_filters::LevelFilter; +use tracing::{debug, error, info}; + +async fn http_client( + request: HttpRequest, +) -> Result> { + let client = Client::builder() + .danger_accept_invalid_certs(true) + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(oauth2::reqwest::Error::Reqwest)?; + + let method = reqwest::Method::from_str(request.method.as_str()) + .map_err(|err| oauth2::reqwest::Error::Other(err.to_string()))?; + + let mut request_builder = client + .request(method, request.url.as_str()) + .body(request.body); + + for (name, value) in &request.headers { + request_builder = request_builder.header(name.as_str(), value.as_bytes()); + } + + let response = client + .execute(request_builder.build().map_err(|err| { + error!("Failed to build request... {:?}", err); + oauth2::reqwest::Error::Reqwest(err) + })?) + .await + .map_err(|err| { + error!("Failed to query url {} error={:?}", request.url, err); + oauth2::reqwest::Error::Reqwest(err) + })?; + + let status_code = StatusCode::from_u16(response.status().as_u16()) + .map_err(|err| oauth2::reqwest::Error::Other(err.to_string()))?; + let headers = response + .headers() + .into_iter() + .map(|(k, v)| { + debug!("header key={:?} value={:?}", k, v); + ( + oauth2::http::HeaderName::from_str(k.as_str()).expect("Failed to parse header"), + oauth2::http::HeaderValue::from_str( + v.to_str().expect("Failed to parse header value"), + ) + .expect("Failed to parse header value"), + ) + }) + .collect(); + + let body = response.bytes().await.map_err(|err| { + error!("Failed to parse body...? {:?}", err); + oauth2::reqwest::Error::Reqwest(err) + })?; + info!("Response body: {:?}", String::from_utf8(body.to_vec())); + + Ok(HttpResponse { + status_code, + headers, + body: body.to_vec(), + }) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let fmt_layer = fmt::layer().with_writer(std::io::stderr); + + let filter_layer = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .parse_lossy("info,kanidm_client=warn,kanidm_cli=info"); + + sketching::tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); + + info!("building client..."); + + // kanidm system oauth2 create-public device_flow device_flow 'https://deviceauth' + let client = BasicClient::new( + ClientId::new("device_code".to_string()), + None, + AuthUrl::new(format!("https://localhost:8443{}", OAUTH2_AUTHORISE))?, + Some(TokenUrl::new(format!( + "https://localhost:8443{}", + OAUTH2_TOKEN_ENDPOINT + ))?), + ) + .set_device_authorization_url(DeviceAuthorizationUrl::new(format!( + "https://localhost:8443{}", + OAUTH2_AUTHORISE_DEVICE + ))?); + + info!("Getting details..."); + + let details: StandardDeviceAuthorizationResponse = client + .exchange_device_code() + .inspect_err(|err| error!("configuration error: {:?}", err))? + .add_scope(Scope::new("read".to_string())) + .request_async(http_client) + .await?; + + println!( + "Open this URL in your browser: {}", + match details.verification_uri_complete() { + Some(uri) => uri.secret().as_str(), + None => details.verification_uri().as_str(), + } + ); + + println!("the code is {}", details.user_code().secret()); + + let token_result = client + .exchange_device_access_token(&details) + .request_async(http_client, tokio::time::sleep, None) + .await?; + println!("Result: {:?}", token_result); + Ok(()) +} diff --git a/tools/device_flow/src/lib.rs b/tools/device_flow/src/lib.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tools/device_flow/src/lib.rs @@ -0,0 +1 @@ +