diff --git a/social-graphic.png b/.github/assets/social-graphic.png similarity index 100% rename from social-graphic.png rename to .github/assets/social-graphic.png diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ff3a4e5..e508873 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,8 +6,10 @@ - ## Other information and Screenshots (if appropriate) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e23e08..e25b48a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: cargo-deny: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -26,7 +26,7 @@ jobs: clippy: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -41,17 +41,19 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Rust version run: rustc --version && cargo --version - name: Clippy - run: cargo clippy --release --all-targets --target=wasm32-wasi -- -D warnings + run: cargo clippy --release --all-targets --target=wasm32-wasip1 -- -D warnings fmt: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -65,7 +67,7 @@ jobs: test: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -80,6 +82,8 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Rust version run: rustc --version && cargo --version @@ -107,10 +111,12 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Build wasm-oidc-plugin run: | - cargo build --target wasm32-wasi --release + cargo build --target wasm32-wasip1 --release - name: Upload plugin as artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d16691..fd93d70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: name: Build and upload artifacts runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -25,10 +25,12 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Build run: | - cargo build --target wasm32-wasi --release + cargo build --target wasm32-wasip1 --release - name: Archive production artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 21a2f1a..36bc845 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: cargo-deny: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -21,7 +21,7 @@ jobs: clippy: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -36,6 +36,8 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Rust version run: rustc --version && cargo --version @@ -43,12 +45,10 @@ jobs: - name: Clippy run: | rustc --version && cargo --version - cargo clippy --release --all-targets --target=wasm32-wasi -- -D warnings + cargo clippy --release --all-targets --target=wasm32-wasip1 -- -D warnings fmt: runs-on: ubuntu-latest - container: - image: antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -59,10 +59,75 @@ jobs: - name: Fmt run: cargo fmt -- --check + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install cargo audit + run: cargo install cargo-audit + + - name: Audit + run: cargo audit -f audit.toml + + verify-project: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Rust version + run: rustc --version && cargo --version + + - name: Verify project + run: cargo verify-project + + outdated: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install cargo-outdated + run: cargo install cargo-outdated + + - name: Rust version + run: rustc --version && cargo --version + + - name: Outdated + run: cargo outdated --root-deps-only --exit-code 1 + test: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -77,6 +142,8 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Rust version run: rustc --version && cargo --version @@ -88,7 +155,7 @@ jobs: runs-on: ubuntu-latest container: image: ghcr.io/antonengelhardt/rust-docker-tools - needs: [cargo-deny, clippy, fmt, test] + needs: [verify-project] steps: - name: Checkout code @@ -104,10 +171,12 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Build wasm-oidc-plugin run: | - cargo build --target wasm32-wasi --release + cargo build --target wasm32-wasip1 --release - name: Upload plugin as artifact uses: actions/upload-artifact@v4 @@ -116,7 +185,7 @@ jobs: path: target/wasm32-wasi/release/wasm_oidc_plugin.wasm docker-image: - needs: [cargo-deny, clippy, fmt, test] + needs: [verify-project] runs-on: ubuntu-latest steps: @@ -141,7 +210,7 @@ jobs: ghcr-image: runs-on: ubuntu-latest - needs: [cargo-deny, clippy, fmt, test] + needs: [verify-project] permissions: contents: read packages: write diff --git a/Cargo.lock b/Cargo.lock index 24fbe8f..0117c8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,15 +66,15 @@ checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" [[package]] name = "anyhow" -version = "1.0.82" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519" +checksum = "74f37166d7d48a0284b99dd824694c26119c700b53bf0d1540cdb147dbdaaf13" [[package]] name = "autocfg" -version = "1.2.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "base16ct" @@ -162,9 +162,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "cpufeatures" -version = "0.2.12" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" dependencies = [ "libc", ] @@ -194,9 +194,9 @@ dependencies = [ [[package]] name = "ct-codecs" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b7eb4404b8195a9abb6356f4ac07d8ba267045c8d6d220ac4dc992e6cc75df" +checksum = "026ac6ceace6298d2c557ef5ed798894962296469ec7842288ea64674201a2d1" [[package]] name = "ctr" @@ -241,6 +241,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -324,9 +335,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "js-sys", @@ -366,6 +377,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + [[package]] name = "hkdf" version = "0.12.4" @@ -408,24 +425,153 @@ dependencies = [ "digest", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "idna" -version = "0.5.0" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "icu_normalizer", + "icu_properties", ] [[package]] name = "indexmap" -version = "2.2.6" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.0", ] [[package]] @@ -445,9 +591,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" dependencies = [ "wasm-bindgen", ] @@ -479,9 +625,9 @@ dependencies = [ [[package]] name = "k256" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956ff9b67e26e1a6a866cb758f12c6f8746208489e3e4a4b5580802f2f0a587b" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", "ecdsa", @@ -493,24 +639,30 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ "spin", ] [[package]] name = "libc" -version = "0.2.153" +version = "0.2.161" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" [[package]] name = "libm" -version = "0.2.8" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" + +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" [[package]] name = "log" @@ -520,9 +672,9 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "memchr" -version = "2.7.2" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "num-bigint-dig" @@ -552,9 +704,9 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.44" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", @@ -563,9 +715,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", "libm", @@ -573,9 +725,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 = "opaque-debug" @@ -688,9 +840,12 @@ dependencies = [ [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] [[package]] name = "primeorder" @@ -703,9 +858,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.81" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" dependencies = [ "unicode-ident", ] @@ -716,15 +871,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14a5a4df5a1ab77235e36a0a0f638687ee1586d21ee9774037693001e94d4e11" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", "log", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -761,9 +916,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.5" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -773,9 +928,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", @@ -784,9 +939,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rfc6979" @@ -821,9 +976,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "sec" @@ -850,18 +1005,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.204" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "f55c3193aca71c12ad7890f1785d2b73e1b9f63a0bbc353c08ef26fe03fc56b5" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.214" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" dependencies = [ "proc-macro2", "quote", @@ -870,11 +1025,12 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.120" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] @@ -953,9 +1109,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "spin" -version = "0.5.2" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "spki" @@ -977,37 +1133,54 @@ dependencies = [ "der 0.7.9", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "subtle" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.60" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e" dependencies = [ "proc-macro2", "quote", @@ -1015,46 +1188,26 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.6.0" +name = "tinystr" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" -[[package]] -name = "unicode-bidi" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" - [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" - -[[package]] -name = "unicode-normalization" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" -dependencies = [ - "tinyvec", -] +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "universal-hash" @@ -1074,9 +1227,9 @@ checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] name = "url" -version = "2.5.2" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "8d157f1b96d14500ffdc1f10ba712e780825526c03d9a49b4d0324b0d9113ada" dependencies = [ "form_urlencoded", "idna", @@ -1084,11 +1237,23 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "version_check" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasi" @@ -1107,19 +1272,20 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", + "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", @@ -1132,9 +1298,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1142,9 +1308,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", @@ -1155,9 +1321,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "wasm-oidc-plugin" @@ -1180,28 +1346,108 @@ dependencies = [ "url", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" -version = "0.7.32" +version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ + "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.32" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ "proc-macro2", "quote", "syn", + "synstructure", ] [[package]] name = "zeroize" -version = "1.7.0" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 8521c37..06c2319 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,10 @@ publish = false name = "wasm-oidc-plugin" version = "0.5.1" -authors = ["WWU Cloud Developer , Anton Engelhardt "] -description = "A plugin for the Envoy-Proxy written in Rust. It is a HTTP Filter, that implements the OIDC Authorization Code Flow. Requests sent to the filter are checked for the presence of a valid session cookie. If the cookie is not present, the user is redirected to the authorization_endpoint to authenticate. After successful authentication, the user is redirected back to the original request with a code in the URL query. The plugin then exchanges the code for a token using the token_endpoint and stores the token in the session. If the cookie is present, the plugin validates the token and passes the request to the backend, if the token is valid (optional)." +authors = [ + "WWU Cloud Developer , Anton Engelhardt ", +] +description = "A Wasm-Pplugin for the Envoy Proxy written in Rust acting as an HTTP-Filter, that implements the OpenID Authorization Code Flow. Requests sent to the filter are checked for the presence of a valid session cookie. If the cookie is not present, the user is redirected to OpenID Provider to authenticate. After successful authentication, the user is redirected back to the original path with the autorization code in the URL query. The plugin then exchanges the code for a token using the token_endpoint and stores the token in the session." license = "Apache-2.0" edition = "2018" @@ -11,38 +13,38 @@ edition = "2018" crate-type = ["cdylib"] [dependencies] +# aes256 +aes-gcm = { version = "0.10.3", features = ["std"] } + +# base64 +base64 = "0.22.1" + +# jsonwebtoken (forked version to support RSA keys longer than 4096 bits) +jwt-simple = { git = "https://github.com/antonengelhardt/rust-jwt-simple", rev = "b92ac707dab1f65fae00569a468793d5eb6dab22" } + # logging log = "0.4.22" +# pkce +pkce = "0.2.0" + # proxy-wasm proxy-wasm = "0.2.2" -# parsing -url = { version = "2.5.2", features = ["serde"] } -serde = { version = "1.0.204", features = ["derive"] } +# serde +url = { version = "2.5.3", features = ["serde"] } +serde = { version = "1.0.214", features = ["derive"] } serde_yaml = "0.9.33" -serde_json = "1.0.120" +serde_json = "1.0.132" serde_urlencoded = "0.7.1" - -# base64 -base64 = "0.22.1" - -# regex -regex = "1.10.5" +regex = "1.11.1" serde_regex = "1.1.0" -# jsonwebtoken (forked version to support RSA keys longer than 4096 bits) -jwt-simple = {git = "https://github.com/antonengelhardt/rust-jwt-simple", rev = "b92ac707dab1f65fae00569a468793d5eb6dab22"} - -# pkce -pkce = "0.2.0" - -# aes256 -aes-gcm = {version = "0.10.3", features = ["std"]} +# secret +sec = { version = "1.0.0", features = ["serde", "deserialize"] } # thiserror -thiserror = "1.0.63" -sec = { version = "1.0.0", features = ["serde", "deserialize"] } +thiserror = "1.0.68" [profile.release] lto = true diff --git a/Dockerfile b/Dockerfile index 7600f4c..a01d4e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,17 @@ -FROM rust:1.75.0 AS builder +FROM rust:1.78.0 AS builder COPY src/ src/ COPY Cargo.toml Cargo.toml COPY Cargo.lock Cargo.lock -RUN rustup target add wasm32-wasi +RUN rustup target add wasm32-wasip1 -RUN cargo build --target=wasm32-wasi --release +RUN cargo build --target=wasm32-wasip1 --release ################################################## -FROM envoyproxy/envoy:v1.29-latest +FROM envoyproxy/envoy:v1.31-latest -COPY --from=builder /target/wasm32-wasi/release/wasm_oidc_plugin.wasm /etc/envoy/proxy-wasm-plugins/wasm_oidc_plugin.wasm +COPY --from=builder /target/wasm32-wasip1/release/wasm_oidc_plugin.wasm /etc/envoy/proxy-wasm-plugins/wasm_oidc_plugin.wasm CMD [ "envoy", "-c", "/etc/envoy/envoy.yaml" ] diff --git a/Makefile b/Makefile index f7b4eba..cb28529 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ build: - cargo build --target wasm32-wasi --release + cargo build --target wasm32-wasip1 --release run: - cargo build --target wasm32-wasi --release + cargo build --target wasm32-wasip1 --release docker-compose up run-background: - cargo build --target wasm32-wasi --release + cargo build --target wasm32-wasip1 --release docker-compose up -d docker-image: docker buildx build --platform linux/amd64 -f Dockerfile -t antonengelhardt/wasm-oidc-plugin:latest . diff --git a/README.md b/README.md index fbf345a..1781c84 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,25 @@ -![Logo (generated with Photoshop Generative AI)](social-graphic.png) +![Logo (generated with Photoshop Generative AI)](.github/assets/social-graphic.png) # wasm-oidc-plugin [![Build Status](https://github.com/antonengelhardt/wasm-oidc-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/antonengelhardt/wasm-oidc-plugin/actions/workflows/build.yml) [![Documentation](https://img.shields.io/badge/docs-blue)](https://antonengelhardt.github.io/wasm-oidc-plugin/wasm_oidc_plugin/index.html#) -A plugin for the [Envoy-Proxy](https://www.envoyproxy.io/) written in [Rust](https://www.rust-lang.org). It is a HTTP Filter, that implements the OIDC Authorization Code Flow. Requests sent to the filter are checked for the presence of a valid session cookie. If the cookie is not present, the user is redirected to the `authorization_endpoint` to authenticate. After successful authentication, the user is redirected back to the original request with a code in the URL query. The plugin then exchanges the code for a token using the `token_endpoint` and stores the token in the session. If the cookie is present, the plugin validates the token and passes the request to the backend, if the token is valid (optional). +A Wasm-plugin for the [Envoy-Proxy](https://www.envoyproxy.io/) written in [Rust](https://www.rust-lang.org) acting as an HTTP Filter, that implements the OpenID Authorization Code Flow. Requests sent to the filter are checked for the presence of a valid session cookie. If the cookie is not present, the user is redirected to the `authorization_endpoint` to authenticate. After successful authentication, the user is redirected back to the original path with the authorization code in the URL query. The plugin then exchanges the code for a token using the `token_endpoint` and stores the token in the session. If the cookie is present and decryptable, the plugin validates the token and passes the request to the backend, if the token is valid (optional). ## Demo -Go to [demo-page](https://demo.wasm-oidc-plugin.ae02.de) to see the plugin in action. [Auth0](https://auth0.com) is used as the OIDC provider. Simply create an account or login with Google. The plugin has been configured to show [httpbin.org](https://httpbin.org) as the upstream. Then open the developer tools and check the cookies or use the [httpbin cookie inspector](https://demo.wasm-oidc-plugin.ae02.de/#/Cookies/get_cookies). You will see a cookie called `oidcSession-0`. This is the session, that holds the authorization state. If you delete the cookie and refresh the page, you will be redirected to the `authorization_endpoint` to authenticate again. +Go to [demo-page](https://demo.wasm-oidc-plugin.ae02.de) to see the plugin in action. [Auth0](https://auth0.com) is used as the OpenID provider. Simply create an account or login with Google. The plugin has been configured to show [httpbin.org](https://httpbin.org) as the upstream. Then open the developer tools and check the cookies or use the [httpbin cookie inspector](https://demo.wasm-oidc-plugin.ae02.de/#/Cookies/get_cookies). You will see a cookie called `oidcSession-0`. This is the session, that holds the authorization state. If you delete the cookie and refresh the page, you will be redirected to the `authorization_endpoint` to authenticate again. ## Why this repo? This repo is the result of a bachelor thesis in Information Systems. It is inspired by two other projects: [oidc-filter](https://github.com/dgn/oidc-filter) & [wasm-oauth-filter](https://github.com/sonhal/wasm-oauth-filter). This project has several advantages and improvements: 1. **Encryption**: The session in which the authorization state is stored is encrypted using AES-256, by providing a Key in the config and a session-based nonce. This prevents the session from being read by the user and potentially modified. If the user tries to modify the session, the decryption fails and the user is redirected to the `authorization_endpoint` to authenticate again. -2. **Configuration**: Many configuration options are available to customize the plugin to your needs. More are coming ;) -3. **Stability**: The plugin aims to be stable and ready for production. All forceful value unwraps are expected to be valid. If the value may be invalid or in the wrong format, error handling is in place. -4. **Optional validation**: The plugin can be configured to validate the token or not. If the validation is disabled, the plugin only checks for the presence of the token and passes the request to the backend. This is because the validation is taking a considerable amount of time. This time becomes worse with the length of the signing key. Cryptographic support is not fully mature in WASM yet, but [there is hope](https://github.com/WebAssembly/wasi-crypto/blob/main/docs/HighLevelGoals.md). -5. **Documentation and comments**: The code is documented and commented, so that it is easy to understand and extend. +2. **Multiple OpenID Providers**: The plugin can be configured with multiple OpenID providers. This is useful if you have multiple services that are protected by different OpenID providers. The user can then choose which provider to authenticate with on some auth page. +3. **Configuration**: Many configuration options are available to customize the plugin to your needs. More are coming ;) +4. **Stability**: The plugin aims to be stable and ready for production. All forceful value unwraps are expected to be valid. If the value may be invalid or in the wrong format, error handling is in place. +5. **Optional validation**: The plugin can be configured to validate the token or not. If the validation is disabled, the plugin only checks for the presence of the token and passes the request to the backend. This is because the validation is taking a considerable amount of time. This time becomes worse with the length of the signing key. Cryptographic support is not fully mature in WASM yet, but [there is hope](https://github.com/WebAssembly/wasi-crypto/blob/main/docs/HighLevelGoals.md). +6. **Documentation and comments**: The code is documented and commented, so that it is easy to understand and extend. ## Install @@ -32,7 +33,7 @@ apt install build-essential # Install Rustup curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # Enable WASM compilation target -cargo build --target wasm32-wasi --release +cargo build --target wasm32-wasip1 --release ``` ## Run @@ -50,7 +51,7 @@ make run 1. **Building the plugin:** ```sh -cargo build --target wasm32-wasi --release +cargo build --target wasm32-wasip1 --release # or make build ``` @@ -73,9 +74,10 @@ To deploy the plugin to production, the following steps are needed (either manua 1. Build the plugin - 1.1 with `cargo build --target wasm32-wasi --release` - this can be done in a [initContainer](./k8s/deployment.yaml) (see [k8s](./k8s) folder) and then copy the binary to the path `/etc/envoy/proxy-wasm-plugins/` in the envoy container. + 1.1 with `cargo build --target wasm32-wasip1 --release` - this can be done in a [initContainer](./k8s/deployment.yaml) (see [k8s](./k8s) folder) and then copy the binary to the path `/etc/envoy/proxy-wasm-plugins/` in the envoy container. + + 1.2 by using the pre-built Docker image [antonengelhardt/wasm-oidc-plugin](https://hub.docker.com/r/antonengelhardt/wasm-oidc-plugin). - 1.2 by using the pre-built Docker image [antonengelhardt/wasm-oidc-plugin](https://hub.docker.com/r/antonengelhardt/wasm-oidc-plugin). 2. Run envoy as a container with the `envoy.yaml` file mounted through the [ConfigMap](./k8s/configmap.yml) as a volume. 3. Set up [Service](./k8s/service.yml), [Certificate](./k8s/certificate-production.yml), [Ingress](./k8s/ingress.yml) to expose the Envoy to the internet. @@ -95,20 +97,30 @@ The plugin is configured via the `envoy.yaml`-file. The following configuration | Name | Type | Description | Example | Required | | ---- | ---- | ----------- | ------- | -------- | -| `config_endpoint` | `string` | The open id configuration endpoint. | `https://accounts.google.com/.well-known/openid-configuration` | ✅ | -| `reload_interval_in_hours` | `u64` | The interval in hours, after which the OIDC configuration is reloaded. | `24` | ✅ | | `exclude_hosts` | `Vec` | A comma separated list Hosts (in Regex expressions), that are excluded from the filter. | `["localhost:10000"]` | ❌ | | `exclude_paths` | `Vec` | A comma separated list of paths (in Regex expressions), that are excluded from the filter. | `["/health"]` | ❌ | -| `exclude_urls` | `Vec` | A comma separated list of URLs (in Regex expressions), that are excluded from the filter. | `["localhost:10000/health"]` | ❌ | +| `exclude_urls` | `Vec` | A comma separated list of URLs (in Regex expressions), that are excluded from the filter. | `["http://localhost:10000/health"]` | ❌ | | `access_token_header_name` | `string` | If set, this name will be used to forward the access token to the backend. | `X-Access-Token` | ❌ | | `access_token_header_prefix` | `string` | The prefix of the header, that is used to forward the access token, if empty "" is used. | `Bearer ` | ❌ | | `id_token_header_name` | `string` | If set, this name will be used to forward the id token to the backend. | `X-Id-Token` | ❌ | | `id_token_header_prefix` | `string` | The prefix of the header, that is used to forward the id token, if empty "" is used. | `Bearer ` | ❌ | | `cookie_name` | `string` | The name of the cookie, that is used to store the session. | `oidcSession` | ✅ | -| `filter_plugin_cookies | `bool` | Whether to filter the cookies that are managed and controlled by the plugin (namely cookie_name and `nonce`). | `true` | ✅ | +| `logout_path` | `string` | The path, that is used to logout the user. The user will be redirected to `end_session_endpoint` of the OIDC provider, if the server supports this; alternatively the user is sent to "/" | `/logout` | ✅ | +| `filter_plugin_cookies` | `bool` | Whether to filter the cookies that are managed and controlled by the plugin (namely cookie_name and `nonce`). | `true` | ✅ | | `cookie_duration` | `u64` | The duration in seconds, after which the session cookie expires. | `86400` | ✅ | | `token_validation` | bool | Whether to validate the token or not. | `true` | ✅ | | `aes_key` | `string` | A base64 encoded AES-256 Key: `openssl rand -base64 32` | `SFDUGDbOsRzSZbv+mvnZdu2x6+Hqe2WRaBABvfxmh3Q=` | ✅ | +| `reload_interval_in_h` | `u64` | The interval in hours, after which the OpenID configuration is reloaded. | `24` | ✅ | +| `open_id_configs` | `Vec` | A list of OpenID Configuration objects. | See below | ✅ | + +#### `OpenIdConfig` + +| Name | Type | Description | Example | Required | +| ---- | ---- | ----------- | ------- | -------- | +| `name` | `string` | The name of the OpenID provider (this will be shown on the Auth Page). | `Google` | ✅ | +| `image` | `string` | The URL to the image of the OpenID provider (this will be shown on the Auth Page). | `https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/2560px-Google_2015_logo.svg.png` | ✅ | +| `config_endpoint` | `string` | The open id configuration endpoint. | `https://accounts.google.com/.well-known/openid-configuration` | ✅ | +| `upstream_cluster` | `string` | The name of the upstream cluster in your Envoy configuration. | `httpbin` | ✅ | | `authority` | `string` | The authority of the `authorization_endpoint`. | `accounts.google.com` | ✅ | | `redirect_uri` | `string` | The redirect URI, that the `authorization_endpoint` will redirect to. | `http://localhost:10000/oidc/callback` | ✅ | | `client_id` | `string` | The client ID, for getting and exchanging the code. | `wasm-oidc-plugin` | ✅ | @@ -117,7 +129,7 @@ The plugin is configured via the `envoy.yaml`-file. The following configuration | `client_secret` | `string` | The client secret, that is used to authenticate with the `authorization_endpoint`. | `secret` | ✅ | | `audience` | `string` | The audience, that is used to validate the token. | `wasm-oidc-plugin` | ✅ | -With these configuration options, the plugin starts and loads more information itself such as the OIDC providers public keys, issuer, etc. +With these configuration options, the plugin starts and loads more information itself such as all OpenID provider's public keys, issuer, etc. ### States @@ -125,10 +137,11 @@ For that a state is used, which determines, what to load next. The following sta | State | Description | | ---- | ----------- | -| `Uninitialized` | The plugin is not initialized yet. | -| `LoadingConfig` | The plugin is loading the configuration from the `config_endpoint`. | -| `LoadingJwks` | The plugin is loading the public keys from the `jwks_uri`. | -| `Ready` | The plugin is ready to handle requests and will reload the configuration after the `reload_interval_in_hours` has passed. | +| `LoadingConfig` | The plugin is loading the configuration from all `config_endpoint`s. | +| `LoadingJwks` | The plugin is loading the public keys from all `jwks_uri`. | +| `Ready` | The plugin is ready to handle requests and will reload the configuration after the `reload_interval_in_h` has passed. | + +Below is a state diagram for one single OpenID Provider ![State Diagram](./docs/sequence-discovery.png) @@ -138,10 +151,11 @@ When a new request arrives, the root context creates a new http context with the Then, one of the following cases is handled: -1. The filter is not configured yet and still loading the configuration. The request is paused and queued until the configuration is loaded. Then, the RootContext resumes the request and the Request is redirected in order to create a new context. -2. The request has the code parameter in the URL query. This means that the user has been redirected back from the `authorization_endpoint` after successful authentication. The plugin exchanges the code for a token using the `token_endpoint` and stores the token in the session. Then, the user is redirected back to the original request. -3. The request has a valid session cookie. The plugin decoded, decrypts and then validates the cookie and passes the request depending on the outcome of the validation of the token. -4. The request has no valid session cookie. The plugin redirects the user to the `authorization_endpoint` to authenticate. Once, the user returns, the second case is handled. +1. The plugin is not configured yet and still loading the configuration. The request is paused and queued until the configuration is loaded. Then, the RootContext resumes the request and the Request is redirected in order to create a new context. +2. The request is excluded from the filter. The request is passed to the backend without any further checks. +3. The request has the authorization code in the URL query. This means that the user has been redirected back from the `authorization_endpoint` after successful authentication. The plugin exchanges the code for a token using the `token_endpoint` and stores the token in the session. Then, the user is redirected back to the original request. +4. The request has a valid session cookie. The plugin decoded, decrypts and then validates the cookie and passes the request depending on the outcome of the validation of the token. +5. The request has no valid session cookie. The plugin redirects the user to the `authorization_endpoint` to authenticate. Once, the user returns, the second case is handled. ![Sequence Diagram](./docs/sequence-authorization-code-flow.png) @@ -190,3 +204,40 @@ cargo-deny check advisories ``` These commands are also run in the CI pipeline. + +## FAQ + +> My OpenID provider uses a different endpoint for the jwks_uri. How can I configure this? + +Google does exactly that: + +```json +{ + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs" +} +``` + +You can add the endpoint in your `envoy.yaml`-file like this: + +```yaml +- name: google + connect_timeout: 5s + type: STRICT_DNS + dns_lookup_family: V4_ONLY + load_assignment: + cluster_name: google + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: accounts.google.com + port_value: 443 + - endpoint: + address: + socket_address: + address: www.googleapis.com + port_value: 443 +``` + +The rest should work fine. diff --git a/audit.toml b/audit.toml new file mode 100644 index 0000000..01406b4 --- /dev/null +++ b/audit.toml @@ -0,0 +1,30 @@ +# All of the options which can be passed via CLI arguments can also be +# permanently specified in this file. + +[advisories] +ignore = ["RUSTSEC-2023-0071"] # advisory IDs to ignore e.g. ["RUSTSEC-2019-0001", ...] +informational_warnings = ["unmaintained"] # warn for categories of informational advisories +severity_threshold = "low" # CVSS severity ("none", "low", "medium", "high", "critical") + +# Advisory Database Configuration +[database] +path = "~/.cargo/advisory-db" # Path where advisory git repo will be cloned +url = "https://github.com/RustSec/advisory-db.git" # URL to git repo +fetch = true # Perform a `git fetch` before auditing (default: true) +stale = false # Allow stale advisory DB (i.e. no commits for 90 days, default: false) + +# Output Configuration +[output] +deny = ["unmaintained"] # exit on error if unmaintained dependencies are found +format = "terminal" # "terminal" (human readable report) or "json" +quiet = false # Only print information on error +show_tree = true # Show inverse dependency trees along with advisories (default: true) + +# Target Configuration +[target] +arch = ["x86_64"] # Ignore advisories for CPU architectures other than these +os = ["linux", "windows"] # Ignore advisories for operating systems other than these + +[yanked] +enabled = true # Warn for yanked crates in Cargo.lock (default: true) +update_index = true # Auto-update the crates.io index (default: true) diff --git a/demo/configmap.yml b/demo/configmap.yml index 6f9d57f..af32bda 100644 --- a/demo/configmap.yml +++ b/demo/configmap.yml @@ -41,12 +41,12 @@ data: configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | - config_endpoint: "https://demo-wasm-oidc-plugin.eu.auth0.com/.well-known/openid-configuration" - reload_interval_in_h: 1 # in hours - - exclude_hosts: [] # or ["httpbin.org"] - exclude_paths: [] # or ["/favicon.ico"] - exclude_urls: [] # or ["http://localhost:10000/#/HTTP_Methods/get_get"] + exclude_hosts: + # - "httpbin.org" + exclude_paths: + # - "/favicon.ico" + exclude_urls: + # - https://httpbin.org/favicon.ico access_token_header_name: # or "Authorization" access_token_header_prefix: "Bearer " @@ -54,19 +54,37 @@ data: id_token_header_prefix: "Bearer " cookie_name: "oidcSession" + logout_path: "/logout" filter_plugin_cookies: true # or false cookie_duration: 8640000 # in seconds token_validation: true # or false aes_key: "redacted" - authority: "demo-wasm-oidc-plugin.eu.auth0.com" - redirect_uri: "https://demo.wasm-oidc-plugin.ae02.de/oidc/callback" - client_id: qxgINfU3gutYjea8hEmpra5JG5jyqeAY - scope: "openid profile email" - claims: "{\"id_token\":{\"groups\":null,\"username\":null}}" - - client_secret: "redacted" - audience: qxgINfU3gutYjea8hEmpra5JG5jyqeAY + reload_interval_in_h: 1 # in hours + ticking_interval_in_ms: 500 # in milliseconds + open_id_configs: + - name: auth0 + image: "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5b/Logo_de_Auth0.svg/2560px-Logo_de_Auth0.svg.png" + config_endpoint: "https://demo-wasm-oidc-plugin.eu.auth0.com/.well-known/openid-configuration" + upstream_cluster: "auth0" + authority: "demo-wasm-oidc-plugin.eu.auth0.com" + redirect_uri: "https://demo.wasm-oidc-plugin.ae02.de/oidc/callback" + client_id: qxgINfU3gutYjea8hEmpra5JG5jyqeAY + scope: "openid profile email" + claims: "{\"id_token\":{\"groups\":null,\"username\":null}}" + client_secret: "redacted" + audience: qxgINfU3gutYjea8hEmpra5JG5jyqeAY + - name: "google (unusable, will not work)" + image: "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/2560px-Google_2015_logo.svg.png" + config_endpoint: "https://accounts.google.com/.well-known/openid-configuration" + upstream_cluster: "google" + authority: "accounts.google.com" + redirect_uri: "http://localhost:10000/oidc/callback" + client_id: "google-client-id" + scope: "openid profile email" + claims: "{\"id_token\":{\"groups\":null,\"username\":null}}" + client_secret: "google-client-secret" + audience: "google-client-id" vm_config: runtime: "envoy.wasm.runtime.v8" @@ -90,12 +108,12 @@ data: socket_address: address: httpbin-service.wasm-oidc-plugin.svc.cluster.local port_value: 80 - - name: oidc + - name: auth0 connect_timeout: 5s - type: LOGICAL_DNS + type: STRICT_DNS dns_lookup_family: V4_ONLY load_assignment: - cluster_name: oidc + cluster_name: auth0 endpoints: - lb_endpoints: - endpoint: @@ -108,3 +126,26 @@ data: typed_config: "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext sni: "demo-wasm-oidc-plugin.eu.auth0.com" + - name: google + connect_timeout: 5s + type: STRICT_DNS + dns_lookup_family: V4_ONLY + load_assignment: + cluster_name: google + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: accounts.google.com + port_value: 443 + - endpoint: + address: + socket_address: + address: www.googleapis.com + port_value: 443 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + sni: "accounts.google.com" diff --git a/docker-compose.yaml b/docker-compose.yaml index 2d02a3a..8a44e61 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,14 +2,14 @@ version: "3.8" services: envoy: - image: envoyproxy/envoy:v1.29-latest + image: envoyproxy/envoy:v1.31-latest hostname: envoy ports: - "9901:9901" - "10000:10000" volumes: - ./envoy.yaml:/etc/envoy/envoy.yaml - - ./target/wasm32-wasi/release:/etc/envoy/proxy-wasm-plugins + - ./target/wasm32-wasip1/release:/etc/envoy/proxy-wasm-plugins networks: - envoymesh # Additional options: diff --git a/envoy.yaml b/envoy.yaml index e84da21..11a2261 100644 --- a/envoy.yaml +++ b/envoy.yaml @@ -31,12 +31,12 @@ static_resources: configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | - config_endpoint: "https://accounts.google.com/.well-known/openid-configuration" - reload_interval_in_h: 1 # in hours - - exclude_hosts: [] # or ["httpbin.org"] - exclude_paths: [] # or ["/favicon.ico"] - exclude_urls: [] # or ["http://localhost:10000/#/HTTP_Methods/get_get"] + exclude_hosts: + # - "httpbin.org" + exclude_paths: + # - "/favicon.ico" + exclude_urls: + # - https://httpbin.org/favicon.ico access_token_header_name: # or "Authorization" access_token_header_prefix: "Bearer " @@ -44,19 +44,26 @@ static_resources: id_token_header_prefix: "Bearer " cookie_name: "oidcSession" # max. 32 characters + logout_path: "/logout" filter_plugin_cookies: true # or false - cookie_duration: 86400 # in seconds + cookie_duration: 8640000 # in seconds token_validation: true # or false aes_key: "i-am-a-forty-four-characters-long-string-key" # generate with `openssl rand -base64 32` - authority: "accounts.google.com" # FQDN of the OIDC provider - redirect_uri: "http://localhost:10000/oidc/callback" # redirect uri that is registered with the OIDC provider - client_id: "wasm-oidc-plugin" # client id that is registered with the OIDC provider - scope: "openid profile email" - claims: "{\"id_token\":{\"groups\":null,\"username\":null}}" - - client_secret: "redacted" - audience: "wasm-oidc-plugin" + reload_interval_in_h: 1 # in hours + ticking_interval_in_ms: 500 # in milliseconds + open_id_configs: + - name: google + image: "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/2560px-Google_2015_logo.svg.png" + config_endpoint: "https://accounts.google.com/.well-known/openid-configuration" + upstream_cluster: "google" + authority: "accounts.google.com" + redirect_uri: "http://localhost:10000/oidc/callback" + client_id: "google-client-id" + scope: "openid profile email" + claims: "{\"id_token\":{\"groups\":null,\"username\":null}}" + client_secret: "google-client-secret" + audience: "google-client-id" vm_config: runtime: "envoy.wasm.runtime.v8" code: @@ -80,12 +87,12 @@ static_resources: address: httpbin port_value: 80 hostname: "httpbin.org" - - name: oidc + - name: google connect_timeout: 5s - type: LOGICAL_DNS + type: STRICT_DNS dns_lookup_family: V4_ONLY load_assignment: - cluster_name: oidc + cluster_name: google endpoints: - lb_endpoints: - endpoint: @@ -93,7 +100,11 @@ static_resources: socket_address: address: accounts.google.com port_value: 443 - # hostname: "accounts.google.com" + - endpoint: + address: + socket_address: + address: www.googleapis.com + port_value: 443 transport_socket: name: envoy.transport_sockets.tls typed_config: diff --git a/k8s/ci.yml b/k8s/ci.yml index 2b9e590..9d08c70 100644 --- a/k8s/ci.yml +++ b/k8s/ci.yml @@ -12,7 +12,7 @@ jobs: cargo-deny: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -26,7 +26,7 @@ jobs: clippy: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -41,6 +41,8 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Rust version run: rustc --version && cargo --version @@ -53,7 +55,7 @@ jobs: fmt: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -67,7 +69,7 @@ jobs: test: runs-on: ubuntu-latest container: - image: antonengelhardt/rust-docker-tools + image: ghcr.io/antonengelhardt/rust-docker-tools steps: - name: Checkout code uses: actions/checkout@v4 @@ -82,6 +84,8 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Rust version run: rustc --version && cargo --version @@ -109,10 +113,12 @@ jobs: ~/.cargo/git/db/ target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Build wasm-oidc-plugin run: | - cargo build --target wasm32-wasi --release + cargo build --target wasm32-wasip1 --release - name: Upload plugin as artifact uses: actions/upload-artifact@v4 diff --git a/k8s/configmap.yml b/k8s/configmap.yml index 748adb1..6fb7756 100644 --- a/k8s/configmap.yml +++ b/k8s/configmap.yml @@ -42,31 +42,38 @@ data: configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" value: | - config_endpoint: "https://accounts.google.com/.well-known/openid-configuration" - reload_interval_in_h: 1 # in hours - - exclude_hosts: [] # or ["httpbin.org"] - exclude_paths: [] # or ["/favicon.ico"] - exclude_urls: [] # or ["http://localhost:10000/#/HTTP_Methods/get_get"] + exclude_hosts: + # - "httpbin.org" + exclude_paths: + # - "/favicon.ico" + exclude_urls: + # - https://httpbin.org/favicon.ico access_token_header_name: # or "Authorization" access_token_header_prefix: "Bearer " id_token_header_name: # or "X-Id-Token" id_token_header_prefix: "Bearer " - cookie_name: "oidcSession" - cookie_duration: 86400 # in seconds + cookie_name: "oidcSession" # max. 32 characters + logout_path: "/logout" + cookie_duration: 8640000 # in seconds token_validation: true # or false aes_key: "i-am-a-forty-four-characters-long-string-key" # generate with `openssl rand -base64 32` - authority: "accounts.google.com" # FQDN of the OIDC provider - redirect_uri: "http://localhost:10000/oidc/callback" # redirect uri that is registered with the OIDC provider - client_id: "wasm-oidc-plugin" # client id that is registered with the OIDC provider - scope: "openid profile email" - claims: "{\"id_token\":{\"groups\":null,\"username\":null}}" - - client_secret: "redacted" - audience: "wasm-oidc-plugin" + reload_interval_in_h: 1 # in hours + ticking_interval_in_ms: 500 # in milliseconds + open_id_configs: + - name: google + image: "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/2560px-Google_2015_logo.svg.png" + config_endpoint: "https://accounts.google.com/.well-known/openid-configuration" + upstream_cluster: "google" + authority: "accounts.google.com" + redirect_uri: "http://localhost:10000/oidc/callback" + client_id: "google-client-id" + scope: "openid profile email" + claims: "{\"id_token\":{\"groups\":null,\"username\":null}}" + client_secret: "google-client-secret" + audience: "google-client-id" vm_config: runtime: "envoy.wasm.runtime.v8" @@ -91,21 +98,26 @@ data: address: httpbin #! This is the hostname of the service you want to access. port_value: 80 hostname: "httpbin.org" #! This is the hostname of the service you want to access. - - name: oidc #! dont change it + - name: google #! must match the upstream_cluster in the plugin's configuration. connect_timeout: 5s - type: LOGICAL_DNS + type: STRICT_DNS dns_lookup_family: V4_ONLY load_assignment: - cluster_name: oidc + cluster_name: google endpoints: - lb_endpoints: - endpoint: address: socket_address: - address: your-domain.com #! Your Auth Server's domain name. + address: accounts.google.com #! Your Auth Server's domain name. + port_value: 443 + - endpoint: + address: + socket_address: + address: www.googleapis.com #! Your Auth Server's domain name. port_value: 443 transport_socket: name: envoy.transport_sockets.tls typed_config: "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext - sni: "your-domain.com" #! Here as well. + sni: "accounts.google.com" #! Your Auth Server's domain name. diff --git a/k8s/deployment-init-container.yaml b/k8s/deployment-init-container.yaml index a6e4b56..9811aba 100644 --- a/k8s/deployment-init-container.yaml +++ b/k8s/deployment-init-container.yaml @@ -28,7 +28,7 @@ spec: spec: initContainers: - name: build-plugin - image: antonengelhardt/rust-docker-tools:latest + image: ghcr.io/antonengelhardt/rust-docker-tools:latest command: - /bin/sh - -c @@ -36,7 +36,7 @@ spec: apk add git git clone -b main https://${GITHUB_PAT}@github.com/your-org/your-repo.git #! Change URL and branch cd your-repo #! Change directory - cargo build --target wasm32-wasi --release + cargo build --target wasm32-wasip1 --release cp target/wasm32-wasi/release/name_of_your_wasm_plugin.wasm /plugins/name_of_your_wasm_plugin.wasm #! Rename, if necessary env: @@ -52,7 +52,7 @@ spec: containers: - name: envoy - image: envoyproxy/envoy:v1.29-latest + image: envoyproxy/envoy:v1.31-latest resources: requests: diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..31964f9 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,852 @@ +// base64 +use base64::{engine::general_purpose::STANDARD_NO_PAD as base64engine, Engine as _}; + +// duration +use std::time::Duration; + +// jwt +use jwt_simple::prelude::*; + +// log +use log::{debug, warn}; + +// std +use std::sync::Arc; +use std::vec; + +// proxy-wasm +use proxy_wasm::traits::*; +use proxy_wasm::types::*; + +// url +use url::{form_urlencoded, Url}; + +use crate::config::PluginConfiguration; +use crate::discovery::OpenIdProvider; +use crate::error::PluginError; +use crate::html; +use crate::responses::{CodeCallback, ProviderSelectionCallback}; +use crate::session; +use crate::session::{AuthorizationState, Session}; + +/// The `ConfiguredOidc is the main filter struct and responsible for the OpenID authentication flow. +/// Requests arriving are checked for a valid cookie. If the cookie is valid, the request is +/// forwarded. If the cookie is not valid, the user is redirected to the authorization endpoint. +pub struct ConfiguredOidc { + /// The configuration of the filter which mainly contains the open id configuration and the + /// keys to validate the JWT + pub open_id_providers: Arc>, + /// Plugin configuration parsed from the envoy configuration + pub plugin_config: Arc, + /// Token id of the current request + pub token_id: Option, + /// ID of the current request + pub request_id: Option, +} + +/// The context is used to process incoming HTTP requests when the filter is configured. +/// * If the host, path or URL is excluded, the request is forwarded. +/// * If the path matches the provider selection endpoint, the user is redirected to the authorization endpoint. +/// * If the path matches the redirect_uri, the code is exchanged for a token. +/// * If the cookie is valid, the request is forwarded. +/// * If the cookie is not valid, the user is redirected to the auth page or the authorization endpoint. +impl HttpContext for ConfiguredOidc { + /// This function is called when the request headers are received. + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + // Get the host, path and scheme from the request headers + let host = self.get_host().unwrap_or_default(); + let path = self.get_http_request_header(":path").unwrap_or_default(); + let scheme = self + .get_http_request_header(":scheme") + .unwrap_or("http".to_string()); + let url = Url::parse(&format!("{}://{}{}", scheme, host, path)) + .unwrap_or(Url::parse("http://example.com").unwrap()); + debug!("url: {}", url); + + // Get x-request-id + let x_request_id = self + .get_http_request_header("x-request-id") + .unwrap_or_default(); + self.request_id = Some(x_request_id.clone()); + debug!("x-request-id: {}", x_request_id); + + // Health check + if path == "/plugin-health" { + self.send_http_response(200, vec![], Some(b"OK")); + return Action::Pause; + } + + // If the host is one of the exclude hosts, forward the request + if self + .plugin_config + .exclude_hosts + .iter() + .any(|x| x.is_match(&host)) + { + debug!("host {} is excluded, forwarding request.", host); + self.filter_proxy_cookies(); + return Action::Continue; + } + + // If the path is one of the exclude paths, forward the request + if self + .plugin_config + .exclude_paths + .iter() + .any(|x| x.is_match(&path)) + { + debug!("path {} is excluded, forwarding request.", path); + self.filter_proxy_cookies(); + return Action::Continue; + } + + // If the URL is one of the exclude URLs, forward the request + if self + .plugin_config + .exclude_urls + .iter() + .any(|x| x.is_match(url.as_str())) + { + debug!("url {} is excluded, forwarding request.", url.as_str()); + self.filter_proxy_cookies(); + return Action::Continue; + } + + // If Path is logout route, clear cookies and redirect to base path + if path == self.plugin_config.logout_path { + match self.logout() { + Ok(action) => return action, + Err(e) => { + warn!( + "logout failed for request {} with error: {}", + self.request_id.clone().unwrap(), + e + ); + self.show_error_page(503, "Logout failed", "Please try again, delete your cookies or contact your system administrator."); + } + } + } + + // If the path matches the provider selection endpoint, redirect to the authorization endpoint + // with the selected provider. + if path.contains("/_wasm-oidc-plugin/provider-selection") { + let query = path.split('?').last().unwrap(); + + // Deserialize the query into a struct + let provider_selection_callback = + serde_urlencoded::from_str::(query).unwrap(); + // TODO: Error handling (crashes: https://localhost:10000/_wasm-oidc-plugin/provider-selection?n-a) + + // Find the provider to authorize with + let provider_to_authorize_with = self + .open_id_providers + .iter() + .find(|provider| { + provider.open_id_config.name + == provider_selection_callback.authorize_with_provider + }) + .unwrap(); + // TODO: Error handling + + // Redirect to the authorization endpoint + self.redirect_to_authorization_endpoint( + provider_to_authorize_with, + Some(provider_selection_callback.return_to), + ); + } + + // If the path matches one of the `redirect_uri`s, exchange the code for a token + if self + .open_id_providers + .iter() + .any(|provider| path.starts_with(provider.open_id_config.redirect_uri.path())) + { + match self.exchange_code_for_token(path) { + Ok(_) => return Action::Pause, + Err(e) => { + warn!( + "token exchange failed for request {} with error: {}", + self.request_id.clone().unwrap(), + e + ); + self.show_error_page(503, "Token exchange failed", "Please try again, delete your cookies or contact your system administrator."); + } + } + return Action::Pause; + } + + // Else, validate the cookie and forward the request if the authorization state is valid + match self.validate_cookie() { + Err(e) => match e { + // disable logging for these errors + PluginError::SessionCookieNotFoundError => {} + PluginError::NonceCookieNotFoundError => {} + _ => warn!("cookie validation failed: {}", e), + }, + Ok(auth_state) => { + // Forward access token in header, if configured + if let Some(header_name) = &self.plugin_config.access_token_header_name { + // Get access token + let access_token = &auth_state.access_token; + // Forward access token in header + self.add_http_request_header( + header_name, + format!( + "{}{}", + self.plugin_config + .access_token_header_prefix + .as_ref() + .unwrap(), + access_token + ) + .as_str(), + ); + } + + // Forward id token in header, if configured + if let Some(header_name) = &self.plugin_config.id_token_header_name { + // Get id token + let id_token = &auth_state.id_token; + // Forward id token in header + self.add_http_request_header( + header_name, + format!( + "{}{}", + self.plugin_config.id_token_header_prefix.as_ref().unwrap(), + id_token + ) + .as_str(), + ); + } + + self.filter_proxy_cookies(); + + // Allow request to pass + return Action::Continue; + } + } + + // If any previous condition was not met, it means that the cookie is not valid or not present. + //hThen, sow the auth page or redirect to the authorization endpoint (depending on the number of providers) + self.generate_auth_page(); + + // Pause the request + Action::Pause + } +} + +/// This context is used to process HTTP responses from the token endpoint. +impl Context for ConfiguredOidc { + /// This function catches the response from the token endpoint. We use an inner function to + /// handle errors more easily. + fn on_http_call_response(&mut self, token_id: u32, _: usize, body_size: usize, _: usize) { + // Store the token in the cookie + match self.store_token_in_cookie(token_id, body_size) { + Ok(_) => { + debug!("token stored in cookie"); + } + Err(e) => { + warn!( + "storing token in cookie failed for request {} with error: {}", + self.request_id.clone().unwrap(), + e + ); + // Send a 503 if storing the token in the cookie failed + self.show_error_page( + 503, + "Storing Token in Cookie failed", + "Please try again, delete your cookies or contact your system administrator.", + ); + } + } + } +} + +/// Helper functions for the `ConfiguredOidc`` struct. +impl ConfiguredOidc { + /// Check if the cookie is valid and if the token is valid. + fn validate_cookie(&self) -> Result { + // Get cookie and nonce + let cookie = self.get_session_cookie_as_string()?; + let nonce = self.get_nonce()?; + + // Try to parse and decrypt the cookie and handle the result + match Session::decode_and_decrypt( + cookie, + self.plugin_config.aes_key.reveal().clone(), + nonce, + ) { + // If the cookie cannot be parsed, this function returns an error + Err(e) => Err(PluginError::CookieValidationError(e.to_string())), + // If the cookie can be parsed, this means that the cookie is trusted because modifications would have + // corrupted the encrypted state. Token validation is only performed if the configuration option is set. + Ok(session) => { + // Only validate the token if the configuration option is set + match self.plugin_config.token_validation { + true => { + // Get authorization state from session + let auth_state = match session.authorization_state { + Some(auth_state) => auth_state, + None => { + return Err(PluginError::AuthorizationStateNotFoundError); + } + }; + + // Validate token + match self.validate_token(&auth_state.id_token, &session.issuer.unwrap()) { + // If the token is valid, this filter passes the request + Ok(_) => { + debug!("token is valid, passing request"); + Ok(auth_state) + } + // If the token is invalid, the error is returned and the user is redirected to the auth page + Err(e) => Err(PluginError::TokenValidationError(e.into())), + } + } + false => match session.authorization_state { + Some(auth_state) => Ok(auth_state), + // If no authorization state is found, return an error + None => Err(PluginError::CookieValidationError( + "No authorization state found".to_string(), + )), + }, + } + } + } + } + + /// Validate the token using the JWT library and a given issuer. + /// This function checks for the given issuer and audience and verifies the signature with the + /// public keys loaded from the JWKs endpoint. + /// + /// ## Arguments + /// * `token` - The token to validate + /// * `issuer` - The issuer to validate the token against + /// + /// ## Returns + /// + /// A result with the following variants: + /// * Ok(()) - If the token is valid + /// * Err(PluginError) - If the token is invalid + fn validate_token(&self, token: &str, issuer: &str) -> Result<(), PluginError> { + // Get provider to use based on issuer + let provider_to_use = match self + .open_id_providers + .iter() + .find(|provider| provider.issuer == issuer) + { + Some(provider) => provider, + None => { + return Err(PluginError::ProviderNotFoundError( + "unknown issuer".to_string(), + )); + } + }; + + // Define allowed issuers and audiences + let mut allowed_issuers = HashSet::new(); + allowed_issuers.insert(provider_to_use.issuer.clone()); + let mut allowed_audiences = HashSet::new(); + allowed_audiences.insert(provider_to_use.open_id_config.audience.clone()); + + // Define verification options + let verification_options = VerificationOptions { + allowed_issuers: Some(allowed_issuers), + allowed_audiences: Some(allowed_audiences), + ..Default::default() + }; + + // Iterate over all public keys of the provider + for public_key in provider_to_use.public_keys.iter() { + // Perform the validation + let validation_result = public_key.verify_token(token, verification_options.to_owned()); + + // Check if the token is valid, the aud and iss are correct and the signature is valid. + match validation_result { + Ok(_) => return Ok(()), + Err(e) => { + debug!("token validation failed: {:?}", e); + continue; + } + } + } + // If no key worked for validation, return an error + Err(PluginError::NoKeyError) + } + + /// Exchange the code for a token using the token endpoint. + /// This function is called when the user is redirected back to the callback URL. + /// The code is extracted from the URL and exchanged for a token using the token endpoint. + /// + /// ## Arguments + /// + /// * `path` - The path of the request + /// + /// ## Returns + /// + /// * Ok(()) - If the token is exchanged successfully + /// * Err(PluginError) - If the token exchange fails + fn exchange_code_for_token(&mut self, path: String) -> Result<(), PluginError> { + debug!("received request for OpenID callback"); + + // Get Query String from URL + let query = path.split('?').last().unwrap_or_default(); + debug!("query: {}", query); + + // Get state from query + let callback_params = serde_urlencoded::from_str::(query)?; + + // Get cookie and nonce + let encoded_cookie = self.get_session_cookie_as_string()?; + let encoded_nonce = self.get_nonce()?; + + // Get session + let session = Session::decode_and_decrypt( + encoded_cookie, + self.plugin_config.aes_key.reveal().clone(), + encoded_nonce, + )?; + + // Get issuer from session or return an error + let issuer = match session.issuer.clone() { + Some(issuer) => issuer, + None => { + return Err(PluginError::IssuerNotFound); + } + }; + + // Get provider to use based on issuer + let provider_to_use = match self + .open_id_providers + .iter() + .find(|provider| provider.issuer == issuer) + { + Some(provider) => provider, + None => { + return Err(PluginError::ProviderNotFoundError( + "unknown issuer".to_string(), + )); + } + }; + + // Get state and code from query + let code = callback_params.code; + debug!("authorization code: {}", code); + let state = callback_params.state; + debug!("client state: {}", state); + debug!("cookie state: {}", session.state); + + // Compare state + if state != session.state { + return Err(PluginError::StateMismatchError); + } + + // Encode client_id and client_secret and build the Authorization header using base64encoding + let auth = format!( + "Basic {}", + base64engine.encode( + format!( + "{}:{}", + provider_to_use.open_id_config.client_id, + provider_to_use.open_id_config.client_secret.reveal() + ) + .as_bytes() + ) + ); + + // Get code verifier from cookie + let code_verifier = session.code_verifier; + + // Build the request body for the token endpoint + let data = form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "authorization_code") + .append_pair("code_verifier", &code_verifier) + .append_pair("code", &code) + .append_pair( + "redirect_uri", + provider_to_use.open_id_config.redirect_uri.as_str(), + ) + .append_pair("state", &state) + .finish(); + + // Dispatch request to token endpoint using built-in envoy function + debug!("sending data to token endpoint: {}", data); + match self.dispatch_http_call( + &provider_to_use.open_id_config.upstream_cluster, + vec![ + (":method", "POST"), + (":path", provider_to_use.token_endpoint.path()), + ( + ":authority", + provider_to_use.open_id_config.authority.as_str(), + ), + ("Authorization", &auth), + ("Content-Type", "application/x-www-form-urlencoded"), + ], + Some(data.as_bytes()), + vec![], + Duration::from_secs(10), + ) { + // If the request fails, this filter logs the error and pauses the request + Err(_) => Err(PluginError::DispatchError), + // If the request is dispatched successfully, this filter pauses the request + Ok(id) => { + self.token_id = Some(id); + Ok(()) + } + } + } + + /// Store the token from the token response in an encrypted cookie. + /// + /// ## Arguments + /// + /// * `token_id` - The token id of the response + /// * `body_size` - The size of the response body + /// + /// ## Returns + /// + /// * Ok(()) - If the token is stored in the cookie successfully + /// * Err(PluginError) - If the token could not be stored in the cookie + fn store_token_in_cookie( + &mut self, + token_id: u32, + body_size: usize, + ) -> Result<(), PluginError> { + // Assess token id + if self.token_id != Some(token_id) { + return Err(PluginError::TokenIdMismatchError); + } + + // Check if the response is valid. If its not 200, investigate the response and log the error. + if self.get_http_call_response_header(":status") != Some("200".to_string()) { + // Get body of response + match self.get_http_call_response_body(0, body_size) { + // If no body is found, log the error + None => return Err(PluginError::NoBodyError), + Some(body) => { + // Parse body + match String::from_utf8(body) { + Ok(decoded) => return Err(PluginError::TokenResponseFormatError(decoded)), + // If parsing fails, log the error + Err(e) => return Err(PluginError::Utf8Error(e)), + } + } + } + } + + // Previously we checked for the status code and the body, so we can assume that the response is valid. + match self.get_http_call_response_body(0, body_size) { + // If no body is found, return the error + None => Err(PluginError::CookieStoreError( + "No body in response".to_string(), + )), + Some(body) => { + // Get nonce and cookie + let encoded_cookie = self.get_session_cookie_as_string()?; + let encoded_nonce = self.get_nonce()?; + + // Get session from cookie + let mut session = Session::decode_and_decrypt( + encoded_cookie, + self.plugin_config.aes_key.reveal().clone(), + encoded_nonce, + )?; + + // Parse authorization state from token response + let authorization_state = serde_json::from_slice::(&body)?; + debug!("authorization state: {:?}", authorization_state); + + // Add authorization state to session + session.authorization_state = Some(authorization_state); + + // Re-encrypt and re-encode session + let (new_session, new_nonce) = + session.encrypt_and_encode(self.plugin_config.aes_key.reveal().clone())?; + + // Build cookie values + let set_cookie_values = Session::make_cookie_values( + &new_session, + &new_nonce, + self.plugin_config.cookie_name.as_str(), + self.plugin_config.cookie_duration, + ); + + // Build cookie headers + let mut headers = Session::make_set_cookie_headers(&set_cookie_values); + + // Set the location header to the original path + let location_header = ("Location", session.original_path.as_str()); + headers.push(location_header); + + // Redirect back to the original URL and set the cookie. + self.send_http_response(307, headers, Some(b"Redirecting...")); + debug!("token stored in cookie"); + Ok(()) + } + } + } + + /// Clear the cookies and redirect to the base path or `end_session_endpoint`. + fn logout(&self) -> Result { + let cookie_values = Session::make_cookie_values("", "", &self.plugin_config.cookie_name, 0); + + let mut headers = Session::make_set_cookie_headers(&cookie_values); + + // Get session from cookie + let cookie = self.get_session_cookie_as_string()?; + let nonce = self.get_nonce()?; + let session = Session::decode_and_decrypt( + cookie, + self.plugin_config.aes_key.reveal().clone(), + nonce, + )?; + + // Get provider to use based on issuer because the end session endpoint is provider-specific + let provider = self + .open_id_providers + .iter() + .find(|provider| provider.issuer == session.issuer.clone().unwrap()) + .unwrap(); + // TODO: Error handling + + // Redirect to end session endpoint, if available (not all OIDC providers support this) + let location = match &provider.end_session_endpoint { + // if the end session endpoint is available, redirect to it + Some(url) => url.as_str(), + // else, redirect to the base path + None => "/", + }; + headers.push(("Location", location)); + headers.push(("Cache-Control", "no-cache")); + + self.send_http_response(307, headers, Some(b"Logging out...")); + + Ok(Action::Pause) + } + + /// Show the auth page or redirect to the authorization endpoint. + fn generate_auth_page(&self) { + // If there is more than one provider, show an auth page where the user selects the provider + if self.open_id_providers.len() > 1 { + debug!("no cookie found or invalid, showing auth page"); + + // Grab the original path and encode it + let original_path = self + .get_http_request_header(":path") + .unwrap_or("/".to_string()); + let original_path_encoded = base64engine.encode(original_path.as_bytes()); + + let mut urls = vec![]; + let mut provider_cards = String::new(); + + // Create a card for each provider which sends the user back to the plugin with the selected provider + for open_id_provider in self.open_id_providers.iter() { + let url = format!( + "/_wasm-oidc-plugin/provider-selection?authorize_with_provider={}&return_to={}", + open_id_provider.open_id_config.name, original_path_encoded + ); + urls.push(url.clone()); + + let provider_card = html::provider_card( + &url, + open_id_provider.open_id_config.name.as_str(), + open_id_provider.open_id_config.image.as_str(), + ); + provider_cards.push_str(&provider_card); + } + + let headers = vec![("cache-control", "no-cache"), ("content-type", "text/html")]; + + // Show the auth page + self.send_http_response( + 200, + headers, + Some(html::auth_page_html(provider_cards).as_bytes()), + ); + } else { + // If there is only one provider, redirect the user to the authorization endpoint right away + debug!("no cookie found or invalid, redirecting to authorization endpoint"); + self.redirect_to_authorization_endpoint(self.open_id_providers.first().unwrap(), None); + } + } + + /// Redirect to the `authorization_endpoint` by sending a HTTP response with a 307 status code. + /// This function generates a PKCE code verifier and challenge, creates a session struct, encrypts + /// and encodes the session, and sets the cookie headers. + /// + /// ## Arguments + /// + /// * `open_id_provider` - The OpenID provider to redirect to + /// * `return_to` - The original path to redirect to after login + pub fn redirect_to_authorization_endpoint( + &self, + open_id_provider: &OpenIdProvider, + return_to: Option, + ) -> Action { + // The `original_path` is the path to which the user should be redirected after login. and it can be + // passed as a query parameter. If the `return_to` parameter is not set, the original path is the current path + // (this is the case when there is only one provider). + let original_path: String = match return_to { + Some(return_to) => match base64engine.decode(return_to.as_bytes()) { + Ok(decoded) => match String::from_utf8(decoded) { + Ok(decoded) => decoded, + Err(_) => "/".to_string(), + }, + Err(_) => "/".to_string(), + }, + None => self + .get_http_request_header(":path") + .unwrap_or("/".to_string()), + }; + + // Generate PKCE code verifier and challenge + let pkce_verifier = pkce::code_verifier(128); + let pkce_verifier_string = String::from_utf8(pkce_verifier.clone()).unwrap(); + let pkce_challenge = pkce::code_challenge(&pkce_verifier); + + // Generate state + let state_string = String::from_utf8(pkce::code_verifier(128)).unwrap(); + + // Create session struct and encrypt it + let (session, nonce) = session::Session { + issuer: Some(open_id_provider.issuer.clone()), + authorization_state: None, + original_path, + code_verifier: pkce_verifier_string, + state: state_string.clone(), + } + .encrypt_and_encode(self.plugin_config.aes_key.reveal().clone()) + .expect("session cookie could not be created"); + + // Build cookie values + let set_cookie_values = Session::make_cookie_values( + &session, + &nonce, + self.plugin_config.cookie_name.as_str(), + self.plugin_config.cookie_duration, + ); + + // Build cookie headers + let mut headers = Session::make_set_cookie_headers(&set_cookie_values); + + // Build URL + let location = Url::parse_with_params( + open_id_provider.auth_endpoint.as_str(), + &[ + ("response_type", "code"), + ("code_challenge", &pkce_challenge), + ("code_challenge_method", "S256"), + ("state", &state_string), + ("client_id", &open_id_provider.open_id_config.client_id), + ( + "redirect_uri", + open_id_provider.open_id_config.redirect_uri.as_str(), + ), + ("scope", &open_id_provider.open_id_config.scope), + ("claims", &open_id_provider.open_id_config.claims), + ], + ) + .unwrap(); + + headers.push(("Location", location.as_str())); + + self.send_http_response(307, headers, Some(b"Redirecting...")); + + Action::Pause + } + + /// Get the cookie of the HTTP request by name + /// + /// ## Arguments + /// + /// * `name` - The name of the cookie to search for + /// + /// ## Returns + /// The value of the cookie if found, None otherwise + fn get_cookie(&self, name: &str) -> Option { + let headers = self.get_http_request_headers(); + for (key, value) in headers.iter() { + if key.to_lowercase().trim() == "cookie" { + let cookies: Vec<_> = value.split(';').collect(); + for cookie_string in cookies { + let cookie_name_end = cookie_string.find('=').unwrap_or(0); + let cookie_name = &cookie_string[0..cookie_name_end]; + if cookie_name.trim() == name { + return Some( + cookie_string[(cookie_name_end + 1)..cookie_string.len()].to_owned(), + ); + } + } + } + } + None + } + + /// Get the host of the HTTP request + /// + /// ## Returns + /// + /// The host is searched in the request headers. If the host is found, the value is returned. + fn get_host(&self) -> Option { + self.get_http_request_header(":authority") + .or_else(|| self.get_http_request_header("host")) + .or_else(|| self.get_http_request_header("x-forwarded-host")) + } + + /// Filter non proxy cookies by checking the cookie name. + /// This function removes all cookies from the request that do not match the cookie name to prevent + /// the cookie from being forwarded to the upstream service. + fn filter_proxy_cookies(&self) { + // Check if the filter_plugin_cookies option is set + if !self.plugin_config.filter_plugin_cookies { + return; + } + + // Get all cookies + let all_cookies = self.get_http_request_header("cookie").unwrap_or_default(); + + // Remove non proxy cookies from request + let filtered_cookies = all_cookies + .split(';') + .filter(|x| !x.contains(&self.plugin_config.cookie_name)) + .filter(|x| !x.contains(&format!("{}-nonce", self.plugin_config.cookie_name))) + .collect::>() + .join(";"); + + // Set the cookie header + self.set_http_request_header("Cookie", Some(&filtered_cookies)); + } + + /// Helper function to get the session cookie as a string by getting the cookie from the request + /// headers and concatenating all cookie parts. + /// + /// ## Returns + /// + /// The session cookie as a string if found, an error otherwise + pub fn get_session_cookie_as_string(&self) -> Result { + let cookie_name = &self.plugin_config.cookie_name; + + // Get the number of cookie parts + let num_parts: u8 = self + .get_cookie(&format!("{cookie_name}-parts")) + .unwrap_or_default() + .parse() + .map_err(|_| PluginError::SessionCookieNotFoundError)?; + + // Get the cookie parts and concatenate them into a string + let values = (0..num_parts) + .map(|i| self.get_cookie(&format!("{cookie_name}-{i}"))) + .collect::>>() + .ok_or(PluginError::SessionCookieNotFoundError)? + .join(""); + + Ok(values) + } + + // Get the encoded nonce from the cookie + pub fn get_nonce(&self) -> Result { + self.get_cookie(format!("{}-nonce", self.plugin_config.cookie_name).as_str()) + .ok_or(PluginError::NonceCookieNotFoundError) + } +} diff --git a/src/config.rs b/src/config.rs index 8cdff36..d14b3a9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,15 @@ +// aes_gcm +use aes_gcm::{Aes256Gcm, KeyInit}; + +// core use core::fmt; -use std::fmt::Debug; -use aes_gcm::{Aes256Gcm, KeyInit}; +// sec use sec::Secret; + +// std +use std::fmt::Debug; + // serde use serde::{Deserialize, Deserializer}; @@ -12,36 +19,16 @@ use regex::Regex; // url use url::Url; -// crate -use crate::responses::SigningKey; - -/// Struct that holds the configuration for the filter and all relevant information for the -/// OpenID Connect Flow. -#[derive(Clone, Debug)] -pub struct OpenIdConfig { - // Everything relevant for the Code Flow - /// The URL of the authorization endpoint - pub auth_endpoint: Url, - - // Everything relevant for the Token Exchange Flow - /// The URL of the token endpoint - pub token_endpoint: Url, - /// The issuer that will be used for the token request - pub issuer: String, - - // Relevant for Validation of the ID Token - /// The public keys that will be used for the validation of the ID Token - pub public_keys: Vec, -} - -/// Struct that holds the configuration for the plugin. It is loaded from the config file -/// `envoy.yaml` +/// Struct that holds the configuration for the plugin. It is loaded from the config file `envoy.yaml` #[derive(Clone, Debug, Deserialize)] pub struct PluginConfiguration { - /// Config endpoint for the plugin. - pub config_endpoint: Url, + // OpenID Connect Configuration + /// A list of OpenID Connect configurations that will be used for the filter + pub open_id_configs: Vec, /// Reload interval in hours pub reload_interval_in_h: u64, + /// The interval in milliseconds that the plugin will wait for the discovery endpoint to respond or send a new request. + pub ticking_interval_in_ms: u64, /// Exclude hosts. Example: localhost:10000 #[serde(with = "serde_regex")] @@ -71,6 +58,8 @@ pub struct PluginConfiguration { // Cookie settings /// The cookie name that will be used for the session cookie pub cookie_name: String, + /// The URL to logout the user + pub logout_path: String, /// Filter out the cookies created and controlled by the plugin /// If the value is true, the cookies will be filtered out pub filter_plugin_cookies: bool, @@ -81,8 +70,22 @@ pub struct PluginConfiguration { /// AES Key #[serde(deserialize_with = "deserialize_aes_key")] pub aes_key: Secret, +} + +/// Struct that holds the configuration for the OpenID Connect provider +#[derive(Clone, Debug, Deserialize)] +pub struct OpenIdConfig { + // Metadata + /// Name of the OpenID Connect Provider + pub name: String, + /// Image of the OpenID Connect Provider, will be shown in the screen where the user can select the provider + pub image: Url, // Everything relevant for the Code Flow + /// Config endpoint for the plugin. + pub config_endpoint: Url, + /// Upstream Cluster name + pub upstream_cluster: String, /// The authority that will be used for the dispatch calls pub authority: String, /// The redirect uri that the authorization endpoint will redirect to and provide the code diff --git a/src/discovery.rs b/src/discovery.rs index d76dd42..2f7fbc8 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,9 +1,5 @@ -// regex -use regex::Regex; - // arc -use std::sync::Arc; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; // duration use std::time::Duration; @@ -12,132 +8,158 @@ use std::time::Duration; use log::{debug, info, warn}; // proxy-wasm -use proxy_wasm::hostcalls; -use proxy_wasm::traits::*; -use proxy_wasm::types::*; +use proxy_wasm::{hostcalls, traits::*, types::*}; + +// regex +use regex::Regex; + +// std +use std::fmt; // url use url::Url; // crate -use crate::config::PluginConfiguration; +use crate::auth::ConfiguredOidc; +use crate::config::{OpenIdConfig, PluginConfiguration}; use crate::error::PluginError; -use crate::responses::{JWKsResponse, OidcDiscoveryResponse, SigningKey}; -use crate::{ConfiguredOidc, OpenIdConfig, PauseRequests}; - -// This is the initial entry point of the plugin. -proxy_wasm::main! {{ - - proxy_wasm::set_log_level(LogLevel::Debug); - - info!("Starting OIDC plugin"); - - // This sets the root context, which is the first context that is called on startup. - // The root context is used to initialize the plugin and load the configuration from the - // plugin config and the discovery endpoints. - // Here, we set state to uninitialized, which means that the plugin is not yet configured. - // The state will be changed to LoadingConfig when the plugin configuration is loaded. - // The token_id is used to verify that the http responses match the dispatches which are - // sent to the discovery endpoints. - proxy_wasm::set_root_context(|_| -> Box { Box::new(OidcDiscovery { - state: OidcRootState::Uninitialized, - waiting: Mutex::new(Vec::new()), - token_id: None, - }) }); -}} - -/// This context is responsible for getting the OIDC configuration, jwks keys -/// and setting the http context. -pub struct OidcDiscovery { - /// The state of the root context. This is an enum which has the following variants: - /// - Uninitialized: The plugin is not yet configured - /// - LoadingConfig: The plugin configuration is being loaded - /// - LoadingJwks: The jwks configuration is being loaded - /// - Ready: The plugin is ready - pub state: OidcRootState, +use crate::pause::PauseRequests; +use crate::responses::{JWKsResponse, OpenIdDiscoveryResponse, SigningKey}; + +/// This is the main context which loads and parses the plugin configuration, handles the discovery of all +/// Open ID Providers and creates the HTTP Contexts. +pub struct Root { + /// Plugin config loaded from the envoy configuration + pub plugin_config: Option>, + /// A set of Open ID Resolvers which are used to load the configuration from the discovery endpoint + pub open_id_resolvers: Mutex>, + /// A set of Open ID Providers which are used to store the configuration from the discovery endpoint + pub open_id_providers: Mutex>, /// Queue of waiting requests which are waiting for the configuration to be loaded - waiting: Mutex>, - /// token_id of the HttpCalls to verify the call is correct - token_id: Option, + pub waiting: Mutex>, + /// Flag to determine if the discovery is active + pub discovery_active: bool, +} + +#[derive(Debug)] +/// A resolver handles the loading of the configuration from the open id discovery endpoint and the jwks endpoint. +pub struct OpenIdResolver { + /// The state of the resolver + pub state: OpenIdResolverState, + /// The configuration from the plugin configuration + pub open_id_config: OpenIdConfig, + /// token_ids of the HttpCalls to verify the call is correct and to determine which response comes in + token_ids: Vec, } -/// The state of the root context is an enum which has the following variants: -/// - Uninitialized: The plugin is not yet configured +/// The state of the resolver is an enum which has the following variants: /// - LoadingConfig: The plugin configuration is being loaded /// - LoadingJwks: The jwks configuration is being loaded /// - Ready: The plugin is ready -/// Each state has a different set of fields which are needed for that specific state. #[allow(clippy::large_enum_variant)] -pub enum OidcRootState { - /// State when the plugin needs to load the plugin configuration - Uninitialized, +#[derive(Debug)] +pub enum OpenIdResolverState { /// The root context is loading the configuration from the open id discovery endpoint - LoadingConfig { - /// Plugin config loaded from the envoy configuration - plugin_config: Arc, - }, - /// The root context is loading the jwks configuration + LoadingConfig, + /// The root context is loading the jwks configuration using the open id configuration LoadingJwks { - /// Plugin config - plugin_config: Arc, - - /// The authorization endpoint to start the code flow - auth_endpoint: Url, - /// The token endpoint to exchange the code for a token - token_endpoint: Url, - /// The issuer - issuer: String, - /// The url from which the public keys can be retrieved - jwks_uri: Url, + /// response from the config endpoint + open_id_response: Arc, }, /// The root context is ready - Ready { - /// Plugin config loaded from the envoy configuration - plugin_config: Arc, - /// Open id config loaded from the open id discovery endpoint and the jwks endpoint - open_id_config: Arc, - }, + Ready, +} + +impl fmt::Display for OpenIdResolverState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OpenIdResolverState::LoadingConfig => write!(f, "LoadingConfig"), + OpenIdResolverState::LoadingJwks { .. } => write!(f, "LoadingJwks"), + OpenIdResolverState::Ready => write!(f, "Ready"), + } + } } -/// The root context is used to create new HTTP contexts and load configuration from the -/// open id discovery endpoint and the jwks endpoint. -/// When `on_configure` is called, the plugin configuration is loaded and the state is set to -/// LoadingConfig. The filter is then ticked immediately to load the configuration. -/// When `on_http_call_response` is called, the Open ID response is parsed and the state is set to -/// LoadingJwks. -/// On the next tick, the jwks endpoint is called and the state is set to Ready once the jwks -/// response is received and successfully parsed. -impl RootContext for OidcDiscovery { +/// The OpenIdProvider struct holds all information about the Open ID Provider that is needed for the +/// plugin to work. This includes the Open ID Configuration, the URLs of the endpoints and the public keys +/// that are used for the validation of the ID Token. +#[derive(Clone, Debug)] +pub struct OpenIdProvider { + pub open_id_config: OpenIdConfig, + /// The URL of the authorization endpoint + pub auth_endpoint: Url, + /// The URL of the token endpoint + pub token_endpoint: Url, + /// The URL of the end session endpoint + pub end_session_endpoint: Option, + /// The issuer that will be used for the token request + pub issuer: String, + /// The public keys that will be used for the validation of the ID Token + pub public_keys: Vec, +} + +/// The root context creates new HTTP Contexts and is responsible for loading the plugin configuration, as +/// well as the discovery of the Open ID Providers. +/// The first step after startup is the loading of the plugin configuration. This is done in the `on_configure` +/// function. The plugin configuration is loaded from the plugin configuration and parsed into the +/// `PluginConfiguration` struct. The configuration is then evaluated and checked if the values are valid. +/// If the configuration is valid, the plugin configuration is stored in the root context and the next state +/// is set. The next state is to load the configuration from the Open ID Providers. This is done by creating +/// a new `OpenIdResolver` for each Open ID Provider in the plugin configuration. The state of the resolver +/// is set to `LoadingConfig` and the configuration is loaded from the Open ID Configuration endpoint. The +/// response is handled in the `on_http_call_response` function. If the response is successful, the state is +/// set to `LoadingJwks` and the jwks endpoint is called. The response is handled in the `on_http_call_response` +/// function. If the response is successful, the state is set to `Ready` and the Open ID Provider is stored in +/// the root context. If all Open ID Providers are in the `Ready` state, the plugin is ready and the waiting +/// requests are resumed. +impl RootContext for Root { /// Called when proxy is being configured. /// This is where the plugin configuration is loaded and the next state is set. fn on_configure(&mut self, _plugin_configuration_size: usize) -> bool { - info!("Plugin is configuring"); + info!("plugin is configuring"); // Load the configuration from the plugin configuration. match self.get_plugin_configuration() { + None => warn!("no plugin configuration"), Some(config_bytes) => { debug!("got plugin configuration"); // Parse the configuration in a yaml format. match serde_yaml::from_slice::(&config_bytes) { + Err(e) => warn!("error parsing plugin configuration: {:?}", e), Ok(plugin_config) => { - debug!("parsed plugin configuration: {:?}", plugin_config); + debug!("parsed plugin configuration: {:#?}", plugin_config); // Evaluate the plugin configuration and check if the values are valid. // Type checking is done by serde, so we only need to check the values. - match OidcDiscovery::evaluate_config(plugin_config.clone()) { - Ok(_) => { - info!("plugin configuration is valid"); - } + match Root::evaluate_config(plugin_config.clone()) { Err(e) => { panic!("plugin configuration is invalid: {:?}", e); } + Ok(_) => { + info!("plugin configuration is valid"); + } } - // Advance to the next state and store the plugin configuration. - self.state = OidcRootState::LoadingConfig { - plugin_config: Arc::new(plugin_config), - }; + self.plugin_config = Some(Arc::new(plugin_config.clone())); + + // Create a new resolver for each open id provider in the plugin configuration. + let mut resolvers = vec![]; + for open_id_config in plugin_config.open_id_configs.clone() { + info!( + "creating resolver for open id config: {:?}", + open_id_config.name + ); + + // Advance to the next state and store the plugin configuration. + let open_id_resolver = OpenIdResolver { + state: OpenIdResolverState::LoadingConfig, + open_id_config, + token_ids: vec![], + }; + resolvers.push(open_id_resolver); + } + self.open_id_resolvers = Mutex::new(resolvers); // Tick immediately to load the configuration. // See `on_tick` for more information. @@ -145,39 +167,34 @@ impl RootContext for OidcDiscovery { return true; } - Err(e) => warn!("error parsing plugin configuration: {:?}", e), } } - None => warn!("no plugin configuration"), } false } - /// Creates the http context with the information from the open_id_config and the plugin configuration. + /// Creates the http context with the information from the open_id_providers and the plugin_configuration. /// This is called whenever a new http context is created by the proxy. - /// When the plugin is not yet ready, the http context is created in `Unconfigured` state and the + /// When the plugin is not yet ready, the http context is created in `PauseRequests` state and the /// context id is added to the waiting queue to be processed later. fn create_http_context(&self, context_id: u32) -> Option> { - match &self.state { - // If the plugin is ready, create the http context in Ready state - // with the open-id config and the plugin config. - OidcRootState::Ready { - open_id_config, - plugin_config, - } => { + // Check if all open id providers are ready + match self.discovery_active { + // If the plugin is ready, create the http context `ConfiguredOidc` with the root context information. + false => { debug!("creating http context with root context information"); // Return the http context. Some(Box::new(ConfiguredOidc { - open_id_config: open_id_config.clone(), - plugin_config: plugin_config.clone(), + open_id_providers: Arc::new(self.open_id_providers.lock().unwrap().to_vec()), + plugin_config: self.plugin_config.clone()?, token_id: None, + request_id: None, })) } - // If the plugin is not ready, return the http context in `Unconfigured` state and add the - // context id to the waiting queue. + // If the plugin is not ready, return the http context in `PauseRequests` state. _ => { warn!("root context is not ready yet, queueing http context."); @@ -192,92 +209,135 @@ impl RootContext for OidcDiscovery { } } - /// The root context is ticking every 400 millis as long as the configuration is not loaded yet. - /// On every tick, the state is checked and the corresponding action is taken. - /// 1. If the state is `Uninitialized`, the configuration is loaded from the plugin configuration. - /// 2. If the state is `LoadingConfig`, the configuration is loaded from the openid configuration endpoint. - /// 3. If the state is `LoadingJwks`, the public key is loaded from the jwks endpoint. - /// 4. If the state is `Ready`, the configuration is reloaded. + /// The root context is ticking every the configured interval (x) as long as the configuration is not loaded yet. + /// + /// On every tick, the plugin is checking if the discovery is active. If the discovery is not active, + /// the plugin is starting the discovery (as it has been waiting for `reload_interval_in_h` * 3600). + /// The discovery is started by setting the discovery active to true and setting the state of all resolvers + /// to `LoadingConfig`. The ticking period is set to x ms to not overload the openid configuration endpoint (x is + /// the configured interval). + /// + /// If the discovery is active, the plugin is checking if all resolvers are in `Ready` state. If all resolvers + /// are in `Ready` state, the plugin is resuming all requests that were sent during the loading phase. The + /// discovery is switched to false and the ticking period is set to the configured interval. + /// + /// If the discovery is active and not all resolvers are in `Ready` state, the plugin is making a call to the + /// openid configuration endpoint or the jwks endpoint depending on the state of the resolver. fn on_tick(&mut self) { debug!("tick"); + // Discovery is not active, start discovery + if !self.discovery_active { + info!("discovery is not active, starting discovery"); + + // Set discovery to active and set the state of all resolvers to `LoadingConfig`. + self.discovery_active = true; + for resolver in self.open_id_resolvers.lock().unwrap().iter_mut() { + resolver.state = OpenIdResolverState::LoadingConfig; + } + // Tick every x ms to not overload the openid configuration endpoint. x is the configured interval. + self.set_tick_period(Duration::from_millis( + self.plugin_config.as_ref().unwrap().ticking_interval_in_ms, + )); + } - // See what the current state is. - match &self.state { - // This state is not possible, but is here to make the compiler happy. - OidcRootState::Uninitialized => { - warn!("plugin is not initialized"); + // If all providers are in `Ready` state, any request that was sent during the loading phase, + // is now resumed. Also, the discovery is switched and the ticking period if set to the + // configured interval. + let all_resolvers_done = self + .open_id_resolvers + .lock() + .unwrap() + .iter_mut() + .all(|r| matches!(r.state, OpenIdResolverState::Ready { .. })); + + if self.discovery_active && all_resolvers_done { + info!( + "discovery is done, resuming {} waiting requests", + self.waiting.lock().unwrap().len() + ); + + // Resume all requests that were sent during the loading phase. See `PauseRequest` for more. + for context_id in self.waiting.lock().unwrap().drain(..) { + debug!("resuming queued request with id {}", context_id); + hostcalls::set_effective_context(context_id).unwrap_or_else(|e| { + warn!("error setting effective context, most likely the tab was closed already: {:?}", e); + }); + hostcalls::resume_http_request().unwrap_or_else(|e| { + warn!( + "error resuming http request, most likely the tab was closed already: {:?}", + e + ); + }); } - // If the plugin is in Loading `LoadingConfig` state, the configuration is loaded from the - // openid configuration endpoint. - OidcRootState::LoadingConfig { plugin_config } => { - // Tick every 300ms to not overload the openid configuration endpoint. - self.set_tick_period(Duration::from_millis(300)); + // Switch discovery to inactive and set the ticking period to the configured interval. + self.discovery_active = false; + self.set_tick_period(Duration::from_secs( + self.plugin_config.as_ref().unwrap().reload_interval_in_h * 3600, + )); + } - // Make call to openid configuration endpoint - // The response is handled in `on_http_call_response`. - match self.dispatch_http_call( - "oidc", - vec![ - (":method", "GET"), - (":path", plugin_config.config_endpoint.as_str()), - (":authority", plugin_config.authority.as_str()), - ], - None, - vec![], - Duration::from_secs(5), - ) { - Ok(id) => { - debug!("dispatched openid config call"); - self.token_id = Some(id); + // Make call to openid configuration endpoint for all providers whose state is not ready. + for resolver in self.open_id_resolvers.lock().unwrap().iter_mut() { + match &resolver.state { + OpenIdResolverState::LoadingConfig { .. } => { + // Make call to openid configuration endpoint and load configuration + // The response is handled in `on_http_call_response`. + match self.dispatch_http_call( + &resolver.open_id_config.upstream_cluster, + vec![ + (":method", "GET"), + (":path", resolver.open_id_config.config_endpoint.path()), + (":authority", resolver.open_id_config.authority.as_str()), + ], + None, + vec![], + Duration::from_secs(5), + ) { + Err(e) => warn!("error dispatching oidc call: {:?}", e), + Ok(id) => { + resolver.token_ids.push(id); + debug!( + "dispatched openid config call to {}, count of unanswered request: {}", + resolver.open_id_config.config_endpoint, + resolver.token_ids.len() + ); + } } - Err(e) => warn!("error dispatching oidc call: {:?}", e), } - } - // If the plugin is in Loading `LoadingJwks` state, the public keys are loaded from the - // jwks endpoint. - OidcRootState::LoadingJwks { - plugin_config, - jwks_uri, - .. - } => { - // Make call to jwks endpoint and load public key + // Make call to jwks endpoint for all providers whose state is not ready. // The response is handled in `on_http_call_response`. - match self.dispatch_http_call( - "oidc", - vec![ - (":method", "GET"), - (":path", jwks_uri.as_str()), - (":authority", plugin_config.authority.as_str()), - ], - None, - vec![], - Duration::from_secs(5), - ) { - Ok(id) => { - debug!("dispatched jwks call"); - self.token_id = Some(id); + OpenIdResolverState::LoadingJwks { open_id_response } => { + match self.dispatch_http_call( + &resolver.open_id_config.upstream_cluster, + vec![ + (":method", "GET"), + (":path", open_id_response.jwks_uri.path()), + (":authority", open_id_response.jwks_uri.host_str().unwrap()), + ], + None, + vec![], + Duration::from_secs(5), + ) { + Err(e) => warn!("error dispatching jwks call: {:?}", e), + Ok(id) => { + resolver.token_ids.push(id); + debug!( + "dispatched jwks call to {}, count of unanswered request: {}", + open_id_response.jwks_uri, + resolver.token_ids.len() + ); + } } - Err(e) => warn!("error dispatching jwks call: {:?}", e), } - } - OidcRootState::Ready { - open_id_config: _, - plugin_config, - .. - } => { - // If this state is reached, the plugin was ready and needs to reload the configuration. - // This is controlled by `reload_interval_in_h` in the plugin configuration. - // The state is set to `LoadingConfig` and the tick period is set to 1ms to load the configuration. - self.state = OidcRootState::LoadingConfig { - plugin_config: plugin_config.clone(), - }; - self.set_tick_period(Duration::from_millis(1)); + OpenIdResolverState::Ready {} => { + // Clear all token ids as the resolver is ready + resolver.token_ids.clear(); + } } } } - /// This is one of those functions that need to be there for some reason but we are /// not sure why. It just doesn't work without it. fn get_type(&self) -> Option { @@ -285,19 +345,9 @@ impl RootContext for OidcDiscovery { } } -/// The context is used to process the response from the OIDC config endpoint and the jwks endpoint. -/// It also utilized the state enum to determine what to do with the response. -/// 1. If the state is `Uninitialized`, the plugin is not initialized and the response is ignored. -/// 2. If the state is `LoadingConfig`, the open id configuration is expected. -/// 3. If the state is `LoadingJwks`, the jwks endpoint is expected. -/// 4. `Ready` is not expected, as the root context doesn't dispatch any calls in that state. -impl Context for OidcDiscovery { - /// Called when the response from the http call is received. - /// It also utilised the state enum to determine what to do with the response. - /// 1. If the state is `Uninitialized`, the plugin is not initialized and the response is ignored. - /// 2. If the state is `LoadingConfig`, the open id configuration is expected. - /// 3. If the state is `LoadingJwks`, the jwks endpoint is expected. - /// 4. `Ready` is not expected, as the root context doesn't dispatch any calls in that state. +/// The context processes all responses from the open id config endpoints and jwks endpoints. +impl Context for Root { + /// Called when the response from any http call (sent from root context) is received. fn on_http_call_response( &mut self, token_id: u32, @@ -305,26 +355,31 @@ impl Context for OidcDiscovery { _body_size: usize, _num_trailers: usize, ) { - // Check for each state what to do with the response. - self.state = match &self.state { - // This state is not possible, but is here to make the compiler happy. - OidcRootState::Uninitialized => { - warn!("plugin is not initialized"); + debug!("received http call response with token_id: {}", token_id); + + // Find resolver to update based on toke_id + let mut binding = self.open_id_resolvers.lock().unwrap(); + let resolver_to_update = match binding + .iter_mut() + .find(|resolver| resolver.token_ids.contains(&token_id)) + { + Some(resolver) => resolver, + None => { + debug!("no resolver found for token_id: {}", token_id); return; } + }; + + debug!( + "token_id {} is for resolver/provider {} in state {}", + token_id, resolver_to_update.open_id_config.name, resolver_to_update.state + ); + // Check for each state what to do with the response. + match &resolver_to_update.state { // If the plugin is in Loading `LoadingConfig` state, the response is expected to be the // openid configuration. - OidcRootState::LoadingConfig { plugin_config } => { - // If the token id is not the same as the one from the call made in - // `self.dispatch_http_call`, the response is ignored. - if self.token_id != Some(token_id) { - warn!("unexpected token id"); - return; - } - - debug!("received openid config response"); - + OpenIdResolverState::LoadingConfig => { // Parse the response body as json. let body = match self.get_http_call_response_body(0, _body_size) { Some(body) => body, @@ -334,55 +389,53 @@ impl Context for OidcDiscovery { } }; - // Parse body - match serde_json::from_slice::(&body) { - Ok(open_id_response) => { - debug!("parsed openid config response: {:?}", open_id_response); - - // Set the state to loading jwks. - OidcRootState::LoadingJwks { - plugin_config: plugin_config.clone(), - auth_endpoint: open_id_response.authorization_endpoint, - token_endpoint: open_id_response.token_endpoint, - issuer: open_id_response.issuer, - jwks_uri: open_id_response.jwks_uri, - } - } + // Parse body using serde_json or fail + match serde_json::from_slice::(&body) { Err(e) => { - // Stay in the same state. - warn!("error parsing config response: {:?}", e); - return; + warn!( + "error parsing config response ({:?}): {:?}", + String::from_utf8(body), + e, + ); + } + Ok(open_id_response) => { + debug!("parsed openid config response: {:#?}", open_id_response); + + // Set the state to `LoadingJwks`. + resolver_to_update.state = OpenIdResolverState::LoadingJwks { + open_id_response: Arc::new(open_id_response), + }; + // And clear all token_ids + resolver_to_update.token_ids.clear(); } } } // If the plugin is in `LoadingJwks` state, the jwks endpoint is expected. - OidcRootState::LoadingJwks { - plugin_config, - auth_endpoint, - token_endpoint, - issuer, - .. + OpenIdResolverState::LoadingJwks { + open_id_response, .. } => { - // If the token id is not the same as the one from the call, return. - if self.token_id != Some(token_id) { - warn!("unexpected token id"); - return; - } - - debug!("received jwks response"); - - // Parse body - let body = self.get_http_call_response_body(0, _body_size).unwrap(); + // Parse body using serde_json or fail + let body = match self.get_http_call_response_body(0, _body_size) { + Some(body) => body, + None => { + warn!("no body in jwks response"); + return; + } + }; match serde_json::from_slice::(&body) { + Err(e) => { + warn!("error parsing jwks body: {:?}", e); + } Ok(jwks_response) => { - debug!("parsed jwks body: {:?}", jwks_response); + debug!("parsed jwks body: {:#?}", jwks_response); // Check if keys are present if jwks_response.keys.is_empty() { warn!("no keys found in jwks response, retry in 1 minute"); - self.set_tick_period(Duration::from_secs(60)); + // TODO: Hmm?? + // self.set_tick_period(Duration::from_secs(60)); return; } @@ -396,64 +449,51 @@ impl Context for OidcDiscovery { keys.push(signing_key); } - // Now that we have loaded all the configuration, we can set the tick period - // to the configured value and advance to the ready state. - self.set_tick_period(Duration::from_secs( - plugin_config.reload_interval_in_h * 3600, - )); - info!("All configuration loaded. Filter is ready. Refreshing config in {} hour(s).", - plugin_config.reload_interval_in_h); - - // Set the state to ready. - OidcRootState::Ready { - open_id_config: Arc::new(OpenIdConfig { - auth_endpoint: auth_endpoint.clone(), - token_endpoint: token_endpoint.clone(), - issuer: issuer.clone(), + // Find OpenIdProvider to update or create a new one + let mut open_id_providers = self.open_id_providers.lock().unwrap(); + let provider = open_id_providers.iter_mut().find(|provider| { + provider.issuer == resolver_to_update.open_id_config.authority + }); + + if let Some(p) = provider { + p.public_keys = keys; + } else { + open_id_providers.push(OpenIdProvider { + open_id_config: resolver_to_update.open_id_config.clone(), + auth_endpoint: open_id_response.authorization_endpoint.clone(), + token_endpoint: open_id_response.token_endpoint.clone(), + end_session_endpoint: open_id_response.end_session_endpoint.clone(), + issuer: open_id_response.issuer.clone(), public_keys: keys, - }), - plugin_config: plugin_config.clone(), + }); } - } - Err(e) => { - warn!("error parsing jwks body: {:?}", e); - // Stay in the same state as the response couldn't be parsed. - return; + + resolver_to_update.state = OpenIdResolverState::Ready {}; + resolver_to_update.token_ids.clear(); } } } // If the plugin is in `Ready` state, the response is ignored and the state is not changed. - OidcRootState::Ready { .. } => { + OpenIdResolverState::Ready { .. } => { warn!("ready state is not expected here"); - return; - } - }; - - // If the plugin is in `Ready` state, any request that was sent during the loading phase, - // is now resumed. - if matches!(self.state, OidcRootState::Ready { .. }) { - for context_id in self.waiting.lock().unwrap().drain(..) { - info!("resuming queued request with id {}", context_id); - hostcalls::set_effective_context(context_id).unwrap_or_else(|e| { - warn!("error setting effective context, most likely the tab was closed already: {:?}", e); - }); - hostcalls::resume_http_request().unwrap_or_else(|e| { - warn!( - "error resuming http request, most likely the tab was closed already: {:?}", - e - ); - }); } } } } -impl OidcDiscovery { +impl Root { /// Evaluate the plugin configuration and check if the values are valid. /// Type checking is done by serde, so we only need to check the values. + /// + /// ## Arguments + /// /// * `plugin_config` - The plugin configuration to be evaluated - /// Returns `Ok` if the configuration is valid, otherwise `Err` with a message. + /// + /// ## Returns + /// + /// * `Ok(())` if the configuration is valid + /// * `Err(PluginError)` if the configuration is invalid pub fn evaluate_config(plugin_config: PluginConfiguration) -> Result<(), PluginError> { // Reload Interval if plugin_config.reload_interval_in_h == 0 { @@ -476,6 +516,19 @@ impl OidcDiscovery { return Err(PluginError::ConfigError("`cookie_name` is empty or not valid meaning that it contains invalid characters like ;, =, :, /, space".to_string())); } + // Logout Path + if plugin_config.logout_path.is_empty() { + return Err(PluginError::ConfigError( + "`logout_path` is empty".to_string(), + )); + } + + if !plugin_config.logout_path.starts_with('/') { + return Err(PluginError::ConfigError( + "`logout_path` does not start with a `/`".to_string(), + )); + } + // Cookie Duration if plugin_config.cookie_duration == 0 { return Err(PluginError::ConfigError( @@ -483,36 +536,38 @@ impl OidcDiscovery { )); } - // Authority - if plugin_config.authority.is_empty() { - return Err(PluginError::ConfigError("`authority` is empty".to_string())); - } + for open_id_provider in plugin_config.open_id_configs { + // Authority + if open_id_provider.authority.is_empty() { + return Err(PluginError::ConfigError("`authority` is empty".to_string())); + } - // Client Id - if plugin_config.client_id.is_empty() { - return Err(PluginError::ConfigError("`client_id` is empty".to_string())); - } + // Client Id + if open_id_provider.client_id.is_empty() { + return Err(PluginError::ConfigError("`client_id` is empty".to_string())); + } - // Scope - if plugin_config.scope.is_empty() { - return Err(PluginError::ConfigError("`scope` is empty".to_string())); - } + // Scope + if open_id_provider.scope.is_empty() { + return Err(PluginError::ConfigError("`scope` is empty".to_string())); + } - // Claims - if plugin_config.claims.is_empty() { - return Err(PluginError::ConfigError("`claims` is empty".to_string())); - } + // Claims + if open_id_provider.claims.is_empty() { + return Err(PluginError::ConfigError("`claims` is empty".to_string())); + } - // Client Secret - if plugin_config.client_secret.reveal().is_empty() { - return Err(PluginError::ConfigError( - "client_secret is empty".to_string(), - )); - } + // Client Secret + if open_id_provider.client_secret.reveal().is_empty() { + return Err(PluginError::ConfigError( + "client_secret is empty".to_string(), + )); + } - // Audience - if plugin_config.audience.is_empty() { - return Err(PluginError::ConfigError("audience is empty".to_string())); + // Audience + if open_id_provider.audience.is_empty() { + return Err(PluginError::ConfigError("audience is empty".to_string())); + } } // Else return Ok diff --git a/src/error.rs b/src/error.rs index a238d91..c8d9b00 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,12 @@ +// proxy-wasm +use proxy_wasm::traits::HttpContext; + // thiserror use thiserror::Error; +// crate +use crate::auth::ConfiguredOidc; + /// Error type for the plugin #[derive(Error, Debug)] #[allow(clippy::enum_variant_names)] @@ -24,10 +30,14 @@ pub enum PluginError { // Token validation errors #[error("error while getting code from callback: {0}")] CodeNotFoundInCallbackError(#[from] serde_urlencoded::de::Error), + #[error("the code is coming from an unknown provider: {0}")] + ProviderNotFoundError(String), #[error("token response is not in the required format: {0}")] TokenResponseFormatError(String), #[error("token validation failed: {0}")] TokenValidationError(#[from] jwt_simple::Error), + #[error("issuer not found in session cookie")] + IssuerNotFound, #[error("no key worked for validation")] NoKeyError, @@ -55,3 +65,170 @@ pub enum PluginError { #[error("state does not match")] StateMismatchError, } + +impl ConfiguredOidc { + pub fn show_error_page(&self, status_code: u32, title: &str, message: &str) { + let headers = vec![("cache-control", "no-cache"), ("content-type", "text/html")]; + let request_id = self.request_id.clone().unwrap_or_default(); + + self.send_http_response( + status_code, + headers, + Some( + format!( + r#" + + + + + + Error - {status_code} + + + +
+ +
+
+

