From 73e412b31e1374615f52a2000f23b216d9c2fa85 Mon Sep 17 00:00:00 2001 From: David Hadley Date: Thu, 16 Jul 2026 11:22:16 +0100 Subject: [PATCH] feat(auth-broker): implement auth-broker package --- .github/workflows/_auth_broker_code.yaml | 70 +++ .github/workflows/_auth_broker_container.yaml | 53 ++ .github/workflows/ci.yaml | 14 + backend/Cargo.lock | 191 ++++-- backend/Cargo.toml | 5 + backend/Dockerfile.auth-broker | 53 ++ backend/Dockerfile.auth-daemon | 3 + backend/Dockerfile.graph-proxy | 3 + backend/Dockerfile.sessionspaces | 3 + backend/auth-broker/Cargo.toml | 35 ++ backend/auth-broker/README.md | 60 ++ backend/auth-broker/src/config.rs | 24 + .../src/ext_authz/authorize_request.rs | 88 +++ .../auth-broker/src/ext_authz/authz_error.rs | 57 ++ .../extract_service_account_token.rs | 11 + backend/auth-broker/src/ext_authz/mod.rs | 10 + .../auth-broker/src/ext_authz/pod_identity.rs | 193 ++++++ .../src/ext_authz/resolve_subject.rs | 45 ++ backend/auth-broker/src/ext_authz/tests.rs | 549 ++++++++++++++++++ backend/auth-broker/src/health.rs | 16 + backend/auth-broker/src/k8s.rs | 65 +++ backend/auth-broker/src/main.rs | 91 +++ backend/auth-broker/src/state.rs | 214 +++++++ backend/auth-core/Cargo.toml | 2 +- backend/auth-daemon/Cargo.toml | 4 +- backend/auth-gateway/Cargo.toml | 2 +- 26 files changed, 1807 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/_auth_broker_code.yaml create mode 100644 .github/workflows/_auth_broker_container.yaml create mode 100644 backend/Dockerfile.auth-broker create mode 100644 backend/auth-broker/Cargo.toml create mode 100644 backend/auth-broker/README.md create mode 100644 backend/auth-broker/src/config.rs create mode 100644 backend/auth-broker/src/ext_authz/authorize_request.rs create mode 100644 backend/auth-broker/src/ext_authz/authz_error.rs create mode 100644 backend/auth-broker/src/ext_authz/extract_service_account_token.rs create mode 100644 backend/auth-broker/src/ext_authz/mod.rs create mode 100644 backend/auth-broker/src/ext_authz/pod_identity.rs create mode 100644 backend/auth-broker/src/ext_authz/resolve_subject.rs create mode 100644 backend/auth-broker/src/ext_authz/tests.rs create mode 100644 backend/auth-broker/src/health.rs create mode 100644 backend/auth-broker/src/k8s.rs create mode 100644 backend/auth-broker/src/main.rs create mode 100644 backend/auth-broker/src/state.rs diff --git a/.github/workflows/_auth_broker_code.yaml b/.github/workflows/_auth_broker_code.yaml new file mode 100644 index 000000000..ba48e78dc --- /dev/null +++ b/.github/workflows/_auth_broker_code.yaml @@ -0,0 +1,70 @@ +name: Auth Broker Code + +on: + workflow_call: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v6 + + - name: Install stable toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1.16.1 + with: + cache: false + components: clippy,rustfmt + + - name: Cache Rust Build + uses: Swatinem/rust-cache@v2.9.1 + with: + shared-key: backend/auth-broker + workspaces: backend + + - name: Check Formatting + working-directory: backend/auth-broker + run: > + cargo fmt + --check + + - name: Lint with Clippy + working-directory: backend/auth-broker + run: > + cargo clippy + --all-targets + --all-features + --no-deps + -- + --deny warnings + + - name: Check Dependencies with Cargo Deny + uses: EmbarkStudios/cargo-deny-action@v2.0.20 + with: + command: check licenses ban + manifest-path: backend/Cargo.toml + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v6 + + - name: Install stable toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1.16.1 + with: + cache: false + components: rustfmt + + - name: Cache Rust Build + uses: Swatinem/rust-cache@v2.9.1 + with: + shared-key: backend/auth-broker + workspaces: backend + + - name: Run Tests + working-directory: backend/auth-broker + run: > + cargo test + --all-targets + --all-features diff --git a/.github/workflows/_auth_broker_container.yaml b/.github/workflows/_auth_broker_container.yaml new file mode 100644 index 000000000..39703d4bf --- /dev/null +++ b/.github/workflows/_auth_broker_container.yaml @@ -0,0 +1,53 @@ +name: Auth Broker Container +on: + workflow_call: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Generate Image Name + run: echo IMAGE_REPOSITORY=ghcr.io/$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]' | tr '[_]' '[\-]')-auth-broker >> $GITHUB_ENV + + - name: Log in to GitHub Docker Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Version from Tag + id: tags + run: echo version=$(echo "${{ github.ref }}" | awk -F '[@v]' '{print $3}') >> $GITHUB_OUTPUT + + - name: Docker Metadata + id: meta + uses: docker/metadata-action@v6.1.0 + with: + images: ${{ env.IMAGE_REPOSITORY }} + tags: | + type=raw,value=${{ steps.tags.outputs.version }} + type=raw,value=staging + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.0.0 + + - name: Build Image + uses: docker/build-push-action@v6.18.0 + with: + context: backend + file: backend/Dockerfile.auth-broker + target: deploy + push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/auth-broker@') }} + load: ${{ !(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/auth-broker@')) }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f7b26700f..fb7c596b7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -79,6 +79,20 @@ jobs: contents: read packages: write + auth_broker_code: + # Deduplicate jobs from pull requests and branch pushes within the same repo. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + uses: ./.github/workflows/_auth_broker_code.yaml + + auth_broker_container: + # Deduplicate jobs from pull requests and branch pushes within the same repo. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository + needs: auth_broker_code + uses: ./.github/workflows/_auth_broker_container.yaml + permissions: + contents: read + packages: write + auth_gateway_code: # Deduplicate jobs from pull requests and branch pushes within the same repo. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b515b2352..34fbb2e03 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -412,7 +412,7 @@ dependencies = [ "fnv", "futures-util", "handlebars", - "http 1.4.0", + "http 1.4.2", "indexmap 2.14.0", "mime", "multer", @@ -573,6 +573,38 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auth-broker" +version = "0.1.0" +dependencies = [ + "anyhow", + "auth-core", + "axum", + "axum-test", + "chrono", + "clap", + "dashmap", + "dotenvy", + "env_logger", + "http 1.4.2", + "k8s-openapi", + "kube", + "migration", + "mockito", + "moka", + "reqwest 0.12.28", + "sea-orm", + "serde", + "serde_json", + "serde_yaml", + "testcontainers", + "testcontainers-modules", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "auth-core" version = "0.1.0" @@ -717,7 +749,7 @@ dependencies = [ "bytes-utils", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "percent-encoding", @@ -750,7 +782,7 @@ dependencies = [ "hex", "hmac 0.12.1", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "lru", "percent-encoding", @@ -777,7 +809,7 @@ dependencies = [ "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "p256 0.11.1", "percent-encoding", "ring 0.17.14", @@ -844,7 +876,7 @@ dependencies = [ "futures-core", "futures-util", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "percent-encoding", "pin-project-lite", @@ -864,7 +896,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -885,7 +917,7 @@ dependencies = [ "h2 0.3.27", "h2 0.4.13", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "hyper 0.14.32", "hyper 1.9.0", @@ -936,7 +968,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -957,7 +989,7 @@ dependencies = [ "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -986,7 +1018,7 @@ dependencies = [ "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1036,7 +1068,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.9.0", @@ -1069,7 +1101,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -1092,7 +1124,7 @@ dependencies = [ "futures-core", "futures-util", "headers", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -1123,7 +1155,7 @@ dependencies = [ "bytes", "futures-util", "hickory-resolver", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.9.0", @@ -1150,7 +1182,7 @@ dependencies = [ "bytesize", "cookie", "expect-json", - "http 1.4.0", + "http 1.4.2", "http-body-util", "hyper 1.9.0", "hyper-util", @@ -1287,7 +1319,7 @@ dependencies = [ "futures-util", "hex", "home", - "http 1.4.0", + "http 1.4.2", "http-body-util", "hyper 1.9.0", "hyper-named-pipe", @@ -1956,6 +1988,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -2964,7 +3010,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.4.2", "indexmap 2.14.0", "slab", "tokio", @@ -3009,6 +3055,12 @@ dependencies = [ "ahash 0.7.8", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -3065,7 +3117,7 @@ dependencies = [ "base64 0.22.1", "bytes", "headers-core", - "http 1.4.0", + "http 1.4.2", "httpdate", "mime", "sha1", @@ -3077,7 +3129,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -3210,9 +3262,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3236,7 +3288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -3247,7 +3299,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "pin-project-lite", ] @@ -3314,7 +3366,7 @@ dependencies = [ "futures-channel", "futures-core", "h2 0.4.13", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "httparse", "httpdate", @@ -3361,7 +3413,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", + "http 1.4.2", "hyper 1.9.0", "hyper-util", "log", @@ -3412,7 +3464,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "hyper 1.9.0", "ipnet", @@ -3833,7 +3885,7 @@ dependencies = [ "either", "futures", "home", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.9.0", @@ -3851,6 +3903,7 @@ dependencies = [ "serde_yaml", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.27.0", "tokio-util", "tower", "tower-http 0.6.8", @@ -3866,7 +3919,7 @@ dependencies = [ "chrono", "derive_more", "form_urlencoded", - "http 1.4.0", + "http 1.4.2", "json-patch", "k8s-openapi", "schemars 1.2.1", @@ -4224,7 +4277,7 @@ dependencies = [ "bytes", "colored", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.9.0", @@ -4268,7 +4321,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http 1.4.2", "httparse", "memchr", "mime", @@ -4455,7 +4508,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.4.0", + "http 1.4.2", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -4477,7 +4530,7 @@ dependencies = [ "chrono", "colored", "futures", - "http 1.4.0", + "http 1.4.2", "jsonwebtoken", "once_cell", "rand 0.8.6", @@ -4530,7 +4583,7 @@ dependencies = [ "dyn-clone", "ed25519-dalek", "hmac 0.12.1", - "http 1.4.0", + "http 1.4.2", "itertools 0.10.5", "log", "oauth2", @@ -4621,7 +4674,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http 1.4.0", + "http 1.4.2", "opentelemetry", "reqwest 0.12.28", ] @@ -4632,7 +4685,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ - "http 1.4.0", + "http 1.4.2", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", @@ -5454,7 +5507,7 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.13", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.9.0", @@ -5501,7 +5554,7 @@ dependencies = [ "encoding_rs", "futures-core", "h2 0.4.13", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.9.0", @@ -5677,7 +5730,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "mime", "rand 0.9.4", "thiserror 2.0.18", @@ -7027,7 +7080,7 @@ dependencies = [ "etcetera 0.11.0", "ferroid", "futures", - "http 1.4.0", + "http 1.4.2", "itertools 0.14.0", "log", "memchr", @@ -7044,6 +7097,15 @@ dependencies = [ "url", ] +[[package]] +name = "testcontainers-modules" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" +dependencies = [ + "testcontainers", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -7227,6 +7289,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.27.0", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -7352,7 +7426,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2 0.4.13", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper 1.9.0", @@ -7423,7 +7497,7 @@ dependencies = [ "axum-core", "cookie", "futures-util", - "http 1.4.0", + "http 1.4.2", "parking_lot", "pin-project-lite", "tower-layer", @@ -7438,7 +7512,7 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags", "bytes", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "pin-project-lite", @@ -7457,7 +7531,7 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "iri-string", "mime", @@ -7487,7 +7561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" dependencies = [ "async-trait", - "http 1.4.0", + "http 1.4.2", "time", "tokio", "tower-cookies", @@ -7506,7 +7580,7 @@ checksum = "8831be41b579a91b2b4084e2a8b5a383ac4e73a93c2a0dbb0c1d232a7e3f7247" dependencies = [ "async-trait", "base64 0.22.1", - "http 1.4.0", + "http 1.4.2", "serde", "serde_json", "time", @@ -7527,7 +7601,7 @@ dependencies = [ "axum-core", "base64 0.22.1", "futures", - "http 1.4.0", + "http 1.4.2", "parking_lot", "rand 0.8.6", "serde", @@ -7634,6 +7708,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.2", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -7642,7 +7733,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -7661,7 +7752,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.2", "httparse", "log", "rand 0.9.4", @@ -7843,7 +7934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64 0.22.1", - "http 1.4.0", + "http 1.4.2", "httparse", "log", ] @@ -8478,7 +8569,7 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http 1.4.0", + "http 1.4.2", "http-body-util", "hyper 1.9.0", "hyper-util", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 72918da95..d217c3081 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -4,6 +4,7 @@ members = [ "auth-core", "auth-core/migration", "auth-daemon", + "auth-broker", "graph-proxy", "auth-gateway", "sessionspaces", @@ -60,3 +61,7 @@ tower-http = { version = "0.6.6", features = ["cors"] } sea-orm = { version = "2.0.0-rc", features = [ "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono" ] } tokio-tungstenite = "0.28.0" tower = "0.5.3" +moka = { version = "0.12.11", features = ["future"] } +env_logger = "0.11.8" +axum-test = "18.4.1" +serde_yaml = "0.9.34" \ No newline at end of file diff --git a/backend/Dockerfile.auth-broker b/backend/Dockerfile.auth-broker new file mode 100644 index 000000000..cb55ad021 --- /dev/null +++ b/backend/Dockerfile.auth-broker @@ -0,0 +1,53 @@ +FROM docker.io/library/rust:1.96.0-bookworm AS build + +WORKDIR /app + +RUN cargo install cargo-auditable + +COPY argo-workflows-openapi/Cargo.toml argo-workflows-openapi/Cargo.toml +COPY graph-proxy/Cargo.toml graph-proxy/ +COPY sessionspaces/Cargo.toml sessionspaces/ +COPY auth-gateway/Cargo.toml auth-gateway/ +COPY auth-core/Cargo.toml auth-core/ +COPY auth-core/migration/Cargo.toml auth-core/migration/ +COPY auth-daemon/Cargo.toml auth-daemon/ +COPY auth-broker/Cargo.toml auth-broker/ +COPY telemetry/build.rs telemetry/build.rs +COPY telemetry/Cargo.toml telemetry/Cargo.toml +COPY Cargo.toml Cargo.lock ./ + +RUN mkdir -p argo-workflows-openapi/src \ + && touch argo-workflows-openapi/src/lib.rs \ + && mkdir -p graph-proxy/src \ + && echo "fn main() {}" > graph-proxy/src/main.rs \ + && mkdir -p sessionspaces/src \ + && echo "fn main() {}" > sessionspaces/src/main.rs \ + && mkdir -p auth-gateway/src \ + && echo "fn main() {}" > auth-gateway/src/main.rs \ + && mkdir -p auth-daemon/src \ + && echo "fn main() {}" > auth-daemon/src/main.rs \ + && mkdir -p auth-broker/src \ + && echo "fn main() {}" > auth-broker/src/main.rs \ + && mkdir -p auth-core/src \ + && touch auth-core/src/lib.rs \ + && echo "fn main() {}" > auth-core/src/main.rs \ + && mkdir -p auth-core/migration/src \ + && touch auth-core/migration/src/lib.rs \ + && mkdir -p telemetry/src \ + && echo "fn prebuild() {}" > telemetry/src/lib.rs + +RUN cargo build --release --package telemetry + +RUN touch --date @0 auth-broker/src/main.rs \ + && cargo build --release --package auth-broker + +COPY . . + +RUN touch auth-broker/src/main.rs \ + && cargo auditable build --release --package auth-broker + +FROM gcr.io/distroless/cc-debian12@sha256:d703b626ba455c4e6c6fbe5f36e6f427c85d51445598d564652a2f334179f96e AS deploy + +COPY --from=build /app/target/release/auth-broker /auth-broker + +ENTRYPOINT ["/auth-broker"] diff --git a/backend/Dockerfile.auth-daemon b/backend/Dockerfile.auth-daemon index f850ac541..59b1a5683 100644 --- a/backend/Dockerfile.auth-daemon +++ b/backend/Dockerfile.auth-daemon @@ -11,6 +11,7 @@ COPY auth-gateway/Cargo.toml auth-gateway/ COPY auth-core/Cargo.toml auth-core/ COPY auth-core/migration/Cargo.toml auth-core/migration/ COPY auth-daemon/Cargo.toml auth-daemon/ +COPY auth-broker/Cargo.toml auth-broker/ COPY telemetry/build.rs telemetry/build.rs COPY telemetry/Cargo.toml telemetry/Cargo.toml COPY Cargo.toml Cargo.lock ./ @@ -25,6 +26,8 @@ RUN mkdir -p argo-workflows-openapi/src \ && echo "fn main() {}" > auth-gateway/src/main.rs \ && mkdir -p auth-daemon/src \ && echo "fn main() {}" > auth-daemon/src/main.rs \ + && mkdir -p auth-broker/src \ + && echo "fn main() {}" > auth-broker/src/main.rs \ && mkdir -p auth-core/src \ && touch auth-core/src/lib.rs \ && echo "fn main() {}" > auth-core/src/main.rs \ diff --git a/backend/Dockerfile.graph-proxy b/backend/Dockerfile.graph-proxy index 71bb18f16..b8fefd22a 100644 --- a/backend/Dockerfile.graph-proxy +++ b/backend/Dockerfile.graph-proxy @@ -15,6 +15,7 @@ COPY auth-core/Cargo.toml auth-core/ COPY auth-core/src/lib.rs auth-core/src/lib.rs COPY auth-core/migration/Cargo.toml auth-core/migration/ COPY auth-daemon/Cargo.toml auth-daemon/ +COPY auth-broker/Cargo.toml auth-broker/ COPY auth-gateway/Cargo.toml auth-gateway/ COPY graph-proxy/Cargo.toml graph-proxy/ COPY telemetry/build.rs telemetry/Cargo.toml telemetry/ @@ -33,6 +34,8 @@ RUN mkdir -p graph-proxy/src \ && touch auth-core/migration/src/lib.rs \ && mkdir -p auth-daemon/src \ && echo "fn prebuild() {}" > auth-daemon/src/main.rs \ + && mkdir -p auth-broker/src \ + && echo "fn main() {}" > auth-broker/src/main.rs \ && mkdir -p telemetry/src \ && echo "fn prebuild() {}" > telemetry/src/lib.rs diff --git a/backend/Dockerfile.sessionspaces b/backend/Dockerfile.sessionspaces index b5ab6483f..4fb8dd9c0 100644 --- a/backend/Dockerfile.sessionspaces +++ b/backend/Dockerfile.sessionspaces @@ -10,6 +10,7 @@ COPY argo-workflows-openapi/Cargo.toml argo-workflows-openapi/Cargo.toml COPY graph-proxy/Cargo.toml graph-proxy/ COPY sessionspaces/Cargo.toml sessionspaces/ COPY auth-daemon/Cargo.toml auth-daemon/ +COPY auth-broker/Cargo.toml auth-broker/ COPY auth-gateway/Cargo.toml auth-gateway/ COPY auth-core/Cargo.toml auth-core/ COPY auth-core/src/lib.rs auth-core/src/lib.rs @@ -26,6 +27,8 @@ RUN mkdir -p argo-workflows-openapi/src \ && echo "fn main() {}" > sessionspaces/src/main.rs \ && mkdir -p auth-daemon/src \ && echo "fn prebuild() {}" > auth-daemon/src/main.rs \ + && mkdir -p auth-broker/src \ + && echo "fn main() {}" > auth-broker/src/main.rs \ && mkdir -p auth-gateway/src \ && echo "fn main() {}" > auth-gateway/src/main.rs \ && mkdir -p telemetry/src \ diff --git a/backend/auth-broker/Cargo.toml b/backend/auth-broker/Cargo.toml new file mode 100644 index 000000000..a2872835e --- /dev/null +++ b/backend/auth-broker/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "auth-broker" +version = "0.1.0" +edition = "2024" +license-file = "../../LICENSE" + +[dependencies] +auth-core = { path = "../auth-core" } +anyhow.workspace = true +axum = { workspace = true, features = ["json"] } +clap = { workspace = true, features = ["env"] } +dotenvy.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["full"] } +tracing.workspace = true +tracing-subscriber = { workspace = true, features = ["env-filter"] } +chrono.workspace = true +kube = { workspace = true, features = ["client", "ws"] } +k8s-openapi = { workspace = true } +moka = { workspace = true, features = ["future"] } +dashmap = "6" + +[dev-dependencies] +axum-test = {workspace = true} +mockito.workspace = true +env_logger = { workspace = true } +sea-orm = { workspace = true, features = ["sqlx-postgres", "sqlx-sqlite"] } +migration = { version = "0.1.0", path = "../auth-core/migration" } +testcontainers = { workspace = true } +testcontainers-modules = { version = "0.15.0", features = ["k3s"] } +http = "1.4.2" +serde_yaml = {workspace=true} diff --git a/backend/auth-broker/README.md b/backend/auth-broker/README.md new file mode 100644 index 000000000..eee64fe85 --- /dev/null +++ b/backend/auth-broker/README.md @@ -0,0 +1,60 @@ +# auth-broker + +External authorization service for Envoy-compatible proxies in Argo Workflows pods. +Handles OAuth2 token refresh and returns bearer tokens only to pods allowed to receive them. + +## Overview + +auth-broker implements Envoy's [External Authorization](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) protocol. When an Argo Workflows pod makes an outbound request, the sidecar proxy forwards the request headers to auth-broker, which: + +1. **Authenticates the pod** via Kubernetes TokenReview (validates the projected service account token). +2. **Verifies pod identity** by fetching the pod from the API server and confirming its UID matches the token. +3. **Resolves the workflow creator** by reading the `workflows.argoproj.io/creator` label from the parent Workflow resource. +4. **Returns a fresh OAuth2 bearer token** for that creator, refreshing it from the OIDC provider as needed. + +## Configuration + +auth-broker reads its configuration from a YAML or JSON file (default: `config.yaml`), +overridable via the `--config` flag or `WORKFLOWS_AUTH_BROKER_CONFIG` environment variable. + +| Field | Description | +|---|---| +| `client_id` | OIDC client ID | +| `client_secret` | OIDC client secret | +| `oidc_provider_url` | OIDC discovery endpoint | +| `port` | Listen port | +| `postgres_*` | PostgreSQL connection parameters | +| `encryption_public_key` | Base64-encoded sodium public key | +| `encryption_private_key` | Base64-encoded sodium private key | + +Set `LOG_LEVEL` to control log verbosity (e.g. `debug`, `info`, `auth_broker=trace`). + +## Building + +```bash +cargo build --release +``` + +## Running + +```bash +cargo run -- serve --config config.yaml +``` + +## Endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/healthz` | Liveness probe (returns 200) | +| `*` | `/*` | Envoy ext_authz handler | + +## Testing + +```bash +cargo test +``` + +The test suite includes: +- **Unit tests** with a mock K8s API and an in-process OIDC server. +- **Integration test** using a real k3s cluster in Docker (`testcontainers`) to validate + end-to-end TokenReview, pod lookup, and OIDC refresh flow. diff --git a/backend/auth-broker/src/config.rs b/backend/auth-broker/src/config.rs new file mode 100644 index 000000000..af4f2d12b --- /dev/null +++ b/backend/auth-broker/src/config.rs @@ -0,0 +1,24 @@ +use auth_core::config::CommonConfig; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +const DEFAULT_TOKEN_REVIEW_AUDIENCE: &str = "workflows-auth-broker"; + +fn default_token_review_audience() -> String { + DEFAULT_TOKEN_REVIEW_AUDIENCE.to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthBrokerConfig { + #[serde(flatten)] + pub common: CommonConfig, + pub encryption_private_key: String, + #[serde(default = "default_token_review_audience")] + pub token_review_audience: String, +} + +impl AuthBrokerConfig { + pub fn from_file>(path: P) -> auth_core::Result { + auth_core::config::load_config_from_file(path) + } +} diff --git a/backend/auth-broker/src/ext_authz/authorize_request.rs b/backend/auth-broker/src/ext_authz/authorize_request.rs new file mode 100644 index 000000000..44808fd88 --- /dev/null +++ b/backend/auth-broker/src/ext_authz/authorize_request.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + http::{HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use tracing::error; + +use super::extract_service_account_token::extract_service_account_token; +use crate::state::AuthBrokerState; + +use super::authz_error::AuthzError; +use super::pod_identity::PodIdentity; +use super::resolve_subject::resolve_subject; + +pub async fn authorize_request( + State(state): State>, + req: Request, +) -> Response { + match authorize_request_inner(state, req).await { + Ok(response) => response, + Err(e) => e.into_response(), + } +} + +async fn authorize_request_inner( + state: Arc, + req: Request, +) -> Result { + let service_account_token = extract_service_account_token(&req)?; + + let token_review = state + .k8s + .create_token_review(&service_account_token) + .await + .map_err(AuthzError::from)?; + + let identity = PodIdentity::new(&token_review)?; + + let pod = state + .k8s + .get_pod(&identity.pod_namespace, &identity.pod_name) + .await + .map_err(|e| { + AuthzError::PodNotFound(format!( + "{}/{}: {e}", + identity.pod_namespace, identity.pod_name + )) + })?; + + if let Some(expected_uid) = &identity.pod_uid { + let actual_uid = pod.metadata.uid.as_deref().unwrap_or(""); + if actual_uid != expected_uid.as_str() { + return Err(AuthzError::PodUidMismatch { + expected: expected_uid.clone(), + actual: actual_uid.to_owned(), + }); + } + } + + let pod_uid = pod.metadata.uid.as_deref().unwrap_or(""); + let pod_key = format!( + "{}/{}/{}", + identity.pod_namespace, identity.pod_name, pod_uid + ); + let subject = match state.get_cached_subject(&pod_key).await { + Some(s) => s, + None => { + let s = resolve_subject(&*state.k8s, &pod).await?; + state.set_cached_subject(pod_key, s.clone()).await; + s + } + }; + + let access_token = state.get_or_refresh_token(&subject).await.map_err(|e| { + error!("ext_authz: token retrieval failed for subject={subject}: {e:?}"); + AuthzError::TokenValidation(format!("{e:?}")) + })?; + + let bearer = format!("Bearer {access_token}"); + let mut response = StatusCode::OK.into_response(); + response.headers_mut().insert( + header::AUTHORIZATION, + HeaderValue::from_str(&bearer).expect("valid header value"), + ); + Ok(response) +} diff --git a/backend/auth-broker/src/ext_authz/authz_error.rs b/backend/auth-broker/src/ext_authz/authz_error.rs new file mode 100644 index 000000000..eeecba56d --- /dev/null +++ b/backend/auth-broker/src/ext_authz/authz_error.rs @@ -0,0 +1,57 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use tracing::warn; + +#[derive(Debug, thiserror::Error)] +pub enum AuthzError { + #[error("missing service account token")] + MissingToken, + #[error("service account token not authenticated")] + TokenNotAuthenticated, + #[error("token review returned no username")] + NoUsername, + #[error("unexpected token username format: {0}")] + UnexpectedUsernameFormat(String), + #[error("token review missing pod name")] + MissingPodName, + #[error("pod not found: {0}")] + PodNotFound(String), + #[error("pod uid mismatch: token says {expected}, k8s reports {actual}")] + PodUidMismatch { expected: String, actual: String }, + #[error("subject resolution failed: {0}")] + SubjectResolution(String), + #[error("token validation failed: {0}")] + TokenValidation(String), +} + +impl From for AuthzError { + fn from(e: anyhow::Error) -> Self { + AuthzError::TokenValidation(e.to_string()) + } +} + +impl From for AuthzError { + fn from(e: auth_core::error::Error) -> Self { + AuthzError::TokenValidation(format!("{e:?}")) + } +} + +impl AuthzError { + fn status_code(&self) -> StatusCode { + match self { + Self::TokenValidation(_) => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::FORBIDDEN, + } + } +} + +impl IntoResponse for AuthzError { + fn into_response(self) -> Response { + let status = self.status_code(); + let message = self.to_string(); + warn!("ext_authz: {message}"); + (status, message).into_response() + } +} diff --git a/backend/auth-broker/src/ext_authz/extract_service_account_token.rs b/backend/auth-broker/src/ext_authz/extract_service_account_token.rs new file mode 100644 index 000000000..426c73e15 --- /dev/null +++ b/backend/auth-broker/src/ext_authz/extract_service_account_token.rs @@ -0,0 +1,11 @@ +use axum::extract::Request; + +use super::authz_error::AuthzError; + +pub fn extract_service_account_token(req: &Request) -> Result { + req.headers() + .get("K8s-Pod-Service-Account-Token") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .ok_or(AuthzError::MissingToken) +} diff --git a/backend/auth-broker/src/ext_authz/mod.rs b/backend/auth-broker/src/ext_authz/mod.rs new file mode 100644 index 000000000..e9b089d93 --- /dev/null +++ b/backend/auth-broker/src/ext_authz/mod.rs @@ -0,0 +1,10 @@ +mod authorize_request; +mod authz_error; +mod extract_service_account_token; +mod pod_identity; +mod resolve_subject; + +pub use authorize_request::authorize_request; + +#[cfg(test)] +mod tests; diff --git a/backend/auth-broker/src/ext_authz/pod_identity.rs b/backend/auth-broker/src/ext_authz/pod_identity.rs new file mode 100644 index 000000000..350593f52 --- /dev/null +++ b/backend/auth-broker/src/ext_authz/pod_identity.rs @@ -0,0 +1,193 @@ +use k8s_openapi::api::authentication::v1::TokenReview; + +use super::authz_error::AuthzError; + +pub struct PodIdentity { + pub pod_namespace: String, + pub pod_name: String, + pub pod_uid: Option, +} + +impl PodIdentity { + pub fn new(token_review: &TokenReview) -> Result { + if !token_review + .status + .as_ref() + .and_then(|s| s.authenticated) + .unwrap_or(false) + { + return Err(AuthzError::TokenNotAuthenticated); + } + + let token_username = token_review + .status + .as_ref() + .and_then(|s| s.user.as_ref()) + .and_then(|u| u.username.as_deref()) + .ok_or(AuthzError::NoUsername)?; + + let (pod_namespace, _service_account_name) = token_username + .strip_prefix("system:serviceaccount:") + .and_then(|rest| rest.split_once(':')) + .ok_or_else(|| AuthzError::UnexpectedUsernameFormat(token_username.to_owned()))?; + + let pod_name = token_review + .status + .as_ref() + .and_then(|s| s.user.as_ref()) + .and_then(|u| u.extra.as_ref()) + .and_then(|e| e.get("authentication.kubernetes.io/pod-name")) + .and_then(|v| v.first()) + .ok_or(AuthzError::MissingPodName)?; + + let pod_uid = token_review + .status + .as_ref() + .and_then(|s| s.user.as_ref()) + .and_then(|u| u.extra.as_ref()) + .and_then(|e| e.get("authentication.kubernetes.io/pod-uid")) + .and_then(|v| v.first()) + .cloned(); + + Ok(PodIdentity { + pod_namespace: pod_namespace.to_owned(), + pod_name: pod_name.clone(), + pod_uid, + }) + } +} + +#[cfg(test)] +mod tests { + use k8s_openapi::api::authentication::v1::{TokenReview, TokenReviewStatus, UserInfo}; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + use super::*; + + fn authenticated_token_review(pod_name: &str, pod_uid: &str) -> TokenReview { + TokenReview { + metadata: ObjectMeta::default(), + spec: Default::default(), + status: Some(TokenReviewStatus { + authenticated: Some(true), + error: None, + user: Some(UserInfo { + extra: Some( + [ + ( + "authentication.kubernetes.io/pod-name".to_string(), + vec![pod_name.to_string()], + ), + ( + "authentication.kubernetes.io/pod-uid".to_string(), + vec![pod_uid.to_string()], + ), + ] + .into_iter() + .collect(), + ), + groups: None, + uid: None, + username: Some("system:serviceaccount:test-ns:test-sa".to_string()), + }), + audiences: None, + }), + } + } + + #[test] + fn extract_pod_identity_from_valid_review() { + let review = authenticated_token_review("my-pod", "uid-123"); + let identity = PodIdentity::new(&review).unwrap(); + assert_eq!(identity.pod_namespace, "test-ns"); + assert_eq!(identity.pod_name, "my-pod"); + assert_eq!(identity.pod_uid.as_deref(), Some("uid-123")); + } + + #[test] + fn reject_unauthenticated_token() { + let review = TokenReview { + metadata: ObjectMeta::default(), + spec: Default::default(), + status: Some(TokenReviewStatus { + authenticated: Some(false), + error: Some("not authenticated".into()), + user: None, + audiences: None, + }), + }; + assert!(matches!( + PodIdentity::new(&review), + Err(AuthzError::TokenNotAuthenticated) + )); + } + + #[test] + fn reject_missing_username() { + let review = TokenReview { + metadata: ObjectMeta::default(), + spec: Default::default(), + status: Some(TokenReviewStatus { + authenticated: Some(true), + error: None, + user: Some(UserInfo { + extra: None, + groups: None, + uid: None, + username: None, + }), + audiences: None, + }), + }; + assert!(matches!( + PodIdentity::new(&review), + Err(AuthzError::NoUsername) + )); + } + + #[test] + fn reject_bad_username_format() { + let review = TokenReview { + metadata: ObjectMeta::default(), + spec: Default::default(), + status: Some(TokenReviewStatus { + authenticated: Some(true), + error: None, + user: Some(UserInfo { + extra: None, + groups: None, + uid: None, + username: Some("not-a-service-account".to_string()), + }), + audiences: None, + }), + }; + assert!(matches!( + PodIdentity::new(&review), + Err(AuthzError::UnexpectedUsernameFormat(_)) + )); + } + + #[test] + fn reject_missing_pod_name() { + let review = TokenReview { + metadata: ObjectMeta::default(), + spec: Default::default(), + status: Some(TokenReviewStatus { + authenticated: Some(true), + error: None, + user: Some(UserInfo { + extra: Some(Default::default()), + groups: None, + uid: None, + username: Some("system:serviceaccount:test-ns:test-sa".to_string()), + }), + audiences: None, + }), + }; + assert!(matches!( + PodIdentity::new(&review), + Err(AuthzError::MissingPodName) + )); + } +} diff --git a/backend/auth-broker/src/ext_authz/resolve_subject.rs b/backend/auth-broker/src/ext_authz/resolve_subject.rs new file mode 100644 index 000000000..5a724f646 --- /dev/null +++ b/backend/auth-broker/src/ext_authz/resolve_subject.rs @@ -0,0 +1,45 @@ +use k8s_openapi::api::core::v1::Pod; + +use crate::k8s::K8sApi; + +use super::authz_error::AuthzError; + +pub async fn resolve_subject(k8s: &dyn K8sApi, pod: &Pod) -> Result { + let pod_namespace = pod.metadata.namespace.as_deref().unwrap_or("default"); + let pod_name = pod.metadata.name.as_deref().unwrap_or(""); + + let workflow_name = pod + .metadata + .labels + .as_ref() + .and_then(|l| l.get("workflows.argoproj.io/workflow")) + .ok_or_else(|| { + AuthzError::SubjectResolution(format!( + "label workflows.argoproj.io/workflow missing on pod \ + {pod_namespace}/{pod_name}" + )) + })?; + + let workflow = k8s + .get_workflow(pod_namespace, workflow_name) + .await + .map_err(|e| { + AuthzError::SubjectResolution(format!( + "failed to get workflow {pod_namespace}/{workflow_name}: {e}" + )) + })?; + + let creator = workflow + .metadata + .labels + .as_ref() + .and_then(|l| l.get("workflows.argoproj.io/creator")) + .ok_or_else(|| { + AuthzError::SubjectResolution(format!( + "label workflows.argoproj.io/creator missing on workflow \ + {pod_namespace}/{workflow_name}" + )) + })?; + + Ok(creator.clone()) +} diff --git a/backend/auth-broker/src/ext_authz/tests.rs b/backend/auth-broker/src/ext_authz/tests.rs new file mode 100644 index 000000000..aeae5bc02 --- /dev/null +++ b/backend/auth-broker/src/ext_authz/tests.rs @@ -0,0 +1,549 @@ +use std::sync::Arc; +use std::time::Duration; + +use axum::Router; +use axum_test::TestServer; +use k8s_openapi::api::authentication::v1::{TokenReview, TokenReviewStatus, UserInfo}; +use k8s_openapi::api::core::v1::{Namespace, Pod, PodSpec, ServiceAccount}; +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DynamicObject, PostParams}; +use kube::config::KubeConfigOptions; +use testcontainers::core::wait::HttpWaitStrategy; +use testcontainers::{GenericImage, ImageExt}; +use testcontainers::{core::WaitFor, runners::AsyncRunner}; + +use auth_core::async_trait::async_trait; +use auth_core::base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use auth_core::config::CommonConfig; +use auth_core::sea_orm::{ActiveValue::Set, Database, DatabaseConnection, EntityTrait}; + +use crate::config::AuthBrokerConfig; +use crate::ext_authz::authorize_request; +use crate::k8s::K8sApi; +use crate::state::AuthBrokerState; + +const TEST_PUBLIC_KEY_B64: &str = "ZpJ703xR7atXbGXI20FkQk3J1qjLxodTP6yk92yPVGM="; +const TEST_PRIVATE_KEY_B64: &str = "yxjSYB/nvdAzktd83diOtADvp3RX/0Kx5V3FgK7YlXk="; + +struct MockK8sApi; + +#[async_trait] +impl K8sApi for MockK8sApi { + async fn create_token_review(&self, _token: &str) -> anyhow::Result { + Ok(TokenReview { + metadata: ObjectMeta::default(), + spec: Default::default(), + status: Some(TokenReviewStatus { + authenticated: Some(true), + error: None, + user: Some(UserInfo { + extra: Some( + [ + ( + "authentication.kubernetes.io/pod-name".to_string(), + vec!["test-pod".to_string()], + ), + ( + "authentication.kubernetes.io/pod-uid".to_string(), + vec!["test-pod-uid-1234".to_string()], + ), + ] + .into_iter() + .collect(), + ), + groups: None, + uid: None, + username: Some("system:serviceaccount:test-ns:test-sa".to_string()), + }), + audiences: None, + }), + }) + } + + async fn get_pod(&self, _namespace: &str, _name: &str) -> anyhow::Result { + Ok(Pod { + metadata: ObjectMeta { + name: Some("test-pod".to_string()), + namespace: Some("test-ns".to_string()), + uid: Some("test-pod-uid-1234".to_string()), + labels: Some( + [( + "workflows.argoproj.io/workflow".to_string(), + "test-workflow".to_string(), + )] + .into_iter() + .collect(), + ), + ..ObjectMeta::default() + }, + spec: Some(PodSpec { + service_account_name: Some("test-sa".to_string()), + ..PodSpec::default() + }), + ..Pod::default() + }) + } + + async fn get_workflow(&self, _namespace: &str, _name: &str) -> anyhow::Result { + let gvk = kube::core::GroupVersionKind::gvk("argoproj.io", "v1alpha1", "Workflow"); + let resource = kube::api::ApiResource::from_gvk_with_plural(&gvk, "workflows"); + let mut workflow = DynamicObject::new("test-workflow", &resource).within("test-ns"); + workflow.metadata.labels = Some( + [( + "workflows.argoproj.io/creator".to_string(), + "test-subject".to_string(), + )] + .into_iter() + .collect(), + ); + Ok(workflow) + } +} + +async fn test_database(issuer_url: &str, refresh_token: &str) -> DatabaseConnection { + let db = Database::connect("sqlite::memory:") + .await + .expect("connect to in-memory SQLite"); + ::up(&db, None) + .await + .expect("run database migrations"); + + let public_key = auth_core::sodiumoxide::crypto::box_::PublicKey::from_slice( + &BASE64 + .decode(TEST_PUBLIC_KEY_B64) + .expect("decode test public key"), + ) + .expect("valid public key bytes"); + + let encrypted = + auth_core::sodiumoxide::crypto::sealedbox::seal(refresh_token.as_bytes(), &public_key); + + let now: chrono::DateTime = chrono::Utc::now().into(); + let expires_at = now + Duration::from_secs(600); + + auth_core::entity::oidc_tokens::Entity::insert(auth_core::entity::oidc_tokens::ActiveModel { + issuer: Set(issuer_url.to_string()), + subject: Set("test-subject".to_string()), + encrypted_refresh_token: Set(encrypted), + expires_at: Set(Some(expires_at)), + created_at: Set(now), + updated_at: Set(now), + }) + .exec(&db) + .await + .expect("insert test token into database"); + + db +} + +async fn create_namespace(client: &kube::Client, name: &str) -> anyhow::Result<()> { + let api: Api = Api::all(client.clone()); + let ns = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": { "name": name } + }))?; + api.create(&PostParams::default(), &ns).await?; + Ok(()) +} + +async fn create_service_account( + client: &kube::Client, + namespace: &str, + name: &str, +) -> anyhow::Result<()> { + let api: Api = Api::namespaced(client.clone(), namespace); + let service_account = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { "name": name } + }))?; + api.create(&PostParams::default(), &service_account).await?; + Ok(()) +} + +async fn install_argo_crd(client: &kube::Client) -> anyhow::Result<()> { + let api: Api = Api::all(client.clone()); + let crd = serde_json::from_value(serde_json::json!({ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "workflows.argoproj.io" + }, + "spec": { + "group": "argoproj.io", + "names": { + "kind": "Workflow", + "listKind": "WorkflowList", + "plural": "workflows", + "singular": "workflow" + }, + "scope": "Namespaced", + "versions": [{ + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "type": "object", + "x-kubernetes-preserve-unknown-fields": true + } + } + }] + } + }))?; + api.create(&PostParams::default(), &crd).await?; + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(()) +} + +async fn create_test_workflow( + client: &kube::Client, + namespace: &str, + workflow_name: &str, + creator: &str, +) -> anyhow::Result<()> { + let gvk = kube::core::GroupVersionKind::gvk("argoproj.io", "v1alpha1", "Workflow"); + let resource = kube::api::ApiResource::from_gvk_with_plural(&gvk, "workflows"); + let api: Api = Api::namespaced_with(client.clone(), namespace, &resource); + let mut workflow = DynamicObject::new(workflow_name, &resource).within(namespace); + workflow.metadata.labels = Some( + [( + "workflows.argoproj.io/creator".to_string(), + creator.to_string(), + )] + .into_iter() + .collect(), + ); + api.create(&PostParams::default(), &workflow).await?; + Ok(()) +} + +async fn create_test_pod( + client: &kube::Client, + namespace: &str, + pod_name: &str, + workflow_name: &str, + service_account_name: &str, + audience: &str, +) -> anyhow::Result<()> { + use k8s_openapi::api::core::v1::Pod; + use kube::{Api, api::PostParams}; + + let api: Api = Api::namespaced(client.clone(), namespace); + + let pod: Pod = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": pod_name, + "labels": { + "workflows.argoproj.io/workflow": workflow_name + } + }, + "spec": { + "serviceAccountName": service_account_name, + "restartPolicy": "Never", + "containers": [{ + "name": "main", + "image": "busybox:1.36.1-musl", + "command": ["sh", "-c", "sleep 3600"], + "volumeMounts": [{ + "name": "projected-token", + "mountPath": "/var/run/test-token", + "readOnly": true + }] + }], + "volumes": [{ + "name": "projected-token", + "projected": { + "sources": [{ + "serviceAccountToken": { + "path": "token", + "audience": audience, + "expirationSeconds": 3600 + } + }] + } + }] + } + }))?; + + api.create(&PostParams::default(), &pod).await?; + Ok(()) +} + +fn init_test() { + let _ = env_logger::try_init(); + let _ = auth_core::rustls::crypto::aws_lc_rs::default_provider().install_default(); + let _ = auth_core::sodiumoxide::init(); +} + +async fn start_mock_oidc() -> (testcontainers::ContainerAsync, String, String) { + let wait_strategy = HttpWaitStrategy::new("default/.well-known/openid-configuration") + .with_expected_status_code(200u16); + let container = GenericImage::new("ghcr.io/navikt/mock-oauth2-server", "3.0.1") + .with_wait_for(WaitFor::http(wait_strategy)) + .with_env_var("SERVER_PORT", "8080") + .start() + .await + .expect("failed to start mock OIDC server"); + let port = container + .get_host_port_ipv4(8080) + .await + .expect("get OIDC container host port"); + + let admin_token_url = format!("http://localhost:{port}/default/token"); + let params = [ + ("grant_type", "refresh_token"), + ("scope", "openid offline_access"), + ("refresh_token", "test-refresh-token"), + ("client_id", "test-client"), + ]; + let token_resp: serde_json::Value = reqwest::Client::new() + .post(&admin_token_url) + .timeout(Duration::from_secs(10)) + .form(¶ms) + .send() + .await + .expect("send OIDC token request") + .json() + .await + .expect("parse OIDC token response"); + + let refresh_token = token_resp["refresh_token"] + .as_str() + .expect("no refresh_token in OIDC response") + .to_string(); + let issuer_url = format!("http://localhost:{port}/default"); + + (container, issuer_url, refresh_token) +} + +fn test_config(issuer_url: &str) -> AuthBrokerConfig { + AuthBrokerConfig { + common: CommonConfig { + client_id: "test-client".into(), + client_secret: String::new(), + oidc_provider_url: issuer_url.into(), + port: 0, + postgres_user: String::new(), + postgres_password: String::new(), + postgres_database: String::new(), + postgres_hostname: String::new(), + postgres_port: 0, + encryption_public_key: TEST_PUBLIC_KEY_B64.into(), + cors_allow: None, + }, + encryption_private_key: TEST_PRIVATE_KEY_B64.into(), + token_review_audience: "workflows-auth-broker".into(), + } +} + +async fn send_auth_request(server: &axum_test::TestServer, token: &str) -> axum_test::TestResponse { + server + .post("/") + .add_header("K8s-Pod-Service-Account-Token", token) + .await +} + +async fn setup_k3s_cluster() -> auth_core::Result<( + kube::Client, + testcontainers::ContainerAsync, +)> { + use testcontainers_modules::{ + k3s::{K3s, KUBE_SECURE_PORT}, + testcontainers::ImageExt, + }; + let k3s_instance = K3s::default() + .with_conf_mount(std::env::temp_dir()) + .with_privileged(true) + .with_userns_mode("host") + .start() + .await?; + + let kube_port = k3s_instance.get_host_port_ipv4(KUBE_SECURE_PORT).await?; + let kube_conf = k3s_instance + .image() + .read_kube_config() + .expect("Cannot read kube conf"); + + let mut kubeconfig: kube::config::Kubeconfig = serde_yaml::from_str(&kube_conf)?; + + let cluster = kubeconfig + .clusters + .first_mut() + .and_then(|it| it.cluster.as_mut()) + .expect("no cluster found in kubeconfig"); + + cluster.server = Some(format!("https://localhost:{kube_port}")); + + let config = + kube::Config::from_custom_kubeconfig(kubeconfig, &KubeConfigOptions::default()).await?; + + let kube_client = kube::Client::try_from(config)?; + + Ok((kube_client, k3s_instance)) +} + +async fn wait_for_pod_running( + client: &kube::Client, + namespace: &str, + pod_name: &str, +) -> anyhow::Result<()> { + use k8s_openapi::api::core::v1::Pod; + use kube::Api; + use std::time::Duration; + use tokio::time::{sleep, timeout}; + + let pods: Api = Api::namespaced(client.clone(), namespace); + + timeout(Duration::from_secs(60), async { + loop { + let pod = pods.get(pod_name).await?; + + let phase = pod.status.as_ref().and_then(|s| s.phase.as_deref()); + + if phase == Some("Running") { + return Ok::<_, anyhow::Error>(()); + } + + sleep(Duration::from_millis(500)).await; + } + }) + .await??; + + Ok(()) +} + +async fn read_projected_token_from_pod( + client: &kube::Client, + namespace: &str, + pod_name: &str, +) -> anyhow::Result { + use k8s_openapi::api::core::v1::Pod; + use kube::{Api, api::AttachParams}; + use tokio::io::AsyncReadExt; + + let pods: Api = Api::namespaced(client.clone(), namespace); + + let mut attached = pods + .exec( + pod_name, + vec!["cat", "/var/run/test-token/token"], + &AttachParams::default().stdout(true).stderr(true), + ) + .await?; + + let mut stdout = attached + .stdout() + .ok_or_else(|| anyhow::anyhow!("pod exec did not provide stdout"))?; + + let mut token = String::new(); + stdout.read_to_string(&mut token).await?; + + let token = token.trim().to_string(); + + if token.is_empty() { + anyhow::bail!("projected service account token was empty"); + } + + Ok(token) +} + +fn assert_auth_response_contains_bearer_token_header(resp: &axum_test::TestResponse) { + resp.assert_status_ok(); + let auth_header = resp + .headers() + .get("authorization") + .expect("response should contain authorization header"); + let auth_str = auth_header + .to_str() + .expect("authorization header should be valid ASCII"); + assert!( + auth_str.starts_with("Bearer "), + "expected Bearer token, got: {auth_str}" + ); + assert!( + auth_str.len() > "Bearer ".len(), + "token should not be empty" + ); +} + +#[tokio::test] +async fn test_authorize_request_with_valid_token_and_mock_k8s() -> auth_core::Result<()> { + init_test(); + let (oidc, issuer_url, refresh_token) = start_mock_oidc().await; + let db = test_database(&issuer_url, &refresh_token).await; + + let config = test_config(&issuer_url); + let k8s = Arc::new(MockK8sApi); + let state = AuthBrokerState::with_k8s_and_db(config, k8s as Arc, db).await?; + + let router = Router::new().fallback(authorize_request).with_state(state); + let server = TestServer::new(router)?; + + let resp = send_auth_request(&server, "some-token").await; + assert_auth_response_contains_bearer_token_header(&resp); + + oidc.stop_with_timeout(Some(60)).await?; + Ok(()) +} + +#[tokio::test] +async fn test_authorize_request_with_valid_token_and_k3s() -> auth_core::Result<()> { + init_test(); + + let (kube_client, k3s) = setup_k3s_cluster().await?; + + let namespace = "test-ns"; + let service_account = "test-sa"; + let pod_name = "test-pod"; + let workflow_name = "test-workflow"; + let audience = "workflows-auth-broker"; + + // ── provision k8s resources ───────────────────── + create_namespace(&kube_client, namespace).await?; + create_service_account(&kube_client, namespace, service_account).await?; + + install_argo_crd(&kube_client).await?; + create_test_workflow(&kube_client, namespace, workflow_name, "test-subject").await?; + + create_test_pod( + &kube_client, + namespace, + pod_name, + workflow_name, + service_account, + audience, + ) + .await?; + + wait_for_pod_running(&kube_client, namespace, pod_name).await?; + + let service_account_token = + read_projected_token_from_pod(&kube_client, namespace, pod_name).await?; + + let (oidc, issuer_url, refresh_token) = start_mock_oidc().await; + let db = test_database(&issuer_url, &refresh_token).await; + + let auth_config = test_config(&issuer_url); + let k8s: Arc = Arc::new(crate::k8s::RealK8sApi::new( + kube_client, + "workflows-auth-broker".into(), + )); + let state = AuthBrokerState::with_k8s_and_db(auth_config, k8s, db).await?; + + let router = Router::new().fallback(authorize_request).with_state(state); + + let server = TestServer::new(router)?; + + let resp = send_auth_request(&server, &service_account_token).await; + assert_auth_response_contains_bearer_token_header(&resp); + + oidc.stop_with_timeout(Some(60)).await?; + k3s.stop_with_timeout(Some(60)).await?; + + Ok(()) +} diff --git a/backend/auth-broker/src/health.rs b/backend/auth-broker/src/health.rs new file mode 100644 index 000000000..c946c4db4 --- /dev/null +++ b/backend/auth-broker/src/health.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use axum::{extract::State, http::StatusCode, response::IntoResponse}; +use tracing::warn; + +use crate::state::AuthBrokerState; + +pub async fn health_check(State(state): State>) -> impl IntoResponse { + match state.check_database().await { + Ok(()) => StatusCode::OK, + Err(e) => { + warn!("health check failed: {e:?}"); + StatusCode::SERVICE_UNAVAILABLE + } + } +} diff --git a/backend/auth-broker/src/k8s.rs b/backend/auth-broker/src/k8s.rs new file mode 100644 index 000000000..74fa31fda --- /dev/null +++ b/backend/auth-broker/src/k8s.rs @@ -0,0 +1,65 @@ +use k8s_openapi::api::authentication::v1::{TokenReview, TokenReviewSpec}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{ApiResource, DynamicObject, PostParams}; +use kube::core::GroupVersionKind; + +use auth_core::async_trait::async_trait; + +#[async_trait] +pub trait K8sApi: Send + Sync { + async fn create_token_review(&self, token: &str) -> anyhow::Result; + async fn get_pod( + &self, + namespace: &str, + name: &str, + ) -> anyhow::Result; + async fn get_workflow(&self, namespace: &str, name: &str) -> anyhow::Result; +} + +pub struct RealK8sApi { + client: kube::Client, + audience: String, +} + +impl RealK8sApi { + pub fn new(client: kube::Client, audience: String) -> Self { + Self { client, audience } + } +} + +#[async_trait] +impl K8sApi for RealK8sApi { + async fn create_token_review(&self, token: &str) -> anyhow::Result { + let api: kube::Api = kube::Api::all(self.client.clone()); + let token_review = TokenReview { + metadata: ObjectMeta::default(), + spec: TokenReviewSpec { + audiences: Some(vec![self.audience.clone()]), + token: Some(token.to_owned()), + }, + status: None, + }; + let created = api.create(&PostParams::default(), &token_review).await?; + Ok(created) + } + + async fn get_pod( + &self, + namespace: &str, + name: &str, + ) -> anyhow::Result { + let pods: kube::Api = + kube::Api::namespaced(self.client.clone(), namespace); + Ok(pods.get(name).await?) + } + + async fn get_workflow(&self, namespace: &str, name: &str) -> anyhow::Result { + let gvk = GroupVersionKind::gvk("argoproj.io", "v1alpha1", "Workflow"); + let api = kube::Api::::namespaced_with( + self.client.clone(), + namespace, + &ApiResource::from_gvk_with_plural(&gvk, "workflows"), + ); + Ok(api.get(name).await?) + } +} diff --git a/backend/auth-broker/src/main.rs b/backend/auth-broker/src/main.rs new file mode 100644 index 000000000..c207efdb2 --- /dev/null +++ b/backend/auth-broker/src/main.rs @@ -0,0 +1,91 @@ +#![forbid(unsafe_code)] +#![doc = include_str!("../README.md")] +#![warn(missing_docs)] +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::OnceLock, +}; + +use axum::{Router, routing::get}; +use clap::Parser; +use tokio::signal::unix::{SignalKind, signal}; +use tracing::info; +use tracing_subscriber::EnvFilter; + +use crate::{config::AuthBrokerConfig, state::AuthBrokerState}; + +mod config; +mod ext_authz; +mod health; +mod k8s; +mod state; + +static CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); + +#[derive(Parser, Debug)] +#[command(author, version, about)] +struct ServeArgs { + #[arg( + short, + long, + env = "WORKFLOWS_AUTH_BROKER_CONFIG", + default_value = "config.yaml" + )] + config: String, +} + +#[derive(Debug, Parser)] +#[allow(clippy::large_enum_variant)] +enum Cli { + Serve(ServeArgs), +} + +#[tokio::main] +async fn main() -> auth_core::Result<()> { + CRYPTO_PROVIDER.get_or_init(|| { + auth_core::rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("Failed to install rustls CryptoProvider"); + }); + + dotenvy::dotenv().ok(); + let args = Cli::parse(); + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_env("LOG_LEVEL")) + .init(); + + match args { + Cli::Serve(args) => { + let config = AuthBrokerConfig::from_file(&args.config) + .map_err(|e| anyhow::anyhow!("failed to read config {}: {e:?}", args.config))?; + let port = config.common.port; + let state = AuthBrokerState::new(config) + .await + .map_err(|e| anyhow::anyhow!("failed to initialise broker state: {e:?}"))?; + + let router = Router::new() + .route("/healthz", get(health::health_check)) + .fallback(ext_authz::authorize_request) + .with_state(state); + + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port); + info!("auth-broker listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| anyhow::anyhow!("failed to bind on {addr}: {e}"))?; + + axum::serve(listener, router.into_make_service()) + .with_graceful_shutdown(shutdown_signal()) + .await + .map_err(|e| anyhow::anyhow!("server error: {e}"))?; + } + } + + Ok(()) +} + +async fn shutdown_signal() { + let mut sigterm = signal(SignalKind::terminate()).expect("Failed to listen for SIGTERM"); + sigterm.recv().await; + info!("received SIGTERM, shutting down"); +} diff --git a/backend/auth-broker/src/state.rs b/backend/auth-broker/src/state.rs new file mode 100644 index 000000000..571af2851 --- /dev/null +++ b/backend/auth-broker/src/state.rs @@ -0,0 +1,214 @@ +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use moka::future::Cache; +use tokio::sync::Mutex; + +use auth_core::database::RefreshTokenInfo; +use auth_core::oauth2::{RefreshToken, TokenResponse}; +use auth_core::oidc::{ + DbConnection, HttpClient, OidcClient, SodiumPublicKey, create_db_connection, + create_oidc_client, decode_public_key, decode_secret_key, exchange_refresh_token, +}; +use auth_core::openidconnect::{IssuerUrl, SubjectIdentifier}; +use auth_core::sodiumoxide::crypto::box_::SecretKey; + +use crate::config::AuthBrokerConfig; +use crate::k8s::{K8sApi, RealK8sApi}; + +type Result = auth_core::Result; + +const TOKEN_GRACE_PERIOD_SECS: i64 = 30; +const DEFAULT_TOKEN_EXPIRY_SECS: u64 = 60; +const SUBJECT_CACHE_TTL_SECS: u64 = 300; +const TOKEN_CACHE_TTL_SECS: u64 = 300; + +#[derive(Clone)] +struct CachedToken { + access_token: String, + expires_at: DateTime, + refresh_token: String, + issuer: IssuerUrl, + subject: String, +} + +impl RefreshTokenInfo for CachedToken { + fn refresh_token_secret(&self) -> &str { + &self.refresh_token + } + fn issuer(&self) -> &str { + self.issuer.as_str() + } + fn subject(&self) -> &str { + &self.subject + } +} + +pub struct AuthBrokerState { + http_client: HttpClient, + oidc_client: OidcClient, + database_connection: DbConnection, + pub k8s: Arc, + public_key: SodiumPublicKey, + secret_key: SecretKey, + subject_cache: Cache, + token_cache: Cache, + refresh_locks: Arc>>>, +} + +impl AuthBrokerState { + pub async fn new(config: AuthBrokerConfig) -> Result> { + let database_connection = create_db_connection(&config.common).await?; + let kube_client = kube::Client::try_default() + .await + .map_err(|e| anyhow::anyhow!("failed to create Kubernetes client: {e}"))?; + let k8s = Arc::new(RealK8sApi::new( + kube_client, + config.token_review_audience.clone(), + )); + Self::with_k8s_and_db(config, k8s as Arc, database_connection).await + } + + pub async fn with_k8s_and_db( + config: AuthBrokerConfig, + k8s: Arc, + database_connection: impl Into, + ) -> Result> { + let (oidc_client, http_client) = create_oidc_client(&config.common).await?; + let public_key = decode_public_key(&config.common.encryption_public_key)?; + let secret_key = decode_secret_key(&config.encryption_private_key)?; + let database_connection = database_connection.into(); + + let token_cache = Cache::builder() + .time_to_live(Duration::from_secs(TOKEN_CACHE_TTL_SECS)) + .build(); + + let refresh_locks: Arc>>> = Arc::new(DashMap::new()); + + let cleanup_locks = refresh_locks.clone(); + let cleanup_cache = token_cache.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + let mut stale = Vec::new(); + for entry in cleanup_locks.iter() { + if cleanup_cache.get(entry.key()).await.is_none() { + stale.push(entry.key().clone()); + } + } + for key in stale { + cleanup_locks.remove(&key); + } + } + }); + + Ok(Arc::new(Self { + http_client, + oidc_client, + database_connection, + k8s, + public_key, + secret_key, + subject_cache: Cache::builder() + .time_to_live(Duration::from_secs(SUBJECT_CACHE_TTL_SECS)) + .build(), + token_cache, + refresh_locks, + })) + } + + pub async fn get_or_refresh_token(&self, subject: &str) -> Result { + if let Some(token) = self.token_cache.get(subject).await { + let grace = Utc::now() + chrono::Duration::seconds(TOKEN_GRACE_PERIOD_SECS); + if token.expires_at > grace { + return Ok(token.access_token.clone()); + } + } + + let lock = self + .refresh_locks + .entry(subject.to_owned()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _guard = lock.lock().await; + + if let Some(token) = self.token_cache.get(subject).await { + let grace = Utc::now() + chrono::Duration::seconds(TOKEN_GRACE_PERIOD_SECS); + if token.expires_at > grace { + return Ok(token.access_token.clone()); + } + } + + let subject_id = SubjectIdentifier::new(subject.to_string()); + let stored = auth_core::database::read_token_from_database( + &self.database_connection, + &subject_id, + None, + &self.public_key, + &self.secret_key, + ) + .await?; + + let refresh_token = RefreshToken::new(stored.refresh_token_secret.clone()); + let token_response = + exchange_refresh_token(&self.oidc_client, &self.http_client, &refresh_token).await?; + + let access_token = token_response.access_token().secret().to_string(); + let expires_in = token_response + .expires_in() + .unwrap_or_else(|| Duration::from_secs(DEFAULT_TOKEN_EXPIRY_SECS)); + let expires_at = Utc::now() + + chrono::Duration::from_std(expires_in) + .map_err(|e| anyhow::anyhow!("token expiry duration out of range: {e}"))?; + + let new_refresh_secret = token_response + .refresh_token() + .map(|rt| rt.secret().to_string()) + .unwrap_or(stored.refresh_token_secret); + + let issuer = IssuerUrl::new(stored.issuer.clone())?; + + if token_response.refresh_token().is_some() { + let new_token_info = CachedToken { + access_token: String::new(), + expires_at: Utc::now(), + refresh_token: new_refresh_secret.clone(), + issuer: issuer.clone(), + subject: subject.to_string(), + }; + auth_core::database::write_token_to_database( + &self.database_connection, + &new_token_info, + &self.public_key, + ) + .await?; + } + + let cached = CachedToken { + access_token: access_token.clone(), + expires_at, + refresh_token: new_refresh_secret, + issuer, + subject: subject.to_string(), + }; + + self.token_cache.insert(subject.to_string(), cached).await; + + Ok(access_token) + } + + pub async fn get_cached_subject(&self, pod_key: &str) -> Option { + self.subject_cache.get(pod_key).await + } + + pub async fn set_cached_subject(&self, pod_key: String, subject: String) { + self.subject_cache.insert(pod_key, subject).await; + } + + pub async fn check_database(&self) -> auth_core::Result<()> { + self.database_connection.ping().await?; + Ok(()) + } +} diff --git a/backend/auth-core/Cargo.toml b/backend/auth-core/Cargo.toml index a62133651..a091eb23f 100644 --- a/backend/auth-core/Cargo.toml +++ b/backend/auth-core/Cargo.toml @@ -27,7 +27,7 @@ rustls = { workspace = true, features = ["aws-lc-rs"] } sea-orm = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = "0.9.34" +serde_yaml = {workspace=true} sodiumoxide = "0.2.7" thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/backend/auth-daemon/Cargo.toml b/backend/auth-daemon/Cargo.toml index 8d1119d16..6add85d49 100644 --- a/backend/auth-daemon/Cargo.toml +++ b/backend/auth-daemon/Cargo.toml @@ -29,7 +29,7 @@ k8s-openapi = { workspace = true } mockito.workspace = true sea-orm = { workspace = true, features = ["sqlx-postgres", "sqlx-sqlite"] } migration = { version = "0.1.0", path = "../auth-core/migration" } -axum-test = "18.4.1" -env_logger = "0.11.8" +axum-test = {workspace=true} +env_logger = { workspace = true } oauth2-test-server = "0.1.3" testcontainers = { workspace = true } diff --git a/backend/auth-gateway/Cargo.toml b/backend/auth-gateway/Cargo.toml index a39fb2297..b0f6bfec4 100644 --- a/backend/auth-gateway/Cargo.toml +++ b/backend/auth-gateway/Cargo.toml @@ -21,7 +21,7 @@ chrono.workspace = true clap.workspace = true dotenvy.workspace = true hyper = "1.8.1" -moka = { version = "0.12.11", features = ["future"] } +moka.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true