Files
Mrunal Patel 084505425b feat(auth): add OIDC/Keycloak authentication with RBAC and scope-based permissions (#935)
* feat(auth): add OIDC/Keycloak authentication with RBAC

Add OAuth2/OIDC authentication to the gateway server with role-based
access control, CLI login flows, and full deployment plumbing.

Server: JWT validation against configurable OIDC issuer (oidc.rs),
JWKS key caching with TTL and rotation handling, method classification
(unauthenticated/sandbox-secret/dual-auth/bearer), identity extraction
with provider-agnostic Identity type, and RBAC enforcement via
AuthzPolicy with configurable admin/user roles and auth-only mode.

CLI: browser-based Authorization Code + PKCE flow, Client Credentials
flow for CI/automation, token storage with refresh, gateway add/login/
logout commands, OIDC bearer token injection over mTLS transport,
discovery endpoint for auto-configuration.

Security: sandbox-secret scope restriction on UpdateConfig (policy
sync only), anti-spoofing header stripping, dual-auth fallthrough
from sandbox-secret to Bearer token.

Deployment: OIDC config wired through DeployOptions, Docker env vars,
Helm values/templates, HelmChart manifest, cluster-entrypoint.sh, and
bootstrap scripts. Keycloak dev server script with pre-configured
realm (test users, roles, PKCE client, CI client).

Tested with Keycloak. The roles claim path and role names are
configurable to support other OIDC providers.

* feat(auth): add OAuth2 scope-based fine-grained permissions

Add opt-in scope enforcement on top of existing OIDC role-based access
control. When --oidc-scopes-claim is set, the server extracts scopes
from the JWT and checks them per-method against an exhaustive scope map.

Scopes: sandbox:read, sandbox:write, provider:read, provider:write,
config:read, config:write, inference:read, inference:write, and
openshell:all (wildcard). Methods not in the scope map require
openshell:all. Scopes layer on top of roles and cannot escalate
privilege. Auth-only mode (empty role names) still enforces scopes
when enabled.

Server: scopes_claim in OidcConfig, scope extraction from JWT
(space-delimited and JSON array formats), standard OIDC scope
filtering, scope check in AuthzPolicy after role check.

CLI: --oidc-scopes on gateway add/start stored in metadata and
consumed by gateway login, --oidc-scopes-claim on gateway start
forwarded to server, scopes parameter in browser and client
credentials OAuth2 flows with openid deduplication.

Deployment: oidc_scopes_claim wired through DeployOptions, docker.rs,
Helm, bootstrap scripts, and cluster entrypoint.

Keycloak: realm config updated with built-in OIDC scopes and 9
OpenShell client scopes as optional on openshell-cli and openshell:all
as default on openshell-ci.

* fix(auth): address branch review findings

Add GetInferenceBundle to sandbox-secret methods so sandbox inference
route refresh works under OIDC. Make GetSandboxConfig dual-auth so CLI
users can read sandbox settings with Bearer tokens.

Preserve OIDC gateway metadata on restart — a bare gateway start
without --oidc-* flags no longer erases the stored OIDC registration.

Document CI client ID requirement (openshell-ci vs openshell-cli) in
the testing guide. Add security note about auth-only mode blast radius
for GitHub Actions.

* fix(auth): complete review findings for OIDC auth boundary

Move OpenShell/GetSandboxConfig from sandbox-secret-only to dual-auth
so CLI users can read sandbox settings with Bearer tokens while sandbox
supervisors continue using the shared secret.

Add sandbox secret interceptor to the inference bundle fetch path so
GetInferenceBundle works under OIDC-enabled gateways. Extract shared
interceptor constructor to avoid duplication.

Add GetSandboxConfig to the config:read scope map so scope enforcement
applies consistently when scopes are enabled.

Refactor OIDC metadata preservation into apply_oidc_gateway_metadata()
with explicit resume semantics — only preserve existing OIDC metadata
on real resume paths, not on fresh deployments.

Update architecture docs and testing guide to reflect the corrected
method classifications and add new test coverage for interceptor
injection, scope requirements, metadata preservation, and dual-auth
classification.

* refactor(auth): use oauth2 crate for CLI OIDC flows

Replace hand-written PKCE generation, authorization URL construction,
token exchange, client credentials, and token refresh with the oauth2
crate's typed API.

Eliminates sha2, hex, and getrandom dependencies from the CLI. The
custom urlencoded() helper and manual form POST logic are replaced by
BasicClient methods with proper type-state safety.

Discovery and the callback server remain custom since the oauth2 crate
does not provide OIDC discovery or a localhost redirect listener.

* refactor(auth): move server auth modules into auth/ directory

Group oidc.rs, authz.rs, identity.rs, and the auth HTTP endpoints
under src/auth/ module directory. No behavioral changes.

  auth/mod.rs      — module root, re-exports HTTP router
  auth/oidc.rs     — JWT validation, JWKS caching, method classification
  auth/authz.rs    — role and scope authorization policy
  auth/identity.rs — provider-agnostic Identity type
  auth/http.rs     — /auth/connect and /auth/oidc-config endpoints

* fix(auth): use RequestBody auth type for client credentials flow

The oauth2 crate defaults to BasicAuth (HTTP Basic header) but Keycloak
and most OIDC providers expect client_secret_post (credentials in the
request body). Set AuthType::RequestBody explicitly to match the
pre-refactor behavior.

Also re-export Identity, IdentityProvider, and JwksCache from the auth
module so ServerState's public API remains nameable by external consumers.

* fix(auth): forward OPENSHELL_OIDC_SCOPES through cluster bootstrap

Pass --oidc-scopes to gateway start so the metadata includes requested
scopes after cluster bootstrap. Without this, users had to manually
edit metadata.json to set scopes for gateway login.

Usage: OPENSHELL_OIDC_SCOPES="openshell:all" mise run cluster

* test(auth): add OIDC e2e tests for RBAC, scopes, and client credentials

Add 10 end-to-end tests covering OIDC authentication against a live
K3s cluster with Keycloak:

RBAC (5 tests): admin can create providers, user cannot, user can list
sandboxes, unauthenticated requests rejected, health probe works
without auth.

Scopes (4 tests): sandbox-scoped token can list sandboxes but not
providers, openshell:all grants full access, no-scopes token denied.

Client credentials (1 test): CI token via client_credentials grant.

Tests are opt-in via OPENSHELL_E2E_OIDC=1 and OPENSHELL_E2E_OIDC_SCOPES=1
env vars. They derive the Keycloak URL from gateway metadata to match
the server's configured issuer.

Run with:

  OPENSHELL_E2E_OIDC=1 OPENSHELL_E2E_OIDC_SCOPES=1 \
  PYTHONPATH=python uv run pytest e2e/python/oidc/ -v

* fix(docs): fix markdown lint errors in OIDC architecture docs

Add blank lines before lists and fenced code blocks to satisfy
markdownlint MD031 and MD032 rules.
2026-04-30 10:37:23 -07:00
..

openshell-vm

Status: Experimental and work in progress (WIP). VM support is under active development and may change.

MicroVM runtime for OpenShell, powered by libkrun. Boots a lightweight ARM64 Linux VM on macOS (Apple Hypervisor.framework) or Linux (KVM) running a single-node k3s cluster with the OpenShell control plane.

Quick Start

# One-time setup: download pre-built runtime (~30s)
mise run vm:setup

# Build and run the VM
mise run vm

Prerequisites

  • macOS (Apple Silicon) or Linux (aarch64 or x86_64 with KVM)
  • Rust toolchain
  • Guest-supervisor cross-compile toolchain (needed on macOS, and on Linux when host arch ≠ guest arch):
    • Matching rustup target: rustup target add aarch64-unknown-linux-gnu (or x86_64-unknown-linux-gnu for an amd64 guest)
    • cargo install --locked cargo-zigbuild and brew install zig (or distro equivalent). build-rootfs.sh uses cargo zigbuild to cross-compile the in-VM openshell-sandbox supervisor binary.
  • mise task runner
  • Docker (for rootfs builds)
  • gh CLI (for downloading pre-built runtime)

macOS-Specific

The binary must be codesigned with the Hypervisor.framework entitlement. The mise run vm flow handles this automatically. To codesign manually:

codesign --entitlements crates/openshell-vm/entitlements.plist --force -s - target/debug/openshell-vm

Setup

Download Pre-Built Runtime (Default)

Downloads libkrun, libkrunfw, and gvproxy from the vm-dev GitHub Release:

mise run vm:setup

Build from Source

Compiles the runtime from source (15-45 minutes, needed for custom kernel work):

FROM_SOURCE=1 mise run vm:setup

On macOS this builds a custom libkrunfw (kernel firmware with bridge/netfilter support) via krunvm, then builds a portable libkrun. On Linux it builds both natively.

Build

Build the openshell-vm binary with embedded runtime:

mise run vm:build

This compresses runtime artifacts, compiles the Rust binary with include_bytes!() embedding, codesigns it (macOS), and stages the sidecar runtime bundle.

Rootfs

The rootfs is an Ubuntu filesystem containing k3s, pre-loaded container images, and the OpenShell binaries. Build it with:

# Base rootfs (~200-300MB, cold starts in ~30-60s)
mise run vm:rootfs -- --base

# Full rootfs (~2GB+, pre-initialized, boots in ~3-5s)
mise run vm:rootfs

Run

Default (Gateway Mode)

Boots the full OpenShell gateway -- k3s + openshell-server + openshell-sandbox:

mise run vm

Or run the binary directly:

./target/debug/openshell-vm

Custom Process

Run an arbitrary process inside a fresh VM instead of k3s:

./target/debug/openshell-vm --exec /bin/sh --vcpus 2 --mem 2048

Execute in a Running VM

Attach to a running VM and run a command:

./target/debug/openshell-vm exec -- ls /
./target/debug/openshell-vm exec -- sh   # interactive shell

Named Instances

Run multiple isolated VM instances side-by-side:

./target/debug/openshell-vm --name dev
./target/debug/openshell-vm --name staging

Each instance gets its own extracted rootfs under ~/.local/share/openshell/openshell-vm/<version>/instances/<name>/rootfs.

CLI Reference

openshell-vm [OPTIONS] [COMMAND]

Options:
  --rootfs <PATH>          Path to aarch64 Linux rootfs directory
  --name <NAME>            Named VM instance (auto-clones rootfs)
  --exec <PATH>            Run a custom process instead of k3s
  --args <ARGS>...         Arguments to the executable
  --env <KEY=VALUE>...     Environment variables
  --workdir <DIR>          Working directory inside the VM [default: /]
  -p, --port <H:G>...     Port mappings (host_port:guest_port)
  --vcpus <N>              Virtual CPUs [default: 4 gateway, 2 exec]
  --mem <MiB>              RAM in MiB [default: 8192 gateway, 2048 exec]
  --krun-log-level <0-5>   libkrun log level [default: 1]
  --net <BACKEND>          Networking: gvproxy, tsi, none [default: gvproxy]
  --reset                  Wipe runtime state before booting

Subcommands:
  prepare-rootfs           Ensure the target rootfs exists
  exec                     Execute a command inside a running VM

mise Tasks Reference

Task Description
vm Build and run the VM
vm:build Build openshell-vm binary with embedded runtime
vm:setup One-time setup: download (or build) the VM runtime
vm:rootfs Build the VM rootfs tarball (-- --base for lightweight)
vm:clean Remove all VM cached artifacts
e2e:vm Boot VM and run smoke e2e tests

Common Workflows

# First time setup
mise run vm:setup              # download pre-built runtime (~30s)
mise run vm                    # build + run

# Day-to-day iteration
mise run vm                    # incremental build + run

# Need fresh rootfs
mise run vm:rootfs -- --base   # rebuild base rootfs
mise run vm:build              # rebuild binary with new rootfs

# Something broken, start over
mise run vm:clean              # wipe everything
mise run vm:setup              # re-download runtime
mise run vm                    # full rebuild + run

# Custom kernel work (rare)
FROM_SOURCE=1 mise run vm:setup

Architecture

Host (macOS / Linux)
  openshell-vm binary
    |-- Embedded runtime (libkrun, libkrunfw, gvproxy, rootfs.tar.zst)
    |-- FFI: loads libkrun at runtime via dlopen
    |-- gvproxy: virtio-net networking (real eth0 + DHCP)
    |-- virtio-fs: shares rootfs with guest
    \-- vsock: host-to-guest command execution (port 10777)

Guest VM (aarch64 Linux)
  PID 1: openshell-vm-init.sh
    |-- Mounts filesystems, configures networking
    |-- Sets up bridge CNI, generates PKI
    \-- Execs k3s server
        |-- openshell-server (gateway control plane)
        \-- openshell-sandbox (pod supervisor)

Environment Variables

Variable When Purpose
OPENSHELL_VM_RUNTIME_COMPRESSED_DIR Build time Path to compressed runtime artifacts
OPENSHELL_VM_RUNTIME_DIR Runtime Override the runtime bundle directory
OPENSHELL_VM_DIAG=1 Runtime Enable diagnostic output inside the VM
FROM_SOURCE=1 vm:setup Build runtime from source instead of downloading

Custom Kernel (libkrunfw)

The stock libkrunfw (e.g. from Homebrew) lacks bridge, netfilter, and conntrack support needed for pod networking. OpenShell builds a custom libkrunfw with these enabled.

Build it via the setup command:

FROM_SOURCE=1 mise run vm:setup

See runtime/README.md for details on the kernel config and troubleshooting.

Testing

Integration tests require a built rootfs and macOS ARM64 with libkrun:

cargo test -p openshell-vm -- --ignored

Individual tests:

# Full gateway boot test (boots VM, waits for gRPC on port 30051)
cargo test -p openshell-vm gateway_boots -- --ignored

# Run a command inside the VM
cargo test -p openshell-vm gateway_exec_runs -- --ignored

# Exec into a running VM
cargo test -p openshell-vm gateway_exec_attaches -- --ignored

Verify kernel capabilities inside a running VM:

./target/debug/openshell-vm exec -- /srv/check-vm-capabilities.sh
./target/debug/openshell-vm exec -- /srv/check-vm-capabilities.sh --json