Error {status_code}

+

{title}

+

{message}

+

Request-ID: {request_id}

+
+ + + + "#, + ) + .as_bytes(), + ), + ); + } +} diff --git a/src/html.rs b/src/html.rs new file mode 100644 index 0000000..680df1b --- /dev/null +++ b/src/html.rs @@ -0,0 +1,277 @@ +/// Generate provider card HTML +/// +/// ## Arguments +/// +/// * `url` - URL to redirect to +/// * `name` - Name of the provider +/// * `logo` - URL to the logo of the provider +pub fn provider_card(url: &str, name: &str, logo: &str) -> String { + format!( + r#" + +
+
+ +
+

{}

+
+
+ "#, + url, logo, name, name + ) +} + +/// Generate the HTML for the authentication page +/// +/// ## Arguments +/// +/// * `provider_cards` - HTML of the provider cards +pub fn auth_page_html(provider_cards: String) -> String { + let version = env!("CARGO_PKG_VERSION"); + format!( + r#" + + + + + + Select Authentication Provider + + + +
+ +
+
+

Select a provider to authenticate with

+
+ {provider_cards} +
+
+ + + + + "# + ) +} diff --git a/src/lib.rs b/src/lib.rs index 81c7943..1165b67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,698 +1,56 @@ -// arc -use std::sync::Arc; -use std::vec; - -// base64 -use base64::{engine::general_purpose::STANDARD_NO_PAD as base64engine, Engine as _}; - -// duration -use std::time::Duration; - -// jwt -use jwt_simple::prelude::*; - -// log -use log::{debug, info, warn}; - -// proxy-wasm -use proxy_wasm::traits::*; -use proxy_wasm::types::*; - -// url -use url::{form_urlencoded, Url}; - -/// This module contains logic to parse and save the current authorization state in a cookie -mod cookie; -use cookie::{AuthorizationState, Session}; +/// This module contains all functions, calls and callbacks to execute the OpenID Authorization Code Flow +mod auth; /// This module contains the structs of the `PluginConfiguration` and `OpenIdConfig` mod config; -use config::{OpenIdConfig, PluginConfiguration}; -/// This module contains the OIDC discovery and JWKs loading logic +/// This module contains the Open ID discovery and JWKs loading logic mod discovery; -/// This module contains the responses for the OIDC discovery and jwks endpoints -mod responses; -use responses::Callback; - /// This module contains the error types for the plugin mod error; -use error::PluginError; - -/// The PauseRequests Context is the filter struct which is used when the filter is not configured. -/// All requests are paused and queued by the RootContext. Once the filter is configured, the -/// request is resumed by the RootContext. -struct PauseRequests { - /// Original path of the request - original_path: Option, -} - -/// The context is used to process incoming HTTP requests when the filter is not configured. -impl HttpContext for PauseRequests { - /// This function is called when the request headers are received. As the filter is not - /// configured, the request is paused and queued by the RootContext. Once the filter is - /// configured, the request is resumed by the RootContext. - fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { - warn!("plugin not ready, pausing request"); - - // Get the original path from the request headers - self.original_path = Some( - self.get_http_request_header(":path") - .unwrap_or("/".to_string()), - ); - - Action::Pause - } - - /// When the filter is configured, this function is called once the root context resumes the - /// request. This function sends a redirect to create a new context for the configured filter. - fn on_http_response_headers(&mut self, _num_headers: usize, _end_of_stream: bool) -> Action { - info!("filter now ready, sending redirect"); - - // Send a redirect to the original path - self.send_http_response( - 307, - vec![ - // Redirect to the requested path - ("location", self.original_path.as_ref().unwrap()), - // Disable caching - ("Cache-Control", "no-cache"), - ], - Some(b"Filter is ready now."), - ); - Action::Continue - } -} - -impl Context for PauseRequests {} - -/// The ConfiguredOidc is the main filter struct and responsible for the OIDC authentication flow. -/// Requests arriving are checked for a valid cookie. If the cookie is valid, the request is -/// forwarded. If the cookie is not valid, the request is redirected to the `authorization endpoint`. -struct ConfiguredOidc { - /// The configuration of the filter which mainly contains the open id configuration and the - /// keys to validate the JWT - pub open_id_config: Arc, - /// Plugin configuration parsed from the envoy configuration - pub plugin_config: Arc, - /// Token id of the current request - pub token_id: Option, -} - -/// The context is used to process incoming HTTP requests when the filter is configured. -/// 1. Check if the request matches any of the exclude hosts, paths, urls. If so, forward the request. -/// 2. If the request is for the OIDC callback, dispatch the request to the token endpoint. -/// 3. If the request contains a cookie, validate the cookie and forward the request. -/// 4. Else, redirect the request to the `authorization endpoint`. -impl HttpContext for ConfiguredOidc { - /// This function is called when the request headers are received. - fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { - // Check if the host regex matches one of the exclude hosts. If so, forward the request. - let host = self.get_host().unwrap_or_default(); - let path = self.get_http_request_header(":path").unwrap_or_default(); - - // Health check - if path == "/plugin-health" { - self.send_http_response(200, vec![], Some(b"OK")); - return Action::Pause; - } - - if self - .plugin_config - .exclude_hosts - .iter() - .any(|x| x.is_match(&host)) - { - debug!("host {} is excluded, forwarding request.", host); - self.filter_proxy_cookies(); - return Action::Continue; - } - - // If the path is one of the exclude paths, forward the request - if self - .plugin_config - .exclude_paths - .iter() - .any(|x| x.is_match(&path)) - { - debug!("path {} is excluded, forwarding request.", path); - self.filter_proxy_cookies(); - return Action::Continue; - } - - let url = Url::parse(&format!("{}{}", host, path)) - .unwrap_or(Url::parse("http://example.com").unwrap()); - if self - .plugin_config - .exclude_urls - .iter() - .any(|x| x.is_match(url.as_str())) - { - debug!("url {} is excluded, forwarding request.", url.as_str()); - self.filter_proxy_cookies(); - return Action::Continue; - } - - // exchanges the code for a token. The response is caught in on_http_call_response. - // If the dispatch fails, a 503 is returned. - if path.starts_with(self.plugin_config.redirect_uri.path()) { - match self.exchange_code_for_token(path) { - Ok(_) => { - return Action::Pause; - } - Err(e) => { - warn!("token exchange failed: {}", e); - self.send_http_response( - 503, - vec![("Cache-Control", "no-cache"), - ("Content-Type", "text/html")], - Some(b"
-

