diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e02868..74e2fae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,9 @@ jobs: with: version: v4.2.2 - name: Shell lint - run: shellcheck scripts/*.sh workspace/*.sh workspace/devbox-shell + run: shellcheck scripts/*.sh workspace/*.sh workspace/devbox-shell workspace/tests/*.sh + - name: Terminal compatibility unit tests + run: workspace/tests/test-devbox-shell.sh - name: Release version consistency run: scripts/check-version.sh - name: Secret scan @@ -140,6 +142,9 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Build image run: docker build --tag "devboxes-${{ matrix.component }}:ci" "${{ matrix.component }}" + - name: Exercise terminal compatibility inside workspace image + if: matrix.component == 'workspace' + run: workspace/tests/test-image-terminal.sh devboxes-workspace:ci kind: name: Clean-cluster install @@ -154,4 +159,8 @@ jobs: go install sigs.k8s.io/kind@v0.31.0 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Run full lifecycle E2E + env: + # GitHub-hosted runners cannot retain an SSH PTY through Kind's port-forward. + # The workspace image job above exercises the same shell with a real TTY. + DEVBOXES_E2E_INTERACTIVE_SSH: "0" run: scripts/kind-e2e.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 239e0a7..0ee0528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to Devboxes are documented here. The project follows [Keep a ## [Unreleased] +## [0.2.0] - 2026-07-13 + +### Added + +- Added native CLI browser authorization with an external system browser, numeric loopback callback, high-entropy state, PKCE S256, CSRF-protected approval and denial, automatic code exchange, and `--no-open` support. +- Added versioned, scoped, expiring CLI bearer tokens with strict claim validation, an optional dedicated signing key, and a domain-separated key derived from the existing access token by default. +- Added a pinned Ghostty `xterm-ghostty` terminfo source with upstream MIT attribution, broad ncurses terminfo packages, and image-level tmux regression coverage. + +### Changed + +- Changed interactive `devbox login` to eliminate terminal token prompting while preserving explicit `--token` and `DEVBOX_TOKEN` compatibility for automation and headless use. +- Changed `devbox-shell` to validate and preserve installed terminal capabilities, retain `DEVBOX_ORIGINAL_TERM` and `COLORTERM`, and fall back deterministically for unknown or untrusted terminal names. +- Configured tmux to use the installed `tmux-256color` entry internally and advertise truecolor only for known capable terminal families. + +### Security + +- Authorization codes are opaque, hash-only at rest, bounded, automatically pruned, bound to the exact client, redirect, PKCE challenge, subject, and expiry, and atomically consumed once. +- CLI authorization and token responses use no-store and referrer protections; login return targets and loopback redirects reject open redirects, non-numeric hosts, unexpected paths, and unsupported PKCE methods. + ## [0.1.2] - 2026-07-13 ### Fixed @@ -41,7 +60,8 @@ All notable changes to Devboxes are documented here. The project follows [Keep a - Portable Helm chart with values schema, namespace-scoped RBAC, configurable storage, ingress, LoadBalancer or NodePort SSH, ServiceMonitor, and disruption budget. - macOS and Linux CLI releases, SHA-256 verification installer, GHCR images, OCI chart publishing, image provenance attestations, and clean Kind install CI. -[Unreleased]: https://github.com/vicotrbb/devboxes/compare/v0.1.2...HEAD +[Unreleased]: https://github.com/vicotrbb/devboxes/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/vicotrbb/devboxes/compare/v0.1.2...v0.2.0 [0.1.2]: https://github.com/vicotrbb/devboxes/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/vicotrbb/devboxes/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/vicotrbb/devboxes/releases/tag/v0.1.0 diff --git a/Makefile b/Makefile index ccd000b..8851e4b 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,8 @@ lint: npm run lint cd controller && uv run ruff format --check . && uv run ruff check . && uv run mypy cd cli && cargo fmt --check && cargo clippy --all-targets --all-features --locked -- -D warnings - shellcheck scripts/*.sh workspace/*.sh workspace/devbox-shell + shellcheck scripts/*.sh workspace/*.sh workspace/devbox-shell workspace/tests/*.sh + workspace/tests/test-devbox-shell.sh ./scripts/check-version.sh test: @@ -24,6 +25,7 @@ helm: images: docker build --tag devboxes-controller:local controller docker build --tag devboxes-workspace:local workspace + workspace/tests/test-image-terminal.sh devboxes-workspace:local clean: rm -rf node_modules controller/.venv controller/.mypy_cache controller/.pytest_cache controller/.ruff_cache controller/.coverage controller/htmlcov cli/target diff --git a/NOTICE b/NOTICE index cd91c24..b9b74c3 100644 --- a/NOTICE +++ b/NOTICE @@ -2,3 +2,7 @@ Devboxes Copyright 2026 Victor Bona This product includes software developed by Devboxes contributors. + +The workspace image includes a generated Ghostty terminfo entry derived from Ghostty, +Copyright (c) 2024 Mitchell Hashimoto and Ghostty contributors, licensed under the MIT +License. The pinned source provenance and license are under workspace/terminfo. diff --git a/README.md b/README.md index 07da1e0..9fda96d 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ kubectl -n devboxes create secret generic devboxes-workspace \ --from-file=SSH_AUTHORIZED_KEYS="$HOME/.ssh/id_ed25519.pub" helm install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ - --version 0.1.2 \ + --version 0.2.0 \ --namespace devboxes ``` @@ -155,6 +155,18 @@ devbox login --url https://devboxes.example.com devbox create atlas --preset medium --ttl 24 --repo owner/project --ssh ``` +Login opens the system browser, asks the current Devboxes browser session to approve the +CLI, exchanges a one-time PKCE authorization code, verifies the resulting scoped token, +and stores it without displaying it. If the browser is not already signed in, the existing +operator login page appears first. This removes token pasting from the terminal; it does +not add SSO or unauthenticated LAN trust. + +For a machine where the CLI cannot open a browser, print the URL and open it manually: + +```bash +devbox login --url https://devboxes.example.com --no-open +``` + For a port-forwarded controller, localhost HTTP is deliberately allowed: ```bash @@ -181,6 +193,10 @@ devbox ssh atlas -- -L 3000:127.0.0.1:3000 The CLI stores its configuration at the platform config directory under `devbox/config.toml` with mode `0600` on Unix. `DEVBOX_URL`, `DEVBOX_TOKEN`, and `DEVBOX_CONFIG` support non-interactive and multi-profile workflows. +Existing automation can continue to use the master token through `DEVBOX_TOKEN` or an +explicit `--token`. Browser login receives an expiring CLI token instead of the master +credential. + ## Credentials and prepared accounts Only `SSH_AUTHORIZED_KEYS` is required. Add optional values to the `devboxes-workspace` Secret to prepare GitHub, Git, Codex, or Claude Code. Public GitHub repositories clone without `GH_TOKEN`. diff --git a/SECURITY.md b/SECURITY.md index 3ce39d0..b73c360 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,7 +32,12 @@ Recommended deployment controls: - keep Kubernetes, ingress, CSI, images, chart, and CLI releases current; - verify release checksums and image provenance attestations. -The CLI refuses plaintext HTTP except exact loopback hosts. It has no skip-TLS-verification option. Workspace SSH disables password, keyboard-interactive, and root login and uses persistent Ed25519 host keys. +The CLI refuses plaintext controller URLs except exact loopback hosts. Browser login uses an +external browser, a `127.0.0.1` ephemeral callback, state validation, PKCE S256, one-time +authorization codes, and expiring scoped tokens. It has no skip-TLS-verification option. +Workspace SSH disables password, keyboard-interactive, and root login and uses persistent +Ed25519 host keys. Incoming terminal names are validated against installed terminfo before +tmux starts. ## Dependency and supply-chain policy diff --git a/charts/devboxes/Chart.yaml b/charts/devboxes/Chart.yaml index e96b4d5..93c8268 100644 --- a/charts/devboxes/Chart.yaml +++ b/charts/devboxes/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: devboxes description: Self-hosted, ephemeral development environments on Kubernetes type: application -version: 0.1.2 -appVersion: "0.1.2" +version: 0.2.0 +appVersion: "0.2.0" kubeVersion: ">=1.29.0-0" home: https://github.com/vicotrbb/devboxes icon: https://raw.githubusercontent.com/vicotrbb/devboxes/main/docs/assets/devboxes-mark.svg diff --git a/charts/devboxes/templates/deployment.yaml b/charts/devboxes/templates/deployment.yaml index ab4d07f..5ded317 100644 --- a/charts/devboxes/templates/deployment.yaml +++ b/charts/devboxes/templates/deployment.yaml @@ -77,6 +77,19 @@ spec: value: {{ .Values.controller.cleanupIntervalSeconds | quote }} - name: DEVBOXES_SESSION_TTL_SECONDS value: {{ .Values.controller.sessionTtlSeconds | quote }} + - name: DEVBOXES_AUTHORIZATION_CODE_TTL_SECONDS + value: {{ int .Values.controller.authorizationCodeTtlSeconds | quote }} + - name: DEVBOXES_AUTHORIZATION_CODE_STORE_SIZE + value: {{ int .Values.controller.authorizationCodeStoreSize | quote }} + - name: DEVBOXES_CLI_TOKEN_TTL_SECONDS + value: {{ int .Values.controller.cliTokenTtlSeconds | quote }} + {{- if .Values.controller.cliSigningKeyKey }} + - name: DEVBOXES_CLI_SIGNING_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.controller.existingSecret }} + key: {{ .Values.controller.cliSigningKeyKey }} + {{- end }} - name: DEVBOXES_LOG_LEVEL value: {{ .Values.controller.logLevel | quote }} - name: DEVBOXES_WORKSPACE_IMAGE diff --git a/charts/devboxes/values.schema.json b/charts/devboxes/values.schema.json index 12ea1e0..57fd4fd 100644 --- a/charts/devboxes/values.schema.json +++ b/charts/devboxes/values.schema.json @@ -23,6 +23,10 @@ "maxTtlHours": {"type": "integer", "minimum": 1, "maximum": 168}, "cleanupIntervalSeconds": {"type": "integer", "minimum": 1}, "sessionTtlSeconds": {"type": "integer", "minimum": 60}, + "authorizationCodeTtlSeconds": {"type": "integer", "minimum": 30, "maximum": 600}, + "authorizationCodeStoreSize": {"type": "integer", "minimum": 16, "maximum": 10000}, + "cliTokenTtlSeconds": {"type": "integer", "minimum": 300, "maximum": 31536000}, + "cliSigningKeyKey": {"type": "string"}, "logLevel": {"type": "string", "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]}, "resources": {"type": "object"}, "podAnnotations": {"type": "object", "additionalProperties": {"type": "string"}}, diff --git a/charts/devboxes/values.yaml b/charts/devboxes/values.yaml index a2a38d6..e9f3384 100644 --- a/charts/devboxes/values.yaml +++ b/charts/devboxes/values.yaml @@ -18,6 +18,12 @@ controller: maxTtlHours: 168 cleanupIntervalSeconds: 60 sessionTtlSeconds: 43200 + authorizationCodeTtlSeconds: 120 + authorizationCodeStoreSize: 1024 + cliTokenTtlSeconds: 2592000 + # Optional key in controller.existingSecret. Empty derives a domain-separated + # signing key from accessTokenKey so no new mandatory credential is required. + cliSigningKeyKey: "" logLevel: INFO resources: requests: diff --git a/cli/Cargo.lock b/cli/Cargo.lock index a04a29e..0d83910 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -79,6 +79,58 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -91,6 +143,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -132,8 +193,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -196,6 +257,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -212,6 +283,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -221,21 +301,45 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "devbox-cli" -version = "0.1.2" +version = "0.2.0" dependencies = [ "anyhow", + "axum", + "base64", "chrono", "clap", "dirs", + "rand 0.9.5", "reqwest", - "rpassword", "serde", "serde_json", + "sha2", "tempfile", "tokio", "toml", + "webbrowser", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -340,6 +444,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -353,6 +467,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -362,8 +488,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -418,6 +544,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -431,6 +563,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -632,6 +765,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -682,12 +864,24 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.2.1" @@ -699,6 +893,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "num-traits" version = "0.2.19" @@ -708,6 +908,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -753,6 +978,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -791,7 +1025,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -827,12 +1061,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -841,7 +1091,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -856,7 +1125,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -923,32 +1192,20 @@ dependencies = [ ] [[package]] -name = "rpassword" -version = "7.5.4" +name = "rustc-hash" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.61.2", -] +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] -name = "rtoolbox" -version = "0.0.5" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "libc", - "windows-sys 0.59.0", + "semver", ] -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - [[package]] name = "rustix" version = "1.1.4" @@ -1021,6 +1278,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1053,6 +1319,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1096,6 +1368,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1117,6 +1400,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1133,6 +1427,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1351,6 +1661,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1389,6 +1700,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -1408,6 +1720,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1444,6 +1762,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1459,6 +1793,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1534,6 +1877,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1602,15 +1970,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1690,6 +2049,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -1719,6 +2084,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 911c25e..d87fce2 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devbox-cli" -version = "0.1.2" +version = "0.2.0" edition = "2024" rust-version = "1.96" description = "Terminal client for self-hosted Kubernetes development environments" @@ -16,15 +16,19 @@ path = "src/main.rs" [dependencies] anyhow = "1" +axum = "0.8" +base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } dirs = "6" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } -rpassword = "7" +rand = "0.9" serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] } +sha2 = "0.10" +tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } toml = "1.1" +webbrowser = "1" [dev-dependencies] tempfile = "3" diff --git a/cli/src/client.rs b/cli/src/client.rs index c456294..32efd8a 100644 --- a/cli/src/client.rs +++ b/cli/src/client.rs @@ -6,7 +6,9 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; -use crate::models::{CreateDevbox, DeleteResult, Devbox, DevboxList, WhoAmI}; +use crate::models::{ + CliTokenRequest, CliTokenResponse, CreateDevbox, DeleteResult, Devbox, DevboxList, WhoAmI, +}; pub struct ApiClient { base_url: String, @@ -16,19 +18,35 @@ pub struct ApiClient { impl ApiClient { pub fn new(base_url: String, token: String) -> Result { - let http = Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(30)) - .user_agent(concat!("devbox-cli/", env!("CARGO_PKG_VERSION"))) - .build() - .context("failed to initialize HTTP client")?; Ok(Self { base_url, token, - http, + http: http_client()?, }) } + pub async fn exchange_cli_code( + base_url: &str, + code: &str, + code_verifier: &str, + redirect_uri: &str, + ) -> Result { + let response = http_client()? + .post(format!("{base_url}/api/v1/auth/cli/token")) + .header("Accept", "application/json") + .json(&CliTokenRequest { + grant_type: "authorization_code", + code, + code_verifier, + client_id: "devbox-cli", + redirect_uri, + }) + .send() + .await + .context("failed to exchange browser authorization")?; + decode_response(response).await + } + pub async fn whoami(&self) -> Result { self.request(Method::GET, "/api/v1/whoami", Option::<&()>::None) .await @@ -99,22 +117,35 @@ impl ApiClient { .send() .await .context("failed to reach Devboxes API")?; - let status = response.status(); - if status == StatusCode::NO_CONTENT { - bail!("the API returned no content where a response was expected"); - } - if !status.is_success() { - let payload = response.json::().await.unwrap_or(Value::Null); - let detail = api_error_detail(&payload); - bail!("Devboxes API returned {status}: {detail}"); - } - response - .json::() - .await - .context("Devboxes API returned an invalid response") + decode_response(response).await } } +fn http_client() -> Result { + Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .user_agent(concat!("devbox-cli/", env!("CARGO_PKG_VERSION"))) + .build() + .context("failed to initialize HTTP client") +} + +async fn decode_response(response: reqwest::Response) -> Result { + let status = response.status(); + if status == StatusCode::NO_CONTENT { + bail!("the API returned no content where a response was expected"); + } + if !status.is_success() { + let payload = response.json::().await.unwrap_or(Value::Null); + let detail = api_error_detail(&payload); + bail!("Devboxes API returned {status}: {detail}"); + } + response + .json::() + .await + .context("Devboxes API returned an invalid response") +} + fn api_error_detail(payload: &Value) -> String { match payload.get("detail") { Some(Value::String(message)) => message.clone(), diff --git a/cli/src/config.rs b/cli/src/config.rs index aaf26db..c91865e 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result, bail}; use reqwest::Url; use serde::{Deserialize, Serialize}; -#[derive(Default, Deserialize, Serialize)] +#[derive(Clone, Default, Deserialize, Serialize)] pub struct StoredConfig { pub url: Option, pub token: Option, @@ -32,22 +32,33 @@ impl StoredConfig { } pub fn resolve(self, url: Option, token: Option) -> Result { + let resolved_url = self.resolve_url(url)?; + let token = token + .or_else(|| std::env::var("DEVBOX_TOKEN").ok()) + .or(self.token) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!("not logged in; run `devbox login` or set DEVBOX_TOKEN") + })?; + let parsed = Url::parse(&resolved_url).context("invalid Devboxes API URL")?; + let server_alias = server_alias(&parsed); + Ok(ResolvedConfig { + url: resolved_url, + token, + server_alias, + }) + } + + pub fn resolve_url(&self, url: Option) -> Result { let url = url .or_else(|| std::env::var("DEVBOX_URL").ok()) - .or(self.url) + .or_else(|| self.url.clone()) .filter(|value| !value.trim().is_empty()) .ok_or_else(|| { anyhow::anyhow!( "Devboxes API URL is not configured; run `devbox login --url https://devboxes.example.com` or set DEVBOX_URL" ) })?; - let token = token - .or_else(|| std::env::var("DEVBOX_TOKEN").ok()) - .or(self.token) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - anyhow::anyhow!("not logged in; run `devbox login` or set DEVBOX_TOKEN") - })?; let parsed = Url::parse(&url).context("invalid Devboxes API URL")?; let local_http = parsed.scheme() == "http" && matches!(parsed.host_str(), Some("localhost" | "127.0.0.1" | "::1")); @@ -60,12 +71,7 @@ impl StoredConfig { if parsed.query().is_some() || parsed.fragment().is_some() { bail!("the Devboxes API URL must not contain a query string or fragment"); } - let server_alias = server_alias(&parsed); - Ok(ResolvedConfig { - url: url.trim_end_matches('/').to_owned(), - token, - server_alias, - }) + Ok(url.trim_end_matches('/').to_owned()) } pub fn save(url: &str, token: &str) -> Result { diff --git a/cli/src/login.rs b/cli/src/login.rs new file mode 100644 index 0000000..242c7f6 --- /dev/null +++ b/cli/src/login.rs @@ -0,0 +1,416 @@ +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use axum::Router; +use axum::extract::{Query, State}; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use rand::RngCore; +use reqwest::Url; +use sha2::{Digest, Sha256}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +const CALLBACK_PATH: &str = "/callback"; +const CLIENT_ID: &str = "devbox-cli"; + +pub struct BrowserAuthorization { + pub code: String, + pub code_verifier: String, + pub redirect_uri: String, +} + +struct LoginAttempt { + state: String, + code_verifier: String, + code_challenge: String, +} + +impl LoginAttempt { + fn generate() -> Self { + let state = random_base64url(); + let code_verifier = random_base64url(); + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); + Self { + state, + code_verifier, + code_challenge, + } + } +} + +enum CallbackResult { + Code(String), + Denied, + Invalid(&'static str), +} + +#[derive(Clone)] +struct CallbackState { + expected_state: String, + sender: Arc>>>, +} + +struct CallbackListener { + address: SocketAddr, + receiver: oneshot::Receiver, + shutdown: oneshot::Sender<()>, + server: JoinHandle>, +} + +pub async fn authorize( + base_url: &str, + login_timeout: Duration, + no_open: bool, +) -> Result { + authorize_with_opener(base_url, login_timeout, no_open, &|url| { + webbrowser::open(url).map_err(|error| error.to_string()) + }) + .await +} + +async fn authorize_with_opener( + base_url: &str, + login_timeout: Duration, + no_open: bool, + opener: &(dyn Fn(&str) -> std::result::Result<(), String> + Sync), +) -> Result { + let attempt = LoginAttempt::generate(); + let listener = start_callback_listener(&attempt.state).await?; + let redirect_uri = format!("http://{}{}", listener.address, CALLBACK_PATH); + let authorization_url = build_authorization_url( + base_url, + &redirect_uri, + &attempt.state, + &attempt.code_challenge, + )?; + + launch_browser(&authorization_url, no_open, opener); + + let CallbackListener { + receiver, + shutdown, + server, + .. + } = listener; + let callback = tokio::select! { + result = wait_for_callback(receiver, login_timeout) => result, + result = tokio::signal::ctrl_c() => { + result.context("failed to listen for cancellation")?; + bail!("browser authorization was cancelled") + } + }; + let _ = shutdown.send(()); + let _ = server.await; + + let code = callback?; + Ok(BrowserAuthorization { + code, + code_verifier: attempt.code_verifier, + redirect_uri, + }) +} + +fn build_authorization_url( + base_url: &str, + redirect_uri: &str, + state: &str, + code_challenge: &str, +) -> Result { + let base = Url::parse(base_url).context("invalid Devboxes API URL")?; + let mut url = base + .join("/auth/cli/authorize") + .context("failed to construct browser authorization URL")?; + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", CLIENT_ID) + .append_pair("redirect_uri", redirect_uri) + .append_pair("state", state) + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url.into()) +} + +fn launch_browser( + authorization_url: &str, + no_open: bool, + opener: &(dyn Fn(&str) -> std::result::Result<(), String> + Sync), +) { + if no_open { + println!("Open this URL to authorize the Devbox CLI:\n{authorization_url}"); + return; + } + if let Err(error) = opener(authorization_url) { + eprintln!("Could not open the browser ({error})."); + println!("Open this URL to authorize the Devbox CLI:\n{authorization_url}"); + } +} + +async fn start_callback_listener(expected_state: &str) -> Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .context("failed to bind the loopback authorization callback")?; + let address = listener + .local_addr() + .context("failed to inspect the loopback authorization callback")?; + if address.ip() != IpAddr::V4(Ipv4Addr::LOCALHOST) { + bail!("authorization callback did not bind to 127.0.0.1"); + } + + let (callback_sender, receiver) = oneshot::channel(); + let state = CallbackState { + expected_state: expected_state.to_owned(), + sender: Arc::new(Mutex::new(Some(callback_sender))), + }; + let app = Router::new() + .route(CALLBACK_PATH, get(callback)) + .fallback(not_found) + .with_state(state); + let (shutdown, shutdown_receiver) = oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_receiver.await; + }) + .await + }); + + Ok(CallbackListener { + address, + receiver, + shutdown, + server, + }) +} + +async fn callback( + State(state): State, + Query(query): Query>, +) -> Response { + let supplied_state = query.get("state").map(String::as_str).unwrap_or_default(); + if supplied_state != state.expected_state { + send_callback( + &state, + CallbackResult::Invalid("callback state did not match"), + ); + return callback_response( + StatusCode::BAD_REQUEST, + "

Authorization failed

The callback state did not match.

", + ); + } + + match (query.get("code"), query.get("error")) { + (Some(code), None) if valid_code(code) => { + send_callback(&state, CallbackResult::Code(code.clone())); + callback_response( + StatusCode::OK, + "

Devbox CLI authorized

You can close this window and return to the terminal.

", + ) + } + (None, Some(error)) if error == "access_denied" => { + send_callback(&state, CallbackResult::Denied); + callback_response( + StatusCode::OK, + "

Authorization denied

You can close this window.

", + ) + } + _ => { + send_callback(&state, CallbackResult::Invalid("callback was malformed")); + callback_response( + StatusCode::BAD_REQUEST, + "

Authorization failed

The callback was malformed.

", + ) + } + } +} + +fn callback_response(status: StatusCode, body: &'static str) -> Response { + let mut response = (status, Html(body)).into_response(); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response.headers_mut().insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + response +} + +async fn not_found() -> impl IntoResponse { + (StatusCode::NOT_FOUND, "Not found") +} + +fn send_callback(state: &CallbackState, result: CallbackResult) { + let sender = state + .sender + .lock() + .expect("callback sender mutex was poisoned") + .take(); + if let Some(sender) = sender { + let _ = sender.send(result); + } +} + +async fn wait_for_callback( + receiver: oneshot::Receiver, + login_timeout: Duration, +) -> Result { + let result = tokio::time::timeout(login_timeout, receiver) + .await + .map_err(|_| anyhow::anyhow!("timed out waiting for browser authorization"))? + .context("browser authorization callback closed unexpectedly")?; + match result { + CallbackResult::Code(code) => Ok(code), + CallbackResult::Denied => bail!("browser authorization was denied"), + CallbackResult::Invalid(message) => bail!("browser authorization failed: {message}"), + } +} + +fn valid_code(code: &str) -> bool { + (32..=256).contains(&code.len()) + && code + .bytes() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, b'-' | b'_')) +} + +fn random_base64url() -> String { + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[test] + fn state_and_pkce_are_high_entropy_and_s256() { + let first = LoginAttempt::generate(); + let second = LoginAttempt::generate(); + + assert_eq!(first.state.len(), 43); + assert_eq!(first.code_verifier.len(), 43); + assert_eq!(first.code_challenge.len(), 43); + assert_ne!(first.state, second.state); + assert_ne!(first.code_verifier, second.code_verifier); + assert_eq!( + first.code_challenge, + URL_SAFE_NO_PAD.encode(Sha256::digest(first.code_verifier.as_bytes())) + ); + } + + #[test] + fn authorization_url_contains_fixed_client_loopback_and_s256() { + let url = build_authorization_url( + "https://devboxes.example.com", + "http://127.0.0.1:49152/callback", + "state-value", + "challenge-value", + ) + .unwrap(); + let parsed = Url::parse(&url).unwrap(); + let query = parsed.query_pairs().collect::>(); + + assert_eq!(parsed.path(), "/auth/cli/authorize"); + assert_eq!(query["response_type"], "code"); + assert_eq!(query["client_id"], CLIENT_ID); + assert_eq!(query["redirect_uri"], "http://127.0.0.1:49152/callback"); + assert_eq!(query["state"], "state-value"); + assert_eq!(query["code_challenge"], "challenge-value"); + assert_eq!(query["code_challenge_method"], "S256"); + } + + #[test] + fn opener_is_invoked_unless_no_open_and_failures_do_not_abort() { + let calls = Arc::new(AtomicUsize::new(0)); + let opener_calls = Arc::clone(&calls); + let opener = move |_: &str| { + opener_calls.fetch_add(1, Ordering::Relaxed); + Err("no browser".to_owned()) + }; + + launch_browser("https://example.test/auth", false, &opener); + assert_eq!(calls.load(Ordering::Relaxed), 1); + launch_browser("https://example.test/auth", true, &opener); + assert_eq!(calls.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn callback_binds_only_ipv4_loopback_ignores_favicon_and_accepts_code() { + let listener = start_callback_listener("expected-state").await.unwrap(); + assert_eq!(listener.address.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST)); + let base = format!("http://{}", listener.address); + assert_eq!( + reqwest::get(format!("{base}/favicon.ico")) + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + + let code = "authorization-code-value-with-enough-entropy"; + assert_eq!( + reqwest::get(format!("{base}/callback?state=expected-state&code={code}")) + .await + .unwrap() + .status(), + StatusCode::OK + ); + assert_eq!( + wait_for_callback(listener.receiver, Duration::from_secs(1)) + .await + .unwrap(), + code + ); + let _ = listener.shutdown.send(()); + listener.server.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn callback_reports_denial_wrong_state_malformed_and_timeout() { + for (query, expected) in [ + ( + "state=expected-state&error=access_denied", + "browser authorization was denied", + ), + ( + "state=wrong&code=authorization-code-value-with-enough-entropy", + "callback state did not match", + ), + ("state=expected-state", "callback was malformed"), + ] { + let listener = start_callback_listener("expected-state").await.unwrap(); + let response = reqwest::get(format!( + "http://{}{CALLBACK_PATH}?{query}", + listener.address + )) + .await + .unwrap(); + assert!(matches!( + response.status(), + StatusCode::OK | StatusCode::BAD_REQUEST + )); + let error = wait_for_callback(listener.receiver, Duration::from_secs(1)) + .await + .unwrap_err(); + assert!(error.to_string().contains(expected)); + let _ = listener.shutdown.send(()); + listener.server.await.unwrap().unwrap(); + } + + let (_sender, receiver) = oneshot::channel(); + let timeout = wait_for_callback(receiver, Duration::from_millis(1)) + .await + .unwrap_err(); + assert!(timeout.to_string().contains("timed out")); + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index e9a0f96..9e3deea 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,5 +1,6 @@ mod client; mod config; +mod login; mod models; use std::io::{self, Write}; @@ -42,7 +43,7 @@ struct Cli { #[derive(Subcommand)] enum Commands { /// Store and verify API credentials. - Login, + Login(LoginArgs), /// Create a prepared devbox. Create(CreateArgs), /// List current devboxes. @@ -59,6 +60,17 @@ enum Commands { Delete(DeleteArgs), } +#[derive(Args)] +struct LoginArgs { + /// Print the authorization URL without opening a browser. + #[arg(long)] + no_open: bool, + + /// Seconds to wait for browser authorization. + #[arg(long, default_value_t = 300, value_parser = clap::value_parser!(u64).range(10..=900))] + timeout: u64, +} + #[derive(Args)] struct CreateArgs { #[arg(value_parser = validate_name)] @@ -117,8 +129,8 @@ struct DeleteArgs { #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - if matches!(&cli.command, Commands::Login) { - return login(cli.url.as_deref(), cli.token.as_deref()).await; + if let Commands::Login(args) = &cli.command { + return login(cli.url.as_deref(), cli.token.as_deref(), args, cli.json).await; } let resolved = StoredConfig::load()?.resolve(cli.url.clone(), cli.token.clone())?; @@ -126,7 +138,7 @@ async fn main() -> Result<()> { let client = ApiClient::new(resolved.url, resolved.token)?; match cli.command { - Commands::Login => unreachable!(), + Commands::Login(_) => unreachable!(), Commands::Create(args) => create(&client, args, cli.json, &server_alias).await, Commands::List => list(&client, cli.json).await, Commands::Status(args) => status(&client, &args.name, cli.json).await, @@ -137,7 +149,12 @@ async fn main() -> Result<()> { } } -async fn login(url: Option<&str>, provided_token: Option<&str>) -> Result<()> { +async fn login( + url: Option<&str>, + provided_token: Option<&str>, + args: &LoginArgs, + json: bool, +) -> Result<()> { let stored = StoredConfig::load()?; let configured_url = url .map(str::to_owned) @@ -149,9 +166,32 @@ async fn login(url: Option<&str>, provided_token: Option<&str>) -> Result<()> { "Devboxes API URL is not configured; pass `--url https://devboxes.example.com` or set DEVBOX_URL" ) })?; - let token = match resolve_login_token(provided_token, std::env::var("DEVBOX_TOKEN").ok()) { - Some(token) => token, - None => rpassword::prompt_password("Devboxes access token: ")?, + let configured_url = stored.resolve_url(Some(configured_url))?; + let token = if let Some(token) = + resolve_login_token(provided_token, std::env::var("DEVBOX_TOKEN").ok()) + { + token + } else { + let authorization = login::authorize( + &configured_url, + Duration::from_secs(args.timeout), + args.no_open, + ) + .await?; + let response = ApiClient::exchange_cli_code( + &configured_url, + &authorization.code, + &authorization.code_verifier, + &authorization.redirect_uri, + ) + .await?; + if response.token_type != "Bearer" + || response.scope != "devboxes:manage" + || response.expires_in == 0 + { + bail!("Devboxes API returned an unsupported CLI token"); + } + response.access_token }; let resolved = stored.resolve(Some(configured_url), Some(token.clone()))?; let identity = ApiClient::new(resolved.url.clone(), resolved.token)? @@ -159,12 +199,23 @@ async fn login(url: Option<&str>, provided_token: Option<&str>) -> Result<()> { .await .context("token verification failed")?; let path = StoredConfig::save(&resolved.url, &token)?; - println!( - "✓ authenticated as {} via {}\n config: {}", - identity.user, - identity.mode, - path.display() - ); + if json { + println!( + "{}", + serde_json::json!({ + "user": identity.user, + "mode": identity.mode, + "config": path, + }) + ); + } else { + println!( + "✓ authenticated as {} via {}\n config: {}", + identity.user, + identity.mode, + path.display() + ); + } Ok(()) } diff --git a/cli/src/models.rs b/cli/src/models.rs index e9784ae..ef9177c 100644 --- a/cli/src/models.rs +++ b/cli/src/models.rs @@ -61,3 +61,20 @@ pub struct WhoAmI { pub user: String, pub mode: String, } + +#[derive(Debug, Serialize)] +pub struct CliTokenRequest<'a> { + pub grant_type: &'static str, + pub code: &'a str, + pub code_verifier: &'a str, + pub client_id: &'static str, + pub redirect_uri: &'a str, +} + +#[derive(Debug, Deserialize)] +pub struct CliTokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + pub scope: String, +} diff --git a/controller/README.md b/controller/README.md index c114c19..e43da8d 100644 --- a/controller/README.md +++ b/controller/README.md @@ -2,6 +2,9 @@ FastAPI controller, authenticated REST API, and server-rendered dashboard for Devboxes. +Authentication supports the master bearer token, signed browser sessions with CSRF, and a +native CLI Authorization Code plus PKCE flow that issues scoped expiring bearer tokens. + ```bash uv sync --extra dev uv run uvicorn devboxes_controller.app:create_app --factory --reload diff --git a/controller/pyproject.toml b/controller/pyproject.toml index 329cbfb..b2e0c80 100644 --- a/controller/pyproject.toml +++ b/controller/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devboxes-controller" -version = "0.1.2" +version = "0.2.0" description = "Controller and dashboard for self-hosted Kubernetes development environments" readme = "README.md" requires-python = ">=3.12" @@ -12,6 +12,7 @@ dependencies = [ "kubernetes>=35,<37", "prometheus-client>=0.22,<1", "pydantic-settings>=2.10,<3", + "pyjwt>=2.10,<3", "python-multipart>=0.0.20,<1", "uvicorn[standard]>=0.35,<1", "urllib3>=2.7,<3", diff --git a/controller/src/devboxes_controller/__init__.py b/controller/src/devboxes_controller/__init__.py index f16cea6..96058f6 100644 --- a/controller/src/devboxes_controller/__init__.py +++ b/controller/src/devboxes_controller/__init__.py @@ -1,3 +1,3 @@ """Devboxes controller package.""" -__version__ = "0.1.2" +__version__ = "0.2.0" diff --git a/controller/src/devboxes_controller/app.py b/controller/src/devboxes_controller/app.py index fd59ae2..3dc67fc 100644 --- a/controller/src/devboxes_controller/app.py +++ b/controller/src/devboxes_controller/app.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager, suppress from pathlib import Path from typing import Annotated +from urllib.parse import urlencode, urlsplit, urlunsplit import uvicorn from fastapi import Depends, FastAPI, Form, HTTPException, Query, Request, status @@ -14,13 +15,33 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from prometheus_client import CONTENT_TYPE_LATEST, Gauge, generate_latest +from pydantic import ValidationError from starlette.middleware.base import RequestResponseEndpoint from . import __version__ -from .auth import CSRF_COOKIE, SESSION_COOKIE, AuthContext, Authenticator +from .auth import ( + CLI_CLIENT_ID, + CLI_SCOPE, + CSRF_COOKIE, + SESSION_COOKIE, + AuthContext, + Authenticator, + AuthorizationCodeStore, + is_loopback_redirect_uri, + is_safe_login_next, + validate_authorization_request, +) from .config import Settings, get_settings from .manager import DevboxConflictError, DevboxManager, DevboxNotFoundError -from .models import CreateDevboxRequest, DeleteResult, Devbox, DevboxList, WhoAmI +from .models import ( + CliTokenRequest, + CliTokenResponse, + CreateDevboxRequest, + DeleteResult, + Devbox, + DevboxList, + WhoAmI, +) from .resources import PRESETS logger = logging.getLogger(__name__) @@ -44,6 +65,10 @@ def create_app( settings = settings or get_settings() manager = manager or DevboxManager(settings) authenticator = Authenticator(settings) + authorization_codes = AuthorizationCodeStore( + ttl_seconds=settings.authorization_code_ttl_seconds, + maximum_codes=settings.authorization_code_store_size, + ) templates = Jinja2Templates(directory=PACKAGE_DIR / "templates") @asynccontextmanager @@ -89,7 +114,7 @@ async def security_headers(request: Request, call_next: RequestResponseEndpoint) "/docs", "/login", "/auth/login", - } or request.url.path.startswith("/api/"): + } or request.url.path.startswith(("/api/", "/auth/cli/")): response.headers.setdefault("Cache-Control", "no-store") return response @@ -120,17 +145,32 @@ async def metrics() -> Response: return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) @app.get("/login", response_class=HTMLResponse, include_in_schema=False) - async def login_page(request: Request) -> Response: + async def login_page( + request: Request, + next_target: Annotated[str, Query(alias="next", max_length=2048)] = "/", + ) -> Response: + if not is_safe_login_next(next_target): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid return path" + ) if authenticator.browser_session_valid(request): - return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(next_target, status_code=status.HTTP_303_SEE_OTHER) return templates.TemplateResponse( request, "login.html", - {"error": None, "cluster_name": settings.cluster_name}, + {"error": None, "cluster_name": settings.cluster_name, "next_target": next_target}, ) @app.post("/auth/login", response_class=HTMLResponse, include_in_schema=False) - async def login(request: Request, token: Annotated[str, Form()]) -> Response: + async def login( + request: Request, + token: Annotated[str, Form()], + next_target: Annotated[str, Form(alias="next", max_length=2048)] = "/", + ) -> Response: + if not is_safe_login_next(next_target): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid return path" + ) if not authenticator.validate_access_token(token): return templates.TemplateResponse( request, @@ -138,11 +178,12 @@ async def login(request: Request, token: Annotated[str, Form()]) -> Response: { "error": "That access token was not accepted.", "cluster_name": settings.cluster_name, + "next_target": next_target, }, status_code=status.HTTP_401_UNAUTHORIZED, ) session, csrf = authenticator.issue_session() - response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + response = RedirectResponse(next_target, status_code=status.HTTP_303_SEE_OTHER) response.set_cookie( SESSION_COOKIE, session, @@ -163,6 +204,124 @@ async def login(request: Request, token: Annotated[str, Form()]) -> Response: ) return response + @app.get("/auth/cli/authorize", response_class=HTMLResponse, include_in_schema=False) + async def cli_authorize_page( + request: Request, + response_type: str, + client_id: str, + redirect_uri: str, + state: str, + code_challenge: str, + code_challenge_method: str, + ) -> Response: + authorization = validate_authorization_request( + response_type=response_type, + client_id=client_id, + redirect_uri=redirect_uri, + state=state, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + csrf = authenticator.browser_csrf(request) + if csrf is None: + next_target = request.url.path + if request.url.query: + next_target += f"?{request.url.query}" + return RedirectResponse( + f"/login?{urlencode({'next': next_target})}", + status_code=status.HTTP_303_SEE_OTHER, + ) + return templates.TemplateResponse( + request, + "cli_authorize.html", + { + "authorization": authorization, + "csrf": csrf, + "cluster_name": settings.cluster_name, + "display_name": settings.display_name, + }, + ) + + @app.post("/auth/cli/authorize", include_in_schema=False) + async def cli_authorize_decision( + request: Request, + action: Annotated[str, Form(max_length=16)], + csrf: Annotated[str, Form(min_length=16, max_length=256)], + response_type: Annotated[str, Form(max_length=16)], + client_id: Annotated[str, Form(max_length=64)], + redirect_uri: Annotated[str, Form(max_length=256)], + state: Annotated[str, Form(max_length=256)], + code_challenge: Annotated[str, Form(max_length=128)], + code_challenge_method: Annotated[str, Form(max_length=16)], + ) -> Response: + authorization = validate_authorization_request( + response_type=response_type, + client_id=client_id, + redirect_uri=redirect_uri, + state=state, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + auth = authenticator.require_form_csrf(request, csrf) + if action == "deny": + location = _append_redirect_query( + authorization.redirect_uri, + {"error": "access_denied", "state": authorization.state}, + ) + elif action == "approve": + code = await authorization_codes.issue( + client_id=authorization.client_id, + redirect_uri=authorization.redirect_uri, + code_challenge=authorization.code_challenge, + subject=auth.subject, + ) + location = _append_redirect_query( + authorization.redirect_uri, + {"code": code, "state": authorization.state}, + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid authorization decision", + ) + return RedirectResponse(location, status_code=status.HTTP_303_SEE_OTHER) + + @app.post("/api/v1/auth/cli/token", tags=["auth"]) + async def cli_token_exchange(request: Request) -> CliTokenResponse: + try: + payload = CliTokenRequest.model_validate(await request.json()) + except (TypeError, ValueError, ValidationError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid authorization code exchange", + ) from None + if ( + payload.grant_type != "authorization_code" + or payload.client_id != CLI_CLIENT_ID + or not is_loopback_redirect_uri(payload.redirect_uri) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid authorization code exchange", + ) + subject = await authorization_codes.consume( + code=payload.code, + client_id=payload.client_id, + redirect_uri=payload.redirect_uri, + code_verifier=payload.code_verifier, + ) + if subject is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid authorization code exchange", + ) + token, expires_in = authenticator.issue_cli_token(subject) + return CliTokenResponse( + access_token=token, + expires_in=expires_in, + scope=CLI_SCOPE, + ) + @app.post("/auth/logout", include_in_schema=False) async def logout(_: Auth) -> Response: response = Response(status_code=status.HTTP_204_NO_CONTENT) @@ -216,7 +375,7 @@ async def documentation(request: Request) -> Response: @app.get("/api/v1/whoami", tags=["auth"]) async def whoami(auth: Auth) -> WhoAmI: - return WhoAmI(user=settings.display_name, mode=auth.mode) + return WhoAmI(user=auth.subject, mode=auth.mode) @app.get("/api/v1/devboxes", tags=["devboxes"]) async def list_devboxes(_: Auth) -> DevboxList: @@ -270,6 +429,11 @@ async def _not_found[T](awaitable: Awaitable[T], name: str) -> T: ) from error +def _append_redirect_query(redirect_uri: str, values: dict[str, str]) -> str: + parsed = urlsplit(redirect_uri) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(values), "")) + + async def _cleanup_loop(manager: DevboxManager, interval: int) -> None: while True: await asyncio.sleep(interval) diff --git a/controller/src/devboxes_controller/auth.py b/controller/src/devboxes_controller/auth.py index f0595e3..5b06e43 100644 --- a/controller/src/devboxes_controller/auth.py +++ b/controller/src/devboxes_controller/auth.py @@ -1,19 +1,39 @@ -"""Authenticate bearer clients and signed browser sessions.""" +"""Authenticate bearer clients, browser sessions, and native CLI authorization.""" +import asyncio import base64 import hashlib import hmac +import ipaddress +import re import secrets import time +from collections import OrderedDict from dataclasses import dataclass +from typing import Final +from urllib.parse import parse_qs, urlsplit +import jwt from fastapi import HTTPException, Request, status +from jwt import InvalidTokenError from .config import Settings SESSION_COOKIE = "devboxes_session" CSRF_COOKIE = "devboxes_csrf" CSRF_HEADER = "X-Devboxes-CSRF" +CLI_CLIENT_ID: Final = "devbox-cli" +CLI_AUDIENCE: Final = "devbox-cli" +CLI_TOKEN_TYPE: Final = "devboxes-cli-v1" # noqa: S105 - public token type claim +CLI_SCOPE: Final = "devboxes:manage" +CLI_CALLBACK_PATH: Final = "/callback" +CLI_RESPONSE_TYPE: Final = "code" +PKCE_METHOD: Final = "S256" +_AUTHORIZATION_PATH: Final = "/auth/cli/authorize" +_STATE_RE = re.compile(r"^[A-Za-z0-9_-]{32,256}$") +_PKCE_RE = re.compile(r"^[A-Za-z0-9._~-]{43,128}$") +_CODE_RE = re.compile(r"^[A-Za-z0-9_-]{32,256}$") +_SIGNING_DOMAIN = b"devboxes:cli-token-signing-key:v1" @dataclass(frozen=True) @@ -21,14 +41,132 @@ class AuthContext: """Describe the authentication mechanism accepted for a request.""" mode: str + subject: str + + +@dataclass(frozen=True) +class AuthorizationRequest: + """Hold one validated native-app authorization request.""" + + response_type: str + client_id: str + redirect_uri: str + state: str + code_challenge: str + code_challenge_method: str + + +@dataclass(frozen=True) +class _AuthorizationCode: + client_id: str + redirect_uri: str + code_challenge: str + subject: str + expires_at: float + + +class AuthorizationCodeStore: + """Issue hashed, bounded, expiring, atomically consumed authorization codes.""" + + def __init__( + self, + *, + ttl_seconds: int = 120, + maximum_codes: int = 1024, + clock: object = time.time, + ) -> None: + self._ttl_seconds = ttl_seconds + self._maximum_codes = maximum_codes + self._clock = clock + self._codes: OrderedDict[str, _AuthorizationCode] = OrderedDict() + self._lock = asyncio.Lock() + + async def issue( + self, + *, + client_id: str, + redirect_uri: str, + code_challenge: str, + subject: str, + ) -> str: + """Create an opaque code while retaining only its SHA-256 digest.""" + code = secrets.token_urlsafe(32) + now = self._now() + record = _AuthorizationCode( + client_id=client_id, + redirect_uri=redirect_uri, + code_challenge=code_challenge, + subject=subject, + expires_at=now + self._ttl_seconds, + ) + async with self._lock: + self._prune(now) + while len(self._codes) >= self._maximum_codes: + self._codes.popitem(last=False) + self._codes[_code_digest(code)] = record + return code + + async def consume( + self, + *, + code: str, + client_id: str, + redirect_uri: str, + code_verifier: str, + ) -> str | None: + """Atomically validate and consume a code, returning its subject.""" + if not _CODE_RE.fullmatch(code) or not _PKCE_RE.fullmatch(code_verifier): + return None + now = self._now() + digest = _code_digest(code) + async with self._lock: + self._prune(now) + record = self._codes.get(digest) + if record is None: + return None + challenge = pkce_s256(code_verifier) + if not ( + hmac.compare_digest(record.client_id, client_id) + and hmac.compare_digest(record.redirect_uri, redirect_uri) + and hmac.compare_digest(record.code_challenge, challenge) + ): + return None + del self._codes[digest] + return record.subject + + async def size(self) -> int: + """Return the pruned code count for diagnostics and tests.""" + async with self._lock: + self._prune(self._now()) + return len(self._codes) + + def _now(self) -> float: + return float(self._clock()) # type: ignore[operator] + + def _prune(self, now: float) -> None: + expired = [digest for digest, code in self._codes.items() if code.expires_at <= now] + for digest in expired: + del self._codes[digest] class Authenticator: - """Issue and validate controller credentials without storing server sessions.""" + """Issue and validate controller credentials without storing browser sessions.""" def __init__(self, settings: Settings) -> None: self._token = settings.access_token.get_secret_value().encode() self._session_ttl = settings.session_ttl_seconds + self._issuer = settings.external_url + self._subject = settings.display_name + self._cli_token_ttl = settings.cli_token_ttl_seconds + configured_signing_key = ( + settings.cli_signing_key.get_secret_value().encode() + if settings.cli_signing_key is not None + else None + ) + self._cli_signing_key = ( + configured_signing_key + or hmac.new(self._token, _SIGNING_DOMAIN, hashlib.sha256).digest() + ) def issue_session(self) -> tuple[str, str]: """Issue a signed browser session and its matching CSRF token.""" @@ -42,6 +180,73 @@ def validate_access_token(self, candidate: str) -> bool: """Compare a candidate controller token in constant time.""" return hmac.compare_digest(candidate.encode(), self._token) + def issue_cli_token(self, subject: str) -> tuple[str, int]: + """Issue a signed, scoped, expiring CLI bearer token.""" + now = int(time.time()) + claims = { + "iss": self._issuer, + "aud": CLI_AUDIENCE, + "sub": subject, + "iat": now, + "nbf": now, + "exp": now + self._cli_token_ttl, + "jti": secrets.token_urlsafe(18), + "token_type": CLI_TOKEN_TYPE, + "scope": CLI_SCOPE, + } + token = jwt.encode( + claims, + self._cli_signing_key, + algorithm="HS256", + headers={"typ": "JWT"}, + ) + return token, self._cli_token_ttl + + def validate_cli_token(self, candidate: str) -> str | None: + """Validate a CLI token and return its subject.""" + if not 64 <= len(candidate) <= 4096: + return None + try: + header = jwt.get_unverified_header(candidate) + if header.get("typ") != "JWT" or header.get("alg") != "HS256": + return None + claims = jwt.decode( + candidate, + self._cli_signing_key, + algorithms=["HS256"], + audience=CLI_AUDIENCE, + issuer=self._issuer, + options={ + "require": [ + "iss", + "aud", + "sub", + "iat", + "nbf", + "exp", + "jti", + "token_type", + "scope", + ] + }, + leeway=30, + ) + issued_at = int(claims["iat"]) + expires_at = int(claims["exp"]) + if ( + claims.get("token_type") != CLI_TOKEN_TYPE + or claims.get("scope") != CLI_SCOPE + or not isinstance(claims.get("sub"), str) + or not claims["sub"] + or expires_at <= issued_at + or expires_at - issued_at > self._cli_token_ttl + or issued_at > int(time.time()) + 30 + ): + return None + return str(claims["sub"]) + except (InvalidTokenError, TypeError, ValueError): + return None + def validate_session(self, candidate: str) -> str | None: """Return the CSRF value for a valid unexpired session.""" try: @@ -60,39 +265,153 @@ def validate_session(self, candidate: str) -> str | None: return csrf async def require(self, request: Request) -> AuthContext: - """Require bearer authentication or a valid browser session and CSRF token.""" + """Require a master/CLI bearer token or a CSRF-protected browser session.""" authorization = request.headers.get("Authorization", "") if authorization.startswith("Bearer "): candidate = authorization.removeprefix("Bearer ").strip() if self.validate_access_token(candidate): - return AuthContext(mode="bearer") + return AuthContext(mode="master-bearer", subject=self._subject) + subject = self.validate_cli_token(candidate) + if subject is not None: + return AuthContext(mode="cli-bearer", subject=subject) raise _unauthorized() - session = request.cookies.get(SESSION_COOKIE) - if not session: - raise _unauthorized() - csrf = self.validate_session(session) + csrf = self.browser_csrf(request) if csrf is None: raise _unauthorized() - if request.method not in {"GET", "HEAD", "OPTIONS"}: supplied_csrf = request.headers.get(CSRF_HEADER, "") - cookie_csrf = request.cookies.get(CSRF_COOKIE, "") - if not ( - supplied_csrf - and hmac.compare_digest(supplied_csrf, csrf) - and hmac.compare_digest(cookie_csrf, csrf) - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Missing or invalid CSRF token", - ) - return AuthContext(mode="session") + if not supplied_csrf or not hmac.compare_digest(supplied_csrf, csrf): + raise _csrf_error() + return AuthContext(mode="browser-session", subject=self._subject) + + def browser_csrf(self, request: Request) -> str | None: + """Return the CSRF value when both browser-session cookies agree.""" + session = request.cookies.get(SESSION_COOKIE) + cookie_csrf = request.cookies.get(CSRF_COOKIE, "") + if not session or not cookie_csrf: + return None + csrf = self.validate_session(session) + if csrf is None or not hmac.compare_digest(cookie_csrf, csrf): + return None + return csrf + + def require_form_csrf(self, request: Request, supplied_csrf: str) -> AuthContext: + """Require a browser session and matching HTML form CSRF token.""" + csrf = self.browser_csrf(request) + if csrf is None: + raise _unauthorized() + if not supplied_csrf or not hmac.compare_digest(supplied_csrf, csrf): + raise _csrf_error() + return AuthContext(mode="browser-session", subject=self._subject) def browser_session_valid(self, request: Request) -> bool: """Return whether a request carries a valid browser session.""" - session = request.cookies.get(SESSION_COOKIE) - return bool(session and self.validate_session(session)) + return self.browser_csrf(request) is not None + + +def validate_authorization_request( + *, + response_type: str, + client_id: str, + redirect_uri: str, + state: str, + code_challenge: str, + code_challenge_method: str, +) -> AuthorizationRequest: + """Validate the fixed native CLI client and its loopback redirect.""" + if response_type != CLI_RESPONSE_TYPE or client_id != CLI_CLIENT_ID: + raise _oauth_error("invalid authorization request") + if code_challenge_method != PKCE_METHOD or not _PKCE_RE.fullmatch(code_challenge): + raise _oauth_error("invalid authorization request") + if not _STATE_RE.fullmatch(state): + raise _oauth_error("invalid authorization request") + if not is_loopback_redirect_uri(redirect_uri): + raise _oauth_error("invalid authorization request") + return AuthorizationRequest( + response_type=response_type, + client_id=client_id, + redirect_uri=redirect_uri, + state=state, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + + +def is_loopback_redirect_uri(candidate: str) -> bool: + """Return whether a URI is an exact numeric HTTP loopback callback.""" + if len(candidate) > 256: + return False + try: + parsed = urlsplit(candidate) + host = parsed.hostname + address = ipaddress.ip_address(host) if host is not None else None + port = parsed.port + except (ValueError, UnicodeError): + return False + return bool( + parsed.scheme == "http" + and address is not None + and address.is_loopback + and port is not None + and 1024 <= port <= 65535 + and parsed.path == CLI_CALLBACK_PATH + and not parsed.username + and parsed.password is None + and not parsed.query + and not parsed.fragment + ) + + +def is_safe_login_next(candidate: str) -> bool: + """Allow only the dashboard root or a fully valid CLI authorization request.""" + if candidate == "/": + return True + if len(candidate) > 2048: + return False + parsed = urlsplit(candidate) + if parsed.scheme or parsed.netloc or parsed.fragment or parsed.path != _AUTHORIZATION_PATH: + return False + try: + query = parse_qs( + parsed.query, + keep_blank_values=True, + strict_parsing=True, + max_num_fields=6, + ) + except ValueError: + return False + expected = { + "response_type", + "client_id", + "redirect_uri", + "state", + "code_challenge", + "code_challenge_method", + } + if set(query) != expected or any(len(values) != 1 for values in query.values()): + return False + try: + validate_authorization_request( + response_type=query["response_type"][0], + client_id=query["client_id"][0], + redirect_uri=query["redirect_uri"][0], + state=query["state"][0], + code_challenge=query["code_challenge"][0], + code_challenge_method=query["code_challenge_method"][0], + ) + except (HTTPException, ValueError): + return False + return True + + +def pkce_s256(verifier: str) -> str: + """Derive the RFC 7636 S256 challenge for a verifier.""" + return _b64(hashlib.sha256(verifier.encode("ascii")).digest()) + + +def _code_digest(code: str) -> str: + return hashlib.sha256(code.encode()).hexdigest() def _unauthorized() -> HTTPException: @@ -103,6 +422,17 @@ def _unauthorized() -> HTTPException: ) +def _csrf_error() -> HTTPException: + return HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Missing or invalid CSRF token", + ) + + +def _oauth_error(message: str) -> HTTPException: + return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) + + def _b64(value: bytes) -> str: return base64.urlsafe_b64encode(value).decode().rstrip("=") diff --git a/controller/src/devboxes_controller/config.py b/controller/src/devboxes_controller/config.py index 1d7ede5..7a5d4cb 100644 --- a/controller/src/devboxes_controller/config.py +++ b/controller/src/devboxes_controller/config.py @@ -34,6 +34,10 @@ class Settings(BaseSettings): max_ttl_hours: int = Field(default=168, le=168) cleanup_interval_seconds: int = 60 session_ttl_seconds: int = 43_200 + authorization_code_ttl_seconds: int = Field(default=120, ge=30, le=600) + authorization_code_store_size: int = Field(default=1024, ge=16, le=10_000) + cli_token_ttl_seconds: int = Field(default=2_592_000, ge=300, le=31_536_000) + cli_signing_key: SecretStr | None = None cookie_secure: bool = True kubeconfig_context: str | None = None log_level: str = "INFO" @@ -53,6 +57,7 @@ def text_is_not_blank(cls, value: str) -> str: "workspace_service_host", "workspace_load_balancer_class", "kubeconfig_context", + "cli_signing_key", mode="before", ) @classmethod @@ -72,7 +77,14 @@ def external_url_is_http(cls, value: str) -> str: raise ValueError("must be an absolute http or https URL") return value - @field_validator("default_ttl_hours", "max_ttl_hours", "cleanup_interval_seconds") + @field_validator( + "default_ttl_hours", + "max_ttl_hours", + "cleanup_interval_seconds", + "authorization_code_ttl_seconds", + "authorization_code_store_size", + "cli_token_ttl_seconds", + ) @classmethod def positive_integer(cls, value: int) -> int: """Require positive timing and TTL configuration values.""" @@ -80,6 +92,14 @@ def positive_integer(cls, value: int) -> int: raise ValueError("must be positive") return value + @field_validator("cli_signing_key") + @classmethod + def cli_signing_key_is_strong(cls, value: SecretStr | None) -> SecretStr | None: + """Require a dedicated CLI signing key to carry strong entropy when set.""" + if value is not None and len(value.get_secret_value().strip()) < 32: + raise ValueError("CLI signing key must contain at least 32 characters") + return value + @field_validator("access_token") @classmethod def access_token_is_not_blank(cls, value: SecretStr) -> SecretStr: diff --git a/controller/src/devboxes_controller/models.py b/controller/src/devboxes_controller/models.py index 89866e0..76138c8 100644 --- a/controller/src/devboxes_controller/models.py +++ b/controller/src/devboxes_controller/models.py @@ -94,6 +94,27 @@ class WhoAmI(BaseModel): mode: str +class CliTokenRequest(BaseModel): + """Validate a native CLI authorization-code exchange.""" + + model_config = ConfigDict(extra="forbid") + + grant_type: str = Field(default="authorization_code", max_length=32) + code: str = Field(min_length=32, max_length=256) + code_verifier: str = Field(min_length=43, max_length=128) + client_id: str = Field(min_length=1, max_length=64) + redirect_uri: str = Field(min_length=1, max_length=256) + + +class CliTokenResponse(BaseModel): + """Return a scoped CLI token without refresh-token material.""" + + access_token: str + token_type: str = "Bearer" # noqa: S105 - OAuth token type, not a credential + expires_in: int + scope: str + + class DeleteResult(BaseModel): """Report the data-retention result of a delete operation.""" diff --git a/controller/src/devboxes_controller/static/styles.css b/controller/src/devboxes_controller/static/styles.css index 6b090ce..27902ae 100644 --- a/controller/src/devboxes_controller/static/styles.css +++ b/controller/src/devboxes_controller/static/styles.css @@ -1403,6 +1403,106 @@ kbd { font-size: 0.75rem; } +.authorization-shell { + width: min(42rem, 100%); + overflow: hidden; + border-radius: var(--radius-lg); + background: var(--bg); + box-shadow: 0 8px 24px oklch(0.18 0.03 256 / 0.16); +} + +.authorization-header { + display: flex; + min-height: 4.5rem; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: 0 var(--space-6); + background: oklch(0.25 0.09 256); + color: var(--white); +} + +.authorization-header .wordmark { + color: var(--white); +} + +.authorization-header .wordmark-mark { + background: var(--white); + color: oklch(0.25 0.09 256); +} + +.authorization-cluster { + color: oklch(0.87 0.025 256); + font-size: 0.78rem; + font-weight: 680; +} + +.authorization-content { + padding: var(--space-7); +} + +.authorization-intro h1 { + max-width: 20ch; + font-size: 2rem; +} + +.authorization-intro > p:last-child { + max-width: 66ch; + margin-bottom: 0; + color: var(--muted); + text-wrap: pretty; +} + +.authorization-intro strong { + color: var(--ink); +} + +.authorization-facts { + margin: var(--space-6) 0; + border-top: 1px solid var(--line); +} + +.authorization-facts > div { + display: grid; + grid-template-columns: 8rem minmax(0, 1fr); + gap: var(--space-4); + padding: var(--space-3) 0; + border-bottom: 1px solid var(--line); +} + +.authorization-facts dt { + color: var(--muted); + font-size: 0.8rem; + font-weight: 700; +} + +.authorization-facts dd { + min-width: 0; + margin: 0; + color: var(--ink); + font-size: 0.88rem; +} + +.authorization-facts code { + overflow-wrap: anywhere; +} + +.authorization-note { + padding: var(--space-4); + border: 1px solid oklch(0.79 0.075 76); + border-radius: var(--radius-md); + background: var(--warning-soft); + color: var(--ink); + font-size: 0.86rem; +} + +.authorization-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-6); +} + @media (max-width: 70rem) { .create-form { grid-template-columns: 1fr 1fr 1fr; @@ -1498,6 +1598,11 @@ kbd { border-radius: 0; } + .authorization-shell { + min-height: 100vh; + border-radius: 0; + } + .login-copy, .login-panel { padding: var(--space-6); @@ -1592,6 +1697,27 @@ kbd { font-size: 2.3rem; } + .authorization-header, + .authorization-content { + padding-right: var(--space-4); + padding-left: var(--space-4); + } + + .authorization-intro h1 { + font-size: 1.7rem; + } + + .authorization-facts > div { + grid-template-columns: 1fr; + gap: var(--space-1); + } + + .authorization-actions { + align-items: stretch; + flex-direction: column-reverse; + margin-top: var(--space-3); + } + .docs-hero { padding: var(--space-6) 0 var(--space-5); } diff --git a/controller/src/devboxes_controller/templates/cli_authorize.html b/controller/src/devboxes_controller/templates/cli_authorize.html new file mode 100644 index 0000000..26944bc --- /dev/null +++ b/controller/src/devboxes_controller/templates/cli_authorize.html @@ -0,0 +1,70 @@ + + + + + + + Authorize Devbox CLI · Devboxes + + + + +
+
+ + + devboxes + + {{ cluster_name }} +
+ +
+
+

Command-line authorization

+

Allow the Devbox CLI to access this installation?

+

+ A local Devbox CLI is waiting for your decision. Approval creates a scoped, + expiring token for {{ display_name }}; it does not reveal or copy + the operator access token. +

+
+ +
+
+
Application
+
Devbox CLI
+
+
+
Access
+
Create and manage devboxes as the current operator
+
+
+
Return address
+
{{ authorization.redirect_uri }}
+
+
+ +

+ Only approve if you started devbox login on this computer. Denying + returns a refusal to the waiting CLI and issues no token. +

+ +
+ + + + + + + + + +
+
+
+ + diff --git a/controller/src/devboxes_controller/templates/docs.html b/controller/src/devboxes_controller/templates/docs.html index f317e2b..ec435a2 100644 --- a/controller/src/devboxes_controller/templates/docs.html +++ b/controller/src/devboxes_controller/templates/docs.html @@ -5,9 +5,9 @@ Documentation · Devboxes - - - + + + diff --git a/controller/src/devboxes_controller/templates/index.html b/controller/src/devboxes_controller/templates/index.html index 1cb0453..46e39b9 100644 --- a/controller/src/devboxes_controller/templates/index.html +++ b/controller/src/devboxes_controller/templates/index.html @@ -5,9 +5,9 @@ Devboxes - - - + + + diff --git a/controller/src/devboxes_controller/templates/login.html b/controller/src/devboxes_controller/templates/login.html index 3c1f695..e0cb262 100644 --- a/controller/src/devboxes_controller/templates/login.html +++ b/controller/src/devboxes_controller/templates/login.html @@ -5,8 +5,8 @@ Sign in · Devboxes - - + +
@@ -35,6 +35,7 @@

Open the workbench

Use the access token your cluster operator created during installation.