From 94ae6f727ef571e34c5f8435579daf53458f8ad9 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Mon, 20 Nov 2023 18:07:12 -0600 Subject: [PATCH] server: user: Implement OIDC auth for users The second major feature for SSHCA will be the ability to sign SSH certificates for users. Naturally, users will need to prove their identity to the server in order for it to issue certificates for them. To implement that, we will use OpenID Connect Identity Tokens. Users will obtain a token from an Identity Provider and include it in their request to the SSHCA server. If the token is valid and issued by a trusted provider, the server will sign the user's keys. The `openidconnect` crate provides everything we need to validate OIDC ID tokens. It supports fetching the OpenID Provider Configuration in order to retrieve the signing keys. These keys are then used to verify the signature of a token; other token metadata are verified as well, including issuer, audience, and expiration. To avoid making an HTTP request to the OIDC IdP for every request, the provider configuration is cached for an hour after each lookup. Clients, such as the `sshca` CLI utility, can use the *GET /user/oidc-config* HTTP path operation to fetch the SSHCA server's OpenID Connect client configuration. The can use the information returned to initiate a login process with the IdP and obtain the identity token to submit to the SSHCA server. --- Cargo.lock | 677 +++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/config.rs | 16 + src/server/mod.rs | 5 + src/server/user.rs | 159 ++++++++++ tests/common/setup.rs | 7 +- tests/test_user.rs | 34 +++ 7 files changed, 895 insertions(+), 4 deletions(-) create mode 100644 src/server/user.rs create mode 100644 tests/test_user.rs diff --git a/Cargo.lock b/Cargo.lock index a9446f8..fbc0ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "argh" version = "0.1.12" @@ -324,6 +339,21 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + [[package]] name = "cipher" version = "0.4.4" @@ -340,6 +370,22 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + [[package]] name = "cpufeatures" version = "0.2.11" @@ -394,6 +440,7 @@ dependencies = [ "platforms", "rustc_version", "subtle", + "zeroize", ] [[package]] @@ -407,6 +454,41 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -414,7 +496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ "cfg-if", - "hashbrown", + "hashbrown 0.14.2", "lock_api", "once_cell", "parking_lot_core", @@ -427,9 +509,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "digest" version = "0.10.7" @@ -463,6 +556,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "dyn-clone" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" + [[package]] name = "ecdsa" version = "0.16.8" @@ -483,6 +582,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ + "pkcs8", "signature", ] @@ -494,9 +594,17 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" dependencies = [ "curve25519-dalek", "ed25519", + "serde", "sha2", + "zeroize", ] +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "elliptic-curve" version = "0.13.6" @@ -509,6 +617,8 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", + "pem-rfc7468", "pkcs8", "rand_core", "sec1", @@ -569,6 +679,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form-data-builder" version = "1.0.1" @@ -683,8 +808,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -714,6 +841,31 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 2.1.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.2" @@ -744,6 +896,27 @@ dependencies = [ "http", ] +[[package]] +name = "hermit-abi" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -797,6 +970,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "httparse", @@ -810,6 +984,69 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.1.0" @@ -817,7 +1054,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.2", + "serde", ] [[package]] @@ -830,6 +1068,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -976,6 +1229,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1034,6 +1305,36 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom", + "http", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + [[package]] name = "object" version = "0.32.1" @@ -1055,12 +1356,97 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openidconnect" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d6050f6a84b81f23c569f5607ad883293e57491036e318fafe6fc4895fadb1" +dependencies = [ + "base64 0.13.1", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand", + "rsa", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror", + "url", +] + +[[package]] +name = "openssl" +version = "0.10.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a257ad03cd8fb16ad4172fedf8094451e1af1c4b70097636ef2eac9a5f0cc33" +dependencies = [ + "bitflags 2.4.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a4130519a360279579c2053038317e40eff64d13fd3f004f9e1b72b8a6aaf9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "overload" version = "0.1.1" @@ -1237,6 +1623,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1364,6 +1756,44 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +[[package]] +name = "reqwest" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" +dependencies = [ + "base64 0.21.5", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1450,6 +1880,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "schannel" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +dependencies = [ + "windows-sys", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1470,6 +1909,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.20" @@ -1485,6 +1947,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.190" @@ -1517,6 +1989,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.4" @@ -1538,6 +2019,35 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23" +dependencies = [ + "base64 0.21.5", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.1.0", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serial_test" version = "2.0.0" @@ -1731,6 +2241,7 @@ dependencies = [ "form-data-builder", "hyper", "jsonwebtoken", + "openidconnect", "rand_core", "serde", "serde_json", @@ -1746,6 +2257,12 @@ dependencies = [ "virt", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "subtle" version = "2.5.0" @@ -1769,6 +2286,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[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 = "tempfile" version = "3.8.1" @@ -1812,6 +2350,50 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +dependencies = [ + "deranged", + "itoa", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +dependencies = [ + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.33.0" @@ -1822,6 +2404,7 @@ dependencies = [ "bytes", "libc", "mio", + "num_cpus", "pin-project-lite", "signal-hook-registry", "socket2 0.5.5", @@ -1840,6 +2423,30 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + [[package]] name = "toml" version = "0.8.6" @@ -1867,7 +2474,7 @@ version = "0.20.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" dependencies = [ - "indexmap", + "indexmap 2.1.0", "serde", "serde_spanned", "toml_datetime", @@ -1976,12 +2583,27 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -1998,6 +2620,18 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "url" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "uuid" version = "1.5.0" @@ -2010,6 +2644,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -2076,6 +2716,18 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.88" @@ -2137,6 +2789,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2212,6 +2873,16 @@ 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", +] + [[package]] name = "zeroize" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 11169dc..93caa1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ argon2 = { version = "0.5.2", default-features = false, features = ["alloc"] } axum = { version = "0.6.20", features = ["multipart", "headers", "json"] } dirs = "5.0.1" jsonwebtoken = { version = "8.3.0", default-features = false } +openidconnect = { version = "3.4.0", default-features = false, features = ["native-tls", "reqwest"] } rand_core = { version = "0.6.4", features = ["getrandom"] } serde = { version = "1.0.190", features = ["derive"] } serde_json = "1.0.108" diff --git a/src/config.rs b/src/config.rs index 4695aa2..e3ebc40 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,6 +72,18 @@ pub struct CaConfig { pub host: HostCaConfig, } +/// OpenID Connect configuration +#[derive(Debug, Deserialize)] +pub struct OidcConfig { + /// OIDC Discovery base URL (without /.well-known/...) + pub discovery_url: String, + /// OAuth2 client ID + pub client_id: String, + /// OAuth2 client secret + #[serde(default)] + pub client_secret: Option, +} + /// Defines a connection to a libvirt VM host #[derive(Debug, Deserialize)] pub struct LibvirtConfig { @@ -91,6 +103,9 @@ pub struct Configuration { /// CA configuration #[serde(default)] pub ca: CaConfig, + /// OpenID Connect configuration for user authorization + #[serde(default)] + pub oidc: Option, } impl Default for Configuration { @@ -99,6 +114,7 @@ impl Default for Configuration { libvirt: vec![], machine_ids: default_machine_ids(), ca: Default::default(), + oidc: Default::default(), } } } diff --git a/src/server/mod.rs b/src/server/mod.rs index 79a03f1..2bbd888 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,4 +1,5 @@ mod host; +mod user; use std::collections::HashMap; use std::sync::Arc; @@ -8,6 +9,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::Router; +use openidconnect::core::CoreProviderMetadata; use tokio::sync::RwLock; use uuid::Uuid; @@ -16,6 +18,7 @@ use crate::config::Configuration; struct Context { config: Arc, cache: RwLock>, + oidc: RwLock>, } type State = Arc; @@ -32,10 +35,12 @@ pub fn make_app(config: Configuration) -> Router { let ctx = Arc::new(Context { config: config.into(), cache: RwLock::new(Default::default()), + oidc: Default::default(), }); Router::new() .route("/", get(|| async { "UP" })) .route("/host/sign", post(host::sign_host_cert)) + .route("/user/oidc-config", get(user::get_oidc_config)) .with_state(ctx) } diff --git a/src/server/user.rs b/src/server/user.rs new file mode 100644 index 0000000..13fd578 --- /dev/null +++ b/src/server/user.rs @@ -0,0 +1,159 @@ +use std::str::FromStr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::async_trait; +use axum::extract::FromRequestParts; +use axum::extract::State; +use axum::headers::authorization::Bearer; +use axum::headers::Authorization; +use axum::http::request::Parts; +use axum::Json; +use axum::{RequestPartsExt, TypedHeader}; +use openidconnect::core::{CoreClient, CoreProviderMetadata}; +use openidconnect::core::{CoreIdToken, CoreIdTokenClaims}; +use openidconnect::reqwest::async_http_client; +use openidconnect::IssuerUrl; +use openidconnect::Nonce; +use openidconnect::{ClientId, ClientSecret}; +use serde::Serialize; +use tracing::{debug, error, trace, warn}; + +use super::{AuthError, Context}; + +/// Response type for GET /user/openid-config +/// +/// This structure contains OpenID configuration information for +/// client utilities. +#[derive(Debug, Default, Serialize)] +pub struct OidcConfigResponse { + url: Option, + client_id: Option, + client_secret: Option, +} + +/// OpenID Connect ID token claims +pub struct Claims(CoreIdTokenClaims); + +/// Axum request extractor for OIDC ID tokens in Authorization headers +/// +/// This extractor parses an OpenID Connect identity token (as a JWT) +/// from the `Authorization` HTTP request header. If the token is +/// valid, a [`Claims`] structure is returned. If the token is not +/// valid (e.g. signed by an untrusted key, expired, for a different +/// audience, etc), [`AuthError`] is returned. +#[async_trait] +impl FromRequestParts> for Claims { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + ctx: &super::State, + ) -> Result { + let config = &ctx.config; + let oidc_config = &config.oidc.as_ref().ok_or_else(|| { + warn!("OpenID Connect not configured"); + AuthError + })?; + + let TypedHeader(Authorization(bearer)) = parts + .extract::>>() + .await + .map_err(|e| { + debug!("Failed to extract token from HTTP request: {}", e); + AuthError + })?; + + let token = CoreIdToken::from_str(bearer.token()).map_err(|e| { + debug!("Failed to parse OIDC ID token: {}", e); + AuthError + })?; + + let client_id = &oidc_config.client_id; + let client_secret = &oidc_config.client_secret; + let provider_metadata = get_metadata(ctx).await.ok_or(AuthError)?; + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(client_id.into()), + client_secret.as_ref().map(|s| ClientSecret::new(s.into())), + ); + let verifier = client.id_token_verifier(); + let claims = token + // Ignore the token nonce, as we have no way of validating it. + .into_claims(&verifier, |_: Option<&Nonce>| Ok(())) + .map_err(|e| { + debug!("Invalid ID token: {}", e); + AuthError + })?; + trace!("Token Claims: {:?}", claims); + debug!( + "Successfully authorized user {} (issuer {})", + claims.subject().as_str(), + claims.issuer().as_str() + ); + Ok(Claims(claims)) + } +} + +/// Retrieve OpenID Connect client configuration +/// +/// Clients can access this resource to determine the appropriate +/// configuration for the OpenID Connect identity provider where they +/// can obtain identity tokens. +pub(super) async fn get_oidc_config( + State(ctx): State, +) -> Json { + let config = &ctx.config; + let res = if let Some(oidc) = &config.oidc { + OidcConfigResponse { + url: Some(oidc.discovery_url.clone()), + client_id: Some(oidc.client_id.clone()), + client_secret: oidc.client_secret.clone(), + } + } else { + Default::default() + }; + Json(res) +} + +/// Get OIDC provider metadata (possibly from cache) +/// +/// This function will return metadata for the configured OIDC identity +/// provider. When called for the first time, it will initiate an +/// HTTP request to the provider's OpenID Provider Configuration +/// Document (i.e. `/.well-known/openid-configuration`). The result is +/// cached for 1 hour, so subsequent calls to this function will not +/// initiate another HTTP request, unless more than 1 hour has passed +/// since the first request. +/// +/// If an error occurs while attempting to fetch the metadata, `None` +/// is returned. +async fn get_metadata(ctx: &super::State) -> Option { + let cache = ctx.oidc.read().await; + if let Some((ts, m)) = &*cache { + if ts.elapsed() < Duration::from_secs(3600) { + debug!("Using cached OIDC provider metadata"); + return Some(m.clone()); + } + }; + let oidc_url = &ctx.config.oidc.as_ref()?.discovery_url; + debug!("Fetching OIDC provider metadata"); + let metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new(oidc_url.into()).unwrap(), + async_http_client, + ) + .await; + match metadata { + Ok(m) => { + drop(cache); + debug!("Caching OIDC provider metadata"); + let mut cache = ctx.oidc.write().await; + (*cache).replace((Instant::now(), m.clone())); + Some(m) + } + Err(e) => { + error!("Failed to get OIDC provider metadata: {}", e); + None + } + } +} diff --git a/tests/common/setup.rs b/tests/common/setup.rs index cb587f1..cd5bf94 100644 --- a/tests/common/setup.rs +++ b/tests/common/setup.rs @@ -8,7 +8,7 @@ use ssh_key::{Algorithm, Fingerprint, PrivateKey, PublicKey}; use tempfile::NamedTempFile; use tracing_subscriber::EnvFilter; -use sshca::config::Configuration; +use sshca::config::{Configuration, OidcConfig}; static INIT: Once = Once::new(); @@ -24,6 +24,11 @@ fn gen_machine_ids() -> Result> { fn gen_config(machine_ids: &Path, host_key: &Path) -> Configuration { let mut config = Configuration { machine_ids: machine_ids.to_str().unwrap().into(), + oidc: Some(OidcConfig { + discovery_url: "https://auth.example.org".into(), + client_id: "sshca".into(), + client_secret: None, + }), ..Default::default() }; config.ca.host.private_key_file = host_key.to_str().unwrap().into(); diff --git a/tests/test_user.rs b/tests/test_user.rs new file mode 100644 index 0000000..13637a1 --- /dev/null +++ b/tests/test_user.rs @@ -0,0 +1,34 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use sshca::server::make_app; + +use common::setup; + +#[tokio::test] +async fn test_oidc_config() { + let (_ctx, config) = setup::setup().await.unwrap(); + + let app = make_app(config); + + let res = app + .oneshot( + Request::builder() + .uri("/user/oidc-config") + .method("GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = hyper::body::to_bytes(res.into_body()).await.unwrap(); + let result: serde_json::Value = + serde_json::from_str(std::str::from_utf8(&body).unwrap()).unwrap(); + assert_eq!(result["url"].as_str(), Some("https://auth.example.org")); + assert_eq!(result["client_id"].as_str(), Some("sshca")); + assert_eq!(result["client_secret"].as_str(), None); +}