503

-

Token exchange failed

-

Please try again, delete your cookies or contact your system administrator.

-
"), - ); - } - } - return Action::Pause; - } - - // Validate the cookie and forward the request if the cookie is valid - match self.validate_cookie() { - Ok(auth_state) => { - // Forward access token in header, if configured - if let Some(header_name) = &self.plugin_config.access_token_header_name { - // Get access token - let access_token = &auth_state.access_token; - // Forward access token in header - self.add_http_request_header( - header_name, - format!( - "{}{}", - self.plugin_config - .access_token_header_prefix - .as_ref() - .unwrap(), - access_token - ) - .as_str(), - ); - } - - // Forward id token in header, if configured - if let Some(header_name) = &self.plugin_config.id_token_header_name { - // Get id token - let id_token = &auth_state.id_token; - // Forward id token in header - self.add_http_request_header( - header_name, - format!( - "{}{}", - self.plugin_config.id_token_header_prefix.as_ref().unwrap(), - id_token - ) - .as_str(), - ); - } - - self.filter_proxy_cookies(); - - // Allow request to pass - return Action::Continue; - } - Err(e) => match e { - // disable logging for these errors - PluginError::SessionCookieNotFoundError => {} - PluginError::NonceCookieNotFoundError => {} - _ => warn!("cookie validation failed: {}", e), - }, - } - - // Redirect to `authorization_endpoint` if no cookie is found or previous cases have returned an error. - // Pausing the request is necessary to create a new context after the redirect. - self.redirect_to_authorization_endpoint(); - - Action::Pause - } -} - -/// This context is used to process HTTP responses from the token endpoint. -impl Context for ConfiguredOidc { - /// This function catches the response from the token endpoint. - fn on_http_call_response(&mut self, token_id: u32, _: usize, body_size: usize, _: usize) { - // Store the token in the cookie - match self.store_token_in_cookie(token_id, body_size) { - Ok(_) => { - debug!("token stored in cookie"); - } - Err(e) => { - warn!("storing token in cookie failed: {}", e); - // Send a 503 if storing the token in the cookie failed - self.send_http_response( - 503, - vec![("Cache-Control", "no-cache"), - ("Content-Type", "text/html")], - Some(b"
-

503

-

Storing Token in Cookie failed

-

Please try again, delete your cookies or contact your system administrator.

-
", - ), - ); - } - } - } -} - -/// Helper functions for the ConfiguredOidc struct. -impl ConfiguredOidc { - /// Get the cookie of the HTTP request by name - /// The cookie is searched in the request headers. If the cookie is found, the value is returned. - /// If the cookie is not found, None is returned. - fn get_cookie(&self, name: &str) -> Option { - let headers = self.get_http_request_headers(); - for (key, value) in headers.iter() { - if key.to_lowercase().trim() == "cookie" { - let cookies: Vec<_> = value.split(';').collect(); - for cookie_string in cookies { - let cookie_name_end = cookie_string.find('=').unwrap_or(0); - let cookie_name = &cookie_string[0..cookie_name_end]; - if cookie_name.trim() == name { - return Some( - cookie_string[(cookie_name_end + 1)..cookie_string.len()].to_owned(), - ); - } - } - } - } - None - } - - /// Get the host of the HTTP request - /// The host is searched in the request headers. If the host is found, the value is returned. - fn get_host(&self) -> Option { - self.get_http_request_header(":authority") - .or_else(|| self.get_http_request_header("host")) - .or_else(|| self.get_http_request_header("x-forwarded-host")) - } - - /// Filter non proxy cookies by checking the cookie name. - /// This function removes all cookies from the request that do not match the cookie name to prevent - /// the cookie from being forwarded to the upstream service. - fn filter_proxy_cookies(&self) { - // Check if the filter_plugin_cookies option is set - if !self.plugin_config.filter_plugin_cookies { - return; - } - - // Get all cookies - let all_cookies = self.get_http_request_header("cookie").unwrap_or_default(); - - // Remove non proxy cookies from request - let filtered_cookies = all_cookies - .split(';') - .filter(|x| !x.contains(&self.plugin_config.cookie_name)) - .filter(|x| !x.contains(&format!("{}-nonce", self.plugin_config.cookie_name))) - .collect::>() - .join(";"); - - // Set the cookie header - self.set_http_request_header("Cookie", Some(&filtered_cookies)); - } - - /// Parse the cookie and validate the token. - /// The cookie is parsed into the `AuthorizationState` struct. The token is validated using the - /// `validate_token` function. If the token is valid, this function returns Ok(()). If the token - /// is invalid, this function returns Err(String) and redirects the requester to the `authorization endpoint`. - fn validate_cookie(&self) -> Result { - // Get cookie and nonce - let cookie = self.get_session_cookie_as_string()?; - let nonce = self.get_nonce()?; - // Try to parse and decrypt the cookie and handle the result - match Session::decode_and_decrypt( - cookie, - self.plugin_config.aes_key.reveal().clone(), - nonce, - ) { - // If the cookie can be parsed, this means that the cookie is trusted because modifications would have - // corrupted the encrypted state. Token validation is only performed if the configuration option is set. - Ok(session) => { - // Only validate the token if the configuration option is set - match self.plugin_config.token_validation { - true => { - // Get authorization state from session - let auth_state = match session.authorization_state { - Some(auth_state) => auth_state, - None => { - return Err(PluginError::AuthorizationStateNotFoundError); - } - }; +/// This module contains the HTML templates for the auth page and UI elements. +mod html; - // Validate token - match self.validate_token(&auth_state.id_token) { - // If the token is valid, this filter passes the request - Ok(_) => { - debug!("token is valid, passing request"); - Ok(auth_state) - } - // If the token is invalid, the error is returned and the requester is redirected to the `authorization endpoint` - Err(e) => Err(PluginError::TokenValidationError(e.into())), - } - } - false => match session.authorization_state { - Some(auth_state) => Ok(auth_state), - // If no authorization state is found, return an error - None => Err(PluginError::CookieValidationError( - "No authorization state found".to_string(), - )), - }, - } - } - // If the cookie cannot be parsed, this filter redirects the requester to the `authorization_endpoint` - Err(e) => Err(PluginError::CookieValidationError(e.to_string())), - } - } +/// This module contains the pause context which is used when the filter is not configured. +mod pause; - /// Validate the token using the JWT library. - /// This function checks for the correct issuer and audience and verifies the signature with the - /// public keys loaded from the JWKs endpoint. - fn validate_token(&self, token: &str) -> Result<(), PluginError> { - // Define allowed issuers and audiences - let mut allowed_issuers = HashSet::new(); - // remove last slash from issuer url - allowed_issuers.insert(self.open_id_config.issuer.clone()); - let mut allowed_audiences = HashSet::new(); - allowed_audiences.insert(self.plugin_config.audience.to_string()); - - // Define verification options - let verification_options = VerificationOptions { - allowed_issuers: Some(allowed_issuers), - allowed_audiences: Some(allowed_audiences), - ..Default::default() - }; - - // Iterate over all public keys - for public_key in &self.open_id_config.public_keys { - // Perform the validation - let validation_result = public_key.verify_token(token, verification_options.to_owned()); - - // Check if the token is valid, the aud and iss are correct and the signature is valid. - match validation_result { - Ok(_) => return Ok(()), - Err(e) => { - debug!("token validation failed: {:?}", e); - continue; - } - } - } - Err(PluginError::NoKeyError) - } - - /// Exchange the code for a token using the token endpoint. - /// This function is called when the user is redirected back to the callback URL. - /// The code is extracted from the URL and exchanged for a token using the token endpoint. - /// * `path` - The path of the request - fn exchange_code_for_token(&mut self, path: String) -> Result<(), PluginError> { - debug!("received request for OIDC callback"); - - // Get Query String from URL - let query = path.split('?').last().unwrap_or_default(); - - // Get state from query - let callback_params = serde_urlencoded::from_str::(query)?; - - // Get cookie and nonce - let encoded_cookie = self.get_session_cookie_as_string()?; - let encoded_nonce = self.get_nonce()?; - - // Get session - let session = Session::decode_and_decrypt( - encoded_cookie, - self.plugin_config.aes_key.reveal().clone(), - encoded_nonce, - )?; - - // Get state and code from query - let code = callback_params.code; - debug!("authorization code: {}", code); - let state = callback_params.state; - debug!("client state: {}", state); - debug!("cookie state: {}", session.state); - - // Compare state - if state != session.state { - return Err(PluginError::StateMismatchError); - } - - // Encode client_id and client_secret and build the Authorization header using base64encoding - let auth = format!( - "Basic {}", - base64engine.encode( - format!( - "{}:{}", - &self.plugin_config.client_id, - &self.plugin_config.client_secret.reveal() - ) - .as_bytes() - ) - ); - - // Get code verifier from cookie - let code_verifier = session.code_verifier; - - // Build the request body for the token endpoint - let data = form_urlencoded::Serializer::new(String::new()) - .append_pair("grant_type", "authorization_code") - .append_pair("code_verifier", &code_verifier) - .append_pair("code", &code) - .append_pair("redirect_uri", self.plugin_config.redirect_uri.as_str()) - .append_pair("state", &state) - .finish(); - - // Get path from token endpoint - let token_endpoint = self.open_id_config.token_endpoint.path(); - - // Dispatch request to token endpoint using built-in envoy function - debug!("sending data to token endpoint: {}", data); - match self.dispatch_http_call( - "oidc", - vec![ - (":method", "POST"), - (":path", token_endpoint), - (":authority", &self.plugin_config.authority), - ("Authorization", &auth), - ("Content-Type", "application/x-www-form-urlencoded"), - ], - Some(data.as_bytes()), - vec![], - Duration::from_secs(10), - ) { - // If the request is dispatched successfully, this filter pauses the request - Ok(id) => { - self.token_id = Some(id); - Ok(()) - } - // If the request fails, this filter logs the error and pauses the request - Err(_) => Err(PluginError::DispatchError), - } - } - - /// Store the token from the token response in a cookie. - /// Parse the token with the `AuthorizationState` struct and store it in an encoded and encrypted cookie. - /// Then, redirect the requester to the original URL. - fn store_token_in_cookie( - &mut self, - token_id: u32, - body_size: usize, - ) -> Result<(), PluginError> { - // Assess token id - if self.token_id != Some(token_id) { - return Err(PluginError::TokenIdMismatchError); - } - - // Check if the response is valid. If its not 200, investigate the response - // and log the error. - if self.get_http_call_response_header(":status") != Some("200".to_string()) { - // Get body of response - match self.get_http_call_response_body(0, body_size) { - Some(body) => { - // Decode body - match String::from_utf8(body) { - Ok(decoded) => return Err(PluginError::TokenResponseFormatError(decoded)), - // If decoding fails, log the error - Err(e) => return Err(PluginError::Utf8Error(e)), - } - } - // If no body is found, log the error - None => return Err(PluginError::NoBodyError), - } - } - - // Catching token response from token endpoint. Previously we checked for the status code and - // the body, so we can assume that the response is valid. - match self.get_http_call_response_body(0, body_size) { - Some(body) => { - // Get nonce and cookie - let encoded_cookie = self.get_session_cookie_as_string()?; - let encoded_nonce = self.get_nonce()?; - - // Get session from cookie - let mut session = Session::decode_and_decrypt( - encoded_cookie, - self.plugin_config.aes_key.reveal().clone(), - encoded_nonce, - )?; - - // Create authorization state from token response - let authorization_state = serde_json::from_slice::(&body)?; - - // Add authorization state to session - session.authorization_state = Some(authorization_state); - - // Create new session - let (new_session, new_nonce) = - session.encrypt_and_encode(self.plugin_config.aes_key.reveal().clone())?; - - // Get original path - let original_path = session.original_path.clone(); - - // Build cookie values - let set_cookie_values = Session::make_cookie_values( - &new_session, - &new_nonce, - self.plugin_config.cookie_name.as_str(), - self.plugin_config.cookie_duration, - self.get_number_of_cookies() as u64, - ); - - // Build cookie headers - let mut set_cookie_headers = Session::make_set_cookie_headers(&set_cookie_values); - - // Set the location header to the original path - let location_header = ("Location", original_path.as_str()); - set_cookie_headers.push(location_header); - - // Redirect back to the original URL and set the cookie. - self.send_http_response(307, set_cookie_headers, Some(b"Redirecting...")); - Ok(()) - } - // If no body is found, return the error - None => Err(PluginError::CookieStoreError( - "No body in response".to_string(), - )), - } - } - - /// Redirect to the` authorization endpoint` by sending a HTTP response with a 307 status code. - /// The original path is encoded and stored in a cookie as well as the PKCE code verifier. - fn redirect_to_authorization_endpoint(&self) -> Action { - debug!("no cookie found or invalid, redirecting to authorization endpoint"); - - // Original path - let original_path = self.get_http_request_header(":path").unwrap_or_default(); - - // Generate PKCE code verifier and challenge - let pkce_verifier = pkce::code_verifier(128); - let pkce_verifier_string = String::from_utf8(pkce_verifier.clone()).unwrap(); - let pkce_challenge = pkce::code_challenge(&pkce_verifier); - - // Generate state - let state_string = String::from_utf8(pkce::code_verifier(128)).unwrap(); - - // Create session struct - let (session, nonce) = cookie::Session { - authorization_state: None, - original_path, - code_verifier: pkce_verifier_string, - state: state_string.clone(), - } - .encrypt_and_encode(self.plugin_config.aes_key.reveal().clone()) - .expect("session cookie could not be created"); - - // Build cookie values - let set_cookie_values = Session::make_cookie_values( - &session, - &nonce, - self.plugin_config.cookie_name.as_str(), - self.plugin_config.cookie_duration, - self.get_number_of_cookies() as u64, - ); - - // Build cookie headers - let mut headers = Session::make_set_cookie_headers(&set_cookie_values); - - // Build URL - let url = Url::parse_with_params( - self.open_id_config.auth_endpoint.as_str(), - &[ - ("response_type", "code"), - ("code_challenge", &pkce_challenge), - ("code_challenge_method", "S256"), - ("state", &state_string), - ("client_id", &self.plugin_config.client_id), - ("redirect_uri", self.plugin_config.redirect_uri.as_str()), - ("scope", &self.plugin_config.scope), - ("claims", &self.plugin_config.claims), - ], - ) - .unwrap(); +/// This module contains the responses for the OpenID discovery and jwks endpoints +mod responses; - // Add location header - headers.push(("Location", url.as_str())); +/// This module contains logic to parse and save the current authorization state in a cookie +mod session; - // Send HTTP response - self.send_http_response( - 307, - // Redirect to `authorization endpoint` along with the cookie - headers, - Some(b"Redirecting..."), - ); - Action::Pause - } +// std +use std::sync::Mutex; +use std::vec; - /// Helper function to get the session cookie as a string by getting the cookie from the request - /// headers and concatenating all cookie parts. - pub fn get_session_cookie_as_string(&self) -> Result { - // Find all cookies that have the cookie_name, split them by ; and remove the name from the cookie - // as well as the leading =. Then join the cookie values together again. - let cookie = self - .get_http_request_header("cookie") - .ok_or(PluginError::SessionCookieNotFoundError)?; +// log +use log::info; - // Split cookie by ; and filter for the cookie name. - let cookies = cookie - .split(';') - .filter(|x| x.contains(self.plugin_config.cookie_name.as_str())) - .filter(|x| !x.contains(format!("{}-nonce", self.plugin_config.cookie_name).as_str())); +// proxy-wasm +use proxy_wasm::traits::*; +use proxy_wasm::types::*; - // Check if cookies have values - for cookie in cookies.clone() { - if cookie.split('=').collect::>().len() < 2 { - return Err(PluginError::SessionCookieNotFoundError); - } - } +// crate +use crate::discovery::Root; - // Then split all cookies by = and get the second element before joining all values together. - let values = cookies - .map(|x| x.split('=').collect::>()[1]) - .collect::>() - // Join the cookie values together again. - .join(""); +// This is the initial entry point of the plugin. +proxy_wasm::main! {{ - Ok(values) - } + proxy_wasm::set_log_level(LogLevel::Debug); - // Get the encoded nonce from the cookie - pub fn get_nonce(&self) -> Result { - self.get_cookie(format!("{}-nonce", self.plugin_config.cookie_name).as_str()) - .ok_or(PluginError::NonceCookieNotFoundError) - } + info!("starting plugin"); - /// Helper function to get the number of cookies from the request headers. - pub fn get_number_of_cookies(&self) -> usize { - let cookie = self.get_http_request_header("cookie").unwrap_or_default(); - cookie.split(';').count() - } -} + // This sets the root context, which is the first context that is called on startup. + // The root context is used to initialize the plugin and load the configuration from the + // plugin config and the discovery endpoints. + proxy_wasm::set_root_context(|_| -> Box { Box::new(Root { + plugin_config: None, + open_id_providers: Mutex::new(vec![]), + open_id_resolvers: Mutex::new(vec![]), + waiting: Mutex::new(Vec::new()), + discovery_active: false, + }) }); +}} diff --git a/src/pause.rs b/src/pause.rs new file mode 100644 index 0000000..be6e1db --- /dev/null +++ b/src/pause.rs @@ -0,0 +1,55 @@ +// log +use log::{info, warn}; + +// proxy-wasm +use proxy_wasm::{ + traits::{Context, HttpContext}, + types::Action, +}; + +/// The PauseRequests Context is the filter struct which is used when the filter is not configured. +/// All requests are paused and queued by the RootContext. Once the filter is configured, the +/// request is resumed by the RootContext. +pub struct PauseRequests { + /// Original path of the request + pub original_path: Option, +} + +/// The context is used to process incoming HTTP requests when the filter is not configured. +impl HttpContext for PauseRequests { + /// This function is called when the request headers are received. As the filter is not + /// configured, the request is paused and queued by the RootContext. Once the filter is + /// configured, the request is resumed by the RootContext. + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + warn!("plugin not ready, pausing request"); + + // Get the original path from the request headers + self.original_path = Some( + self.get_http_request_header(":path") + .unwrap_or("/".to_string()), + ); + + Action::Pause + } + + /// When the filter is configured, this function is called once the root context resumes the + /// request. This function sends a redirect to create a new context for the configured filter. + fn on_http_response_headers(&mut self, _num_headers: usize, _end_of_stream: bool) -> Action { + info!("filter now ready, sending redirect"); + + // Send a redirect to the original path + self.send_http_response( + 307, + vec![ + // Redirect to the requested path + ("location", self.original_path.as_ref().unwrap()), + // Disable caching + ("Cache-Control", "no-cache"), + ], + Some(b"Filter is ready now."), + ); + Action::Continue + } +} + +impl Context for PauseRequests {} diff --git a/src/responses.rs b/src/responses.rs index 98bfc84..72badf5 100644 --- a/src/responses.rs +++ b/src/responses.rs @@ -20,13 +20,15 @@ use url::Url; /// [OpenID Connect Discovery Response](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig) #[derive(Deserialize, Debug)] -pub struct OidcDiscoveryResponse { +pub struct OpenIdDiscoveryResponse { /// The issuer of the OpenID Connect Provider pub issuer: String, /// The authorization endpoint to start the code flow pub authorization_endpoint: Url, /// The token endpoint to exchange the code for a token pub token_endpoint: Url, + /// The URL to logout the user + pub end_session_endpoint: Option, /// The jwks uri to load the jwks response from pub jwks_uri: Url, } @@ -112,9 +114,18 @@ impl From for SigningKey { /// Struct that defines how the callback looks like to serialize it better with serde #[derive(Deserialize, Debug)] -pub struct Callback { +pub struct CodeCallback { /// The code that is returned from the authorization endpoint pub code: String, /// The state that is returned from the authorization endpoint pub state: String, } + +/// Struct that defines how the callback looks like to serialize it better with serde +#[derive(Deserialize, Debug)] +pub struct ProviderSelectionCallback { + /// The name of the provider that the user selected + pub authorize_with_provider: String, + /// The return_to path that the user should be redirected to after the provider selection + pub return_to: String, +} diff --git a/src/cookie.rs b/src/session.rs similarity index 93% rename from src/cookie.rs rename to src/session.rs index 7f88739..dbc5773 100644 --- a/src/cookie.rs +++ b/src/session.rs @@ -16,6 +16,7 @@ use std::fmt::Debug; // serde use serde::{Deserialize, Serialize}; +// crate use crate::error::PluginError; /// Struct parse the cookie from the request into a struct in order to access the fields and @@ -39,6 +40,8 @@ pub struct AuthorizationState { /// the original path, the PKCE code verifier and the state #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { + /// Issuer of the OpenID Connect Provider + pub issuer: Option, /// Authorization state pub authorization_state: Option, /// Original Path to which the user should be redirected after login @@ -86,7 +89,6 @@ impl Session { encoded_nonce: &str, cookie_name: &str, cookie_duration: u64, - number_current_cookies: u64, ) -> Vec { // Split every 4000 bytes let cookie_parts = encoded_cookie @@ -106,6 +108,10 @@ impl Session { cookie_values.push(cookie_value); } + let num_parts = cookie_values.len(); + let num_parts_cookie_value = format!("{cookie_name}-parts={num_parts}; Path=/; HttpOnly; Secure; Max-Age={cookie_duration}; "); + cookie_values.push(num_parts_cookie_value); + // Build nonce cookie value let nonce_cookie_value = format!( "{}-nonce={}; Path=/; HttpOnly; Secure; Max-Age={}; ", @@ -113,15 +119,6 @@ impl Session { ); cookie_values.push(nonce_cookie_value); - // Overwrite the old cookies because decryption will fail if older and expired cookies are - // still present. - for i in cookie_values.len()..number_current_cookies as usize { - cookie_values.push(format!( - "{}-{}=; Path=/; HttpOnly; Secure; Max-Age=0", - cookie_name, i - )); - } - cookie_values }