From f891f23887defa8c9a03b8adffa1e47b94eea6db Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 21 Apr 2026 15:09:15 -0700 Subject: [PATCH 01/10] 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. --- Cargo.lock | 32 ++ Cargo.toml | 6 + architecture/oidc-auth.md | 472 +++++++++++++++++ architecture/oidc-local-testing.md | 393 ++++++++++++++ crates/openshell-bootstrap/src/docker.rs | 21 + crates/openshell-bootstrap/src/lib.rs | 52 +- crates/openshell-bootstrap/src/metadata.rs | 34 +- crates/openshell-bootstrap/src/oidc_token.rs | 92 ++++ crates/openshell-cli/Cargo.toml | 6 + crates/openshell-cli/src/auth.rs | 4 +- crates/openshell-cli/src/completers.rs | 6 +- crates/openshell-cli/src/lib.rs | 1 + crates/openshell-cli/src/main.rs | 176 +++++-- crates/openshell-cli/src/oidc_auth.rs | 477 +++++++++++++++++ crates/openshell-cli/src/run.rs | 225 ++++++-- crates/openshell-cli/src/ssh.rs | 14 +- crates/openshell-cli/src/tls.rs | 72 ++- crates/openshell-core/src/config.rs | 64 +++ crates/openshell-core/src/lib.rs | 2 +- crates/openshell-sandbox/Cargo.toml | 2 +- crates/openshell-sandbox/src/grpc_client.rs | 45 +- crates/openshell-server/Cargo.toml | 5 +- crates/openshell-server/src/auth.rs | 20 +- crates/openshell-server/src/authz.rs | 222 ++++++++ crates/openshell-server/src/cli.rs | 38 ++ crates/openshell-server/src/grpc/policy.rs | 79 ++- crates/openshell-server/src/identity.rs | 41 ++ crates/openshell-server/src/lib.rs | 28 + crates/openshell-server/src/multiplex.rs | 152 +++++- crates/openshell-server/src/oidc.rs | 484 ++++++++++++++++++ crates/openshell-vm/src/lib.rs | 7 +- deploy/docker/cluster-entrypoint.sh | 17 + .../helm/openshell/templates/statefulset.yaml | 20 + deploy/helm/openshell/values.yaml | 18 + .../kube/manifests/openshell-helmchart.yaml | 7 + scripts/keycloak-dev.sh | 128 +++++ scripts/keycloak-realm.json | 94 ++++ tasks/cluster.toml | 4 + tasks/keycloak.toml | 16 + tasks/scripts/cluster-bootstrap.sh | 8 + tasks/scripts/cluster-deploy-fast.sh | 16 + tasks/scripts/cluster-stop.sh | 23 + 42 files changed, 3482 insertions(+), 141 deletions(-) create mode 100644 architecture/oidc-auth.md create mode 100644 architecture/oidc-local-testing.md create mode 100644 crates/openshell-bootstrap/src/oidc_token.rs create mode 100644 crates/openshell-cli/src/oidc_auth.rs create mode 100644 crates/openshell-server/src/authz.rs create mode 100644 crates/openshell-server/src/identity.rs create mode 100644 crates/openshell-server/src/oidc.rs create mode 100755 scripts/keycloak-dev.sh create mode 100644 scripts/keycloak-realm.json create mode 100644 tasks/keycloak.toml create mode 100755 tasks/scripts/cluster-stop.sh diff --git a/Cargo.lock b/Cargo.lock index 967cce7f9..f67e2e8e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2372,6 +2372,21 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "k8s-openapi" version = "0.21.1" @@ -3078,12 +3093,15 @@ name = "openshell-cli" version = "0.0.0" dependencies = [ "anyhow", + "base64 0.22.1", "bytes", "clap", "clap_complete", "crossterm 0.28.1", "dialoguer", "futures", + "getrandom 0.3.4", + "hex", "http-body-util", "hyper", "hyper-rustls", @@ -3105,6 +3123,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "sha2 0.10.9", "tar", "temp-env", "tempfile", @@ -3330,6 +3349,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "ipnet", + "jsonwebtoken", "metrics", "metrics-exporter-prometheus", "miette", @@ -4918,6 +4938,18 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "sketches-ddsketch" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index cffad2cc1..7fe3ef1c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,12 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } # Clipboard (OSC 52) base64 = "0.22" +# Crypto / Auth +sha2 = "0.10" +rand = "0.9" +jsonwebtoken = "9" +getrandom = "0.3" + # Filesystem embedding include_dir = "0.7" diff --git a/architecture/oidc-auth.md b/architecture/oidc-auth.md new file mode 100644 index 000000000..eae307641 --- /dev/null +++ b/architecture/oidc-auth.md @@ -0,0 +1,472 @@ +# OIDC Authentication + +OpenShell supports OAuth2/OIDC (OpenID Connect) as an authentication mode alongside mTLS and Cloudflare Access. When enabled, the gateway server validates JWT bearer tokens on gRPC requests against an OIDC provider's JWKS endpoint. The CLI acquires tokens via browser-based login (Authorization Code + PKCE) or environment variables (Client Credentials). + +## Architecture + +``` + +-------------------+ + | Keycloak / | + | OIDC Provider | + +--------+----------+ + | + JWKS (cached) | Token exchange + +---------+--------+---------+ + | | + v v ++----------+ Bearer token +-----------+ Auth Code +---------+ +| | -----------------> | | <-------------- | | +| CLI | gRPC metadata | Gateway | + PKCE | Browser | +| | <----------------- | Server | | | ++----------+ response +-----------+ +---------+ +``` + +## Auth Modes + +OpenShell determines the authentication strategy per gateway via the `auth_mode` field in gateway metadata (`~/.config/openshell/gateways//metadata.json`): + +| `auth_mode` | Transport | Identity | Token Storage | +|---|---|---|---| +| `"mtls"` | mTLS client cert | Cert CN | N/A | +| `"plaintext"` | HTTP (no TLS) | None | N/A | +| `"cloudflare_jwt"` | Edge TLS (CF Tunnel) | CF Access JWT | `edge_token` file | +| `"oidc"` | mTLS or plaintext | OIDC JWT | `oidc_token.json` | + +## Token Acquisition + +### Interactive: Authorization Code + PKCE + +Used by `openshell gateway login` for interactive CLI sessions. The login flow accepts a `client_id` (the OIDC client application) and an optional `audience` (the API resource server). When `audience` differs from `client_id` — common with providers like Entra ID — it is appended to the authorization URL so the issued token targets the correct API. + +``` +CLI Browser Keycloak + | | | + | 1. Discover OIDC endpoints | | + | GET {issuer}/.well-known/openid-configuration | + | | | + | 2. Generate PKCE pair | | + | code_verifier = random(32 bytes) -> base64url | + | code_challenge = base64url(SHA256(code_verifier)) | + | state = random(16 bytes) -> hex | + | | | + | 3. Start localhost callback | | + | on 127.0.0.1: | | + | | | + | 4. Open browser | | + | -------xdg-open------------->| | + | | 5. Redirect to Keycloak | + | | /auth?response_type=code | + | | &client_id={client_id} | + | | &redirect_uri=localhost:... | + | | &code_challenge=... | + | | &code_challenge_method=S256 | + | | &state=... | + | | [&audience={audience}] | + | | --------------------------->| + | | | + | | 6. User logs | + | | in | + | | | + | | 7. Redirect back | + | | <-- ?code=...&state=... ---| + | | | + | 8. Receive code on callback | | + | <----GET /callback?code=..---| | + | | | + | 9. Validate state matches | | + | | | + | 10. Exchange code for tokens | | + | POST {token_endpoint} | | + | grant_type=authorization_code | + | code=... | | + | redirect_uri=... | | + | client_id={client_id} | | + | code_verifier=... ------------------------------------->| + | | | + | <-- { access_token, refresh_token, expires_in } -----------| + | | | + | 11. Store token bundle | | + | ~/.config/openshell/gateways//oidc_token.json | +``` + +### Non-Interactive: Client Credentials + +Used for CI/automation when `OPENSHELL_OIDC_CLIENT_SECRET` is set. The optional `audience` parameter is included when the API resource server differs from the client ID. + +``` +CI Agent Keycloak + | | + | POST {token_endpoint} | + | grant_type=client_credentials | + | client_id={client_id} | + | client_secret={OPENSHELL_OIDC_CLIENT_SECRET} | + | [audience={audience}] --------------------------------->| + | | + | <-- { access_token, expires_in } -------------------------| + | | + | Store token bundle (no refresh_token) | +``` + +## Token Storage + +OIDC tokens are stored as JSON at `~/.config/openshell/gateways//oidc_token.json` with `0600` permissions: + +```json +{ + "access_token": "eyJhbGci...", + "refresh_token": "eyJhbGci...", + "expires_at": 1718400300, + "issuer": "http://localhost:8180/realms/openshell", + "client_id": "openshell-cli" +} +``` + +The CLI checks `expires_at` before each request. If the token is within 30 seconds of expiry and a `refresh_token` is available, it silently refreshes via the token endpoint's `refresh_token` grant. If refresh fails, the user is prompted to re-authenticate with `openshell gateway login`. + +## Per-Request Flow + +On every gRPC call, the CLI interceptor injects the token as a standard HTTP header: + +``` +authorization: Bearer eyJhbGci... +``` + +The server-side auth middleware (`AuthGrpcRouter` in `multiplex.rs`) classifies each request into one of three categories and processes it accordingly: + +1. **Strip internal markers** — remove `x-openshell-auth-source` from incoming headers to prevent spoofing. +2. **Unauthenticated?** — health probes and reflection pass through with no auth. +3. **Sandbox-secret?** — supervisor RPCs validate the `x-sandbox-secret` header against the server's SSH handshake secret. On success, mark the request with an internal `x-openshell-auth-source: sandbox-secret` header for downstream authorization. +4. **Dual-auth?** — methods like `UpdateConfig` try sandbox-secret first; if no valid secret, fall through to Bearer token validation. +5. **Bearer token** — extract `authorization: Bearer `, decode the JWT header for `kid`, look up the signing key in the JWKS cache, and validate signature (RS256), `exp`, `iss`, `aud` claims. +6. **Authorize** — on successful authentication, check RBAC roles via `AuthzPolicy` (in `authz.rs`). +7. On any failure, return `UNAUTHENTICATED` or `PERMISSION_DENIED` status. + +## JWKS Key Caching + +The server fetches the OIDC provider's JSON Web Key Set at startup via discovery: + +``` +GET {issuer}/.well-known/openid-configuration -> jwks_uri +GET {jwks_uri} -> { keys: [...] } +``` + +Keys are cached in memory with a configurable TTL (default: 1 hour). A `refresh_mutex` serializes refresh operations so concurrent requests coalesce into a single HTTP fetch. The cache refreshes: +- When the TTL expires (on next request, re-checked under the mutex to avoid thundering herd). +- Immediately when a JWT references a `kid` not in the cache (handles key rotation). + +## Method Authentication Categories + +Every gRPC method falls into one of three categories, defined in `oidc.rs`: + +### Unauthenticated + +These methods require no authentication at all — health probes and infrastructure endpoints. + +| Method / Prefix | Reason | +|---|---| +| `OpenShell/Health` | Kubernetes liveness/readiness probes | +| `Inference/Health` | Inference service health probes | +| `/grpc.reflection.*` | gRPC server reflection (debugging tools) | +| `/grpc.health.*` | gRPC health check protocol | + +### Sandbox-Secret Authenticated + +Sandbox-to-server RPCs authenticate via the `x-sandbox-secret` metadata header, which must match the server's SSH handshake secret. These methods do not use OIDC Bearer tokens. + +| Method | Purpose | +|---|---| +| `GetSandboxConfig` (both services) | Supervisor fetches sandbox configuration | +| `ReportPolicyStatus` | Supervisor reports policy enforcement status | +| `PushSandboxLogs` | Supervisor streams sandbox logs to gateway | +| `GetSandboxProviderEnvironment` | Supervisor fetches provider credentials | +| `SubmitPolicyAnalysis` | Supervisor submits policy analysis results | + +### Dual-Auth + +These methods accept either an OIDC Bearer token (CLI users) or a sandbox secret (supervisor). The middleware tries sandbox-secret first; if not present, it falls through to Bearer token validation. + +| Method | Purpose | +|---|---| +| `UpdateConfig` | Policy and settings mutations | + +**Sandbox-secret restriction on `UpdateConfig`:** When a sandbox-secret-authenticated caller invokes `UpdateConfig`, the handler in `policy.rs` enforces strict scope limits via `validate_sandbox_secret_update()`. The caller: +- **Must** provide a sandbox `name` (sandbox-scoped only). +- **Must** include a `policy` payload (policy sync only). +- **May not** set `global = true` (no global config mutation). +- **May not** set `delete_setting` (no setting deletion). +- **May not** provide a `setting_key` (no setting mutation). + +This ensures the sandbox supervisor can sync its own policy on startup but cannot modify global configuration or sandbox settings. + +## Role-Based Access Control (RBAC) + +After JWT validation, the server checks the user's roles against a per-method requirement. Roles are extracted from a configurable claim path in the JWT. + +### Role Mapping + +| Operation | Required Role | +|---|---| +| Health probes, reflection | (no auth — unauthenticated) | +| Supervisor RPCs (GetSandboxConfig, etc.) | (sandbox secret — no RBAC) | +| UpdateConfig via sandbox secret | (sandbox secret — scope-restricted, no RBAC) | +| Sandbox create, list, delete, exec, SSH | user role | +| Provider list, get | user role | +| Provider create, update, delete | admin role | +| Global config/policy updates | admin role | +| Draft policy approvals/rejections | admin role | +| All other authenticated RPCs | user role | + +### Configurable Roles + +The roles claim path and role names are configurable to support different OIDC providers. Each provider stores roles differently in the JWT: + +| Provider | Roles Claim | Example Admin Role | Example User Role | +|---|---|---|---| +| Keycloak | `realm_access.roles` (default) | `openshell-admin` | `openshell-user` | +| Microsoft Entra ID | `roles` | `OpenShell.Admin` | `OpenShell.User` | +| Okta | `groups` | `openshell-admin` | `openshell-user` | +| GitHub | N/A | (empty — skip RBAC) | (empty — skip RBAC) | + +When both `--oidc-admin-role` and `--oidc-user-role` are set to empty strings, RBAC is skipped entirely — any valid JWT is authorized. This supports providers like GitHub that don't emit roles in JWTs (authentication-only mode). + +## Server Configuration + +### Server Binary Flags + +These flags configure JWT validation on the `openshell-server` binary: + +| Flag | Env Var | Default | Description | +|---|---|---|---| +| `--oidc-issuer` | `OPENSHELL_OIDC_ISSUER` | (none) | OIDC issuer URL (enables JWT validation) | +| `--oidc-audience` | `OPENSHELL_OIDC_AUDIENCE` | `openshell-cli` | Expected `aud` claim in validated JWTs | +| `--oidc-jwks-ttl` | `OPENSHELL_OIDC_JWKS_TTL` | `3600` | JWKS cache TTL in seconds | +| `--oidc-roles-claim` | `OPENSHELL_OIDC_ROLES_CLAIM` | `realm_access.roles` | Dot-separated path to roles array in JWT | +| `--oidc-admin-role` | `OPENSHELL_OIDC_ADMIN_ROLE` | `openshell-admin` | Role name for admin access | +| `--oidc-user-role` | `OPENSHELL_OIDC_USER_ROLE` | `openshell-user` | Role name for user access | + +When `--oidc-issuer` is not set, OIDC validation is disabled and the server falls back to mTLS-only or plaintext behavior. + +### Gateway Start Flags (CLI) + +The `openshell gateway start` command exposes flags that configure both the server and the local gateway metadata: + +| Flag | Default | Description | +|---|---|---| +| `--oidc-issuer` | (none) | OIDC issuer URL; passed to the server binary | +| `--oidc-audience` | `openshell-cli` | Expected `aud` claim; passed to the server binary | +| `--oidc-client-id` | `openshell-cli` | Client ID stored in gateway metadata for CLI login flows | +| `--oidc-roles-claim` | (none) | Passed to the server binary if set | +| `--oidc-admin-role` | (none) | Passed to the server binary if set | +| `--oidc-user-role` | (none) | Passed to the server binary if set | + +The `--oidc-client-id` flag is **not** a server flag — it is stored in gateway metadata and used by the CLI during login. The `--oidc-audience` flag is both a server flag (for JWT validation) and stored in metadata (for token requests). + +### Helm Values + +```yaml +server: + oidc: + issuer: "https://keycloak.example.com/realms/openshell" + audience: "openshell-cli" + jwksTtl: 3600 +``` + +### Discovery Endpoint + +The server exposes `GET /auth/oidc-config` which returns the configured OIDC issuer and audience. This allows CLI auto-discovery during `gateway add`. + +## Provider Examples + +### Keycloak + +```bash +openshell gateway start \ + --oidc-issuer http://keycloak:8180/realms/openshell +# Defaults work: realm_access.roles, openshell-admin, openshell-user +``` + +### Microsoft Entra ID + +Register an app in Azure Portal with app roles `OpenShell.Admin` and `OpenShell.User`. With Entra ID the client ID (the SPA/public app registration) and audience (the API app registration, e.g. `api://openshell`) are typically different: + +```bash +openshell gateway start \ + --oidc-issuer https://login.microsoftonline.com/{tenant-id}/v2.0 \ + --oidc-audience api://openshell \ + --oidc-client-id {client-id} \ + --oidc-roles-claim roles \ + --oidc-admin-role OpenShell.Admin \ + --oidc-user-role OpenShell.User +``` + +CLI registration (separate client ID and audience): + +```bash +openshell gateway add https://gateway:8080 \ + --oidc-issuer https://login.microsoftonline.com/{tenant-id}/v2.0 \ + --oidc-client-id {client-id} \ + --oidc-audience api://openshell +``` + +### Okta + +Create an authorization server with a `groups` claim, then: + +```bash +openshell gateway start \ + --oidc-issuer https://dev-xxxxx.okta.com/oauth2/default \ + --oidc-roles-claim groups \ + --oidc-admin-role openshell-admin \ + --oidc-user-role openshell-user +``` + +### GitHub (Authentication Only) + +GitHub's OIDC tokens (from Actions) don't carry roles. Use empty role names to skip RBAC — any valid GitHub JWT is authorized: + +```bash +openshell gateway start \ + --oidc-issuer https://token.actions.githubusercontent.com \ + --oidc-audience https://github.com/{org} \ + --oidc-admin-role "" \ + --oidc-user-role "" +``` + +## CLI Commands + +### Register an OIDC Gateway + +```bash +openshell gateway add http://gateway:8080 \ + --oidc-issuer http://keycloak:8180/realms/openshell + +# With custom client ID: +openshell gateway add http://gateway:8080 \ + --oidc-issuer http://keycloak:8180/realms/openshell \ + --oidc-client-id my-client + +# With separate client ID and audience (e.g. Entra ID): +openshell gateway add http://gateway:8080 \ + --oidc-issuer https://login.microsoftonline.com/{tenant-id}/v2.0 \ + --oidc-client-id {client-id} \ + --oidc-audience api://openshell +``` + +### Start a K3s Gateway with OIDC + +```bash +openshell gateway start \ + --oidc-issuer http://keycloak:8180/realms/openshell \ + --plaintext + +# With RBAC configuration: +openshell gateway start \ + --oidc-issuer http://keycloak:8180/realms/openshell \ + --oidc-client-id openshell-cli \ + --oidc-roles-claim realm_access.roles \ + --oidc-admin-role openshell-admin \ + --oidc-user-role openshell-user +``` + +### Authenticate + +```bash +# Interactive (opens browser) +openshell gateway login +# Expected: ✓ Authenticated to gateway 'openshell' as admin@test + +# CI / automation +OPENSHELL_OIDC_CLIENT_SECRET=secret openshell gateway login +``` + +### Logout + +```bash +openshell gateway logout +# Expected: ✓ Logged out of gateway 'openshell' +``` + +## Keycloak Setup + +### Realm Configuration + +The `scripts/keycloak-realm.json` file provides a pre-configured realm for development: + +- **Realm**: `openshell` +- **Clients**: + - `openshell-cli` — Public client, Authorization Code + PKCE, redirect URIs `http://127.0.0.1:*` + - `openshell-ci` — Confidential client, Client Credentials grant, secret `ci-test-secret` +- **Roles**: `openshell-admin`, `openshell-user` +- **Test Users**: + - `admin@test` / `admin` (roles: `openshell-admin`, `openshell-user`) + - `user@test` / `user` (roles: `openshell-user`) + +### Dev Server + +```bash +# Start Keycloak on port 8180 +./scripts/keycloak-dev.sh start + +# Check status +./scripts/keycloak-dev.sh status + +# Stop +./scripts/keycloak-dev.sh stop +``` + +Admin console: `http://localhost:8180/admin` (admin/admin). + +## Coexistence with Other Auth Modes + +OIDC is additive — it does not replace mTLS or Cloudflare Access. When OIDC is configured, the `AuthGrpcRouter` processes requests through the three-category classification: + +``` +Request arrives + | + +-- Strip x-openshell-auth-source (anti-spoofing) + | + +-- OIDC not configured? --> Pass through (mTLS/plaintext fallback) + | + +-- Unauthenticated method? --> Pass through + | + +-- Sandbox-secret method? + | +-- Valid x-sandbox-secret --> Mark auth-source, pass through + | +-- Invalid/missing --> UNAUTHENTICATED + | + +-- Dual-auth method? + | +-- Valid x-sandbox-secret --> Mark auth-source, pass through + | +-- No sandbox secret --> Fall through to Bearer + | + +-- Has "authorization: Bearer" header? + | +-- Validate JWT --> Check RBAC --> Authenticated (OIDC) + | +-- Invalid JWT --> UNAUTHENTICATED + | + +-- No bearer header --> UNAUTHENTICATED +``` + +The CLI determines which auth mode to use based on `auth_mode` in gateway metadata. Only one mode is active per gateway registration. + +## Key Files + +| Component | File | +|---|---| +| Server OIDC validation + method classification | `crates/openshell-server/src/oidc.rs` | +| Server auth middleware | `crates/openshell-server/src/multiplex.rs` (`AuthGrpcRouter`) | +| Server authorization (RBAC) | `crates/openshell-server/src/authz.rs` (`AuthzPolicy`) | +| Sandbox-secret scope enforcement | `crates/openshell-server/src/grpc/policy.rs` (`validate_sandbox_secret_update`) | +| Server config | `crates/openshell-core/src/config.rs` (`OidcConfig`) | +| Server CLI flags | `crates/openshell-server/src/main.rs` | +| Server discovery endpoint | `crates/openshell-server/src/auth.rs` (`/auth/oidc-config`) | +| CLI OIDC flows | `crates/openshell-cli/src/oidc_auth.rs` | +| CLI interceptor | `crates/openshell-cli/src/tls.rs` (`EdgeAuthInterceptor`) | +| CLI auth dispatch | `crates/openshell-cli/src/main.rs` (`apply_auth`) | +| CLI gateway commands | `crates/openshell-cli/src/run.rs` (`gateway_add`, `gateway_login`) | +| Token storage | `crates/openshell-bootstrap/src/oidc_token.rs` | +| Gateway metadata | `crates/openshell-bootstrap/src/metadata.rs` | +| Bootstrap pipeline | `crates/openshell-bootstrap/src/lib.rs`, `docker.rs` | +| K3s entrypoint | `deploy/docker/cluster-entrypoint.sh` | +| HelmChart template | `deploy/kube/manifests/openshell-helmchart.yaml` | +| Helm values | `deploy/helm/openshell/values.yaml` | +| Helm statefulset | `deploy/helm/openshell/templates/statefulset.yaml` | +| Keycloak dev script | `scripts/keycloak-dev.sh` | +| Keycloak realm config | `scripts/keycloak-realm.json` | diff --git a/architecture/oidc-local-testing.md b/architecture/oidc-local-testing.md new file mode 100644 index 000000000..be5725e65 --- /dev/null +++ b/architecture/oidc-local-testing.md @@ -0,0 +1,393 @@ +# OIDC Local Testing Guide + +Step-by-step instructions for testing OIDC/Keycloak authentication locally, +including both standalone server testing and full end-to-end K3s testing. + +## Prerequisites + +- Docker or Podman +- Rust toolchain (edition 2024, rust 1.88+) +- `grpcurl` (for raw gRPC testing) +- `jq` (for JSON parsing) + +## 1. Start Keycloak + +```bash +mise run keycloak +``` + +Wait for "Keycloak is ready." The script prints connection info including test users. + +Verify: +```bash +curl -s http://localhost:8180/realms/openshell/.well-known/openid-configuration | jq .issuer +# Expected: "http://localhost:8180/realms/openshell" +``` + +## 2. Standalone Server Testing (No K3s) + +Start the server directly with OIDC enabled. No Kubernetes cluster required. + +```bash +cargo run -p openshell-server -- \ + --disable-tls \ + --db-url sqlite:/tmp/openshell-test.db \ + --ssh-handshake-secret test \ + --oidc-issuer http://localhost:8180/realms/openshell +``` + +You should see: +``` +OIDC JWT validation enabled (issuer: http://localhost:8180/realms/openshell) +Server listening address=0.0.0.0:8080 +``` + +K8s compute driver warnings are expected and non-fatal. + +### 2a. Test Health (unauthenticated — should succeed) + +```bash +grpcurl -plaintext -import-path proto -proto openshell.proto \ + 127.0.0.1:8080 openshell.v1.OpenShell/Health +# Expected: SERVICE_STATUS_HEALTHY +``` + +### 2b. Test without token (should fail) + +```bash +grpcurl -plaintext -import-path proto -proto openshell.proto \ + 127.0.0.1:8080 openshell.v1.OpenShell/ListSandboxes +# Expected: Code: Unauthenticated, Message: missing authorization header +``` + +### 2c. Get tokens from Keycloak + +```bash +ADMIN_TOKEN=$(curl -s -X POST http://localhost:8180/realms/openshell/protocol/openid-connect/token \ + -d 'grant_type=password&client_id=openshell-cli&username=admin@test&password=admin' \ + | jq -r .access_token) + +USER_TOKEN=$(curl -s -X POST http://localhost:8180/realms/openshell/protocol/openid-connect/token \ + -d 'grant_type=password&client_id=openshell-cli&username=user@test&password=user' \ + | jq -r .access_token) +``` + +### 2d. Test authenticated access + +```bash +# Admin can list sandboxes +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + 127.0.0.1:8080 openshell.v1.OpenShell/ListSandboxes +# Expected: {} (empty list) + +# User can list sandboxes +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $USER_TOKEN" \ + 127.0.0.1:8080 openshell.v1.OpenShell/ListSandboxes +# Expected: {} (empty list) +``` + +### 2e. Test RBAC + +```bash +# User CANNOT create provider (requires openshell-admin) +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $USER_TOKEN" \ + -d '{"provider":{"name":"test","type":"claude","credentials":{"key":"val"}}}' \ + 127.0.0.1:8080 openshell.v1.OpenShell/CreateProvider +# Expected: Code: PermissionDenied, Message: role 'openshell-admin' required + +# Admin CAN create provider +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $ADMIN_TOKEN" \ + -d '{"provider":{"name":"test","type":"claude","credentials":{"key":"val"}}}' \ + 127.0.0.1:8080 openshell.v1.OpenShell/CreateProvider +# Expected: success +``` + +### 2f. Test sandbox secret auth + +```bash +# Correct secret — should succeed (returns NOT_FOUND since sandbox doesn't exist) +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "x-sandbox-secret: test" \ + -d '{"sandbox_id":"fake"}' \ + 127.0.0.1:8080 openshell.v1.OpenShell/GetSandboxConfig +# Expected: Code: NotFound (sandbox doesn't exist, but auth passed) + +# Wrong secret — should fail at auth +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "x-sandbox-secret: wrong" \ + -d '{"sandbox_id":"fake"}' \ + 127.0.0.1:8080 openshell.v1.OpenShell/GetSandboxConfig +# Expected: Code: Unauthenticated, Message: invalid sandbox secret + +# No secret — should fail at auth +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -d '{"sandbox_id":"fake"}' \ + 127.0.0.1:8080 openshell.v1.OpenShell/GetSandboxConfig +# Expected: Code: Unauthenticated, Message: sandbox secret required +``` + +### 2g. Test OIDC discovery endpoint + +```bash +curl -s http://127.0.0.1:8080/auth/oidc-config | jq . +# Expected: {"audience":"openshell-cli","issuer":"http://localhost:8180/realms/openshell"} +``` + +Stop the standalone server (Ctrl+C) before proceeding to K3s testing. + +## 3. CLI OIDC Flow (Standalone) + +With the standalone server running from step 2: + +```bash +# Register the gateway with OIDC auth +cargo run -p openshell-cli --features bundled-z3 -- gateway add http://127.0.0.1:8080 \ + --oidc-issuer http://localhost:8180/realms/openshell + +# Browser opens to Keycloak. Login with: admin@test / admin +# Expected: ✓ Authenticated to gateway 'localhost' as admin@test + +# Verify stored token +cat ~/.config/openshell/gateways/127.0.0.1/oidc_token.json | jq . + +# Test authenticated CLI command +cargo run -p openshell-cli --features bundled-z3 -- sandbox list +``` + +### Test client credentials (CI mode) + +```bash +OPENSHELL_OIDC_CLIENT_SECRET=ci-test-secret \ +cargo run -p openshell-cli --features bundled-z3 -- gateway login +# Expected: ✓ Authenticated to gateway (no browser opened) +``` + +### Test logout + +```bash +cargo run -p openshell-cli --features bundled-z3 -- gateway logout +# Expected: ✓ Logged out of gateway + +cargo run -p openshell-cli --features bundled-z3 -- sandbox list +# Expected: error (no token) +``` + +## 4. End-to-End K3s Testing + +This deploys a full K3s cluster with OIDC enforcement and tests sandbox +creation, RBAC, login/logout, and token expiry. + +### 4a. Bootstrap the cluster with OIDC + +Keycloak runs on the host. The K3s container reaches it via the host IP. +The `OPENSHELL_OIDC_ISSUER` env var tells the deploy script to pass the +issuer to the Helm chart so the gateway starts with JWT validation enabled. + +```bash +HOST_IP=$(hostname -I | awk '{print $1}') +OPENSHELL_OIDC_ISSUER="http://${HOST_IP}:8180/realms/openshell" mise run cluster +``` + +Wait for "Deploy complete!" and verify OIDC is active: + +```bash +CONTAINER=$(docker ps --format '{{.Names}}' | grep openshell-cluster) +docker exec $CONTAINER kubectl -n openshell logs openshell-0 | grep OIDC +# Expected: OIDC JWT validation enabled (issuer: http://...) +``` + +### 4b. Login to the gateway + +The bootstrap step above configures the gateway metadata with the OIDC +issuer automatically. Authenticate with Keycloak: + +```bash +openshell gateway login +# Login with: admin@test / admin +# Expected: ✓ Authenticated to gateway 'openshell' as admin@test +``` + +### 4c. Create and list sandboxes + +```bash +# Login as admin +openshell gateway login +# Login with: admin@test / admin +# Expected: ✓ Authenticated to gateway 'openshell' as admin@test + +# Create a sandbox +openshell sandbox create +# Expected: Created sandbox: + +# List sandboxes +openshell sandbox list +# Expected: shows the created sandbox +``` + +### 4d. Verify authentication enforcement + +```bash +# Logout +openshell gateway logout +# Expected: ✓ Logged out of gateway 'openshell' + +# Should fail without token +openshell sandbox list +# Expected: Unauthenticated error + +# Login again +openshell gateway login +# Login with: admin@test / admin + +# Should work again +openshell sandbox list +# Expected: shows sandboxes +``` + +### 4e. Verify token expiry + +Keycloak access tokens expire after 5 minutes by default. + +```bash +# Wait 5+ minutes, then: +openshell sandbox list +# Expected: Unauthenticated: ExpiredSignature + +# Re-login +openshell gateway login +openshell sandbox list +# Expected: success +``` + +### 4f. Verify RBAC + +```bash +# Login as admin +openshell gateway login +# Login with: admin@test / admin + +# Admin can create a provider +openshell provider create \ + --name test-provider --type claude --credential API_KEY=test123 +# Expected: success + +# Login as user (openshell-user only, no openshell-admin) +openshell gateway login +# Login with: user@test / user +# Expected: ✓ Authenticated to gateway 'openshell' as user@test + +# User can list sandboxes +openshell sandbox list +# Expected: success + +# User can list providers +openshell provider list +# Expected: shows test-provider + +# User CANNOT create a provider +openshell provider create \ + --name blocked --type claude --credential API_KEY=nope +# Expected: PermissionDenied: role 'openshell-admin' required + +# User CANNOT delete a provider +openshell provider delete test-provider +# Expected: PermissionDenied: role 'openshell-admin' required + +# User CAN create sandboxes +openshell sandbox create +# Expected: success +``` + +### 4g. Test client credentials (CI mode) + +```bash +OPENSHELL_OIDC_CLIENT_SECRET=ci-test-secret \ +openshell gateway login +# Expected: ✓ Authenticated to gateway 'openshell' (no browser) + +openshell sandbox list +# Expected: success +``` + +### 4h. Clean up sandboxes + +```bash +# Login as admin to clean up +openshell gateway login +# Login with: admin@test / admin + +openshell sandbox list +# Note sandbox names, then: +openshell sandbox delete + +openshell provider delete test-provider +``` + +## 5. Cleanup + +```bash +# Stop the cluster +mise run cluster:stop + +# Stop Keycloak +mise run keycloak:stop +``` + +## Test Users + +| Username | Password | Roles | +|---|---|---| +| `admin@test` | `admin` | `openshell-admin`, `openshell-user` | +| `user@test` | `user` | `openshell-user` | + +## OIDC Clients + +| Client ID | Type | Grant | Secret | +|---|---|---|---| +| `openshell-cli` | Public | Auth Code + PKCE | N/A | +| `openshell-ci` | Confidential | Client Credentials | `ci-test-secret` | + +## Method Authentication Categories + +| Category | Methods | Auth Mechanism | +|---|---|---| +| Unauthenticated | Health, gRPC reflection | None | +| Sandbox-secret | GetSandboxConfig, GetSandboxProviderEnvironment, ReportPolicyStatus, PushSandboxLogs, SubmitPolicyAnalysis | `x-sandbox-secret` header | +| Dual-auth | UpdateConfig | Bearer token OR `x-sandbox-secret` | +| OIDC Bearer | All other RPCs | `authorization: Bearer ` | + +## Role Requirements + +| Operation | Required Role | +|---|---| +| Sandbox create, list, delete, exec, SSH | `openshell-user` | +| Provider list, get | `openshell-user` | +| Provider create, update, delete | `openshell-admin` | +| Global config/policy updates | `openshell-admin` | +| Draft policy approvals | `openshell-admin` | + +## Troubleshooting + +**"missing authorization header"** — No OIDC token stored. Run `openshell gateway login`. + +**"invalid token: ExpiredSignature"** — Token expired (default 5 min). Run `openshell gateway login`. + +**"PermissionDenied: role 'openshell-admin' required"** — Logged in as a user without the admin role. Login as `admin@test`. + +**"sandbox secret required for this method"** — A sandbox-to-server RPC was called without the `x-sandbox-secret` header. + +**"OIDC discovery request failed"** — Server can't reach Keycloak. Use the host IP (not `localhost`) for K3s deployments. + +**"invalid token: unknown signing key"** — JWKS key mismatch. Restart the server to refresh the cache. + +**No "OIDC JWT validation enabled" in K3s logs** — The `OPENSHELL_OIDC_ISSUER` env var was not set when deploying. Re-run `OPENSHELL_OIDC_ISSUER="http://:8180/realms/openshell" mise run cluster gateway` to rebuild and redeploy with OIDC enabled. + +**"InvalidIssuer"** — The issuer URL in the OIDC token does not match the server's configured issuer. Ensure the gateway metadata `oidc_issuer` uses the same URL the server was started with (typically the host IP, not `localhost`). + +**"connection refused" with grpcurl** — On Fedora/systems where `localhost` resolves to IPv6, use `127.0.0.1` instead of `localhost`. + +**"no such table: objects"** — Using `sqlite::memory:` which doesn't run migrations. Use a file path like `sqlite:/tmp/openshell-test.db`. diff --git a/crates/openshell-bootstrap/src/docker.rs b/crates/openshell-bootstrap/src/docker.rs index 65482739f..e9bb00cfc 100644 --- a/crates/openshell-bootstrap/src/docker.rs +++ b/crates/openshell-bootstrap/src/docker.rs @@ -501,6 +501,11 @@ pub async fn ensure_container( registry_token: Option<&str>, device_ids: &[String], resume: bool, + oidc_issuer: Option<&str>, + oidc_audience: &str, + oidc_roles_claim: Option<&str>, + oidc_admin_role: Option<&str>, + oidc_user_role: Option<&str>, ) -> Result { let container_name = container_name(name); @@ -782,6 +787,22 @@ pub async fn ensure_container( env_vars.push("GPU_ENABLED=true".to_string()); } + // OIDC JWT authentication: pass issuer and audience to the entrypoint + // so the HelmChart manifest configures the server pod for JWT validation. + if let Some(issuer) = oidc_issuer { + env_vars.push(format!("OIDC_ISSUER={issuer}")); + env_vars.push(format!("OIDC_AUDIENCE={oidc_audience}")); + if let Some(claim) = oidc_roles_claim { + env_vars.push(format!("OIDC_ROLES_CLAIM={claim}")); + } + if let Some(role) = oidc_admin_role { + env_vars.push(format!("OIDC_ADMIN_ROLE={role}")); + } + if let Some(role) = oidc_user_role { + env_vars.push(format!("OIDC_USER_ROLE={role}")); + } + } + let env = Some(env_vars); let config = ContainerCreateBody { diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index 53f659fc6..a2d1baf8a 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -4,6 +4,7 @@ pub mod build; pub mod edge_token; pub mod errors; +pub mod oidc_token; pub mod image; pub mod constants; @@ -123,6 +124,18 @@ pub struct DeployOptions { /// When false, an existing gateway is left as-is and deployment is /// skipped (the caller is responsible for prompting the user first). pub recreate: bool, + /// OIDC issuer URL. When set, the server validates Bearer JWTs. + pub oidc_issuer: Option, + /// OIDC audience for the API resource server. Defaults to "openshell-cli". + pub oidc_audience: String, + /// OIDC client ID for CLI login. Defaults to "openshell-cli". + pub oidc_client_id: String, + /// OIDC roles claim path (e.g. "realm_access.roles"). + pub oidc_roles_claim: Option, + /// OIDC admin role name. + pub oidc_admin_role: Option, + /// OIDC user role name. + pub oidc_user_role: Option, } impl DeployOptions { @@ -139,6 +152,12 @@ impl DeployOptions { registry_token: None, gpu: vec![], recreate: false, + oidc_issuer: None, + oidc_audience: "openshell-cli".to_string(), + oidc_client_id: "openshell-cli".to_string(), + oidc_roles_claim: None, + oidc_admin_role: None, + oidc_user_role: None, } } @@ -208,6 +227,20 @@ impl DeployOptions { self.recreate = recreate; self } + + /// Set the OIDC issuer URL for JWT-based authentication. + #[must_use] + pub fn with_oidc_issuer(mut self, issuer: impl Into) -> Self { + self.oidc_issuer = Some(issuer.into()); + self + } + + /// Set the OIDC audience (client ID). + #[must_use] + pub fn with_oidc_audience(mut self, audience: impl Into) -> Self { + self.oidc_audience = audience.into(); + self + } } #[derive(Debug, Clone)] @@ -272,6 +305,12 @@ where let registry_token = options.registry_token; let gpu = options.gpu; let recreate = options.recreate; + let oidc_issuer = options.oidc_issuer; + let oidc_audience = options.oidc_audience; + let oidc_client_id = options.oidc_client_id; + let oidc_roles_claim = options.oidc_roles_claim; + let oidc_admin_role = options.oidc_admin_role; + let oidc_user_role = options.oidc_user_role; // Wrap on_log in Arc> so we can share it with pull_remote_image // which needs a 'static callback for the bollard streaming pull. @@ -458,6 +497,11 @@ where registry_token.as_deref(), &device_ids, resume, + oidc_issuer.as_deref(), + &oidc_audience, + oidc_roles_claim.as_deref(), + oidc_admin_role.as_deref(), + oidc_user_role.as_deref(), ) .await?; let port = actual_port; @@ -559,13 +603,19 @@ where } // Create and store gateway metadata. - let metadata = create_gateway_metadata_with_host( + let mut metadata = create_gateway_metadata_with_host( &name, remote_opts.as_ref(), port, ssh_gateway_host.as_deref(), disable_tls, ); + if oidc_issuer.is_some() { + metadata.auth_mode = Some("oidc".to_string()); + metadata.oidc_issuer = oidc_issuer.clone(); + metadata.oidc_client_id = Some(oidc_client_id.clone()); + metadata.oidc_audience = Some(oidc_audience.clone()); + } store_gateway_metadata(&name, &metadata)?; Ok(metadata) diff --git a/crates/openshell-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index 8e6b8a070..f11a4ad03 100644 --- a/crates/openshell-bootstrap/src/metadata.rs +++ b/crates/openshell-bootstrap/src/metadata.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// Gateway metadata stored alongside deployment info. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GatewayMetadata { /// The gateway name. pub name: String, @@ -46,6 +46,20 @@ pub struct GatewayMetadata { alias = "cf_auth_url" )] pub edge_auth_url: Option, + + /// OIDC issuer URL (set when `auth_mode == "oidc"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oidc_issuer: Option, + + /// OIDC client ID for the CLI login flow (set when `auth_mode == "oidc"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oidc_client_id: Option, + + /// OIDC audience for the resource server (API). When different from + /// client_id, the CLI requests this audience in the token exchange. + /// When `None`, defaults to the client_id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oidc_audience: Option, } impl GatewayMetadata { @@ -134,8 +148,7 @@ pub fn create_gateway_metadata_with_host( remote_host, resolved_host, auth_mode: disable_tls.then(|| "plaintext".to_string()), - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() } } @@ -461,9 +474,7 @@ mod tests { gateway_port: 8080, remote_host: Some("user@openshell-dev".to_string()), resolved_host: Some("10.0.0.5".to_string()), - auth_mode: None, - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }; let json = serde_json::to_string(&meta).unwrap(); let parsed: GatewayMetadata = serde_json::from_str(&json).unwrap(); @@ -552,13 +563,8 @@ mod tests { let meta = GatewayMetadata { name: "t".into(), gateway_endpoint: "https://localhost:8080".into(), - is_remote: false, gateway_port: 8080, - remote_host: None, - resolved_host: None, - auth_mode: None, - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }; assert_eq!(meta.gateway_host(), None); } @@ -572,9 +578,7 @@ mod tests { gateway_port: 8080, remote_host: Some("user@10.0.0.5".into()), resolved_host: Some("10.0.0.5".into()), - auth_mode: None, - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }; assert_eq!(meta.gateway_host(), Some("10.0.0.5")); } diff --git a/crates/openshell-bootstrap/src/oidc_token.rs b/crates/openshell-bootstrap/src/oidc_token.rs new file mode 100644 index 000000000..35262c040 --- /dev/null +++ b/crates/openshell-bootstrap/src/oidc_token.rs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OIDC token storage. +//! +//! Stores OIDC token bundles (access token, refresh token, metadata) at +//! `$XDG_CONFIG_HOME/openshell/gateways//oidc_token.json`. +//! File permissions are `0600` (owner-only). + +use crate::paths::gateways_dir; +use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::paths::{ensure_parent_dir_restricted, set_file_owner_only}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// OIDC token bundle persisted to disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OidcTokenBundle { + /// OAuth2 access token (JWT). + pub access_token: String, + + /// OAuth2 refresh token. `None` for client_credentials grants. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + + /// Unix timestamp when the access token expires. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + + /// OIDC issuer URL. + pub issuer: String, + + /// OIDC client ID used to obtain the token. + pub client_id: String, +} + +/// Path to the stored OIDC token bundle for a gateway. +pub fn oidc_token_path(gateway_name: &str) -> Result { + Ok(gateways_dir()?.join(gateway_name).join("oidc_token.json")) +} + +/// Store an OIDC token bundle for a gateway. +pub fn store_oidc_token(gateway_name: &str, bundle: &OidcTokenBundle) -> Result<()> { + let path = oidc_token_path(gateway_name)?; + ensure_parent_dir_restricted(&path)?; + let json = serde_json::to_string_pretty(bundle) + .into_diagnostic() + .wrap_err("failed to serialize OIDC token bundle")?; + std::fs::write(&path, json) + .into_diagnostic() + .wrap_err_with(|| format!("failed to write OIDC token to {}", path.display()))?; + set_file_owner_only(&path)?; + Ok(()) +} + +/// Load a stored OIDC token bundle for a gateway. +/// +/// Returns `None` if the token file does not exist or cannot be parsed. +pub fn load_oidc_token(gateway_name: &str) -> Option { + let path = oidc_token_path(gateway_name).ok()?; + if !path.exists() { + return None; + } + let contents = std::fs::read_to_string(&path).ok()?; + serde_json::from_str(&contents).ok() +} + +/// Remove a stored OIDC token. +pub fn remove_oidc_token(gateway_name: &str) -> Result<()> { + let path = oidc_token_path(gateway_name)?; + if path.exists() { + std::fs::remove_file(&path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to remove {}", path.display()))?; + } + Ok(()) +} + +/// Check if the stored access token is expired or near expiry. +/// +/// Returns `true` if the token expires within the next 30 seconds. +pub fn is_token_expired(bundle: &OidcTokenBundle) -> bool { + let Some(expires_at) = bundle.expires_at else { + // No expiry info — assume valid. + return false; + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now + 30 >= expires_at +} diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index b3a006fdd..fea9dc331 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -58,6 +58,12 @@ anyhow = { workspace = true } # File archiving (tar-over-SSH sync) tar = "0.4" +# OIDC/Auth +sha2 = { workspace = true } +base64 = { workspace = true } +hex = "0.4" +getrandom = { workspace = true } + # WebSocket (Cloudflare tunnel proxy) tokio-tungstenite = { workspace = true } diff --git a/crates/openshell-cli/src/auth.rs b/crates/openshell-cli/src/auth.rs index e961828c4..ec85541d3 100644 --- a/crates/openshell-cli/src/auth.rs +++ b/crates/openshell-cli/src/auth.rs @@ -132,7 +132,7 @@ pub async fn browser_auth_flow(gateway_endpoint: &str) -> Result { let mut _input = String::new(); std::io::stdin().read_line(&mut _input).ok(); - if let Err(e) = open_browser(&auth_url) { + if let Err(e) = open_browser_url(&auth_url) { debug!(error = %e, "failed to open browser"); eprintln!("Could not open browser automatically."); eprintln!("Open this URL in your browser:"); @@ -164,7 +164,7 @@ pub async fn browser_auth_flow(gateway_endpoint: &str) -> Result { } /// Open a URL in the default browser. -fn open_browser(url: &str) -> std::result::Result<(), String> { +pub fn open_browser_url(url: &str) -> std::result::Result<(), String> { #[cfg(target_os = "macos")] { std::process::Command::new("open") diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index 3c2a8b336..ddafeb167 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -175,12 +175,8 @@ mod tests { name: "alpha".to_string(), gateway_endpoint: "https://alpha.example.com".to_string(), is_remote: true, - gateway_port: 0, - remote_host: None, - resolved_host: None, auth_mode: Some("cloudflare_jwt".to_string()), - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }, ) .unwrap(); diff --git a/crates/openshell-cli/src/lib.rs b/crates/openshell-cli/src/lib.rs index 1746547ef..d518557b7 100644 --- a/crates/openshell-cli/src/lib.rs +++ b/crates/openshell-cli/src/lib.rs @@ -12,6 +12,7 @@ pub mod auth; pub mod bootstrap; pub mod completers; pub mod edge_tunnel; +pub mod oidc_auth; pub(crate) mod policy_update; pub mod run; pub mod ssh; diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 9a7c41216..b3ceed233 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -122,17 +122,50 @@ fn resolve_gateway_name(gateway_flag: &Option) -> Option { .or_else(load_active_gateway) } -/// Apply edge authentication token from local storage when the gateway uses edge auth. +/// Apply authentication token from local storage based on gateway auth mode. /// -/// When the resolved gateway has `auth_mode == "cloudflare_jwt"`, loads the -/// stored edge token from disk and sets it on the `TlsOptions`. The token is -/// always read from gateway metadata rather than supplied via a CLI flag. -fn apply_edge_auth(tls: &mut TlsOptions, gateway_name: &str) { - if let Some(meta) = get_gateway_metadata(gateway_name) - && meta.auth_mode.as_deref() == Some("cloudflare_jwt") - && let Some(token) = load_edge_token(gateway_name) - { - tls.edge_token = Some(token); +/// Handles both Cloudflare Access (`edge_token`) and OIDC (`oidc_token`) +/// auth modes by loading the stored token and setting it on `TlsOptions`. +/// For OIDC, automatically refreshes the token if it's near expiry. +fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) { + let Some(meta) = get_gateway_metadata(gateway_name) else { + return; + }; + match meta.auth_mode.as_deref() { + Some("cloudflare_jwt") => { + if let Some(token) = load_edge_token(gateway_name) { + tls.edge_token = Some(token); + } + } + Some("oidc") => { + let Some(bundle) = openshell_bootstrap::oidc_token::load_oidc_token(gateway_name) + else { + return; + }; + if openshell_bootstrap::oidc_token::is_token_expired(&bundle) { + // Try to refresh the token in-place using block_in_place + // so the async refresh can run within the sync apply_auth call. + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(openshell_cli::oidc_auth::oidc_refresh_token(&bundle)) + }) { + Ok(refreshed) => { + let _ = + openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed); + tls.oidc_token = Some(refreshed.access_token); + } + Err(e) => { + tracing::warn!("OIDC token refresh failed: {e}"); + // Use the expired token anyway — server will reject it + // with a clear error prompting re-login. + tls.oidc_token = Some(bundle.access_token); + } + } + } else { + tls.oidc_token = Some(bundle.access_token); + } + } + _ => {} } } @@ -821,6 +854,31 @@ enum GatewayCommands { /// (`--gpus all`) otherwise. #[arg(long)] gpu: bool, + + /// OIDC issuer URL for JWT-based authentication. + /// When set, the K3s server will validate Bearer tokens against this issuer. + #[arg(long)] + oidc_issuer: Option, + + /// OIDC audience for the API resource server. + #[arg(long, default_value = "openshell-cli", requires = "oidc_issuer")] + oidc_audience: String, + + /// OIDC client ID stored in gateway metadata for CLI login. + #[arg(long, default_value = "openshell-cli", requires = "oidc_issuer")] + oidc_client_id: String, + + /// Dot-separated path to the roles array in the JWT claims. + #[arg(long, requires = "oidc_issuer")] + oidc_roles_claim: Option, + + /// Role name that grants admin access. + #[arg(long, requires = "oidc_issuer")] + oidc_admin_role: Option, + + /// Role name that grants standard user access. + #[arg(long, requires = "oidc_issuer")] + oidc_user_role: Option, }, /// Stop the gateway (preserves state). @@ -897,9 +955,24 @@ enum GatewayCommands { /// With `http://...`, stores a local plaintext registration instead. #[arg(long, conflicts_with = "remote")] local: bool, + + /// Register as an OIDC-authenticated gateway using the given issuer URL. + /// The server must be configured with `--oidc-issuer` matching this URL. + #[arg(long, conflicts_with = "remote")] + oidc_issuer: Option, + + /// OIDC client ID for the CLI login flow (defaults to "openshell-cli"). + #[arg(long, default_value = "openshell-cli", requires = "oidc_issuer")] + oidc_client_id: String, + + /// OIDC audience for the API resource server. When different from + /// the client ID, the CLI requests this audience in the token exchange. + /// Defaults to the client ID value. + #[arg(long, requires = "oidc_issuer")] + oidc_audience: Option, }, - /// Authenticate with an edge-authenticated gateway. + /// Authenticate with an edge-authenticated or OIDC gateway. /// /// Opens a browser for the edge proxy's login flow and stores the /// token locally. Use this to re-authenticate when a token expires. @@ -910,6 +983,17 @@ enum GatewayCommands { name: Option, }, + /// Clear stored authentication credentials for a gateway. + /// + /// Removes the locally stored OIDC token or edge token so subsequent + /// commands require re-authentication via `gateway login`. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Logout { + /// Gateway name (defaults to the active gateway). + #[arg(add = ArgValueCompleter::new(completers::complete_gateway_names))] + name: Option, + }, + /// Select the active gateway. /// /// When called without a name, opens an interactive chooser on a TTY and @@ -1725,6 +1809,12 @@ async fn main() -> Result<()> { registry_username, registry_token, gpu, + oidc_issuer, + oidc_audience, + oidc_client_id, + oidc_roles_claim, + oidc_admin_role, + oidc_user_role, } => { let gpu = if gpu { vec!["auto".to_string()] @@ -1743,6 +1833,12 @@ async fn main() -> Result<()> { registry_username.as_deref(), registry_token.as_deref(), gpu, + oidc_issuer.as_deref(), + &oidc_audience, + &oidc_client_id, + oidc_roles_claim.as_deref(), + oidc_admin_role.as_deref(), + oidc_user_role.as_deref(), ) .await?; } @@ -1772,6 +1868,9 @@ async fn main() -> Result<()> { remote, ssh_key, local, + oidc_issuer, + oidc_client_id, + oidc_audience, } => { run::gateway_add( &endpoint, @@ -1779,6 +1878,9 @@ async fn main() -> Result<()> { remote.as_deref(), ssh_key.as_deref(), local, + oidc_issuer.as_deref(), + &oidc_client_id, + oidc_audience.as_deref(), ) .await?; } @@ -1794,6 +1896,18 @@ async fn main() -> Result<()> { })?; run::gateway_login(&name).await?; } + GatewayCommands::Logout { name } => { + let name = name + .or_else(|| resolve_gateway_name(&cli.gateway)) + .ok_or_else(|| { + miette::miette!( + "No active gateway.\n\ + Specify a gateway name: openshell gateway logout \n\ + Or set one with: openshell gateway select " + ) + })?; + run::gateway_logout(&name)?; + } GatewayCommands::Select { name } => { run::gateway_select(name.as_deref(), &cli.gateway)?; } @@ -1855,7 +1969,7 @@ async fn main() -> Result<()> { Some(Commands::Status) => { if let Ok(ctx) = resolve_gateway(&cli.gateway, &cli.gateway_endpoint) { let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); run::gateway_status(&ctx.name, &ctx.endpoint, &tls).await?; } else { println!("{}", "Gateway Status".cyan().bold()); @@ -1954,7 +2068,7 @@ async fn main() -> Result<()> { let spec = openshell_core::forward::ForwardSpec::parse(&port)?; let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); let name = resolve_sandbox_name(name, &ctx.name)?; run::sandbox_forward(&ctx.endpoint, &name, &spec, background, &tls).await?; if background { @@ -1982,7 +2096,7 @@ async fn main() -> Result<()> { }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); let name = resolve_sandbox_name(name, &ctx.name)?; run::sandbox_logs( &ctx.endpoint, @@ -2027,7 +2141,7 @@ async fn main() -> Result<()> { }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); match policy_cmd { PolicyCommands::Set { name, @@ -2135,7 +2249,7 @@ async fn main() -> Result<()> { }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); match settings_cmd { SettingsCommands::Get { name, global, json } => { @@ -2189,7 +2303,7 @@ async fn main() -> Result<()> { }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); match draft_cmd { DraftCommands::Get { name, status } => { let name = resolve_sandbox_name(name, &ctx.name)?; @@ -2242,7 +2356,7 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let endpoint = &ctx.endpoint; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); match command { InferenceCommands::Set { provider, @@ -2367,7 +2481,7 @@ async fn main() -> Result<()> { } let endpoint = &ctx.endpoint; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); // The user already has a configured gateway. Disable // auto-bootstrap in the retry path so we don't // silently replace their selected gateway with a new @@ -2425,7 +2539,7 @@ async fn main() -> Result<()> { } => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); let sandbox_dest = dest.as_deref(); let local = std::path::Path::new(&local_path); if !local.exists() { @@ -2460,7 +2574,7 @@ async fn main() -> Result<()> { } => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); let local_dest = std::path::Path::new(dest.as_deref().unwrap_or(".")); eprintln!( "Downloading sandbox:{} -> {}", @@ -2475,7 +2589,7 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let endpoint = &ctx.endpoint; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); match other { SandboxCommands::Create { .. } | SandboxCommands::Upload { .. } @@ -2555,7 +2669,7 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let endpoint = &ctx.endpoint; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); match command { ProviderCommands::Create { @@ -2610,7 +2724,7 @@ async fn main() -> Result<()> { Some(Commands::Term { theme }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); let channel = openshell_cli::tls::build_channel(&ctx.endpoint, &tls).await?; openshell_tui::run(channel, &ctx.name, &ctx.endpoint, theme).await?; } @@ -2642,7 +2756,7 @@ async fn main() -> Result<()> { None => tls, }; if let Some(ref g) = gateway_name_opt { - apply_edge_auth(&mut effective_tls, g); + apply_auth(&mut effective_tls, g); } run::sandbox_ssh_proxy(&gw, &sid, &tok, &effective_tls).await?; } @@ -2661,7 +2775,7 @@ async fn main() -> Result<()> { meta.gateway_endpoint }; let mut tls = tls.with_gateway_name(&g); - apply_edge_auth(&mut tls, &g); + apply_auth(&mut tls, &g); run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?; } // Legacy name mode with --server only (no --gateway-name). @@ -2797,12 +2911,8 @@ mod tests { name: name.to_string(), gateway_endpoint: endpoint.to_string(), is_remote: true, - gateway_port: 0, - remote_host: None, - resolved_host: None, auth_mode: Some("cloudflare_jwt".to_string()), - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() } } @@ -3221,7 +3331,7 @@ mod tests { } #[test] - fn apply_edge_auth_uses_stored_token() { + fn apply_auth_uses_stored_token() { let tmp = tempfile::tempdir().unwrap(); with_tmp_xdg(tmp.path(), || { store_gateway_metadata( @@ -3232,7 +3342,7 @@ mod tests { store_edge_token("edge-gateway", "token-123").unwrap(); let mut tls = TlsOptions::default(); - apply_edge_auth(&mut tls, "edge-gateway"); + apply_auth(&mut tls, "edge-gateway"); assert_eq!(tls.edge_token.as_deref(), Some("token-123")); }); diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs new file mode 100644 index 000000000..563461f2f --- /dev/null +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -0,0 +1,477 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OIDC authentication flows for CLI gateway login. +//! +//! Implements Authorization Code + PKCE (interactive browser flow) and +//! Client Credentials (CI/automation) OAuth2 grant types against a +//! Keycloak-compatible OIDC provider. + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use bytes::Bytes; +use http_body_util::Full; +use hyper::service::service_fn; +use hyper::{Method, Response, StatusCode}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder; +use miette::{IntoDiagnostic, Result}; +use openshell_bootstrap::oidc_token::OidcTokenBundle; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tracing::debug; + +const AUTH_TIMEOUT: Duration = Duration::from_secs(120); + +/// OIDC discovery document (subset of fields we need). +#[derive(Debug, Deserialize)] +struct OidcDiscovery { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, +} + +/// Token endpoint response. +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + expires_in: Option, +} + +/// Discover OIDC endpoints from the issuer's well-known configuration. +/// +/// Validates that the discovery document's `issuer` field matches the +/// configured issuer URL to prevent SSRF or misdirection. +async fn discover(issuer: &str) -> Result { + let normalized_issuer = issuer.trim_end_matches('/'); + let url = format!("{normalized_issuer}/.well-known/openid-configuration"); + let resp: OidcDiscovery = reqwest::get(&url) + .await + .into_diagnostic()? + .json() + .await + .into_diagnostic()?; + + let discovered_issuer = resp.issuer.trim_end_matches('/'); + if discovered_issuer != normalized_issuer { + return Err(miette::miette!( + "OIDC discovery issuer mismatch: expected '{}', got '{}'", + normalized_issuer, + discovered_issuer + )); + } + Ok(resp) +} + +/// Generate a random PKCE code verifier (43-128 unreserved chars). +fn generate_code_verifier() -> String { + let mut buf = [0u8; 32]; + csprng_fill(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +/// Compute the S256 code challenge from a code verifier. +fn compute_code_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +/// Generate a random state parameter. +fn generate_state() -> String { + let mut buf = [0u8; 16]; + csprng_fill(&mut buf); + hex::encode(buf) +} + +/// Fill a buffer with cryptographically secure random bytes from the OS. +fn csprng_fill(buf: &mut [u8]) { + getrandom::fill(buf).expect("OS RNG failed"); +} + +/// Run the OIDC Authorization Code + PKCE browser flow. +/// +/// Opens the user's browser to the Keycloak login page and waits for +/// the authorization code redirect on a localhost callback server. +pub async fn oidc_browser_auth_flow( + issuer: &str, + client_id: &str, + audience: Option<&str>, +) -> Result { + let discovery = discover(issuer).await?; + + let code_verifier = generate_code_verifier(); + let code_challenge = compute_code_challenge(&code_verifier); + let state = generate_state(); + + let listener = TcpListener::bind("127.0.0.1:0").await.into_diagnostic()?; + let port = listener.local_addr().into_diagnostic()?.port(); + let redirect_uri = format!("http://127.0.0.1:{port}/callback"); + + let mut auth_url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}&scope=openid", + discovery.authorization_endpoint, + urlencoded(client_id), + urlencoded(&redirect_uri), + urlencoded(&code_challenge), + urlencoded(&state), + ); + // Request a specific API audience when configured (needed for providers + // like Entra ID where the API audience differs from the client ID). + if let Some(aud) = audience { + auth_url.push_str(&format!("&audience={}", urlencoded(aud))); + } + + let (tx, rx) = oneshot::channel::(); + let expected_state = state.clone(); + + let server_handle = tokio::spawn(run_oidc_callback_server(listener, tx, expected_state)); + + eprintln!(" Opening browser for OIDC authentication..."); + if let Err(e) = crate::auth::open_browser_url(&auth_url) { + debug!(error = %e, "failed to open browser"); + eprintln!("Could not open browser automatically."); + eprintln!("Open this URL in your browser:"); + eprintln!(" {auth_url}"); + eprintln!(); + } else { + eprintln!(" Browser opened. Waiting for authentication..."); + } + + let code = tokio::select! { + result = rx => { + result.map_err(|_| miette::miette!("OIDC callback channel closed unexpectedly"))? + } + () = tokio::time::sleep(AUTH_TIMEOUT) => { + return Err(miette::miette!( + "OIDC authentication timed out after {} seconds.\n\ + Try again with: openshell gateway login", + AUTH_TIMEOUT.as_secs() + )); + } + }; + + server_handle.abort(); + + // Exchange the authorization code for tokens. + let token_response = exchange_code( + &discovery.token_endpoint, + client_id, + &code, + &redirect_uri, + &code_verifier, + ) + .await?; + + Ok(bundle_from_response(token_response, issuer, client_id)) +} + +/// Run the OIDC Client Credentials flow (for CI/automation). +/// +/// Reads `OPENSHELL_OIDC_CLIENT_SECRET` from the environment. +pub async fn oidc_client_credentials_flow( + issuer: &str, + client_id: &str, + audience: Option<&str>, +) -> Result { + let client_secret = std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").map_err(|_| { + miette::miette!( + "OPENSHELL_OIDC_CLIENT_SECRET environment variable is required for client credentials flow" + ) + })?; + + let discovery = discover(issuer).await?; + + let mut params = vec![ + ("grant_type", "client_credentials"), + ("client_id", client_id), + ("client_secret", client_secret.as_str()), + ]; + if let Some(aud) = audience { + params.push(("audience", aud)); + } + + let client = reqwest::Client::new(); + let resp: TokenResponse = client + .post(&discovery.token_endpoint) + .form(¶ms) + .send() + .await + .into_diagnostic()? + .json() + .await + .into_diagnostic()?; + + Ok(bundle_from_response(resp, issuer, client_id)) +} + +/// Refresh an OIDC token using the refresh_token grant. +/// +/// Preserves the existing refresh token if the server does not return a new +/// one (per OAuth 2.0 spec, the refresh response may omit `refresh_token`). +pub async fn oidc_refresh_token(bundle: &OidcTokenBundle) -> Result { + let refresh_token = bundle.refresh_token.as_deref().ok_or_else(|| { + miette::miette!("no refresh token available — re-authenticate with: openshell gateway login") + })?; + + let discovery = discover(&bundle.issuer).await?; + + let params = [ + ("grant_type", "refresh_token"), + ("client_id", bundle.client_id.as_str()), + ("refresh_token", refresh_token), + ]; + + let client = reqwest::Client::new(); + let resp: TokenResponse = client + .post(&discovery.token_endpoint) + .form(¶ms) + .send() + .await + .into_diagnostic()? + .json() + .await + .into_diagnostic()?; + + let mut refreshed = bundle_from_response(resp, &bundle.issuer, &bundle.client_id); + // Preserve the old refresh token if the server didn't return a new one. + if refreshed.refresh_token.is_none() { + refreshed.refresh_token = bundle.refresh_token.clone(); + } + Ok(refreshed) +} + +/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed. +/// +/// Returns the access token string. +pub async fn ensure_valid_oidc_token(gateway_name: &str) -> Result { + let bundle = openshell_bootstrap::oidc_token::load_oidc_token(gateway_name).ok_or_else(|| { + miette::miette!( + "No OIDC token stored for gateway '{gateway_name}'.\n\ + Authenticate with: openshell gateway login" + ) + })?; + + if !openshell_bootstrap::oidc_token::is_token_expired(&bundle) { + return Ok(bundle.access_token); + } + + // Token expired — try to refresh. + debug!(gateway = gateway_name, "OIDC token expired, attempting refresh"); + let refreshed = oidc_refresh_token(&bundle).await?; + openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed)?; + Ok(refreshed.access_token) +} + +// ── Helpers ────────────────────────────────────────────────────────── + +fn bundle_from_response(resp: TokenResponse, issuer: &str, client_id: &str) -> OidcTokenBundle { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + OidcTokenBundle { + access_token: resp.access_token, + refresh_token: resp.refresh_token, + expires_at: resp.expires_in.map(|ei| now + ei), + issuer: issuer.to_string(), + client_id: client_id.to_string(), + } +} + +async fn exchange_code( + token_endpoint: &str, + client_id: &str, + code: &str, + redirect_uri: &str, + code_verifier: &str, +) -> Result { + let params = [ + ("grant_type", "authorization_code"), + ("client_id", client_id), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", code_verifier), + ]; + + let client = reqwest::Client::new(); + let resp: TokenResponse = client + .post(token_endpoint) + .form(¶ms) + .send() + .await + .into_diagnostic()? + .json() + .await + .into_diagnostic()?; + + Ok(resp) +} + +/// Minimal percent-encoding for URL query parameter values. +fn urlencoded(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push_str(&format!("{b:02X}")); + } + } + } + out +} + +/// Percent-decode a URL query parameter value. +fn percent_decode(s: &str) -> String { + let mut out = Vec::with_capacity(s.len()); + let mut bytes = s.bytes(); + while let Some(b) = bytes.next() { + if b == b'%' { + let hi = bytes.next().and_then(|b| char::from(b).to_digit(16)); + let lo = bytes.next().and_then(|b| char::from(b).to_digit(16)); + if let (Some(h), Some(l)) = (hi, lo) { + out.push((h * 16 + l) as u8); + } else { + out.push(b'%'); + } + } else if b == b'+' { + out.push(b' '); + } else { + out.push(b); + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_string()) +} + +/// Callback server state. +struct CallbackState { + expected_state: String, + tx: Mutex>>, +} + +impl CallbackState { + fn take_sender(&self) -> Option> { + self.tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } +} + +/// Run the ephemeral callback server for the OIDC redirect. +/// +/// Listens for `GET /callback?code=...&state=...`. +async fn run_oidc_callback_server( + listener: TcpListener, + tx: oneshot::Sender, + expected_state: String, +) { + let state = Arc::new(CallbackState { + expected_state, + tx: Mutex::new(Some(tx)), + }); + + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let state = Arc::clone(&state); + tokio::spawn(async move { + let service = service_fn(move |req| { + let state = Arc::clone(&state); + async move { + Ok::<_, Infallible>(handle_oidc_callback(req, state).await) + } + }); + + if let Err(error) = Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(stream), service) + .await + { + debug!(error = %error, "OIDC callback server connection failed"); + } + }); + } +} + +async fn handle_oidc_callback( + req: hyper::Request, + state: Arc, +) -> Response> { + if req.method() != Method::GET || !req.uri().path().starts_with("/callback") { + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("not found"))) + .expect("response"); + } + + let query = req.uri().query().unwrap_or(""); + let params: std::collections::HashMap = query + .split('&') + .filter_map(|pair| { + let mut parts = pair.splitn(2, '='); + let key = percent_decode(parts.next()?); + let value = percent_decode(parts.next().unwrap_or("")); + Some((key, value)) + }) + .collect(); + + // Check for error response from the IdP. + if let Some(error) = params.get("error") { + let desc = params.get("error_description").map_or("", String::as_str); + debug!(error = %error, description = %desc, "OIDC auth error"); + let _ = state.take_sender(); + return html_response( + StatusCode::BAD_REQUEST, + &format!("Authentication failed: {error}. {desc}"), + ); + } + + let code = match params.get("code") { + Some(c) if !c.is_empty() => c, + _ => { + let _ = state.take_sender(); + return html_response(StatusCode::BAD_REQUEST, "Missing authorization code."); + } + }; + + let received_state = params.get("state").map_or("", String::as_str); + if received_state != state.expected_state { + debug!("OIDC state mismatch"); + let _ = state.take_sender(); + return html_response(StatusCode::FORBIDDEN, "State parameter mismatch."); + } + + if let Some(sender) = state.take_sender() { + let _ = sender.send(code.clone()); + } + + html_response( + StatusCode::OK, + "Authentication successful! You can close this tab and return to the terminal.", + ) +} + +fn html_response(status: StatusCode, message: &str) -> Response> { + let body = format!( + "\ +

{message}

" + ); + Response::builder() + .status(status) + .header("content-type", "text/html") + .body(Full::new(Bytes::from(body))) + .expect("response") +} diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index ba25488b8..8e9d1c1df 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -896,8 +896,7 @@ fn plaintext_gateway_metadata( remote_host, resolved_host, auth_mode: Some("plaintext".to_string()), - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() } } @@ -963,6 +962,9 @@ pub async fn gateway_add( remote: Option<&str>, ssh_key: Option<&str>, local: bool, + oidc_issuer: Option<&str>, + oidc_client_id: &str, + oidc_audience: Option<&str>, ) -> Result<()> { // If the endpoint starts with ssh://, parse it into an SSH destination // and a gateway endpoint automatically. The host is resolved via @@ -1044,6 +1046,78 @@ pub async fn gateway_add( )); } + // OIDC takes precedence over plaintext/mTLS/edge detection — the user + // explicitly opted in with --oidc-issuer regardless of scheme. + if let Some(issuer) = oidc_issuer { + // When --local is combined with --oidc-issuer, extract mTLS certs + // from the running container so the CLI can establish a TLS + // connection while using OIDC for application-level auth. + if local { + let endpoint_port = url::Url::parse(&endpoint).ok().and_then(|u| u.port()); + eprintln!("• Extracting TLS certificates from gateway container..."); + openshell_bootstrap::extract_and_store_pki(name, None, endpoint_port) + .await?; + } + + let metadata = GatewayMetadata { + name: name.to_string(), + gateway_endpoint: endpoint.clone(), + is_remote: !local, + auth_mode: Some("oidc".to_string()), + oidc_issuer: Some(issuer.to_string()), + oidc_client_id: Some(oidc_client_id.to_string()), + oidc_audience: oidc_audience.map(String::from), + ..Default::default() + }; + + store_gateway_metadata(name, &metadata)?; + save_active_gateway(name)?; + + eprintln!( + "{} Gateway '{}' added and set as active", + "✓".green().bold(), + name, + ); + eprintln!(" {} {}", "Endpoint:".dimmed(), endpoint); + eprintln!(" {} oidc", "Auth:".dimmed()); + if local { + eprintln!("{} TLS certificates extracted", "✓".green().bold()); + } + eprintln!(); + + // Check for client_credentials env var (CI mode). + if std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").is_ok() { + match crate::oidc_auth::oidc_client_credentials_flow(issuer, oidc_client_id, oidc_audience).await { + Ok(bundle) => { + openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?; + eprintln!( + "{} Authenticated via client credentials", + "✓".green().bold() + ); + } + Err(e) => { + eprintln!("{} Authentication failed: {e}", "!".yellow()); + } + } + } else { + match crate::oidc_auth::oidc_browser_auth_flow(issuer, oidc_client_id, oidc_audience).await { + Ok(bundle) => { + openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?; + eprintln!("{} Authenticated successfully", "✓".green().bold()); + } + Err(e) => { + eprintln!("{} Authentication skipped: {e}", "!".yellow()); + eprintln!( + " Authenticate later with: {}", + "openshell gateway login".dimmed(), + ); + } + } + } + + return Ok(()); + } + if endpoint.starts_with("http://") { let metadata = plaintext_gateway_metadata(name, &endpoint, remote, local); let gateway_type = gateway_type_label(&metadata); @@ -1099,8 +1173,7 @@ pub async fn gateway_add( remote_host, resolved_host, auth_mode: Some("mtls".to_string()), - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }; store_gateway_metadata(name, &metadata)?; @@ -1124,12 +1197,8 @@ pub async fn gateway_add( name: name.to_string(), gateway_endpoint: endpoint.clone(), is_remote: true, - gateway_port: 0, - remote_host: None, - resolved_host: None, auth_mode: Some("cloudflare_jwt".to_string()), - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }; store_gateway_metadata(name, &metadata)?; @@ -1162,9 +1231,9 @@ pub async fn gateway_add( Ok(()) } -/// Re-authenticate with an edge-authenticated gateway. +/// Re-authenticate with an edge-authenticated or OIDC gateway. /// -/// Opens a browser for edge proxy login and stores the updated token. +/// Dispatches to the appropriate auth flow based on `auth_mode`. pub async fn gateway_login(name: &str) -> Result<()> { let metadata = openshell_bootstrap::load_gateway_metadata(name).map_err(|_| { miette::miette!( @@ -1173,18 +1242,91 @@ pub async fn gateway_login(name: &str) -> Result<()> { ) })?; - if metadata.auth_mode.as_deref() != Some("cloudflare_jwt") { - return Err(miette::miette!( - "Gateway '{name}' does not use edge authentication.\n\ - Only edge-authenticated gateways support browser login." - )); + match metadata.auth_mode.as_deref() { + Some("cloudflare_jwt") => { + let token = crate::auth::browser_auth_flow(&metadata.gateway_endpoint).await?; + openshell_bootstrap::edge_token::store_edge_token(name, &token)?; + eprintln!("{} Authenticated to gateway '{name}'", "✓".green().bold()); + } + Some("oidc") => { + let issuer = metadata.oidc_issuer.as_deref().ok_or_else(|| { + miette::miette!("Gateway '{name}' has OIDC auth but no issuer URL in metadata") + })?; + let client_id = metadata.oidc_client_id.as_deref().unwrap_or("openshell-cli"); + let audience = metadata.oidc_audience.as_deref(); + + let bundle = if std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").is_ok() { + crate::oidc_auth::oidc_client_credentials_flow(issuer, client_id, audience).await? + } else { + crate::oidc_auth::oidc_browser_auth_flow(issuer, client_id, audience).await? + }; + + let username = jwt_preferred_username(&bundle.access_token); + openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?; + + if let Some(user) = username { + eprintln!( + "{} Authenticated to gateway '{name}' as {user}", + "✓".green().bold(), + ); + } else { + eprintln!("{} Authenticated to gateway '{name}'", "✓".green().bold()); + } + } + _ => { + return Err(miette::miette!( + "Gateway '{name}' does not use edge or OIDC authentication.\n\ + Only edge-authenticated and OIDC gateways support browser login." + )); + } } - let token = crate::auth::browser_auth_flow(&metadata.gateway_endpoint).await?; - openshell_bootstrap::edge_token::store_edge_token(name, &token)?; + Ok(()) +} + +/// Extract `preferred_username` from a JWT payload without signature verification. +fn jwt_preferred_username(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + payload, + ) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + claims + .get("preferred_username") + .and_then(|v| v.as_str()) + .map(String::from) +} + +/// Clear stored authentication credentials for a gateway. +pub fn gateway_logout(name: &str) -> Result<()> { + let metadata = openshell_bootstrap::load_gateway_metadata(name).map_err(|_| { + miette::miette!( + "Unknown gateway '{name}'.\n\ + List available gateways: openshell gateway select" + ) + })?; - eprintln!("{} Authenticated to gateway '{name}'", "✓".green().bold(),); + match metadata.auth_mode.as_deref() { + Some("oidc") => { + openshell_bootstrap::oidc_token::remove_oidc_token(name)?; + } + Some("cloudflare_jwt") => { + openshell_bootstrap::edge_token::remove_edge_token(name)?; + } + _ => { + return Err(miette::miette!( + "Gateway '{name}' uses {} authentication — no stored credentials to clear.", + metadata.auth_mode.as_deref().unwrap_or("mtls") + )); + } + } + eprintln!( + "{} Logged out of gateway '{name}'", + "✓".green().bold(), + ); Ok(()) } @@ -1435,6 +1577,12 @@ pub async fn gateway_admin_deploy( registry_username: Option<&str>, registry_token: Option<&str>, gpu: Vec, + oidc_issuer: Option<&str>, + oidc_audience: &str, + oidc_client_id: &str, + oidc_roles_claim: Option<&str>, + oidc_admin_role: Option<&str>, + oidc_user_role: Option<&str>, ) -> Result<()> { let location = if remote.is_some() { "remote" } else { "local" }; @@ -1501,6 +1649,20 @@ pub async fn gateway_admin_deploy( if let Some(token) = registry_token { options = options.with_registry_token(token); } + if let Some(issuer) = oidc_issuer { + options = options.with_oidc_issuer(issuer); + options = options.with_oidc_audience(oidc_audience); + options.oidc_client_id = oidc_client_id.to_string(); + if let Some(claim) = oidc_roles_claim { + options.oidc_roles_claim = Some(claim.to_string()); + } + if let Some(role) = oidc_admin_role { + options.oidc_admin_role = Some(role.to_string()); + } + if let Some(role) = oidc_user_role { + options.oidc_user_role = Some(role.to_string()); + } + } let handle = deploy_gateway_with_panel(options, name, location).await?; @@ -5512,12 +5674,8 @@ mod tests { name: name.to_string(), gateway_endpoint: endpoint.to_string(), is_remote: true, - gateway_port: 0, - remote_host: None, - resolved_host: None, auth_mode: Some("cloudflare_jwt".to_string()), - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() } } @@ -5899,13 +6057,8 @@ mod tests { GatewayMetadata { name: "local".to_string(), gateway_endpoint: "http://127.0.0.1:8080".to_string(), - is_remote: false, gateway_port: 8080, - remote_host: None, - resolved_host: None, - auth_mode: None, - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }, ]; @@ -5934,13 +6087,8 @@ mod tests { let gateway = GatewayMetadata { name: "local".to_string(), gateway_endpoint: "https://127.0.0.1:8080".to_string(), - is_remote: false, gateway_port: 8080, - remote_host: None, - resolved_host: None, - auth_mode: None, - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }; assert_eq!(gateway_auth_label(&gateway), "mtls"); @@ -5985,7 +6133,7 @@ mod tests { with_tmp_xdg(tmpdir.path(), || { let runtime = tokio::runtime::Runtime::new().expect("create runtime"); runtime.block_on(async { - gateway_add("http://127.0.0.1:8080", None, None, None, false) + gateway_add("http://127.0.0.1:8080", None, None, None, false, None, "openshell-cli", None) .await .expect("register plaintext gateway"); }); @@ -6010,6 +6158,9 @@ mod tests { None, None, true, + None, + "openshell-cli", + None, ) .await .expect("register plaintext gateway"); diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index f66075428..b446b23a1 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -1113,15 +1113,11 @@ async fn connect_gateway( port: u16, tls: &TlsOptions, ) -> Result> { - // When using edge bearer auth, route through the WebSocket tunnel proxy - // regardless of the origin scheme. The proxy handles edge auth headers - // and TLS termination at the edge; the origin may be plaintext HTTP - // behind the tunnel. - if tls.is_bearer_auth() { - let token = tls - .edge_token - .as_deref() - .ok_or_else(|| miette::miette!("edge token required for tunnel"))?; + // When using Cloudflare edge bearer auth, route through the WebSocket + // tunnel proxy regardless of the origin scheme. The proxy handles edge + // auth headers and TLS termination at the edge; the origin may be + // plaintext HTTP behind the tunnel. OIDC tokens bypass the tunnel. + if let Some(token) = tls.edge_token.as_deref() { let gateway_url = format!("https://{host}:{port}"); let proxy = crate::edge_tunnel::start_tunnel_proxy(&gateway_url, token).await?; let tcp = TcpStream::connect(proxy.local_addr) diff --git a/crates/openshell-cli/src/tls.rs b/crates/openshell-cli/src/tls.rs index cd6483530..190e1525f 100644 --- a/crates/openshell-cli/src/tls.rs +++ b/crates/openshell-cli/src/tls.rs @@ -34,6 +34,9 @@ pub struct TlsOptions { /// Edge auth bearer token — when set, disables mTLS client certs and /// injects authentication headers on every gRPC request instead. pub edge_token: Option, + /// OIDC bearer token — when set, injects `authorization: Bearer ` + /// on every gRPC request. Takes precedence over `edge_token`. + pub oidc_token: Option, } impl TlsOptions { @@ -44,6 +47,7 @@ impl TlsOptions { key, gateway_name: None, edge_token: None, + oidc_token: None, } } @@ -90,9 +94,9 @@ impl TlsOptions { } } - /// Returns `true` when using edge token auth (no mTLS client certs). + /// Returns `true` when using bearer token auth (edge or OIDC). pub fn is_bearer_auth(&self) -> bool { - self.edge_token.is_some() + self.edge_token.is_some() || self.oidc_token.is_some() } } @@ -258,9 +262,10 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result { return endpoint.connect().await.into_diagnostic(); } - // When edge bearer auth is active and the server is HTTPS, + // When Cloudflare edge bearer auth is active and the server is HTTPS, // route traffic through a local WebSocket tunnel proxy instead. - if tls.is_bearer_auth() && server.starts_with("https://") { + // OIDC tokens bypass the tunnel — they connect directly. + if tls.edge_token.is_some() && server.starts_with("https://") { let token = tls .edge_token .as_deref() @@ -283,10 +288,28 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result { .http2_keep_alive_interval(Duration::from_secs(10)) .keep_alive_while_idle(true); - let tls_config = if tls.is_bearer_auth() { - // Bearer mode without HTTPS (e.g. http:// direct) — no tunnel needed, - // but also no TLS config to set. This branch shouldn't normally happen - // (edge endpoints are always HTTPS) but handle gracefully. + let tls_config = if tls.oidc_token.is_some() { + // OIDC bearer auth over HTTPS: use mTLS certs for the transport layer + // when available (server may still require client certs), and layer + // the Bearer token on top via the interceptor. + match require_tls_materials(server, tls) { + Ok(materials) => build_tonic_tls_config(&materials), + Err(_) => { + let resolved = tls.with_default_paths(server); + if let Some(ca_path) = resolved.ca.as_ref() { + if let Ok(ca_pem) = std::fs::read(ca_path) { + ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem)) + } else { + ClientTlsConfig::new() + } + } else { + ClientTlsConfig::new() + } + } + } + } else if tls.edge_token.is_some() { + // Edge bearer mode — routed through tunnel above; if we reach here + // the server is not HTTPS so connect plaintext. return endpoint.connect().await.into_diagnostic(); } else { // Standard mTLS: private CA + client cert. @@ -308,22 +331,37 @@ pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result { Ok(OpenShellClient::with_interceptor(channel, interceptor)) } -/// Interceptor that injects edge authentication headers into every outgoing -/// gRPC request. When no token is set, acts as a no-op. -/// -/// Currently sends Cloudflare Access headers for compatibility: -/// - `Cf-Access-Jwt-Assertion` header -/// - `CF_Authorization` cookie +/// Interceptor that injects authentication headers into every outgoing +/// gRPC request. Supports OIDC Bearer tokens (standard `authorization` +/// header) and Cloudflare Access tokens (custom headers). When no token +/// is set, acts as a no-op. OIDC takes precedence over edge tokens. #[derive(Clone)] pub struct EdgeAuthInterceptor { + /// Standard `authorization: Bearer ` for OIDC. + bearer_value: Option>, + /// CF-specific `Cf-Access-Jwt-Assertion` header. header_value: Option>, + /// CF-specific `Cookie: CF_Authorization=` header. cookie_value: Option>, } impl EdgeAuthInterceptor { /// Create an interceptor from [`TlsOptions`]. Returns a no-op interceptor - /// when no edge token is configured. + /// when no auth token is configured. pub fn maybe_from(tls: &TlsOptions) -> Result { + // OIDC bearer token takes precedence. + if let Some(ref token) = tls.oidc_token { + let bearer: tonic::metadata::MetadataValue = + format!("Bearer {token}") + .parse() + .map_err(|_| miette::miette!("invalid OIDC token value"))?; + return Ok(Self { + bearer_value: Some(bearer), + header_value: None, + cookie_value: None, + }); + } + let (header_value, cookie_value) = match tls.edge_token.as_deref() { Some(t) => { let hv: tonic::metadata::MetadataValue = t @@ -338,6 +376,7 @@ impl EdgeAuthInterceptor { None => (None, None), }; Ok(Self { + bearer_value: None, header_value, cookie_value, }) @@ -349,6 +388,9 @@ impl tonic::service::Interceptor for EdgeAuthInterceptor { &mut self, mut req: tonic::Request<()>, ) -> std::result::Result, tonic::Status> { + if let Some(ref val) = self.bearer_value { + req.metadata_mut().insert("authorization", val.clone()); + } if let Some(ref val) = self.header_value { req.metadata_mut() .insert("cf-access-jwt-assertion", val.clone()); diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 2fbdb1b1d..baf947cea 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -109,6 +109,10 @@ pub struct Config { /// TLS configuration. When `None`, the server listens on plaintext HTTP. pub tls: Option, + /// OIDC configuration. When `Some`, the server validates Bearer JWTs. + #[serde(default)] + pub oidc: Option, + /// Database URL for persistence. pub database_url: String, @@ -220,6 +224,58 @@ pub struct TlsConfig { pub allow_unauthenticated: bool, } +/// OIDC (OpenID Connect) configuration for JWT-based authentication. +/// +/// When configured, the server validates `authorization: Bearer ` +/// headers on gRPC requests against the specified issuer's JWKS endpoint. +/// +/// The roles claim path is configurable to support different providers: +/// - Keycloak: `realm_access.roles` (default) +/// - Entra ID / Okta: `roles` +/// - Custom: any dot-separated path into the JWT claims +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OidcConfig { + /// OIDC issuer URL (e.g., `http://localhost:8180/realms/openshell`). + pub issuer: String, + + /// Expected audience (`aud`) claim. Typically the OIDC client ID. + pub audience: String, + + /// JWKS cache TTL in seconds. Defaults to 3600 (1 hour). + #[serde(default = "default_jwks_ttl_secs")] + pub jwks_ttl_secs: u64, + + /// Dot-separated path to the roles array in the JWT claims. + /// Defaults to `realm_access.roles` (Keycloak). + /// Examples: `roles` (Entra ID), `groups` (Okta), `custom.path.roles`. + #[serde(default = "default_roles_claim")] + pub roles_claim: String, + + /// Role name that grants admin access. Defaults to `openshell-admin`. + #[serde(default = "default_admin_role")] + pub admin_role: String, + + /// Role name that grants standard user access. Defaults to `openshell-user`. + #[serde(default = "default_user_role")] + pub user_role: String, +} + +const fn default_jwks_ttl_secs() -> u64 { + 3600 +} + +fn default_roles_claim() -> String { + "realm_access.roles".to_string() +} + +fn default_admin_role() -> String { + "openshell-admin".to_string() +} + +fn default_user_role() -> String { + "openshell-user".to_string() +} + impl Config { /// Create a new config with optional TLS. pub fn new(tls: Option) -> Self { @@ -229,6 +285,7 @@ impl Config { metrics_bind_address: None, log_level: default_log_level(), tls, + oidc: None, database_url: String::new(), compute_drivers: default_compute_drivers(), sandbox_namespace: default_sandbox_namespace(), @@ -381,6 +438,13 @@ impl Config { self.host_gateway_ip = ip.into(); self } + + /// Set the OIDC configuration for JWT-based authentication. + #[must_use] + pub fn with_oidc(mut self, oidc: OidcConfig) -> Self { + self.oidc = Some(oidc); + self + } } fn default_bind_address() -> SocketAddr { diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 28afdd414..b6598782e 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -19,7 +19,7 @@ pub mod paths; pub mod proto; pub mod settings; -pub use config::{ComputeDriverKind, Config, TlsConfig}; +pub use config::{ComputeDriverKind, Config, OidcConfig, TlsConfig}; pub use error::{ComputeDriverError, Error, Result}; /// Build version string derived from git metadata. diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 78d8ac741..70c42ba43 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -35,7 +35,7 @@ miette = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } hmac = "0.12" -sha2 = "0.10" +sha2 = { workspace = true } hex = "0.4" russh = "0.57" rand_core = "0.6" diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index 0af6476c5..d13884681 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -14,6 +14,7 @@ use openshell_core::proto::{ SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateConfigRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; +use tonic::service::interceptor::InterceptedService; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use tracing::debug; @@ -83,10 +84,37 @@ pub async fn connect_channel_pub(endpoint: &str) -> Result { connect_channel(endpoint).await } -/// Connect to the OpenShell server (mTLS or plaintext based on endpoint scheme). -async fn connect(endpoint: &str) -> Result> { +/// Interceptor that injects the sandbox shared secret into every gRPC request. +/// +/// The server validates this header on sandbox-to-server RPCs (GetSandboxConfig, +/// GetSandboxProviderEnvironment, etc.) instead of requiring an OIDC Bearer token. +#[derive(Clone)] +pub(crate) struct SandboxSecretInterceptor { + secret: Option>, +} + +impl tonic::service::Interceptor for SandboxSecretInterceptor { + fn call( + &mut self, + mut req: tonic::Request<()>, + ) -> std::result::Result, tonic::Status> { + if let Some(ref val) = self.secret { + req.metadata_mut().insert("x-sandbox-secret", val.clone()); + } + Ok(req) + } +} + +type AuthenticatedClient = OpenShellClient>; + +/// Connect to the OpenShell server with sandbox secret authentication. +async fn connect(endpoint: &str) -> Result { let channel = connect_channel(endpoint).await?; - Ok(OpenShellClient::new(channel)) + let secret = std::env::var("OPENSHELL_SSH_HANDSHAKE_SECRET") + .ok() + .and_then(|s| s.parse().ok()); + let interceptor = SandboxSecretInterceptor { secret }; + Ok(OpenShellClient::with_interceptor(channel, interceptor)) } /// Fetch sandbox policy from OpenShell server via gRPC. @@ -106,7 +134,7 @@ pub async fn fetch_policy(endpoint: &str, sandbox_id: &str) -> Result, + client: &mut AuthenticatedClient, sandbox_id: &str, ) -> Result> { let response = client @@ -130,7 +158,7 @@ async fn fetch_policy_with_client( /// Sync a locally-discovered policy using an existing client connection. async fn sync_policy_with_client( - client: &mut OpenShellClient, + client: &mut AuthenticatedClient, sandbox: &str, policy: &ProtoSandboxPolicy, ) -> Result<()> { @@ -220,7 +248,7 @@ pub async fn fetch_provider_environment( /// and status reporting, avoiding per-request TLS handshake overhead. #[derive(Clone)] pub struct CachedOpenShellClient { - client: OpenShellClient, + client: AuthenticatedClient, } /// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. @@ -239,13 +267,12 @@ pub struct SettingsPollResult { impl CachedOpenShellClient { pub async fn connect(endpoint: &str) -> Result { debug!(endpoint = %endpoint, "Connecting openshell gRPC client for policy polling"); - let channel = connect_channel(endpoint).await?; - let client = OpenShellClient::new(channel); + let client = connect(endpoint).await?; Ok(Self { client }) } /// Get a clone of the underlying tonic client for direct RPC calls. - pub fn raw_client(&self) -> OpenShellClient { + pub fn raw_client(&self) -> AuthenticatedClient { self.client.clone() } diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index c2c392f33..d9f31220d 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -73,10 +73,11 @@ reqwest = { workspace = true } uuid = { workspace = true } url = { workspace = true } hmac = "0.12" -sha2 = "0.10" +sha2 = { workspace = true } +jsonwebtoken = { workspace = true } hex = "0.4" russh = "0.57" -rand = "0.9" +rand = { workspace = true } petname = "2" ipnet = "2" diff --git a/crates/openshell-server/src/auth.rs b/crates/openshell-server/src/auth.rs index b896d062c..3f45f1814 100644 --- a/crates/openshell-server/src/auth.rs +++ b/crates/openshell-server/src/auth.rs @@ -16,9 +16,9 @@ //! CLI's ephemeral localhost server captures and stores the token. use axum::{ - Router, + Json, Router, extract::{Query, State}, - http::HeaderMap, + http::{HeaderMap, StatusCode}, response::{Html, IntoResponse}, routing::get, }; @@ -58,9 +58,25 @@ struct ConnectParams { pub fn router(state: Arc) -> Router { Router::new() .route("/auth/connect", get(auth_connect)) + .route("/auth/oidc-config", get(oidc_config_handler)) .with_state(state) } +/// OIDC configuration discovery endpoint. +/// +/// Returns the OIDC issuer and audience when OIDC is configured on the server, +/// so CLI clients can auto-discover settings during `gateway add`. +async fn oidc_config_handler(State(state): State>) -> impl IntoResponse { + match &state.config.oidc { + Some(oidc) => Json(serde_json::json!({ + "issuer": oidc.issuer, + "audience": oidc.audience, + })) + .into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + /// Handle the auth connect request. /// /// Reads `CF_Authorization` from the cookie header (server-side extraction diff --git a/crates/openshell-server/src/authz.rs b/crates/openshell-server/src/authz.rs new file mode 100644 index 000000000..b7e21c121 --- /dev/null +++ b/crates/openshell-server/src/authz.rs @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Authorization policy evaluation. +//! +//! Determines whether an authenticated identity is allowed to call a given +//! gRPC method. This module owns the RBAC policy — which methods require +//! which roles — while authentication providers (OIDC, mTLS, etc.) own +//! identity verification. +//! +//! This separation follows RFC 0001's control-plane identity design: +//! authentication is a driver concern, authorization is a gateway concern. + +use crate::identity::Identity; +use tonic::Status; +use tracing::debug; + +/// gRPC methods that require the admin role. +/// All other authenticated methods require the user role. +const ADMIN_METHODS: &[&str] = &[ + // Provider management + "/openshell.v1.OpenShell/CreateProvider", + "/openshell.v1.OpenShell/UpdateProvider", + "/openshell.v1.OpenShell/DeleteProvider", + // Global config and policy + "/openshell.v1.OpenShell/UpdateConfig", + // Draft policy approvals + "/openshell.v1.OpenShell/ApproveDraftChunk", + "/openshell.v1.OpenShell/ApproveAllDraftChunks", + "/openshell.v1.OpenShell/RejectDraftChunk", + "/openshell.v1.OpenShell/EditDraftChunk", + "/openshell.v1.OpenShell/UndoDraftChunk", + "/openshell.v1.OpenShell/ClearDraftChunks", +]; + +/// Authorization policy configuration. +/// +/// Supports two modes: +/// - **RBAC mode**: both `admin_role` and `user_role` are non-empty. +/// - **Authentication-only mode**: both are empty (any valid token is authorized). +/// +/// Partial configuration (one empty, one set) is rejected at construction +/// to prevent accidentally leaving admin endpoints unprotected. +#[derive(Debug, Clone)] +pub struct AuthzPolicy { + /// Role name that grants admin access. Empty disables admin checks. + pub admin_role: String, + /// Role name that grants standard user access. Empty disables user checks. + pub user_role: String, +} + +impl AuthzPolicy { + /// Validate the policy configuration. + /// + /// Returns an error if only one of admin/user role is set — either + /// both must be set (RBAC mode) or both empty (auth-only mode). + pub fn validate(&self) -> Result<(), String> { + let admin_set = !self.admin_role.is_empty(); + let user_set = !self.user_role.is_empty(); + if admin_set != user_set { + return Err(format!( + "OIDC RBAC misconfiguration: admin_role={:?}, user_role={:?}. \ + Either set both roles (RBAC mode) or leave both empty (authentication-only mode).", + self.admin_role, self.user_role, + )); + } + Ok(()) + } +} + +impl AuthzPolicy { + /// Check whether the identity is authorized to call the given method. + /// + /// Returns `Ok(())` if authorized, `Err(PERMISSION_DENIED)` if not. + /// When both role names are empty, all authenticated callers are authorized + /// (authentication-only mode for providers like GitHub). + pub fn check(&self, identity: &Identity, method: &str) -> Result<(), Status> { + let required = if ADMIN_METHODS.contains(&method) { + &self.admin_role + } else { + &self.user_role + }; + + // Empty role name = skip RBAC for this level. + if required.is_empty() { + return Ok(()); + } + + // Admin role implicitly satisfies user role requirements. + let has_role = identity.roles.iter().any(|r| r == required) + || (!self.admin_role.is_empty() + && required == &self.user_role + && identity.roles.iter().any(|r| r == &self.admin_role)); + + if has_role { + Ok(()) + } else { + debug!( + sub = %identity.subject, + required_role = required, + user_roles = ?identity.roles, + method = method, + "authorization denied" + ); + Err(Status::permission_denied(format!( + "role '{required}' required" + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::IdentityProvider; + + fn default_policy() -> AuthzPolicy { + AuthzPolicy { + admin_role: "openshell-admin".to_string(), + user_role: "openshell-user".to_string(), + } + } + + fn identity_with_roles(roles: &[&str]) -> Identity { + Identity { + subject: "test-user".to_string(), + display_name: None, + roles: roles.iter().map(|r| (*r).to_string()).collect(), + provider: IdentityProvider::Oidc, + } + } + + #[test] + fn user_can_access_user_methods() { + let id = identity_with_roles(&["openshell-user"]); + let policy = default_policy(); + assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok()); + } + + #[test] + fn user_cannot_access_admin_methods() { + let id = identity_with_roles(&["openshell-user"]); + let policy = default_policy(); + assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_err()); + } + + #[test] + fn admin_can_access_admin_methods() { + let id = identity_with_roles(&["openshell-admin", "openshell-user"]); + let policy = default_policy(); + assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_ok()); + } + + #[test] + fn admin_only_can_access_user_methods() { + let id = identity_with_roles(&["openshell-admin"]); + let policy = default_policy(); + assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok()); + } + + #[test] + fn empty_roles_rejected() { + let id = identity_with_roles(&[]); + let policy = default_policy(); + assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_err()); + } + + #[test] + fn empty_role_names_skip_rbac() { + let id = identity_with_roles(&[]); + let policy = AuthzPolicy { + admin_role: String::new(), + user_role: String::new(), + }; + assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok()); + assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_ok()); + } + + #[test] + fn custom_role_names() { + let id = identity_with_roles(&["OpenShell.Admin", "OpenShell.User"]); + let policy = AuthzPolicy { + admin_role: "OpenShell.Admin".to_string(), + user_role: "OpenShell.User".to_string(), + }; + assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_ok()); + assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok()); + } + + #[test] + fn validate_accepts_both_roles_set() { + let policy = default_policy(); + assert!(policy.validate().is_ok()); + } + + #[test] + fn validate_accepts_both_roles_empty() { + let policy = AuthzPolicy { + admin_role: String::new(), + user_role: String::new(), + }; + assert!(policy.validate().is_ok()); + } + + #[test] + fn validate_rejects_partial_empty_admin_only() { + let policy = AuthzPolicy { + admin_role: "admin".to_string(), + user_role: String::new(), + }; + assert!(policy.validate().is_err()); + } + + #[test] + fn validate_rejects_partial_empty_user_only() { + let policy = AuthzPolicy { + admin_role: String::new(), + user_role: "user".to_string(), + }; + assert!(policy.validate().is_err()); + } +} diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 2e6e2823b..3ab310ea6 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -188,6 +188,33 @@ struct Args { /// certificate. Ignored when --disable-tls is set. #[arg(long, env = "OPENSHELL_DISABLE_GATEWAY_AUTH")] disable_gateway_auth: bool, + + /// OIDC issuer URL for JWT-based authentication. + /// When set, the server validates `authorization: Bearer` tokens on gRPC + /// requests against the issuer's JWKS endpoint. + #[arg(long, env = "OPENSHELL_OIDC_ISSUER")] + oidc_issuer: Option, + + /// Expected OIDC audience claim (typically the client ID). + #[arg(long, env = "OPENSHELL_OIDC_AUDIENCE", default_value = "openshell-cli")] + oidc_audience: String, + + /// JWKS key cache TTL in seconds. + #[arg(long, env = "OPENSHELL_OIDC_JWKS_TTL", default_value_t = 3600)] + oidc_jwks_ttl: u64, + + /// Dot-separated path to the roles array in the JWT claims. + /// Keycloak: "realm_access.roles" (default). Entra ID: "roles". Okta: "groups". + #[arg(long, env = "OPENSHELL_OIDC_ROLES_CLAIM", default_value = "realm_access.roles")] + oidc_roles_claim: String, + + /// Role name that grants admin access. + #[arg(long, env = "OPENSHELL_OIDC_ADMIN_ROLE", default_value = "openshell-admin")] + oidc_admin_role: String, + + /// Role name that grants standard user access. + #[arg(long, env = "OPENSHELL_OIDC_USER_ROLE", default_value = "openshell-user")] + oidc_user_role: String, } pub fn command() -> Command { @@ -304,6 +331,17 @@ async fn run_from_args(args: Args) -> Result<()> { config = config.with_host_gateway_ip(ip); } + if let Some(issuer) = args.oidc_issuer { + config = config.with_oidc(openshell_core::OidcConfig { + issuer, + audience: args.oidc_audience, + jwks_ttl_secs: args.oidc_jwks_ttl, + roles_claim: args.oidc_roles_claim, + admin_role: args.oidc_admin_role, + user_role: args.oidc_user_role, + }); + } + let vm_config = VmComputeConfig { state_dir: args.vm_driver_state_dir, driver_dir: args.driver_dir, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 8ef8cb5c7..f4bacc1aa 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -10,7 +10,7 @@ #![allow(clippy::cast_precision_loss)] // f64->f32 for confidence scores #![allow(clippy::items_after_statements)] // DB_PORTS const inside function -use crate::ServerState; +use crate::{ServerState, oidc}; use crate::persistence::{DraftChunkRecord, PolicyRecord, Store}; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; @@ -296,6 +296,36 @@ fn truncate_for_log(input: &str, max_chars: usize) -> String { } } +fn is_sandbox_secret_authenticated(request: &Request) -> bool { + oidc::is_sandbox_secret_authenticated(request.metadata()) +} + +/// Sandbox-secret-authenticated callers may only perform sandbox-scoped policy +/// sync. They must not be able to mutate global config or sandbox settings. +fn validate_sandbox_secret_update(req: &UpdateConfigRequest) -> Result<(), Status> { + if req.global { + return Err(Status::permission_denied( + "sandbox secret cannot mutate global config", + )); + } + if req.delete_setting { + return Err(Status::permission_denied( + "sandbox secret cannot delete settings", + )); + } + if req.name.trim().is_empty() { + return Err(Status::permission_denied( + "sandbox secret may only perform sandbox policy sync", + )); + } + if req.policy.is_none() || !req.setting_key.trim().is_empty() { + return Err(Status::permission_denied( + "sandbox secret may only perform sandbox policy sync", + )); + } + Ok(()) +} + // --------------------------------------------------------------------------- // Config handlers // --------------------------------------------------------------------------- @@ -473,7 +503,11 @@ pub(super) async fn handle_update_config( state: &Arc, request: Request, ) -> Result, Status> { + let sandbox_secret_auth = is_sandbox_secret_authenticated(&request); let req = request.into_inner(); + if sandbox_secret_auth { + validate_sandbox_secret_update(&req)?; + } let key = req.setting_key.trim(); let has_policy = req.policy.is_some(); let has_setting = !key.is_empty(); @@ -2581,6 +2615,49 @@ mod tests { use std::sync::Arc; use tonic::Code; + #[test] + fn sandbox_secret_update_validation_allows_sandbox_policy_sync() { + let req = UpdateConfigRequest { + name: "sandbox-1".to_string(), + policy: Some(ProtoSandboxPolicy::default()), + ..Default::default() + }; + assert!(validate_sandbox_secret_update(&req).is_ok()); + } + + #[test] + fn sandbox_secret_update_validation_rejects_global_mutation() { + let req = UpdateConfigRequest { + global: true, + policy: Some(ProtoSandboxPolicy::default()), + ..Default::default() + }; + let err = validate_sandbox_secret_update(&req).unwrap_err(); + assert_eq!(err.code(), Code::PermissionDenied); + } + + #[test] + fn sandbox_secret_update_validation_rejects_setting_mutation() { + let req = UpdateConfigRequest { + name: "sandbox-1".to_string(), + setting_key: "inference.model".to_string(), + setting_value: Some(SettingValue { value: None }), + ..Default::default() + }; + let err = validate_sandbox_secret_update(&req).unwrap_err(); + assert_eq!(err.code(), Code::PermissionDenied); + } + + #[test] + fn sandbox_secret_marker_detected_from_metadata() { + let mut req = Request::new(()); + req.metadata_mut().insert( + oidc::INTERNAL_AUTH_SOURCE_HEADER, + oidc::AUTH_SOURCE_SANDBOX_SECRET.parse().unwrap(), + ); + assert!(is_sandbox_secret_authenticated(&req)); + } + // ---- Sandbox without policy ---- #[tokio::test] diff --git a/crates/openshell-server/src/identity.rs b/crates/openshell-server/src/identity.rs new file mode 100644 index 000000000..6569f4775 --- /dev/null +++ b/crates/openshell-server/src/identity.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Provider-agnostic identity representation. +//! +//! Any authentication backend (OIDC, mTLS, static RBAC, OS users) produces +//! an `Identity` that the authorization layer can evaluate without knowing +//! which provider authenticated the caller. + +/// Authenticated caller identity. +/// +/// Produced by an authentication provider and consumed by the authorization +/// layer. The gateway's auth middleware converts provider-specific claims +/// (OIDC JWT, mTLS cert CN, etc.) into this common representation. +#[derive(Debug, Clone)] +pub struct Identity { + /// Unique subject identifier (OIDC `sub`, cert CN, username, etc.). + pub subject: String, + + /// Human-readable display name (OIDC `preferred_username`, cert CN, etc.). + pub display_name: Option, + + /// Roles granted to this identity (OIDC `realm_access.roles`, cert OU, etc.). + pub roles: Vec, + + /// Which authentication provider produced this identity. + pub provider: IdentityProvider, +} + +/// Authentication provider that produced an identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdentityProvider { + /// OIDC/OAuth2 JWT bearer token. + Oidc, + /// mTLS client certificate. + Mtls, + /// Cloudflare Access JWT. + CloudflareAccess, + /// Internal (skip-listed methods, sandbox supervisor RPCs). + Internal, +} diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 4a5ca55bd..769b20734 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -22,10 +22,13 @@ mod auth; pub mod cli; mod compute; +mod authz; mod grpc; mod http; +pub mod identity; mod inference; mod multiplex; +pub mod oidc; mod persistence; mod sandbox_index; mod sandbox_watch; @@ -90,6 +93,9 @@ pub struct ServerState { /// Registry of active supervisor sessions and pending relay channels. pub supervisor_sessions: Arc, + + /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. + pub oidc_cache: Option>, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -110,6 +116,7 @@ impl ServerState { sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, + oidc_cache: Option>, ) -> Self { Self { config, @@ -122,6 +129,7 @@ impl ServerState { ssh_connections_by_sandbox: Mutex::new(HashMap::new()), settings_mutex: tokio::sync::Mutex::new(()), supervisor_sessions, + oidc_cache, } } } @@ -150,6 +158,25 @@ pub async fn run_server( let store = Arc::new(Store::connect(database_url).await?); + let oidc_cache = if let Some(ref oidc) = config.oidc { + // Validate RBAC configuration before starting. + let policy = authz::AuthzPolicy { + admin_role: oidc.admin_role.clone(), + user_role: oidc.user_role.clone(), + }; + policy + .validate() + .map_err(|e| Error::config(e))?; + + let cache = oidc::JwksCache::new(oidc) + .await + .map_err(|e| Error::config(format!("OIDC initialization failed: {e}")))?; + info!("OIDC JWT validation enabled (issuer: {})", oidc.issuer); + Some(Arc::new(cache)) + } else { + None + }; + let sandbox_index = SandboxIndex::new(); let sandbox_watch_bus = SandboxWatchBus::new(); let supervisor_sessions = Arc::new(supervisor_session::SupervisorSessionRegistry::new()); @@ -171,6 +198,7 @@ pub async fn run_server( sandbox_watch_bus, tracing_log_bus, supervisor_sessions, + oidc_cache, )); state.compute.spawn_watchers(); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index e0c159958..8075c498d 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -29,7 +29,7 @@ use tower::{ServiceBuilder, ServiceExt}; use tower_http::trace::TraceLayer; use tracing::Span; -use crate::{OpenShellService, ServerState, http_router, inference::InferenceService}; +use crate::{OpenShellService, ServerState, authz::AuthzPolicy, http_router, inference::InferenceService, oidc}; /// Maximum inbound gRPC message size (1 MB). /// @@ -61,7 +61,16 @@ impl MultiplexService { .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); let inference = InferenceServer::new(InferenceService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); - let grpc_service = GrpcRouter::new(openshell, inference); + let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { + admin_role: oidc.admin_role.clone(), + user_role: oidc.user_role.clone(), + }); + let grpc_service = AuthGrpcRouter::new( + GrpcRouter::new(openshell, inference), + self.state.oidc_cache.clone(), + authz_policy, + self.state.config.ssh_handshake_secret.clone(), + ); let http_service = http_router(self.state.clone()); let grpc_service = ServiceBuilder::new() @@ -154,6 +163,145 @@ where } } +/// gRPC router wrapper that authenticates and authorizes requests. +/// +/// When `oidc_cache` is `Some`, extracts the `authorization: Bearer ` +/// header, validates the JWT (authentication), then checks RBAC roles +/// (authorization) before forwarding to the inner gRPC router. +/// +/// Authentication is provider-specific (currently OIDC via `oidc.rs`). +/// Authorization is provider-agnostic (via `authz.rs`). This separation +/// aligns with RFC 0001's control-plane identity design. +#[derive(Clone)] +pub struct AuthGrpcRouter { + inner: S, + oidc_cache: Option>, + authz_policy: Option, + /// SSH handshake secret used to validate sandbox-to-server RPCs. + sandbox_secret: String, +} + +impl AuthGrpcRouter { + fn new( + inner: S, + oidc_cache: Option>, + authz_policy: Option, + sandbox_secret: String, + ) -> Self { + Self { + inner, + oidc_cache, + authz_policy, + sandbox_secret, + } + } +} + +impl tower::Service> for AuthGrpcRouter +where + S: tower::Service, Response = Response> + + Clone + + Send + + 'static, + S::Future: Send, + S::Error: Send + Into>, + B: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + let oidc_cache = self.oidc_cache.clone(); + let authz_policy = self.authz_policy.clone(); + let sandbox_secret = self.sandbox_secret.clone(); + let mut inner = self.inner.clone(); + + Box::pin(async move { + let mut req = req; + oidc::clear_internal_auth_markers(req.headers_mut()); + + // If OIDC is not configured, pass through directly. + let Some(cache) = oidc_cache else { + return inner.ready().await?.call(req).await; + }; + + let path = req.uri().path().to_string(); + + // Health probes and reflection — truly unauthenticated. + if oidc::is_unauthenticated_method(&path) { + return inner.ready().await?.call(req).await; + } + + // Sandbox-to-server RPCs — authenticated via shared secret, + // not OIDC Bearer tokens. + if oidc::is_sandbox_secret_method(&path) { + if let Err(status) = oidc::validate_sandbox_secret(req.headers(), &sandbox_secret) { + let response = status.into_http(); + let (parts, body) = response.into_parts(); + let body = tonic::body::BoxBody::new(body); + return Ok(Response::from_parts(parts, body)); + } + oidc::mark_sandbox_secret_authenticated(req.headers_mut()); + return inner.ready().await?.call(req).await; + } + + // Dual-auth methods (e.g. UpdateConfig) — accept either a + // Bearer token (CLI users) or sandbox secret (supervisor). + if oidc::is_dual_auth_method(&path) { + if oidc::validate_sandbox_secret(req.headers(), &sandbox_secret).is_ok() { + oidc::mark_sandbox_secret_authenticated(req.headers_mut()); + return inner.ready().await?.call(req).await; + } + // Fall through to Bearer token validation below. + } + + // Extract Bearer token from the authorization header. + let token = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + + let Some(token) = token else { + let status = tonic::Status::unauthenticated("missing authorization header"); + let response = status.into_http(); + // Convert the response body type. + let (parts, body) = response.into_parts(); + let body = tonic::body::BoxBody::new(body); + return Ok(Response::from_parts(parts, body)); + }; + + // Authenticate: validate the JWT and produce an Identity. + let identity = match cache.validate_token(token).await { + Ok(id) => id, + Err(status) => { + let response = status.into_http(); + let (parts, body) = response.into_parts(); + let body = tonic::body::BoxBody::new(body); + return Ok(Response::from_parts(parts, body)); + } + }; + + // Authorize: check RBAC roles against the method. + if let Some(ref policy) = authz_policy { + if let Err(status) = policy.check(&identity, &path) { + let response = status.into_http(); + let (parts, body) = response.into_parts(); + let body = tonic::body::BoxBody::new(body); + return Ok(Response::from_parts(parts, body)); + } + } + + inner.ready().await?.call(req).await + }) + } +} + /// Service that multiplexes between gRPC and HTTP. #[derive(Clone)] pub struct MultiplexedService { diff --git a/crates/openshell-server/src/oidc.rs b/crates/openshell-server/src/oidc.rs new file mode 100644 index 000000000..7933f4bcc --- /dev/null +++ b/crates/openshell-server/src/oidc.rs @@ -0,0 +1,484 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OIDC JWT authentication provider. +//! +//! Validates `authorization: Bearer ` headers against a Keycloak (or +//! any OIDC-compliant) issuer using cached JWKS keys. Produces an +//! `Identity` that the authorization layer (`authz.rs`) evaluates. +//! +//! This module owns authentication (verifying who the caller is). +//! Authorization (deciding what the caller can do) is in `authz.rs`. + +use crate::identity::{Identity, IdentityProvider}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use openshell_core::OidcConfig; +use reqwest::Client; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tonic::Status; +use tracing::{debug, info, warn}; + +/// Internal metadata header set by the auth middleware after it validates +/// a sandbox-secret-authenticated request. This is stripped from all incoming +/// requests first so external callers cannot spoof it. +pub const INTERNAL_AUTH_SOURCE_HEADER: &str = "x-openshell-auth-source"; +/// Internal auth-source marker for requests authenticated via the shared +/// sandbox secret. +pub const AUTH_SOURCE_SANDBOX_SECRET: &str = "sandbox-secret"; + +/// Truly unauthenticated methods — health probes and infrastructure. +const UNAUTHENTICATED_METHODS: &[&str] = &[ + "/openshell.v1.OpenShell/Health", + "/openshell.inference.v1.Inference/Health", +]; + +/// Path prefixes that bypass OIDC validation (gRPC reflection, health probes). +const UNAUTHENTICATED_PREFIXES: &[&str] = &[ + "/grpc.reflection.", + "/grpc.health.", +]; + +/// Sandbox-to-server RPCs that use the shared sandbox secret instead of +/// OIDC Bearer tokens. These require the `x-sandbox-secret` metadata header +/// matching the server's SSH handshake secret. +const SANDBOX_SECRET_METHODS: &[&str] = &[ + "/openshell.v1.OpenShell/GetSandboxConfig", + "/openshell.v1.OpenShell/ReportPolicyStatus", + "/openshell.v1.OpenShell/PushSandboxLogs", + "/openshell.v1.OpenShell/GetSandboxProviderEnvironment", + "/openshell.v1.OpenShell/SubmitPolicyAnalysis", + "/openshell.sandbox.v1.SandboxService/GetSandboxConfig", +]; + +/// Methods that accept either OIDC Bearer token (CLI users) or sandbox +/// secret (supervisor). UpdateConfig is called by both CLI (policy/settings +/// mutations) and the sandbox supervisor (policy sync on startup). +const DUAL_AUTH_METHODS: &[&str] = &[ + "/openshell.v1.OpenShell/UpdateConfig", +]; + +/// Returns `true` if the method accepts either Bearer or sandbox-secret auth. +pub fn is_dual_auth_method(path: &str) -> bool { + DUAL_AUTH_METHODS.contains(&path) +} + +/// Returns `true` if the method needs no authentication at all. +pub fn is_unauthenticated_method(path: &str) -> bool { + UNAUTHENTICATED_METHODS.contains(&path) + || UNAUTHENTICATED_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) +} + +/// Returns `true` if the method authenticates via the sandbox shared secret +/// rather than an OIDC Bearer token. +pub fn is_sandbox_secret_method(path: &str) -> bool { + SANDBOX_SECRET_METHODS.contains(&path) +} + +/// Validate the `x-sandbox-secret` header against the server's handshake secret. +pub fn validate_sandbox_secret( + headers: &http::HeaderMap, + expected_secret: &str, +) -> Result<(), Status> { + let provided = headers + .get("x-sandbox-secret") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + Status::unauthenticated("sandbox secret required for this method") + })?; + + if provided != expected_secret { + return Err(Status::unauthenticated("invalid sandbox secret")); + } + + Ok(()) +} + +/// Remove internal auth-source markers from the request before any auth +/// decision is made so external callers cannot spoof them. +pub fn clear_internal_auth_markers(headers: &mut http::HeaderMap) { + headers.remove(INTERNAL_AUTH_SOURCE_HEADER); +} + +/// Mark the request as authenticated via the shared sandbox secret. +pub fn mark_sandbox_secret_authenticated(headers: &mut http::HeaderMap) { + headers.insert( + INTERNAL_AUTH_SOURCE_HEADER, + http::HeaderValue::from_static(AUTH_SOURCE_SANDBOX_SECRET), + ); +} + +/// Returns `true` if the request metadata indicates sandbox-secret auth. +pub fn is_sandbox_secret_authenticated(metadata: &tonic::metadata::MetadataMap) -> bool { + metadata + .get(INTERNAL_AUTH_SOURCE_HEADER) + .and_then(|v| v.to_str().ok()) + == Some(AUTH_SOURCE_SANDBOX_SECRET) +} + +/// Cached JWKS key set fetched from the OIDC issuer. +/// +/// A `refresh_mutex` ensures that only one refresh runs at a time, +/// preventing a "thundering herd" when the TTL expires or a new `kid` +/// is encountered under concurrent load. +pub struct JwksCache { + keys: Arc>>, + jwks_uri: String, + ttl: Duration, + last_refresh: Arc>, + /// Serializes JWKS refresh operations so concurrent requests coalesce + /// into a single HTTP fetch rather than stampeding the OIDC provider. + refresh_mutex: tokio::sync::Mutex<()>, + http: Client, + config: OidcConfig, +} + +impl std::fmt::Debug for JwksCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JwksCache") + .field("jwks_uri", &self.jwks_uri) + .field("ttl", &self.ttl) + .finish() + } +} + +/// OIDC discovery document (subset of fields we need). +#[derive(Deserialize)] +struct OidcDiscovery { + issuer: String, + jwks_uri: String, +} + +/// JWKS key set. +#[derive(Deserialize)] +struct JwkSet { + keys: Vec, +} + +/// A single JWK key. +#[derive(Deserialize)] +struct JwkKey { + kid: Option, + kty: String, + #[serde(default)] + n: String, + #[serde(default)] + e: String, +} + +/// Claims extracted from a validated JWT. +#[derive(Debug, Deserialize)] +pub struct OidcClaims { + pub sub: String, + #[serde(default)] + pub preferred_username: Option, + #[serde(default)] + pub email: Option, + /// Roles extracted from the configurable claim path. + #[serde(skip)] + pub roles: Vec, + /// Raw claims for flexible role extraction. + #[serde(flatten)] + extra: serde_json::Value, +} + +impl OidcClaims { + /// Extract roles from the JWT claims using a dot-separated path. + /// + /// Supports paths like: + /// - `realm_access.roles` (Keycloak) + /// - `roles` (Entra ID) + /// - `groups` (Okta) + fn extract_roles(&mut self, roles_claim: &str) { + let mut value = &self.extra; + for segment in roles_claim.split('.') { + match value.get(segment) { + Some(v) => value = v, + None => return, + } + } + if let Some(arr) = value.as_array() { + self.roles = arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + } +} + +impl JwksCache { + /// Create a new JWKS cache, discovering the JWKS URI and fetching the + /// initial key set. + pub async fn new(config: &OidcConfig) -> Result { + let http = Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| format!("failed to create HTTP client: {e}"))?; + + // Discover JWKS URI from the OIDC discovery endpoint. + let discovery_url = format!( + "{}/.well-known/openid-configuration", + config.issuer.trim_end_matches('/') + ); + info!(url = %discovery_url, "Discovering OIDC configuration"); + + let discovery: OidcDiscovery = http + .get(&discovery_url) + .send() + .await + .map_err(|e| format!("OIDC discovery request failed: {e}"))? + .json() + .await + .map_err(|e| format!("OIDC discovery response parse failed: {e}"))?; + + // Validate the discovery document's issuer matches our configured issuer. + let expected = config.issuer.trim_end_matches('/'); + let actual = discovery.issuer.trim_end_matches('/'); + if expected != actual { + return Err(format!( + "OIDC discovery issuer mismatch: expected '{expected}', got '{actual}'" + )); + } + + info!(jwks_uri = %discovery.jwks_uri, "OIDC JWKS URI discovered"); + + let cache = Self { + keys: Arc::new(RwLock::new(HashMap::new())), + jwks_uri: discovery.jwks_uri, + ttl: Duration::from_secs(config.jwks_ttl_secs), + last_refresh: Arc::new(RwLock::new( + Instant::now() - Duration::from_secs(config.jwks_ttl_secs + 1), + )), + refresh_mutex: tokio::sync::Mutex::new(()), + http, + config: config.clone(), + }; + + cache.refresh_keys().await?; + Ok(cache) + } + + /// Fetch the JWKS and update the cached keys. + async fn refresh_keys(&self) -> Result<(), String> { + debug!(uri = %self.jwks_uri, "Refreshing JWKS keys"); + + let jwk_set: JwkSet = self + .http + .get(&self.jwks_uri) + .send() + .await + .map_err(|e| format!("JWKS fetch failed: {e}"))? + .json() + .await + .map_err(|e| format!("JWKS parse failed: {e}"))?; + + let mut new_keys = HashMap::new(); + for key in &jwk_set.keys { + if key.kty != "RSA" { + continue; + } + let Some(ref kid) = key.kid else { + continue; + }; + match DecodingKey::from_rsa_components(&key.n, &key.e) { + Ok(dk) => { + new_keys.insert(kid.clone(), dk); + } + Err(e) => { + warn!(kid = %kid, error = %e, "Failed to parse JWK"); + } + } + } + + info!(count = new_keys.len(), "JWKS keys loaded"); + *self.keys.write().await = new_keys; + *self.last_refresh.write().await = Instant::now(); + Ok(()) + } + + /// Refresh keys if the TTL has elapsed. + /// + /// Holds the refresh mutex so concurrent callers coalesce into a single + /// HTTP fetch. The second caller will re-check the TTL after acquiring + /// the lock and find it fresh. + async fn refresh_if_stale(&self) -> Result<(), String> { + let last = *self.last_refresh.read().await; + if last.elapsed() <= self.ttl { + return Ok(()); + } + let _guard = self.refresh_mutex.lock().await; + // Re-check after acquiring the lock — another task may have refreshed. + let last = *self.last_refresh.read().await; + if last.elapsed() <= self.ttl { + return Ok(()); + } + self.refresh_keys().await + } + + /// Refresh keys unconditionally, coalescing concurrent callers. + async fn refresh_keys_coalesced(&self) -> Result<(), String> { + let _guard = self.refresh_mutex.lock().await; + self.refresh_keys().await + } + + /// Validate a JWT and return an `Identity`. + /// + /// This is the authentication step — it verifies the caller's identity + /// but does not check authorization (that's `authz::AuthzPolicy::check`). + pub async fn validate_token(&self, token: &str) -> Result { + self.refresh_if_stale().await.map_err(|e| { + warn!(error = %e, "JWKS refresh failed"); + Status::internal("OIDC key refresh failed") + })?; + + // Decode the header to find the key ID. + let header = decode_header(token).map_err(|e| { + debug!(error = %e, "Failed to decode JWT header"); + Status::unauthenticated("invalid token") + })?; + + let kid = header.kid.ok_or_else(|| { + debug!("JWT has no kid in header"); + Status::unauthenticated("invalid token: missing kid") + })?; + + // Look up the key in cache. + let keys = self.keys.read().await; + let decoding_key = match keys.get(&kid) { + Some(k) => k.clone(), + None => { + // Key not found -- try refreshing once (key rotation). + drop(keys); + self.refresh_keys_coalesced().await.map_err(|e| { + warn!(error = %e, "JWKS refresh on kid miss failed"); + Status::internal("OIDC key refresh failed") + })?; + let keys = self.keys.read().await; + keys.get(&kid).cloned().ok_or_else(|| { + debug!(kid = %kid, "JWT kid not found in JWKS"); + Status::unauthenticated("invalid token: unknown signing key") + })? + } + }; + + // Validate the JWT. + let mut validation = Validation::new(Algorithm::RS256); + validation.set_issuer(&[&self.config.issuer]); + validation.set_audience(&[&self.config.audience]); + + let token_data = decode::(token, &decoding_key, &validation).map_err(|e| { + debug!(error = %e, "JWT validation failed"); + Status::unauthenticated(format!("invalid token: {e}")) + })?; + + let mut claims = token_data.claims; + claims.extract_roles(&self.config.roles_claim); + + Ok(Identity { + subject: claims.sub, + display_name: claims.preferred_username, + roles: claims.roles, + provider: IdentityProvider::Oidc, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn health_is_unauthenticated() { + assert!(is_unauthenticated_method("/openshell.v1.OpenShell/Health")); + } + + #[test] + fn sandbox_operations_require_auth() { + assert!(!is_unauthenticated_method("/openshell.v1.OpenShell/CreateSandbox")); + assert!(!is_sandbox_secret_method("/openshell.v1.OpenShell/CreateSandbox")); + } + + #[test] + fn reflection_is_unauthenticated() { + assert!(is_unauthenticated_method( + "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo" + )); + assert!(is_unauthenticated_method( + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo" + )); + } + + #[test] + fn grpc_health_is_unauthenticated() { + assert!(is_unauthenticated_method("/grpc.health.v1.Health/Check")); + } + + #[test] + fn sandbox_rpcs_use_sandbox_secret() { + assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/GetSandboxConfig")); + assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/GetSandboxProviderEnvironment")); + assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/ReportPolicyStatus")); + assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/PushSandboxLogs")); + assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/SubmitPolicyAnalysis")); + } + + #[test] + fn sandbox_secret_validation() { + let mut headers = http::HeaderMap::new(); + headers.insert("x-sandbox-secret", "test-secret".parse().unwrap()); + assert!(validate_sandbox_secret(&headers, "test-secret").is_ok()); + assert!(validate_sandbox_secret(&headers, "wrong-secret").is_err()); + } + + #[test] + fn sandbox_secret_missing_header() { + let headers = http::HeaderMap::new(); + assert!(validate_sandbox_secret(&headers, "test-secret").is_err()); + } + + #[test] + fn extract_roles_keycloak_path() { + let json = serde_json::json!({ + "sub": "user1", + "realm_access": { "roles": ["openshell-user", "openshell-admin"] } + }); + let mut claims: OidcClaims = serde_json::from_value(json).unwrap(); + claims.extract_roles("realm_access.roles"); + assert_eq!(claims.roles, vec!["openshell-user", "openshell-admin"]); + } + + #[test] + fn extract_roles_flat_path() { + // Entra ID / Okta style: roles at top level + let json = serde_json::json!({ + "sub": "user1", + "roles": ["OpenShell.Admin", "OpenShell.User"] + }); + let mut claims: OidcClaims = serde_json::from_value(json).unwrap(); + claims.extract_roles("roles"); + assert_eq!(claims.roles, vec!["OpenShell.Admin", "OpenShell.User"]); + } + + #[test] + fn extract_roles_groups_path() { + // Okta style: groups claim + let json = serde_json::json!({ + "sub": "user1", + "groups": ["everyone", "openshell-admin"] + }); + let mut claims: OidcClaims = serde_json::from_value(json).unwrap(); + claims.extract_roles("groups"); + assert_eq!(claims.roles, vec!["everyone", "openshell-admin"]); + } + + #[test] + fn extract_roles_missing_claim() { + let json = serde_json::json!({ "sub": "user1" }); + let mut claims: OidcClaims = serde_json::from_value(json).unwrap(); + claims.extract_roles("realm_access.roles"); + assert!(claims.roles.is_empty()); + } +} diff --git a/crates/openshell-vm/src/lib.rs b/crates/openshell-vm/src/lib.rs index 2b78a7669..30610490c 100644 --- a/crates/openshell-vm/src/lib.rs +++ b/crates/openshell-vm/src/lib.rs @@ -1733,13 +1733,8 @@ fn bootstrap_gateway(rootfs: &Path, gateway_name: &str, gateway_port: u16) -> Re let metadata = openshell_bootstrap::GatewayMetadata { name: gateway_name.to_string(), gateway_endpoint: format!("https://127.0.0.1:{gateway_port}"), - is_remote: false, gateway_port, - remote_host: None, - resolved_host: None, - auth_mode: None, - edge_team_domain: None, - edge_auth_url: None, + ..Default::default() }; let exec_socket = vm_exec_socket_path(rootfs); diff --git a/deploy/docker/cluster-entrypoint.sh b/deploy/docker/cluster-entrypoint.sh index b045bf222..72f9c7311 100644 --- a/deploy/docker/cluster-entrypoint.sh +++ b/deploy/docker/cluster-entrypoint.sh @@ -506,6 +506,23 @@ if [ -f "$HELMCHART" ]; then sed -i "s|__DISABLE_GATEWAY_AUTH__|false|g" "$HELMCHART" fi + # OIDC JWT authentication: when OIDC_ISSUER is set, the server validates + # Bearer tokens on gRPC requests against the issuer's JWKS endpoint. + if [ -n "${OIDC_ISSUER:-}" ]; then + echo "Enabling OIDC authentication (issuer: ${OIDC_ISSUER})" + sed -i "s|__OIDC_ISSUER__|${OIDC_ISSUER}|g" "$HELMCHART" + sed -i "s|__OIDC_AUDIENCE__|${OIDC_AUDIENCE:-openshell-cli}|g" "$HELMCHART" + sed -i "s|__OIDC_ROLES_CLAIM__|${OIDC_ROLES_CLAIM:-realm_access.roles}|g" "$HELMCHART" + sed -i "s|__OIDC_ADMIN_ROLE__|${OIDC_ADMIN_ROLE:-openshell-admin}|g" "$HELMCHART" + sed -i "s|__OIDC_USER_ROLE__|${OIDC_USER_ROLE:-openshell-user}|g" "$HELMCHART" + else + sed -i "s|__OIDC_ISSUER__||g" "$HELMCHART" + sed -i "s|__OIDC_AUDIENCE__|openshell-cli|g" "$HELMCHART" + sed -i "s|__OIDC_ROLES_CLAIM__||g" "$HELMCHART" + sed -i "s|__OIDC_ADMIN_ROLE__||g" "$HELMCHART" + sed -i "s|__OIDC_USER_ROLE__||g" "$HELMCHART" + fi + # Disable TLS entirely: the server listens on plaintext HTTP. # Used when a reverse proxy / tunnel terminates TLS at the edge. if [ "${DISABLE_TLS:-}" = "true" ]; then diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index 37ebcae80..7989289ae 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -104,6 +104,26 @@ spec: value: "true" {{- end }} {{- end }} + {{- if .Values.server.oidc.issuer }} + - name: OPENSHELL_OIDC_ISSUER + value: {{ .Values.server.oidc.issuer | quote }} + - name: OPENSHELL_OIDC_AUDIENCE + value: {{ .Values.server.oidc.audience | quote }} + - name: OPENSHELL_OIDC_JWKS_TTL + value: {{ .Values.server.oidc.jwksTtl | quote }} + {{- if .Values.server.oidc.rolesClaim }} + - name: OPENSHELL_OIDC_ROLES_CLAIM + value: {{ .Values.server.oidc.rolesClaim | quote }} + {{- end }} + {{- if .Values.server.oidc.adminRole }} + - name: OPENSHELL_OIDC_ADMIN_ROLE + value: {{ .Values.server.oidc.adminRole | quote }} + {{- end }} + {{- if .Values.server.oidc.userRole }} + - name: OPENSHELL_OIDC_USER_ROLE + value: {{ .Values.server.oidc.userRole | quote }} + {{- end }} + {{- end }} volumeMounts: - name: openshell-data mountPath: /var/openshell diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 4ac8ab43a..2ea52a5f3 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -110,6 +110,24 @@ server: clientCaSecretName: openshell-server-client-ca # K8s secret mounted into sandbox pods for mTLS to the server clientTlsSecretName: openshell-client-tls + # OIDC (OpenID Connect) configuration for JWT-based authentication. + # When issuer is set, the server validates Bearer tokens on gRPC requests. + oidc: + # OIDC issuer URL (e.g. https://keycloak.example.com/realms/openshell). + issuer: "" + # Expected audience claim for the API resource server. + # This should match the server's --oidc-audience, NOT the CLI client ID. + audience: "openshell-cli" + # JWKS key cache TTL in seconds. + jwksTtl: 3600 + # Dot-separated path to the roles array in the JWT claims. + # Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". + rolesClaim: "" + # Role name for admin access. Leave empty (with userRole also empty) for + # authentication-only mode. Both must be set or both empty. + adminRole: "" + # Role name for standard user access. + userRole: "" # NetworkPolicy restricting SSH ingress on sandbox pods to the gateway only. networkPolicy: diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index a09e0f300..a181c20f6 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -38,6 +38,13 @@ spec: hostGatewayIP: __HOST_GATEWAY_IP__ disableGatewayAuth: __DISABLE_GATEWAY_AUTH__ disableTls: __DISABLE_TLS__ + oidc: + issuer: "__OIDC_ISSUER__" + audience: "__OIDC_AUDIENCE__" + jwksTtl: 3600 + rolesClaim: "__OIDC_ROLES_CLAIM__" + adminRole: "__OIDC_ADMIN_ROLE__" + userRole: "__OIDC_USER_ROLE__" tls: certSecretName: openshell-server-tls clientCaSecretName: openshell-server-client-ca diff --git a/scripts/keycloak-dev.sh b/scripts/keycloak-dev.sh new file mode 100755 index 000000000..a330d329b --- /dev/null +++ b/scripts/keycloak-dev.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Start/stop a Keycloak dev instance for OIDC testing. +# Usage: +# ./scripts/keycloak-dev.sh start # start Keycloak on port 8180 +# ./scripts/keycloak-dev.sh stop # stop and remove the container +# ./scripts/keycloak-dev.sh status # check if Keycloak is running + +set -euo pipefail + +CONTAINER_NAME="openshell-keycloak" +KEYCLOAK_IMAGE="quay.io/keycloak/keycloak:24.0" +KEYCLOAK_PORT="${KEYCLOAK_PORT:-8180}" +REALM_FILE="$(cd "$(dirname "$0")" && pwd)/keycloak-realm.json" +HEALTH_TIMEOUT=90 + +# Container runtime: honour CONTAINER_RUNTIME, else prefer docker, fall back to podman. +if [ -n "${CONTAINER_RUNTIME:-}" ]; then + CTR="$CONTAINER_RUNTIME" +elif command -v docker &>/dev/null; then + CTR=docker +elif command -v podman &>/dev/null; then + CTR=podman +else + echo "Error: neither docker nor podman found in PATH" >&2 + exit 1 +fi + +cmd_start() { + # Idempotent: if the container is already running, just print info. + if $CTR inspect "$CONTAINER_NAME" &>/dev/null; then + if $CTR inspect --format '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -q true; then + echo "Keycloak is already running on port $KEYCLOAK_PORT" + print_info + return 0 + fi + echo "Removing stopped container $CONTAINER_NAME..." + $CTR rm "$CONTAINER_NAME" >/dev/null + fi + + if [ ! -f "$REALM_FILE" ]; then + echo "Error: realm file not found: $REALM_FILE" >&2 + exit 1 + fi + + echo "Starting Keycloak ($KEYCLOAK_IMAGE) on port $KEYCLOAK_PORT..." + + $CTR run -d \ + --name "$CONTAINER_NAME" \ + -p "${KEYCLOAK_PORT}:8080" \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + -v "${REALM_FILE}:/opt/keycloak/data/import/realm.json:ro,z" \ + "$KEYCLOAK_IMAGE" \ + start-dev --import-realm + + echo "Waiting for Keycloak to become healthy (up to ${HEALTH_TIMEOUT}s)..." + local elapsed=0 + while [ $elapsed -lt $HEALTH_TIMEOUT ]; do + if curl -sf "http://localhost:${KEYCLOAK_PORT}/realms/master" >/dev/null 2>&1; then + echo "Keycloak is ready." + print_info + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + + echo "Error: Keycloak did not become healthy within ${HEALTH_TIMEOUT}s" >&2 + echo "Logs:" + $CTR logs --tail 30 "$CONTAINER_NAME" + exit 1 +} + +cmd_stop() { + if $CTR inspect "$CONTAINER_NAME" &>/dev/null; then + echo "Stopping and removing $CONTAINER_NAME..." + $CTR stop "$CONTAINER_NAME" 2>/dev/null || true + $CTR rm "$CONTAINER_NAME" 2>/dev/null || true + echo "Done." + else + echo "Container $CONTAINER_NAME is not running." + fi +} + +cmd_status() { + if $CTR inspect "$CONTAINER_NAME" &>/dev/null; then + if $CTR inspect --format '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -q true; then + echo "Keycloak is running on port $KEYCLOAK_PORT" + print_info + return 0 + fi + echo "Container $CONTAINER_NAME exists but is not running." + return 1 + fi + echo "Container $CONTAINER_NAME does not exist." + return 1 +} + +print_info() { + local issuer="http://localhost:${KEYCLOAK_PORT}/realms/openshell" + echo "" + echo " Issuer URL: $issuer" + echo " Discovery: ${issuer}/.well-known/openid-configuration" + echo " Admin console: http://localhost:${KEYCLOAK_PORT}/admin (admin/admin)" + echo "" + echo " Test users:" + echo " admin@test / admin (role: openshell-admin)" + echo " user@test / user (role: openshell-user)" + echo "" + echo " Get a token:" + echo " curl -s -X POST ${issuer}/protocol/openid-connect/token \\" + echo " -d 'grant_type=password&client_id=openshell-cli&username=admin@test&password=admin' \\" + echo " | jq -r .access_token" + echo "" +} + +case "${1:-help}" in + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + *) + echo "Usage: $0 {start|stop|status}" + exit 1 + ;; +esac diff --git a/scripts/keycloak-realm.json b/scripts/keycloak-realm.json new file mode 100644 index 000000000..0859f811d --- /dev/null +++ b/scripts/keycloak-realm.json @@ -0,0 +1,94 @@ +{ + "realm": "openshell", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "roles": { + "realm": [ + { + "name": "openshell-admin", + "description": "Full administrative access to OpenShell" + }, + { + "name": "openshell-user", + "description": "Standard user access to OpenShell" + } + ] + }, + "defaultRoles": ["openshell-user"], + "clients": [ + { + "clientId": "openshell-cli", + "name": "OpenShell CLI", + "description": "Public client for interactive CLI login (Authorization Code + PKCE)", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "redirectUris": ["http://127.0.0.1:*", "http://localhost:*"], + "webOrigins": ["http://127.0.0.1:*", "http://localhost:*"], + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "protocol": "openid-connect", + "fullScopeAllowed": true + }, + { + "clientId": "openshell-ci", + "name": "OpenShell CI", + "description": "Confidential client for CI/automation (Client Credentials grant)", + "enabled": true, + "publicClient": false, + "secret": "ci-test-secret", + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "protocol": "openid-connect", + "fullScopeAllowed": true + } + ], + "users": [ + { + "username": "admin@test", + "email": "admin@test", + "emailVerified": true, + "enabled": true, + "firstName": "Admin", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "admin", + "temporary": false + } + ], + "realmRoles": ["openshell-admin", "openshell-user"] + }, + { + "username": "user@test", + "email": "user@test", + "emailVerified": true, + "enabled": true, + "firstName": "Regular", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "user", + "temporary": false + } + ], + "realmRoles": ["openshell-user"] + } + ] +} diff --git a/tasks/cluster.toml b/tasks/cluster.toml index da06178f1..248e61119 100644 --- a/tasks/cluster.toml +++ b/tasks/cluster.toml @@ -30,6 +30,10 @@ description = "Pull-mode deploy using local registry pushes" run = "tasks/scripts/cluster-deploy-fast.sh all" hide = true +["cluster:stop"] +description = "Stop and remove the local cluster container" +run = "tasks/scripts/cluster-stop.sh" + ["cluster:push:gateway"] description = "Tag and push gateway image to pull registry" run = "tasks/scripts/cluster-push-component.sh gateway" diff --git a/tasks/keycloak.toml b/tasks/keycloak.toml new file mode 100644 index 000000000..fc058f0ba --- /dev/null +++ b/tasks/keycloak.toml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Keycloak dev instance for OIDC testing + +[keycloak] +description = "Start a local Keycloak instance for OIDC testing" +run = "scripts/keycloak-dev.sh start" + +["keycloak:stop"] +description = "Stop and remove the local Keycloak instance" +run = "scripts/keycloak-dev.sh stop" + +["keycloak:status"] +description = "Check if the local Keycloak instance is running" +run = "scripts/keycloak-dev.sh status" diff --git a/tasks/scripts/cluster-bootstrap.sh b/tasks/scripts/cluster-bootstrap.sh index d75bbc058..048d66eb3 100755 --- a/tasks/scripts/cluster-bootstrap.sh +++ b/tasks/scripts/cluster-bootstrap.sh @@ -273,6 +273,14 @@ if [ -n "${GATEWAY_HOST:-}" ]; then fi fi +if [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then + DEPLOY_CMD+=(--oidc-issuer "${OPENSHELL_OIDC_ISSUER}") + [ -n "${OPENSHELL_OIDC_AUDIENCE:-}" ] && DEPLOY_CMD+=(--oidc-audience "${OPENSHELL_OIDC_AUDIENCE}") + [ -n "${OPENSHELL_OIDC_ROLES_CLAIM:-}" ] && DEPLOY_CMD+=(--oidc-roles-claim "${OPENSHELL_OIDC_ROLES_CLAIM}") + [ -n "${OPENSHELL_OIDC_ADMIN_ROLE:-}" ] && DEPLOY_CMD+=(--oidc-admin-role "${OPENSHELL_OIDC_ADMIN_ROLE}") + [ -n "${OPENSHELL_OIDC_USER_ROLE:-}" ] && DEPLOY_CMD+=(--oidc-user-role "${OPENSHELL_OIDC_USER_ROLE}") +fi + "${DEPLOY_CMD[@]}" # Clear the fast-deploy state file so the next incremental deploy diff --git a/tasks/scripts/cluster-deploy-fast.sh b/tasks/scripts/cluster-deploy-fast.sh index b4d79d4eb..5c675463f 100755 --- a/tasks/scripts/cluster-deploy-fast.sh +++ b/tasks/scripts/cluster-deploy-fast.sh @@ -428,6 +428,21 @@ if [[ "${needs_helm_upgrade}" == "1" ]]; then HOST_GATEWAY_ARGS="--set server.hostGatewayIP=${HOST_GATEWAY_IP}" fi + OIDC_HELM_ARGS="" + if [[ -n "${OPENSHELL_OIDC_ISSUER:-}" ]]; then + OIDC_HELM_ARGS="--set server.oidc.issuer=${OPENSHELL_OIDC_ISSUER}" + OIDC_HELM_ARGS="${OIDC_HELM_ARGS} --set server.oidc.audience=${OPENSHELL_OIDC_AUDIENCE:-openshell-cli}" + if [[ -n "${OPENSHELL_OIDC_ROLES_CLAIM:-}" ]]; then + OIDC_HELM_ARGS="${OIDC_HELM_ARGS} --set server.oidc.rolesClaim=${OPENSHELL_OIDC_ROLES_CLAIM}" + fi + if [[ -n "${OPENSHELL_OIDC_ADMIN_ROLE:-}" ]]; then + OIDC_HELM_ARGS="${OIDC_HELM_ARGS} --set server.oidc.adminRole=${OPENSHELL_OIDC_ADMIN_ROLE}" + fi + if [[ -n "${OPENSHELL_OIDC_USER_ROLE:-}" ]]; then + OIDC_HELM_ARGS="${OIDC_HELM_ARGS} --set server.oidc.userRole=${OPENSHELL_OIDC_USER_ROLE}" + fi + fi + cluster_exec "helm upgrade openshell ${CONTAINER_CHART_DIR} \ --namespace openshell \ --set image.repository=${IMAGE_REPO_BASE}/gateway \ @@ -438,6 +453,7 @@ if [[ "${needs_helm_upgrade}" == "1" ]]; then --set server.tls.clientCaSecretName=openshell-server-client-ca \ --set server.tls.clientTlsSecretName=openshell-client-tls \ ${HOST_GATEWAY_ARGS} \ + ${OIDC_HELM_ARGS} \ ${helm_wait_args}" helm_end=$(date +%s) log_duration "Helm upgrade" "${helm_start}" "${helm_end}" diff --git a/tasks/scripts/cluster-stop.sh b/tasks/scripts/cluster-stop.sh new file mode 100755 index 000000000..232305fe5 --- /dev/null +++ b/tasks/scripts/cluster-stop.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +normalize_name() { + echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' +} + +CLUSTER_NAME=${CLUSTER_NAME:-$(basename "$PWD")} +CLUSTER_NAME=$(normalize_name "${CLUSTER_NAME}") +CONTAINER_NAME="openshell-cluster-${CLUSTER_NAME}" + +if ! docker ps -aq --filter "name=^${CONTAINER_NAME}$" | grep -q .; then + echo "No cluster container '${CONTAINER_NAME}' found." + exit 0 +fi + +echo "Stopping cluster '${CLUSTER_NAME}'..." +docker rm -f "${CONTAINER_NAME}" >/dev/null +echo "Cluster '${CLUSTER_NAME}' stopped and removed." From 8cd66c634170bf2e3cd29d511395a44db8fee589 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 21 Apr 2026 15:09:50 -0700 Subject: [PATCH 02/10] 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. --- architecture/oidc-auth.md | 72 +++- architecture/oidc-local-testing.md | 159 ++++++++- crates/openshell-bootstrap/src/docker.rs | 4 + crates/openshell-bootstrap/src/lib.rs | 7 +- crates/openshell-bootstrap/src/metadata.rs | 5 + crates/openshell-cli/src/main.rs | 26 +- crates/openshell-cli/src/oidc_auth.rs | 43 ++- crates/openshell-cli/src/run.rs | 78 +++- crates/openshell-core/src/config.rs | 6 + crates/openshell-server/src/authz.rs | 337 ++++++++++++++++-- crates/openshell-server/src/cli.rs | 25 +- crates/openshell-server/src/grpc/policy.rs | 2 +- crates/openshell-server/src/identity.rs | 3 + crates/openshell-server/src/lib.rs | 7 +- crates/openshell-server/src/multiplex.rs | 6 +- crates/openshell-server/src/oidc.rs | 137 ++++++- deploy/docker/cluster-entrypoint.sh | 2 + .../helm/openshell/templates/statefulset.yaml | 4 + deploy/helm/openshell/values.yaml | 2 + .../kube/manifests/openshell-helmchart.yaml | 1 + scripts/keycloak-realm.json | 260 +++++++++++++- tasks/scripts/cluster-bootstrap.sh | 1 + tasks/scripts/cluster-deploy-fast.sh | 3 + 23 files changed, 1097 insertions(+), 93 deletions(-) diff --git a/architecture/oidc-auth.md b/architecture/oidc-auth.md index eae307641..476d910fa 100644 --- a/architecture/oidc-auth.md +++ b/architecture/oidc-auth.md @@ -229,6 +229,72 @@ The roles claim path and role names are configurable to support different OIDC p When both `--oidc-admin-role` and `--oidc-user-role` are set to empty strings, RBAC is skipped entirely — any valid JWT is authorized. This supports providers like GitHub that don't emit roles in JWTs (authentication-only mode). +## Scope-Based Fine-Grained Permissions + +Scopes provide opt-in, per-method access control on top of roles. When `--oidc-scopes-claim` is set, the server extracts scopes from the JWT and checks them against an exhaustive method-to-scope map. A caller must have both the required role AND the required scope. + +### Scope Definitions + +| Scope | Operations | +|---|---| +| `sandbox:read` | GetSandbox, ListSandboxes, WatchSandbox, GetSandboxLogs, GetSandboxPolicyStatus, ListSandboxPolicies | +| `sandbox:write` | CreateSandbox, DeleteSandbox, ExecSandbox, CreateSshSession, RevokeSshSession | +| `provider:read` | GetProvider, ListProviders | +| `provider:write` | CreateProvider, UpdateProvider, DeleteProvider | +| `config:read` | GetGatewayConfig, GetDraftPolicy, GetDraftHistory | +| `config:write` | UpdateConfig (Bearer), ApproveDraftChunk, ApproveAllDraftChunks, RejectDraftChunk, EditDraftChunk, UndoDraftChunk, ClearDraftChunks | +| `inference:read` | GetClusterInference | +| `inference:write` | SetClusterInference | +| `openshell:all` | All of the above (wildcard) | + +Methods not listed in the scope map require `openshell:all`. Scopes cannot escalate privilege — `openshell:all` on a user-role token still cannot call admin methods. + +### Authorization Flow + +``` +Request arrives (Bearer-authenticated) + │ + ├── Role check (existing) + │ └── Does identity have required role? No → PERMISSION_DENIED + │ + └── Scope check (only if --oidc-scopes-claim is configured) + ├── Does identity have openshell:all? → proceed + ├── Does identity have required scope for this method? → proceed + └── No → PERMISSION_DENIED("scope 'X' required") +``` + +When `--oidc-scopes-claim` is not set (default), scope enforcement is disabled and roles alone determine access. Auth-only mode (empty role names) still enforces scopes when enabled. + +### Scope Extraction + +The server extracts scopes from the JWT claim path configured by `--oidc-scopes-claim`. Two formats are supported: + +- **Space-delimited string** (Keycloak, Entra ID): `"openid sandbox:read sandbox:write"` +- **JSON array** (Okta): `["sandbox:read", "sandbox:write"]` + +Standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are filtered out before enforcement. + +### CLI Scope Requests + +The `--oidc-scopes` flag on `gateway add` and `gateway start` is stored in gateway metadata and included in OAuth2 token requests: + +- **Browser flow**: appended to the `scope` parameter alongside `openid` +- **Client credentials flow**: sent as-is (without `openid`, which is inappropriate for service tokens) +- **Token refresh**: scopes are not re-sent; the authorization server preserves them per RFC 6749 §6 + +### Provider Compatibility + +| Provider | Scopes Claim | Format | Fine-Grained Selection | +|---|---|---|---| +| Keycloak | `scope` | Space-delimited | Yes — client requests specific scopes | +| Okta | `scp` | JSON array | Yes — client requests specific scopes | +| Entra ID | `scp` | Space-delimited | Limited — uses `.default` for all granted permissions | +| GitHub | N/A | N/A | No — use with scopes disabled | + +### Keycloak Client Scopes + +The dev realm (`scripts/keycloak-realm.json`) includes all 9 OpenShell scopes as **optional scopes** on `openshell-cli` and `openshell:all` as a **default scope** on `openshell-ci`. Built-in Keycloak scopes (`openid`, `profile`, `email`, `roles`, `web-origins`, `acr`) are assigned as default scopes on both clients so roles and profile claims are always present regardless of optional scope requests. + ## Server Configuration ### Server Binary Flags @@ -243,6 +309,7 @@ These flags configure JWT validation on the `openshell-server` binary: | `--oidc-roles-claim` | `OPENSHELL_OIDC_ROLES_CLAIM` | `realm_access.roles` | Dot-separated path to roles array in JWT | | `--oidc-admin-role` | `OPENSHELL_OIDC_ADMIN_ROLE` | `openshell-admin` | Role name for admin access | | `--oidc-user-role` | `OPENSHELL_OIDC_USER_ROLE` | `openshell-user` | Role name for user access | +| `--oidc-scopes-claim` | `OPENSHELL_OIDC_SCOPES_CLAIM` | (empty) | Claim path for scopes; enables scope enforcement when set | When `--oidc-issuer` is not set, OIDC validation is disabled and the server falls back to mTLS-only or plaintext behavior. @@ -258,6 +325,8 @@ The `openshell gateway start` command exposes flags that configure both the serv | `--oidc-roles-claim` | (none) | Passed to the server binary if set | | `--oidc-admin-role` | (none) | Passed to the server binary if set | | `--oidc-user-role` | (none) | Passed to the server binary if set | +| `--oidc-scopes-claim` | (none) | Passed to the server binary; enables scope enforcement | +| `--oidc-scopes` | (none) | Stored in gateway metadata; included in CLI token requests | The `--oidc-client-id` flag is **not** a server flag — it is stored in gateway metadata and used by the CLI during login. The `--oidc-audience` flag is both a server flag (for JWT validation) and stored in metadata (for token requests). @@ -269,6 +338,7 @@ server: issuer: "https://keycloak.example.com/realms/openshell" audience: "openshell-cli" jwksTtl: 3600 + scopesClaim: "scope" # enable scope enforcement (Keycloak) ``` ### Discovery Endpoint @@ -438,7 +508,7 @@ Request arrives | +-- No sandbox secret --> Fall through to Bearer | +-- Has "authorization: Bearer" header? - | +-- Validate JWT --> Check RBAC --> Authenticated (OIDC) + | +-- Validate JWT --> Check RBAC --> Check scopes (if enabled) --> Authenticated (OIDC) | +-- Invalid JWT --> UNAUTHENTICATED | +-- No bearer header --> UNAUTHENTICATED diff --git a/architecture/oidc-local-testing.md b/architecture/oidc-local-testing.md index be5725e65..74daf7c09 100644 --- a/architecture/oidc-local-testing.md +++ b/architecture/oidc-local-testing.md @@ -327,7 +327,158 @@ openshell sandbox delete openshell provider delete test-provider ``` -## 5. Cleanup +## 5. Scope-Based Permissions Testing + +Scopes provide fine-grained, per-method access control on top of roles. This section tests scope enforcement using both the standalone server and K3s. + +### 5a. Standalone server with scope enforcement + +```bash +cargo run -p openshell-server -- \ + --disable-tls \ + --db-url sqlite:/tmp/openshell-scopes-test.db \ + --ssh-handshake-secret test \ + --oidc-issuer http://localhost:8180/realms/openshell \ + --oidc-scopes-claim scope +``` + +### 5b. Get tokens with specific scopes + +```bash +# Token with sandbox scopes only +TOKEN_SANDBOX=$(curl -s -X POST http://localhost:8180/realms/openshell/protocol/openid-connect/token \ + -d 'grant_type=password&client_id=openshell-cli&username=admin@test&password=admin' \ + -d 'scope=openid sandbox:read sandbox:write' \ + | jq -r .access_token) + +# Token with all scopes +TOKEN_ALL=$(curl -s -X POST http://localhost:8180/realms/openshell/protocol/openid-connect/token \ + -d 'grant_type=password&client_id=openshell-cli&username=admin@test&password=admin' \ + -d 'scope=openid openshell:all' \ + | jq -r .access_token) + +# Token without OpenShell scopes (roles-only) +TOKEN_NO_SCOPES=$(curl -s -X POST http://localhost:8180/realms/openshell/protocol/openid-connect/token \ + -d 'grant_type=password&client_id=openshell-cli&username=admin@test&password=admin' \ + | jq -r .access_token) +``` + +### 5c. Inspect tokens + +```bash +# Verify scopes are in the JWT +echo "$TOKEN_SANDBOX" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{scope, realm_access, preferred_username}' +# Expected: scope contains "sandbox:read sandbox:write", realm_access has roles, preferred_username is set + +echo "$TOKEN_NO_SCOPES" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.scope' +# Expected: "openid email profile" (no OpenShell scopes) +``` + +### 5d. Test scope enforcement with grpcurl + +```bash +# Sandbox-scoped token — ListSandboxes should work +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $TOKEN_SANDBOX" \ + 127.0.0.1:8080 openshell.v1.OpenShell/ListSandboxes +# Expected: success (empty list) + +# Sandbox-scoped token — ListProviders should FAIL +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $TOKEN_SANDBOX" \ + 127.0.0.1:8080 openshell.v1.OpenShell/ListProviders +# Expected: PermissionDenied: scope 'provider:read' required + +# openshell:all token — everything works +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $TOKEN_ALL" \ + 127.0.0.1:8080 openshell.v1.OpenShell/ListProviders +# Expected: success + +# No-scopes token — denied +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $TOKEN_NO_SCOPES" \ + 127.0.0.1:8080 openshell.v1.OpenShell/ListSandboxes +# Expected: PermissionDenied: scope 'sandbox:read' required +``` + +### 5e. Test CLI with scopes + +Stop the standalone server. Register a gateway with scopes: + +```bash +openshell gateway add http://127.0.0.1:8080 \ + --oidc-issuer http://localhost:8180/realms/openshell \ + --oidc-scopes "sandbox:read sandbox:write" +``` + +Or for K3s testing: + +```bash +HOST_IP=$(hostname -I | awk '{print $1}') +OPENSHELL_OIDC_ISSUER="http://${HOST_IP}:8180/realms/openshell" \ +OPENSHELL_OIDC_SCOPES_CLAIM="scope" \ +mise run cluster + +# Update gateway metadata with scopes +jq '.oidc_scopes = "sandbox:read sandbox:write"' \ + ~/.config/openshell/gateways/openshell/metadata.json > /tmp/meta.json \ + && mv /tmp/meta.json ~/.config/openshell/gateways/openshell/metadata.json +``` + +Then login and test: + +```bash +openshell gateway login +# Login with: admin@test / admin + +openshell sandbox list # should work (has sandbox:read) +openshell provider list # should fail (no provider:read scope) +``` + +### 5f. Test openshell:all via CLI + +```bash +jq '.oidc_scopes = "openshell:all"' \ + ~/.config/openshell/gateways/openshell/metadata.json > /tmp/meta.json \ + && mv /tmp/meta.json ~/.config/openshell/gateways/openshell/metadata.json + +openshell gateway login +openshell sandbox list # should work +openshell provider list # should work +``` + +### 5g. Test CI client credentials with scopes + +```bash +OPENSHELL_OIDC_CLIENT_SECRET=ci-test-secret openshell gateway login +# openshell-ci has openshell:all as a default scope + +openshell sandbox list # should work +openshell provider list # should work +``` + +### 5h. Test without scope enforcement (default behavior preserved) + +Restart the server WITHOUT `--oidc-scopes-claim`: + +```bash +cargo run -p openshell-server -- \ + --disable-tls \ + --db-url sqlite:/tmp/openshell-noscopes-test.db \ + --ssh-handshake-secret test \ + --oidc-issuer http://localhost:8180/realms/openshell +``` + +```bash +# Token without scopes should work (roles-only mode) +grpcurl -plaintext -import-path proto -proto openshell.proto \ + -H "authorization: Bearer $TOKEN_NO_SCOPES" \ + 127.0.0.1:8080 openshell.v1.OpenShell/ListSandboxes +# Expected: success — scopes are not enforced +``` + +## 6. Cleanup ```bash # Stop the cluster @@ -391,3 +542,9 @@ mise run keycloak:stop **"connection refused" with grpcurl** — On Fedora/systems where `localhost` resolves to IPv6, use `127.0.0.1` instead of `localhost`. **"no such table: objects"** — Using `sqlite::memory:` which doesn't run migrations. Use a file path like `sqlite:/tmp/openshell-test.db`. + +**"scope 'X' required"** — The server has `--oidc-scopes-claim` enabled and the token is missing the required scope. Either request the scope during login (`--oidc-scopes "sandbox:read sandbox:write"`) or use `openshell:all` for full access. + +**Token has scopes but server doesn't enforce them** — The server was started without `--oidc-scopes-claim`. Add `--oidc-scopes-claim scope` (for Keycloak) to enable enforcement. + +**Scopes missing from token after Keycloak login** — The browser may have reused an old Keycloak session with the previous scope set. Sign out at `http://localhost:8180/realms/openshell/account/#/` and re-run `openshell gateway login`. diff --git a/crates/openshell-bootstrap/src/docker.rs b/crates/openshell-bootstrap/src/docker.rs index e9bb00cfc..2091889e3 100644 --- a/crates/openshell-bootstrap/src/docker.rs +++ b/crates/openshell-bootstrap/src/docker.rs @@ -506,6 +506,7 @@ pub async fn ensure_container( oidc_roles_claim: Option<&str>, oidc_admin_role: Option<&str>, oidc_user_role: Option<&str>, + oidc_scopes_claim: Option<&str>, ) -> Result { let container_name = container_name(name); @@ -801,6 +802,9 @@ pub async fn ensure_container( if let Some(role) = oidc_user_role { env_vars.push(format!("OIDC_USER_ROLE={role}")); } + if let Some(claim) = oidc_scopes_claim { + env_vars.push(format!("OIDC_SCOPES_CLAIM={claim}")); + } } let env = Some(env_vars); diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index a2d1baf8a..411be09c1 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -4,8 +4,8 @@ pub mod build; pub mod edge_token; pub mod errors; -pub mod oidc_token; pub mod image; +pub mod oidc_token; pub mod constants; mod docker; @@ -136,6 +136,8 @@ pub struct DeployOptions { pub oidc_admin_role: Option, /// OIDC user role name. pub oidc_user_role: Option, + /// OIDC scopes claim path. When set, the server enforces scope-based permissions. + pub oidc_scopes_claim: Option, } impl DeployOptions { @@ -158,6 +160,7 @@ impl DeployOptions { oidc_roles_claim: None, oidc_admin_role: None, oidc_user_role: None, + oidc_scopes_claim: None, } } @@ -311,6 +314,7 @@ where let oidc_roles_claim = options.oidc_roles_claim; let oidc_admin_role = options.oidc_admin_role; let oidc_user_role = options.oidc_user_role; + let oidc_scopes_claim = options.oidc_scopes_claim; // Wrap on_log in Arc> so we can share it with pull_remote_image // which needs a 'static callback for the bollard streaming pull. @@ -502,6 +506,7 @@ where oidc_roles_claim.as_deref(), oidc_admin_role.as_deref(), oidc_user_role.as_deref(), + oidc_scopes_claim.as_deref(), ) .await?; let port = actual_port; diff --git a/crates/openshell-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index f11a4ad03..e53260974 100644 --- a/crates/openshell-bootstrap/src/metadata.rs +++ b/crates/openshell-bootstrap/src/metadata.rs @@ -60,6 +60,11 @@ pub struct GatewayMetadata { /// When `None`, defaults to the client_id. #[serde(default, skip_serializing_if = "Option::is_none")] pub oidc_audience: Option, + + /// Space-separated OAuth2 scopes to request during OIDC login. + /// When set, tokens will include these scopes for fine-grained access control. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oidc_scopes: Option, } impl GatewayMetadata { diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index b3ceed233..cb4a32429 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -150,8 +150,10 @@ fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) { .block_on(openshell_cli::oidc_auth::oidc_refresh_token(&bundle)) }) { Ok(refreshed) => { - let _ = - openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed); + let _ = openshell_bootstrap::oidc_token::store_oidc_token( + gateway_name, + &refreshed, + ); tls.oidc_token = Some(refreshed.access_token); } Err(e) => { @@ -879,6 +881,15 @@ enum GatewayCommands { /// Role name that grants standard user access. #[arg(long, requires = "oidc_issuer")] oidc_user_role: Option, + + /// Space-separated OAuth2 scopes to request during OIDC login. + #[arg(long, requires = "oidc_issuer")] + oidc_scopes: Option, + + /// Dot-separated path to the scopes value in the JWT claims. + /// When set, the server enforces scope-based permissions on top of roles. + #[arg(long, requires = "oidc_issuer")] + oidc_scopes_claim: Option, }, /// Stop the gateway (preserves state). @@ -970,6 +981,11 @@ enum GatewayCommands { /// Defaults to the client ID value. #[arg(long, requires = "oidc_issuer")] oidc_audience: Option, + + /// Space-separated OAuth2 scopes to request during OIDC login. + /// When set, tokens will include these scopes for fine-grained access control. + #[arg(long, requires = "oidc_issuer")] + oidc_scopes: Option, }, /// Authenticate with an edge-authenticated or OIDC gateway. @@ -1815,6 +1831,8 @@ async fn main() -> Result<()> { oidc_roles_claim, oidc_admin_role, oidc_user_role, + oidc_scopes, + oidc_scopes_claim, } => { let gpu = if gpu { vec!["auto".to_string()] @@ -1839,6 +1857,8 @@ async fn main() -> Result<()> { oidc_roles_claim.as_deref(), oidc_admin_role.as_deref(), oidc_user_role.as_deref(), + oidc_scopes.as_deref(), + oidc_scopes_claim.as_deref(), ) .await?; } @@ -1871,6 +1891,7 @@ async fn main() -> Result<()> { oidc_issuer, oidc_client_id, oidc_audience, + oidc_scopes, } => { run::gateway_add( &endpoint, @@ -1881,6 +1902,7 @@ async fn main() -> Result<()> { oidc_issuer.as_deref(), &oidc_client_id, oidc_audience.as_deref(), + oidc_scopes.as_deref(), ) .await?; } diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 563461f2f..17581d7f2 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -104,6 +104,7 @@ pub async fn oidc_browser_auth_flow( issuer: &str, client_id: &str, audience: Option<&str>, + scopes: Option<&str>, ) -> Result { let discovery = discover(issuer).await?; @@ -115,13 +116,25 @@ pub async fn oidc_browser_auth_flow( let port = listener.local_addr().into_diagnostic()?.port(); let redirect_uri = format!("http://127.0.0.1:{port}/callback"); + let scope_value = match scopes { + Some(s) => { + let extra: Vec<&str> = s.split_whitespace().filter(|&sc| sc != "openid").collect(); + if extra.is_empty() { + "openid".to_string() + } else { + format!("openid {}", extra.join(" ")) + } + } + None => "openid".to_string(), + }; let mut auth_url = format!( - "{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}&scope=openid", + "{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}&scope={}", discovery.authorization_endpoint, urlencoded(client_id), urlencoded(&redirect_uri), urlencoded(&code_challenge), urlencoded(&state), + urlencoded(&scope_value), ); // Request a specific API audience when configured (needed for providers // like Entra ID where the API audience differs from the client ID). @@ -180,6 +193,7 @@ pub async fn oidc_client_credentials_flow( issuer: &str, client_id: &str, audience: Option<&str>, + scopes: Option<&str>, ) -> Result { let client_secret = std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").map_err(|_| { miette::miette!( @@ -197,6 +211,9 @@ pub async fn oidc_client_credentials_flow( if let Some(aud) = audience { params.push(("audience", aud)); } + if let Some(s) = scopes { + params.push(("scope", s)); + } let client = reqwest::Client::new(); let resp: TokenResponse = client @@ -218,7 +235,9 @@ pub async fn oidc_client_credentials_flow( /// one (per OAuth 2.0 spec, the refresh response may omit `refresh_token`). pub async fn oidc_refresh_token(bundle: &OidcTokenBundle) -> Result { let refresh_token = bundle.refresh_token.as_deref().ok_or_else(|| { - miette::miette!("no refresh token available — re-authenticate with: openshell gateway login") + miette::miette!( + "no refresh token available — re-authenticate with: openshell gateway login" + ) })?; let discovery = discover(&bundle.issuer).await?; @@ -252,19 +271,23 @@ pub async fn oidc_refresh_token(bundle: &OidcTokenBundle) -> Result Result { - let bundle = openshell_bootstrap::oidc_token::load_oidc_token(gateway_name).ok_or_else(|| { - miette::miette!( - "No OIDC token stored for gateway '{gateway_name}'.\n\ + let bundle = + openshell_bootstrap::oidc_token::load_oidc_token(gateway_name).ok_or_else(|| { + miette::miette!( + "No OIDC token stored for gateway '{gateway_name}'.\n\ Authenticate with: openshell gateway login" - ) - })?; + ) + })?; if !openshell_bootstrap::oidc_token::is_token_expired(&bundle) { return Ok(bundle.access_token); } // Token expired — try to refresh. - debug!(gateway = gateway_name, "OIDC token expired, attempting refresh"); + debug!( + gateway = gateway_name, + "OIDC token expired, attempting refresh" + ); let refreshed = oidc_refresh_token(&bundle).await?; openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed)?; Ok(refreshed.access_token) @@ -391,9 +414,7 @@ async fn run_oidc_callback_server( tokio::spawn(async move { let service = service_fn(move |req| { let state = Arc::clone(&state); - async move { - Ok::<_, Infallible>(handle_oidc_callback(req, state).await) - } + async move { Ok::<_, Infallible>(handle_oidc_callback(req, state).await) } }); if let Err(error) = Builder::new(TokioExecutor::new()) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 8e9d1c1df..8ef2238bc 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -965,6 +965,7 @@ pub async fn gateway_add( oidc_issuer: Option<&str>, oidc_client_id: &str, oidc_audience: Option<&str>, + oidc_scopes: Option<&str>, ) -> Result<()> { // If the endpoint starts with ssh://, parse it into an SSH destination // and a gateway endpoint automatically. The host is resolved via @@ -1055,8 +1056,7 @@ pub async fn gateway_add( if local { let endpoint_port = url::Url::parse(&endpoint).ok().and_then(|u| u.port()); eprintln!("• Extracting TLS certificates from gateway container..."); - openshell_bootstrap::extract_and_store_pki(name, None, endpoint_port) - .await?; + openshell_bootstrap::extract_and_store_pki(name, None, endpoint_port).await?; } let metadata = GatewayMetadata { @@ -1067,6 +1067,7 @@ pub async fn gateway_add( oidc_issuer: Some(issuer.to_string()), oidc_client_id: Some(oidc_client_id.to_string()), oidc_audience: oidc_audience.map(String::from), + oidc_scopes: oidc_scopes.map(String::from), ..Default::default() }; @@ -1087,7 +1088,14 @@ pub async fn gateway_add( // Check for client_credentials env var (CI mode). if std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").is_ok() { - match crate::oidc_auth::oidc_client_credentials_flow(issuer, oidc_client_id, oidc_audience).await { + match crate::oidc_auth::oidc_client_credentials_flow( + issuer, + oidc_client_id, + oidc_audience, + oidc_scopes, + ) + .await + { Ok(bundle) => { openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?; eprintln!( @@ -1100,7 +1108,14 @@ pub async fn gateway_add( } } } else { - match crate::oidc_auth::oidc_browser_auth_flow(issuer, oidc_client_id, oidc_audience).await { + match crate::oidc_auth::oidc_browser_auth_flow( + issuer, + oidc_client_id, + oidc_audience, + oidc_scopes, + ) + .await + { Ok(bundle) => { openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?; eprintln!("{} Authenticated successfully", "✓".green().bold()); @@ -1252,13 +1267,19 @@ pub async fn gateway_login(name: &str) -> Result<()> { let issuer = metadata.oidc_issuer.as_deref().ok_or_else(|| { miette::miette!("Gateway '{name}' has OIDC auth but no issuer URL in metadata") })?; - let client_id = metadata.oidc_client_id.as_deref().unwrap_or("openshell-cli"); + let client_id = metadata + .oidc_client_id + .as_deref() + .unwrap_or("openshell-cli"); let audience = metadata.oidc_audience.as_deref(); + let scopes = metadata.oidc_scopes.as_deref(); let bundle = if std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").is_ok() { - crate::oidc_auth::oidc_client_credentials_flow(issuer, client_id, audience).await? + crate::oidc_auth::oidc_client_credentials_flow(issuer, client_id, audience, scopes) + .await? } else { - crate::oidc_auth::oidc_browser_auth_flow(issuer, client_id, audience).await? + crate::oidc_auth::oidc_browser_auth_flow(issuer, client_id, audience, scopes) + .await? }; let username = jwt_preferred_username(&bundle.access_token); @@ -1287,11 +1308,8 @@ pub async fn gateway_login(name: &str) -> Result<()> { /// Extract `preferred_username` from a JWT payload without signature verification. fn jwt_preferred_username(token: &str) -> Option { let payload = token.split('.').nth(1)?; - let decoded = base64::Engine::decode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - payload, - ) - .ok()?; + let decoded = + base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload).ok()?; let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; claims .get("preferred_username") @@ -1323,10 +1341,7 @@ pub fn gateway_logout(name: &str) -> Result<()> { } } - eprintln!( - "{} Logged out of gateway '{name}'", - "✓".green().bold(), - ); + eprintln!("{} Logged out of gateway '{name}'", "✓".green().bold(),); Ok(()) } @@ -1583,6 +1598,8 @@ pub async fn gateway_admin_deploy( oidc_roles_claim: Option<&str>, oidc_admin_role: Option<&str>, oidc_user_role: Option<&str>, + oidc_scopes: Option<&str>, + oidc_scopes_claim: Option<&str>, ) -> Result<()> { let location = if remote.is_some() { "remote" } else { "local" }; @@ -1662,10 +1679,22 @@ pub async fn gateway_admin_deploy( if let Some(role) = oidc_user_role { options.oidc_user_role = Some(role.to_string()); } + if let Some(claim) = oidc_scopes_claim { + options.oidc_scopes_claim = Some(claim.to_string()); + } } let handle = deploy_gateway_with_panel(options, name, location).await?; + // Persist oidc_scopes in gateway metadata so `gateway login` can + // request the correct scopes later. + if let Some(scopes) = oidc_scopes { + if let Ok(mut meta) = openshell_bootstrap::load_gateway_metadata(name) { + meta.oidc_scopes = Some(scopes.to_string()); + let _ = store_gateway_metadata(name, &meta); + } + } + // Wait for the gRPC endpoint to actually accept connections before // declaring the gateway ready. The Docker health check may pass before // the gRPC listener inside the pod is fully bound. @@ -6133,9 +6162,19 @@ mod tests { with_tmp_xdg(tmpdir.path(), || { let runtime = tokio::runtime::Runtime::new().expect("create runtime"); runtime.block_on(async { - gateway_add("http://127.0.0.1:8080", None, None, None, false, None, "openshell-cli", None) - .await - .expect("register plaintext gateway"); + gateway_add( + "http://127.0.0.1:8080", + None, + None, + None, + false, + None, + "openshell-cli", + None, + None, + ) + .await + .expect("register plaintext gateway"); }); let metadata = load_gateway_metadata("127.0.0.1").expect("load stored gateway"); @@ -6161,6 +6200,7 @@ mod tests { None, "openshell-cli", None, + None, ) .await .expect("register plaintext gateway"); diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index baf947cea..924d95121 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -258,6 +258,12 @@ pub struct OidcConfig { /// Role name that grants standard user access. Defaults to `openshell-user`. #[serde(default = "default_user_role")] pub user_role: String, + + /// Dot-separated path to the scopes value in the JWT claims. + /// When non-empty, the server enforces scope-based permissions on top of roles. + /// Keycloak: `scope` (space-delimited string). Okta: `scp` (JSON array). + #[serde(default)] + pub scopes_claim: String, } const fn default_jwks_ttl_secs() -> u64 { diff --git a/crates/openshell-server/src/authz.rs b/crates/openshell-server/src/authz.rs index b7e21c121..a7b5aab9b 100644 --- a/crates/openshell-server/src/authz.rs +++ b/crates/openshell-server/src/authz.rs @@ -31,8 +31,68 @@ const ADMIN_METHODS: &[&str] = &[ "/openshell.v1.OpenShell/EditDraftChunk", "/openshell.v1.OpenShell/UndoDraftChunk", "/openshell.v1.OpenShell/ClearDraftChunks", + // Cluster inference write + "/openshell.inference.v1.Inference/SetClusterInference", ]; +/// Exhaustive mapping of Bearer-authenticated gRPC methods to required scopes. +/// Methods not listed here require `openshell:all` when scope enforcement is enabled. +const SCOPED_METHODS: &[(&str, &str)] = &[ + // sandbox:read + ("/openshell.v1.OpenShell/GetSandbox", "sandbox:read"), + ("/openshell.v1.OpenShell/ListSandboxes", "sandbox:read"), + ("/openshell.v1.OpenShell/WatchSandbox", "sandbox:read"), + ("/openshell.v1.OpenShell/GetSandboxLogs", "sandbox:read"), + ( + "/openshell.v1.OpenShell/GetSandboxPolicyStatus", + "sandbox:read", + ), + ( + "/openshell.v1.OpenShell/ListSandboxPolicies", + "sandbox:read", + ), + // sandbox:write + ("/openshell.v1.OpenShell/CreateSandbox", "sandbox:write"), + ("/openshell.v1.OpenShell/DeleteSandbox", "sandbox:write"), + ("/openshell.v1.OpenShell/ExecSandbox", "sandbox:write"), + ("/openshell.v1.OpenShell/CreateSshSession", "sandbox:write"), + ("/openshell.v1.OpenShell/RevokeSshSession", "sandbox:write"), + // provider:read + ("/openshell.v1.OpenShell/GetProvider", "provider:read"), + ("/openshell.v1.OpenShell/ListProviders", "provider:read"), + // provider:write + ("/openshell.v1.OpenShell/CreateProvider", "provider:write"), + ("/openshell.v1.OpenShell/UpdateProvider", "provider:write"), + ("/openshell.v1.OpenShell/DeleteProvider", "provider:write"), + // config:read + ("/openshell.v1.OpenShell/GetGatewayConfig", "config:read"), + ("/openshell.v1.OpenShell/GetDraftPolicy", "config:read"), + ("/openshell.v1.OpenShell/GetDraftHistory", "config:read"), + // config:write + ("/openshell.v1.OpenShell/UpdateConfig", "config:write"), + ("/openshell.v1.OpenShell/ApproveDraftChunk", "config:write"), + ( + "/openshell.v1.OpenShell/ApproveAllDraftChunks", + "config:write", + ), + ("/openshell.v1.OpenShell/RejectDraftChunk", "config:write"), + ("/openshell.v1.OpenShell/EditDraftChunk", "config:write"), + ("/openshell.v1.OpenShell/UndoDraftChunk", "config:write"), + ("/openshell.v1.OpenShell/ClearDraftChunks", "config:write"), + // inference:read + ( + "/openshell.inference.v1.Inference/GetClusterInference", + "inference:read", + ), + // inference:write + ( + "/openshell.inference.v1.Inference/SetClusterInference", + "inference:write", + ), +]; + +const SCOPE_ALL: &str = "openshell:all"; + /// Authorization policy configuration. /// /// Supports two modes: @@ -47,6 +107,8 @@ pub struct AuthzPolicy { pub admin_role: String, /// Role name that grants standard user access. Empty disables user checks. pub user_role: String, + /// When true, enforce scope-based permissions on top of roles. + pub scopes_enabled: bool, } impl AuthzPolicy { @@ -81,31 +143,61 @@ impl AuthzPolicy { &self.user_role }; - // Empty role name = skip RBAC for this level. - if required.is_empty() { + // Empty role name = skip role check for this level (auth-only mode). + // Scope enforcement still applies if enabled. + if !required.is_empty() { + // Admin role implicitly satisfies user role requirements. + let has_role = identity.roles.iter().any(|r| r == required) + || (!self.admin_role.is_empty() + && required == &self.user_role + && identity.roles.iter().any(|r| r == &self.admin_role)); + + if !has_role { + debug!( + sub = %identity.subject, + required_role = required, + user_roles = ?identity.roles, + method = method, + "authorization denied: missing role" + ); + return Err(Status::permission_denied(format!( + "role '{required}' required" + ))); + } + } + + if self.scopes_enabled { + self.check_scope(identity, method)?; + } + + Ok(()) + } + + fn check_scope(&self, identity: &Identity, method: &str) -> Result<(), Status> { + if identity.scopes.iter().any(|s| s == SCOPE_ALL) { return Ok(()); } - // Admin role implicitly satisfies user role requirements. - let has_role = identity.roles.iter().any(|r| r == required) - || (!self.admin_role.is_empty() - && required == &self.user_role - && identity.roles.iter().any(|r| r == &self.admin_role)); + let required_scope = SCOPED_METHODS + .iter() + .find(|(m, _)| *m == method) + .map(|(_, s)| *s) + .unwrap_or(SCOPE_ALL); - if has_role { - Ok(()) - } else { - debug!( - sub = %identity.subject, - required_role = required, - user_roles = ?identity.roles, - method = method, - "authorization denied" - ); - Err(Status::permission_denied(format!( - "role '{required}' required" - ))) + if identity.scopes.iter().any(|s| s == required_scope) { + return Ok(()); } + + debug!( + sub = %identity.subject, + required_scope = required_scope, + user_scopes = ?identity.scopes, + method = method, + "authorization denied: missing scope" + ); + Err(Status::permission_denied(format!( + "scope '{required_scope}' required" + ))) } } @@ -118,6 +210,15 @@ mod tests { AuthzPolicy { admin_role: "openshell-admin".to_string(), user_role: "openshell-user".to_string(), + scopes_enabled: false, + } + } + + fn scoped_policy() -> AuthzPolicy { + AuthzPolicy { + admin_role: "openshell-admin".to_string(), + user_role: "openshell-user".to_string(), + scopes_enabled: true, } } @@ -126,6 +227,17 @@ mod tests { subject: "test-user".to_string(), display_name: None, roles: roles.iter().map(|r| (*r).to_string()).collect(), + scopes: vec![], + provider: IdentityProvider::Oidc, + } + } + + fn identity_with_roles_and_scopes(roles: &[&str], scopes: &[&str]) -> Identity { + Identity { + subject: "test-user".to_string(), + display_name: None, + roles: roles.iter().map(|r| (*r).to_string()).collect(), + scopes: scopes.iter().map(|s| (*s).to_string()).collect(), provider: IdentityProvider::Oidc, } } @@ -134,35 +246,55 @@ mod tests { fn user_can_access_user_methods() { let id = identity_with_roles(&["openshell-user"]); let policy = default_policy(); - assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok()); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); } #[test] fn user_cannot_access_admin_methods() { let id = identity_with_roles(&["openshell-user"]); let policy = default_policy(); - assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_err()); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .is_err() + ); } #[test] fn admin_can_access_admin_methods() { let id = identity_with_roles(&["openshell-admin", "openshell-user"]); let policy = default_policy(); - assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_ok()); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .is_ok() + ); } #[test] fn admin_only_can_access_user_methods() { let id = identity_with_roles(&["openshell-admin"]); let policy = default_policy(); - assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok()); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); } #[test] fn empty_roles_rejected() { let id = identity_with_roles(&[]); let policy = default_policy(); - assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_err()); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_err() + ); } #[test] @@ -171,9 +303,18 @@ mod tests { let policy = AuthzPolicy { admin_role: String::new(), user_role: String::new(), + scopes_enabled: false, }; - assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok()); - assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_ok()); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .is_ok() + ); } #[test] @@ -182,9 +323,18 @@ mod tests { let policy = AuthzPolicy { admin_role: "OpenShell.Admin".to_string(), user_role: "OpenShell.User".to_string(), + scopes_enabled: false, }; - assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_ok()); - assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok()); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .is_ok() + ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); } #[test] @@ -198,6 +348,7 @@ mod tests { let policy = AuthzPolicy { admin_role: String::new(), user_role: String::new(), + scopes_enabled: false, }; assert!(policy.validate().is_ok()); } @@ -207,6 +358,7 @@ mod tests { let policy = AuthzPolicy { admin_role: "admin".to_string(), user_role: String::new(), + scopes_enabled: false, }; assert!(policy.validate().is_err()); } @@ -216,7 +368,134 @@ mod tests { let policy = AuthzPolicy { admin_role: String::new(), user_role: "user".to_string(), + scopes_enabled: false, }; assert!(policy.validate().is_err()); } + + // ---- Scope enforcement tests ---- + + #[test] + fn scopes_disabled_skips_scope_check() { + let id = identity_with_roles(&["openshell-user"]); + let policy = default_policy(); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); + } + + #[test] + fn scoped_access_allowed() { + let id = + identity_with_roles_and_scopes(&["openshell-user"], &["sandbox:read", "sandbox:write"]); + let policy = scoped_policy(); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateSandbox") + .is_ok() + ); + } + + #[test] + fn scoped_access_denied() { + let id = identity_with_roles_and_scopes(&["openshell-user"], &["sandbox:read"]); + let policy = scoped_policy(); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); + let err = policy + .check(&id, "/openshell.v1.OpenShell/CreateSandbox") + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(err.message().contains("sandbox:write")); + } + + #[test] + fn no_openshell_scopes_denied() { + let id = identity_with_roles_and_scopes(&["openshell-user"], &[]); + let policy = scoped_policy(); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_err() + ); + } + + #[test] + fn openshell_all_with_user_role() { + let id = identity_with_roles_and_scopes(&["openshell-user"], &["openshell:all"]); + let policy = scoped_policy(); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/GetProvider") + .is_ok() + ); + // admin methods still denied by role check + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .is_err() + ); + } + + #[test] + fn openshell_all_with_admin_role() { + let id = identity_with_roles_and_scopes(&["openshell-admin"], &["openshell:all"]); + let policy = scoped_policy(); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/CreateProvider") + .is_ok() + ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); + } + + #[test] + fn unknown_method_requires_openshell_all() { + let id = identity_with_roles_and_scopes(&["openshell-user"], &["sandbox:read"]); + let policy = scoped_policy(); + let err = policy + .check(&id, "/openshell.v1.OpenShell/SomeFutureMethod") + .unwrap_err(); + assert!(err.message().contains("openshell:all")); + } + + #[test] + fn auth_only_mode_with_scopes_still_enforces_scopes() { + let policy = AuthzPolicy { + admin_role: String::new(), + user_role: String::new(), + scopes_enabled: true, + }; + let id_with_scope = identity_with_roles_and_scopes(&[], &["sandbox:read"]); + assert!( + policy + .check(&id_with_scope, "/openshell.v1.OpenShell/ListSandboxes") + .is_ok() + ); + let id_without_scope = identity_with_roles_and_scopes(&[], &[]); + assert!( + policy + .check(&id_without_scope, "/openshell.v1.OpenShell/ListSandboxes") + .is_err() + ); + } } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 3ab310ea6..2507a1f2b 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -205,16 +205,34 @@ struct Args { /// Dot-separated path to the roles array in the JWT claims. /// Keycloak: "realm_access.roles" (default). Entra ID: "roles". Okta: "groups". - #[arg(long, env = "OPENSHELL_OIDC_ROLES_CLAIM", default_value = "realm_access.roles")] + #[arg( + long, + env = "OPENSHELL_OIDC_ROLES_CLAIM", + default_value = "realm_access.roles" + )] oidc_roles_claim: String, /// Role name that grants admin access. - #[arg(long, env = "OPENSHELL_OIDC_ADMIN_ROLE", default_value = "openshell-admin")] + #[arg( + long, + env = "OPENSHELL_OIDC_ADMIN_ROLE", + default_value = "openshell-admin" + )] oidc_admin_role: String, /// Role name that grants standard user access. - #[arg(long, env = "OPENSHELL_OIDC_USER_ROLE", default_value = "openshell-user")] + #[arg( + long, + env = "OPENSHELL_OIDC_USER_ROLE", + default_value = "openshell-user" + )] oidc_user_role: String, + + /// Dot-separated path to the scopes value in the JWT claims. + /// When set, the server enforces scope-based permissions on top of roles. + /// Keycloak: "scope". Okta: "scp". Leave empty to disable scope enforcement. + #[arg(long, env = "OPENSHELL_OIDC_SCOPES_CLAIM", default_value = "")] + oidc_scopes_claim: String, } pub fn command() -> Command { @@ -339,6 +357,7 @@ async fn run_from_args(args: Args) -> Result<()> { roles_claim: args.oidc_roles_claim, admin_role: args.oidc_admin_role, user_role: args.oidc_user_role, + scopes_claim: args.oidc_scopes_claim, }); } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index f4bacc1aa..58fcbd8d5 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -10,8 +10,8 @@ #![allow(clippy::cast_precision_loss)] // f64->f32 for confidence scores #![allow(clippy::items_after_statements)] // DB_PORTS const inside function -use crate::{ServerState, oidc}; use crate::persistence::{DraftChunkRecord, PolicyRecord, Store}; +use crate::{ServerState, oidc}; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; use openshell_core::proto::{ diff --git a/crates/openshell-server/src/identity.rs b/crates/openshell-server/src/identity.rs index 6569f4775..fc06c4776 100644 --- a/crates/openshell-server/src/identity.rs +++ b/crates/openshell-server/src/identity.rs @@ -23,6 +23,9 @@ pub struct Identity { /// Roles granted to this identity (OIDC `realm_access.roles`, cert OU, etc.). pub roles: Vec, + /// OAuth2 scopes granted to this identity. Empty when scope enforcement is disabled. + pub scopes: Vec, + /// Which authentication provider produced this identity. pub provider: IdentityProvider, } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 769b20734..dac5b4f02 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -20,9 +20,9 @@ //! [`compute::vm`]; keep this file driver-agnostic going forward. mod auth; +mod authz; pub mod cli; mod compute; -mod authz; mod grpc; mod http; pub mod identity; @@ -163,10 +163,9 @@ pub async fn run_server( let policy = authz::AuthzPolicy { admin_role: oidc.admin_role.clone(), user_role: oidc.user_role.clone(), + scopes_enabled: !oidc.scopes_claim.is_empty(), }; - policy - .validate() - .map_err(|e| Error::config(e))?; + policy.validate().map_err(|e| Error::config(e))?; let cache = oidc::JwksCache::new(oidc) .await diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 8075c498d..a4a8f070f 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -29,7 +29,10 @@ use tower::{ServiceBuilder, ServiceExt}; use tower_http::trace::TraceLayer; use tracing::Span; -use crate::{OpenShellService, ServerState, authz::AuthzPolicy, http_router, inference::InferenceService, oidc}; +use crate::{ + OpenShellService, ServerState, authz::AuthzPolicy, http_router, inference::InferenceService, + oidc, +}; /// Maximum inbound gRPC message size (1 MB). /// @@ -64,6 +67,7 @@ impl MultiplexService { let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { admin_role: oidc.admin_role.clone(), user_role: oidc.user_role.clone(), + scopes_enabled: !oidc.scopes_claim.is_empty(), }); let grpc_service = AuthGrpcRouter::new( GrpcRouter::new(openshell, inference), diff --git a/crates/openshell-server/src/oidc.rs b/crates/openshell-server/src/oidc.rs index 7933f4bcc..3ab1dad2c 100644 --- a/crates/openshell-server/src/oidc.rs +++ b/crates/openshell-server/src/oidc.rs @@ -37,10 +37,7 @@ const UNAUTHENTICATED_METHODS: &[&str] = &[ ]; /// Path prefixes that bypass OIDC validation (gRPC reflection, health probes). -const UNAUTHENTICATED_PREFIXES: &[&str] = &[ - "/grpc.reflection.", - "/grpc.health.", -]; +const UNAUTHENTICATED_PREFIXES: &[&str] = &["/grpc.reflection.", "/grpc.health."]; /// Sandbox-to-server RPCs that use the shared sandbox secret instead of /// OIDC Bearer tokens. These require the `x-sandbox-secret` metadata header @@ -57,9 +54,7 @@ const SANDBOX_SECRET_METHODS: &[&str] = &[ /// Methods that accept either OIDC Bearer token (CLI users) or sandbox /// secret (supervisor). UpdateConfig is called by both CLI (policy/settings /// mutations) and the sandbox supervisor (policy sync on startup). -const DUAL_AUTH_METHODS: &[&str] = &[ - "/openshell.v1.OpenShell/UpdateConfig", -]; +const DUAL_AUTH_METHODS: &[&str] = &["/openshell.v1.OpenShell/UpdateConfig"]; /// Returns `true` if the method accepts either Bearer or sandbox-secret auth. pub fn is_dual_auth_method(path: &str) -> bool { @@ -69,7 +64,9 @@ pub fn is_dual_auth_method(path: &str) -> bool { /// Returns `true` if the method needs no authentication at all. pub fn is_unauthenticated_method(path: &str) -> bool { UNAUTHENTICATED_METHODS.contains(&path) - || UNAUTHENTICATED_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) + || UNAUTHENTICATED_PREFIXES + .iter() + .any(|prefix| path.starts_with(prefix)) } /// Returns `true` if the method authenticates via the sandbox shared secret @@ -86,9 +83,7 @@ pub fn validate_sandbox_secret( let provided = headers .get("x-sandbox-secret") .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - Status::unauthenticated("sandbox secret required for this method") - })?; + .ok_or_else(|| Status::unauthenticated("sandbox secret required for this method"))?; if provided != expected_secret { return Err(Status::unauthenticated("invalid sandbox secret")); @@ -185,6 +180,8 @@ pub struct OidcClaims { extra: serde_json::Value, } +const STANDARD_OIDC_SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"]; + impl OidcClaims { /// Extract roles from the JWT claims using a dot-separated path. /// @@ -207,6 +204,37 @@ impl OidcClaims { .collect(); } } + + /// Extract scopes from the JWT claims using a dot-separated path. + /// + /// Handles two formats: + /// - Space-delimited string: `"openid sandbox:read sandbox:write"` (Keycloak, Entra) + /// - JSON array: `["sandbox:read", "sandbox:write"]` (Okta) + /// + /// Filters out standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`). + fn extract_scopes(&self, scopes_claim: &str) -> Vec { + let mut value = &self.extra; + for segment in scopes_claim.split('.') { + match value.get(segment) { + Some(v) => value = v, + None => return vec![], + } + } + + let raw: Vec = if let Some(s) = value.as_str() { + s.split_whitespace().map(String::from).collect() + } else if let Some(arr) = value.as_array() { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + } else { + return vec![]; + }; + + raw.into_iter() + .filter(|s| !STANDARD_OIDC_SCOPES.contains(&s.as_str())) + .collect() + } } impl JwksCache { @@ -377,10 +405,17 @@ impl JwksCache { let mut claims = token_data.claims; claims.extract_roles(&self.config.roles_claim); + let scopes = if self.config.scopes_claim.is_empty() { + vec![] + } else { + claims.extract_scopes(&self.config.scopes_claim) + }; + Ok(Identity { subject: claims.sub, display_name: claims.preferred_username, roles: claims.roles, + scopes, provider: IdentityProvider::Oidc, }) } @@ -397,8 +432,12 @@ mod tests { #[test] fn sandbox_operations_require_auth() { - assert!(!is_unauthenticated_method("/openshell.v1.OpenShell/CreateSandbox")); - assert!(!is_sandbox_secret_method("/openshell.v1.OpenShell/CreateSandbox")); + assert!(!is_unauthenticated_method( + "/openshell.v1.OpenShell/CreateSandbox" + )); + assert!(!is_sandbox_secret_method( + "/openshell.v1.OpenShell/CreateSandbox" + )); } #[test] @@ -418,11 +457,21 @@ mod tests { #[test] fn sandbox_rpcs_use_sandbox_secret() { - assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/GetSandboxConfig")); - assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/GetSandboxProviderEnvironment")); - assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/ReportPolicyStatus")); - assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/PushSandboxLogs")); - assert!(is_sandbox_secret_method("/openshell.v1.OpenShell/SubmitPolicyAnalysis")); + assert!(is_sandbox_secret_method( + "/openshell.v1.OpenShell/GetSandboxConfig" + )); + assert!(is_sandbox_secret_method( + "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" + )); + assert!(is_sandbox_secret_method( + "/openshell.v1.OpenShell/ReportPolicyStatus" + )); + assert!(is_sandbox_secret_method( + "/openshell.v1.OpenShell/PushSandboxLogs" + )); + assert!(is_sandbox_secret_method( + "/openshell.v1.OpenShell/SubmitPolicyAnalysis" + )); } #[test] @@ -481,4 +530,56 @@ mod tests { claims.extract_roles("realm_access.roles"); assert!(claims.roles.is_empty()); } + + #[test] + fn extract_scopes_space_delimited() { + let json = serde_json::json!({ + "sub": "user1", + "scope": "openid sandbox:read sandbox:write" + }); + let claims: OidcClaims = serde_json::from_value(json).unwrap(); + let scopes = claims.extract_scopes("scope"); + assert_eq!(scopes, vec!["sandbox:read", "sandbox:write"]); + } + + #[test] + fn extract_scopes_json_array() { + let json = serde_json::json!({ + "sub": "user1", + "scp": ["sandbox:read", "provider:read"] + }); + let claims: OidcClaims = serde_json::from_value(json).unwrap(); + let scopes = claims.extract_scopes("scp"); + assert_eq!(scopes, vec!["sandbox:read", "provider:read"]); + } + + #[test] + fn extract_scopes_filters_standard_oidc_scopes() { + let json = serde_json::json!({ + "sub": "user1", + "scope": "openid profile email sandbox:read offline_access" + }); + let claims: OidcClaims = serde_json::from_value(json).unwrap(); + let scopes = claims.extract_scopes("scope"); + assert_eq!(scopes, vec!["sandbox:read"]); + } + + #[test] + fn extract_scopes_missing_claim() { + let json = serde_json::json!({ "sub": "user1" }); + let claims: OidcClaims = serde_json::from_value(json).unwrap(); + let scopes = claims.extract_scopes("scope"); + assert!(scopes.is_empty()); + } + + #[test] + fn extract_scopes_openid_only_yields_empty() { + let json = serde_json::json!({ + "sub": "user1", + "scope": "openid" + }); + let claims: OidcClaims = serde_json::from_value(json).unwrap(); + let scopes = claims.extract_scopes("scope"); + assert!(scopes.is_empty()); + } } diff --git a/deploy/docker/cluster-entrypoint.sh b/deploy/docker/cluster-entrypoint.sh index 72f9c7311..9287adc48 100644 --- a/deploy/docker/cluster-entrypoint.sh +++ b/deploy/docker/cluster-entrypoint.sh @@ -515,12 +515,14 @@ if [ -f "$HELMCHART" ]; then sed -i "s|__OIDC_ROLES_CLAIM__|${OIDC_ROLES_CLAIM:-realm_access.roles}|g" "$HELMCHART" sed -i "s|__OIDC_ADMIN_ROLE__|${OIDC_ADMIN_ROLE:-openshell-admin}|g" "$HELMCHART" sed -i "s|__OIDC_USER_ROLE__|${OIDC_USER_ROLE:-openshell-user}|g" "$HELMCHART" + sed -i "s|__OIDC_SCOPES_CLAIM__|${OIDC_SCOPES_CLAIM:-}|g" "$HELMCHART" else sed -i "s|__OIDC_ISSUER__||g" "$HELMCHART" sed -i "s|__OIDC_AUDIENCE__|openshell-cli|g" "$HELMCHART" sed -i "s|__OIDC_ROLES_CLAIM__||g" "$HELMCHART" sed -i "s|__OIDC_ADMIN_ROLE__||g" "$HELMCHART" sed -i "s|__OIDC_USER_ROLE__||g" "$HELMCHART" + sed -i "s|__OIDC_SCOPES_CLAIM__||g" "$HELMCHART" fi # Disable TLS entirely: the server listens on plaintext HTTP. diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index 7989289ae..86f6dc3ed 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -123,6 +123,10 @@ spec: - name: OPENSHELL_OIDC_USER_ROLE value: {{ .Values.server.oidc.userRole | quote }} {{- end }} + {{- if .Values.server.oidc.scopesClaim }} + - name: OPENSHELL_OIDC_SCOPES_CLAIM + value: {{ .Values.server.oidc.scopesClaim | quote }} + {{- end }} {{- end }} volumeMounts: - name: openshell-data diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 2ea52a5f3..18e671375 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -128,6 +128,8 @@ server: adminRole: "" # Role name for standard user access. userRole: "" + # Dot-separated path to the scopes array in the JWT claims. + scopesClaim: "" # NetworkPolicy restricting SSH ingress on sandbox pods to the gateway only. networkPolicy: diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index a181c20f6..81743746a 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -45,6 +45,7 @@ spec: rolesClaim: "__OIDC_ROLES_CLAIM__" adminRole: "__OIDC_ADMIN_ROLE__" userRole: "__OIDC_USER_ROLE__" + scopesClaim: "__OIDC_SCOPES_CLAIM__" tls: certSecretName: openshell-server-tls clientCaSecretName: openshell-server-client-ca diff --git a/scripts/keycloak-realm.json b/scripts/keycloak-realm.json index 0859f811d..7c5234c25 100644 --- a/scripts/keycloak-realm.json +++ b/scripts/keycloak-realm.json @@ -25,6 +25,259 @@ ] }, "defaultRoles": ["openshell-user"], + "clientScopes": [ + { + "name": "openid", + "description": "OpenID Connect scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "firstName", + "claim.name": "given_name", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "lastName", + "claim.name": "family_name", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "email", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "emailVerified", + "claim.name": "email_verified", + "jsonType.label": "boolean", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "name": "roles", + "description": "OpenID Connect scope for roles", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false" + }, + "protocolMappers": [ + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "name": "web-origins", + "description": "OpenID Connect scope for allowed web origins", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false" + }, + "protocolMappers": [ + { + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "name": "acr", + "description": "OpenID Connect scope for ACR", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false" + } + }, + { + "name": "sandbox:read", + "description": "Read sandbox resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "sandbox:write", + "description": "Write sandbox resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "provider:read", + "description": "Read provider resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "provider:write", + "description": "Write provider resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "config:read", + "description": "Read configuration resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "config:write", + "description": "Write configuration resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "inference:read", + "description": "Read inference resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "inference:write", + "description": "Write inference resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "name": "openshell:all", + "description": "Full access to all OpenShell resources", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + } + ], "clients": [ { "clientId": "openshell-cli", @@ -41,7 +294,9 @@ "pkce.code.challenge.method": "S256" }, "protocol": "openid-connect", - "fullScopeAllowed": true + "fullScopeAllowed": true, + "defaultClientScopes": ["openid", "profile", "email", "roles", "web-origins", "acr"], + "optionalClientScopes": ["sandbox:read", "sandbox:write", "provider:read", "provider:write", "config:read", "config:write", "inference:read", "inference:write", "openshell:all"] }, { "clientId": "openshell-ci", @@ -54,7 +309,8 @@ "directAccessGrantsEnabled": false, "serviceAccountsEnabled": true, "protocol": "openid-connect", - "fullScopeAllowed": true + "fullScopeAllowed": true, + "defaultClientScopes": ["openid", "profile", "email", "roles", "web-origins", "acr", "openshell:all"] } ], "users": [ diff --git a/tasks/scripts/cluster-bootstrap.sh b/tasks/scripts/cluster-bootstrap.sh index 048d66eb3..e12dace9c 100755 --- a/tasks/scripts/cluster-bootstrap.sh +++ b/tasks/scripts/cluster-bootstrap.sh @@ -279,6 +279,7 @@ if [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then [ -n "${OPENSHELL_OIDC_ROLES_CLAIM:-}" ] && DEPLOY_CMD+=(--oidc-roles-claim "${OPENSHELL_OIDC_ROLES_CLAIM}") [ -n "${OPENSHELL_OIDC_ADMIN_ROLE:-}" ] && DEPLOY_CMD+=(--oidc-admin-role "${OPENSHELL_OIDC_ADMIN_ROLE}") [ -n "${OPENSHELL_OIDC_USER_ROLE:-}" ] && DEPLOY_CMD+=(--oidc-user-role "${OPENSHELL_OIDC_USER_ROLE}") + [ -n "${OPENSHELL_OIDC_SCOPES_CLAIM:-}" ] && DEPLOY_CMD+=(--oidc-scopes-claim "${OPENSHELL_OIDC_SCOPES_CLAIM}") fi "${DEPLOY_CMD[@]}" diff --git a/tasks/scripts/cluster-deploy-fast.sh b/tasks/scripts/cluster-deploy-fast.sh index 5c675463f..e7f4d224d 100755 --- a/tasks/scripts/cluster-deploy-fast.sh +++ b/tasks/scripts/cluster-deploy-fast.sh @@ -441,6 +441,9 @@ if [[ "${needs_helm_upgrade}" == "1" ]]; then if [[ -n "${OPENSHELL_OIDC_USER_ROLE:-}" ]]; then OIDC_HELM_ARGS="${OIDC_HELM_ARGS} --set server.oidc.userRole=${OPENSHELL_OIDC_USER_ROLE}" fi + if [[ -n "${OPENSHELL_OIDC_SCOPES_CLAIM:-}" ]]; then + OIDC_HELM_ARGS="${OIDC_HELM_ARGS} --set server.oidc.scopesClaim=${OPENSHELL_OIDC_SCOPES_CLAIM}" + fi fi cluster_exec "helm upgrade openshell ${CONTAINER_CHART_DIR} \ From b7cabe5a65c8b6ba896d92dc2ef4cf4caa3f983f Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 21 Apr 2026 16:12:28 -0700 Subject: [PATCH 03/10] fix(auth): address branch review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- architecture/oidc-auth.md | 2 ++ architecture/oidc-local-testing.md | 19 +++++++++++++++++++ crates/openshell-bootstrap/src/lib.rs | 10 +++++++++- crates/openshell-server/src/oidc.rs | 6 +++++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/architecture/oidc-auth.md b/architecture/oidc-auth.md index 476d910fa..020f42d20 100644 --- a/architecture/oidc-auth.md +++ b/architecture/oidc-auth.md @@ -229,6 +229,8 @@ The roles claim path and role names are configurable to support different OIDC p When both `--oidc-admin-role` and `--oidc-user-role` are set to empty strings, RBAC is skipped entirely — any valid JWT is authorized. This supports providers like GitHub that don't emit roles in JWTs (authentication-only mode). +**Security note on authentication-only mode:** In this mode, the server validates token signature, issuer, and audience, but does not restrict which principals can call which methods. Any entity able to mint a valid token for the configured audience gains full access. For GitHub Actions, this means any workflow in any repository that can request a token with the configured audience is authorized. Consider using scope enforcement (`--oidc-scopes-claim`) or restricting the audience to limit the blast radius. + ## Scope-Based Fine-Grained Permissions Scopes provide opt-in, per-method access control on top of roles. When `--oidc-scopes-claim` is set, the server extracts scopes from the JWT and checks them against an exhaustive method-to-scope map. A caller must have both the required role AND the required scope. diff --git a/architecture/oidc-local-testing.md b/architecture/oidc-local-testing.md index 74daf7c09..a3904553d 100644 --- a/architecture/oidc-local-testing.md +++ b/architecture/oidc-local-testing.md @@ -160,7 +160,14 @@ cargo run -p openshell-cli --features bundled-z3 -- sandbox list ### Test client credentials (CI mode) +The CI client (`openshell-ci`) is separate from the interactive client (`openshell-cli`). +Register the gateway with the CI client ID first: + ```bash +cargo run -p openshell-cli --features bundled-z3 -- gateway add http://127.0.0.1:8080 \ + --oidc-issuer http://localhost:8180/realms/openshell \ + --oidc-client-id openshell-ci + OPENSHELL_OIDC_CLIENT_SECRET=ci-test-secret \ cargo run -p openshell-cli --features bundled-z3 -- gateway login # Expected: ✓ Authenticated to gateway (no browser opened) @@ -304,13 +311,25 @@ openshell sandbox create ### 4g. Test client credentials (CI mode) +The CI client uses `openshell-ci` (confidential) instead of `openshell-cli` (public). +Update the gateway metadata to use the CI client, then login: + ```bash +jq '.oidc_client_id = "openshell-ci"' \ + ~/.config/openshell/gateways/openshell/metadata.json > /tmp/meta.json \ + && mv /tmp/meta.json ~/.config/openshell/gateways/openshell/metadata.json + OPENSHELL_OIDC_CLIENT_SECRET=ci-test-secret \ openshell gateway login # Expected: ✓ Authenticated to gateway 'openshell' (no browser) openshell sandbox list # Expected: success + +# Restore interactive client for further testing +jq '.oidc_client_id = "openshell-cli"' \ + ~/.config/openshell/gateways/openshell/metadata.json > /tmp/meta.json \ + && mv /tmp/meta.json ~/.config/openshell/gateways/openshell/metadata.json ``` ### 4h. Clean up sandboxes diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index 411be09c1..ced650ed6 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -607,7 +607,9 @@ where wait_for_gateway_ready(&target_docker, &name, &mut gateway_log).await?; } - // Create and store gateway metadata. + // Create and store gateway metadata. On resume, preserve existing + // OIDC fields so a bare `gateway start` without `--oidc-*` flags + // doesn't erase a previously configured OIDC registration. let mut metadata = create_gateway_metadata_with_host( &name, remote_opts.as_ref(), @@ -620,6 +622,12 @@ where metadata.oidc_issuer = oidc_issuer.clone(); metadata.oidc_client_id = Some(oidc_client_id.clone()); metadata.oidc_audience = Some(oidc_audience.clone()); + } else if let Ok(existing) = load_gateway_metadata(&name) { + metadata.auth_mode = existing.auth_mode; + metadata.oidc_issuer = existing.oidc_issuer; + metadata.oidc_client_id = existing.oidc_client_id; + metadata.oidc_audience = existing.oidc_audience; + metadata.oidc_scopes = existing.oidc_scopes; } store_gateway_metadata(&name, &metadata)?; diff --git a/crates/openshell-server/src/oidc.rs b/crates/openshell-server/src/oidc.rs index 3ab1dad2c..2e6ca061a 100644 --- a/crates/openshell-server/src/oidc.rs +++ b/crates/openshell-server/src/oidc.rs @@ -49,12 +49,16 @@ const SANDBOX_SECRET_METHODS: &[&str] = &[ "/openshell.v1.OpenShell/GetSandboxProviderEnvironment", "/openshell.v1.OpenShell/SubmitPolicyAnalysis", "/openshell.sandbox.v1.SandboxService/GetSandboxConfig", + "/openshell.inference.v1.Inference/GetInferenceBundle", ]; /// Methods that accept either OIDC Bearer token (CLI users) or sandbox /// secret (supervisor). UpdateConfig is called by both CLI (policy/settings /// mutations) and the sandbox supervisor (policy sync on startup). -const DUAL_AUTH_METHODS: &[&str] = &["/openshell.v1.OpenShell/UpdateConfig"]; +const DUAL_AUTH_METHODS: &[&str] = &[ + "/openshell.v1.OpenShell/UpdateConfig", + "/openshell.v1.OpenShell/GetSandboxConfig", +]; /// Returns `true` if the method accepts either Bearer or sandbox-secret auth. pub fn is_dual_auth_method(path: &str) -> bool { From 8fe7944fde7b790608a77306ffe44ecf26aecc1b Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 21 Apr 2026 16:25:26 -0700 Subject: [PATCH 04/10] fix(auth): complete review findings for OIDC auth boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- architecture/oidc-auth.md | 9 +- architecture/oidc-local-testing.md | 19 ++- crates/openshell-bootstrap/src/lib.rs | 131 ++++++++++++++++++-- crates/openshell-sandbox/src/grpc_client.rs | 59 +++++++-- crates/openshell-server/src/authz.rs | 19 +++ crates/openshell-server/src/oidc.rs | 22 +++- 6 files changed, 222 insertions(+), 37 deletions(-) diff --git a/architecture/oidc-auth.md b/architecture/oidc-auth.md index 020f42d20..c759c933e 100644 --- a/architecture/oidc-auth.md +++ b/architecture/oidc-auth.md @@ -175,11 +175,12 @@ Sandbox-to-server RPCs authenticate via the `x-sandbox-secret` metadata header, | Method | Purpose | |---|---| -| `GetSandboxConfig` (both services) | Supervisor fetches sandbox configuration | +| `SandboxService/GetSandboxConfig` | Supervisor fetches sandbox configuration | | `ReportPolicyStatus` | Supervisor reports policy enforcement status | | `PushSandboxLogs` | Supervisor streams sandbox logs to gateway | | `GetSandboxProviderEnvironment` | Supervisor fetches provider credentials | | `SubmitPolicyAnalysis` | Supervisor submits policy analysis results | +| `Inference/GetInferenceBundle` | Supervisor fetches resolved inference routes and provider API keys | ### Dual-Auth @@ -188,6 +189,7 @@ These methods accept either an OIDC Bearer token (CLI users) or a sandbox secret | Method | Purpose | |---|---| | `UpdateConfig` | Policy and settings mutations | +| `OpenShell/GetSandboxConfig` | CLI reads effective sandbox policy and settings; sandbox callers may still use the shared secret | **Sandbox-secret restriction on `UpdateConfig`:** When a sandbox-secret-authenticated caller invokes `UpdateConfig`, the handler in `policy.rs` enforces strict scope limits via `validate_sandbox_secret_update()`. The caller: - **Must** provide a sandbox `name` (sandbox-scoped only). @@ -207,8 +209,9 @@ After JWT validation, the server checks the user's roles against a per-method re | Operation | Required Role | |---|---| | Health probes, reflection | (no auth — unauthenticated) | -| Supervisor RPCs (GetSandboxConfig, etc.) | (sandbox secret — no RBAC) | +| Supervisor-only RPCs (`SandboxService/GetSandboxConfig`, `GetInferenceBundle`, etc.) | (sandbox secret — no RBAC) | | UpdateConfig via sandbox secret | (sandbox secret — scope-restricted, no RBAC) | +| OpenShell/GetSandboxConfig via Bearer | user role | | Sandbox create, list, delete, exec, SSH | user role | | Provider list, get | user role | | Provider create, update, delete | admin role | @@ -243,7 +246,7 @@ Scopes provide opt-in, per-method access control on top of roles. When `--oidc-s | `sandbox:write` | CreateSandbox, DeleteSandbox, ExecSandbox, CreateSshSession, RevokeSshSession | | `provider:read` | GetProvider, ListProviders | | `provider:write` | CreateProvider, UpdateProvider, DeleteProvider | -| `config:read` | GetGatewayConfig, GetDraftPolicy, GetDraftHistory | +| `config:read` | GetGatewayConfig, GetSandboxConfig, GetDraftPolicy, GetDraftHistory | | `config:write` | UpdateConfig (Bearer), ApproveDraftChunk, ApproveAllDraftChunks, RejectDraftChunk, EditDraftChunk, UndoDraftChunk, ClearDraftChunks | | `inference:read` | GetClusterInference | | `inference:write` | SetClusterInference | diff --git a/architecture/oidc-local-testing.md b/architecture/oidc-local-testing.md index a3904553d..0f7e5fcc1 100644 --- a/architecture/oidc-local-testing.md +++ b/architecture/oidc-local-testing.md @@ -109,24 +109,21 @@ grpcurl -plaintext -import-path proto -proto openshell.proto \ ### 2f. Test sandbox secret auth ```bash -# Correct secret — should succeed (returns NOT_FOUND since sandbox doesn't exist) -grpcurl -plaintext -import-path proto -proto openshell.proto \ +# Correct secret — should succeed (returns an empty bundle when no routes are configured) +grpcurl -plaintext -import-path proto -proto inference.proto \ -H "x-sandbox-secret: test" \ - -d '{"sandbox_id":"fake"}' \ - 127.0.0.1:8080 openshell.v1.OpenShell/GetSandboxConfig -# Expected: Code: NotFound (sandbox doesn't exist, but auth passed) + 127.0.0.1:8080 openshell.inference.v1.Inference/GetInferenceBundle +# Expected: success with { "routes": [], ... } # Wrong secret — should fail at auth -grpcurl -plaintext -import-path proto -proto openshell.proto \ +grpcurl -plaintext -import-path proto -proto inference.proto \ -H "x-sandbox-secret: wrong" \ - -d '{"sandbox_id":"fake"}' \ - 127.0.0.1:8080 openshell.v1.OpenShell/GetSandboxConfig + 127.0.0.1:8080 openshell.inference.v1.Inference/GetInferenceBundle # Expected: Code: Unauthenticated, Message: invalid sandbox secret # No secret — should fail at auth -grpcurl -plaintext -import-path proto -proto openshell.proto \ - -d '{"sandbox_id":"fake"}' \ - 127.0.0.1:8080 openshell.v1.OpenShell/GetSandboxConfig +grpcurl -plaintext -import-path proto -proto inference.proto \ + 127.0.0.1:8080 openshell.inference.v1.Inference/GetInferenceBundle # Expected: Code: Unauthenticated, Message: sandbox secret required ``` diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index ced650ed6..33c9b8637 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -246,6 +246,34 @@ impl DeployOptions { } } +fn apply_oidc_gateway_metadata( + metadata: &mut GatewayMetadata, + resume: bool, + existing: Option<&GatewayMetadata>, + oidc_issuer: Option<&str>, + oidc_client_id: &str, + oidc_audience: &str, +) { + if let Some(issuer) = oidc_issuer { + metadata.auth_mode = Some("oidc".to_string()); + metadata.oidc_issuer = Some(issuer.to_string()); + metadata.oidc_client_id = Some(oidc_client_id.to_string()); + metadata.oidc_audience = Some(oidc_audience.to_string()); + return; + } + + if resume + && let Some(existing) = existing + && existing.auth_mode.as_deref() == Some("oidc") + { + metadata.auth_mode = existing.auth_mode.clone(); + metadata.oidc_issuer = existing.oidc_issuer.clone(); + metadata.oidc_client_id = existing.oidc_client_id.clone(); + metadata.oidc_audience = existing.oidc_audience.clone(); + metadata.oidc_scopes = existing.oidc_scopes.clone(); + } +} + #[derive(Debug, Clone)] pub struct GatewayHandle { name: String, @@ -617,18 +645,19 @@ where ssh_gateway_host.as_deref(), disable_tls, ); - if oidc_issuer.is_some() { - metadata.auth_mode = Some("oidc".to_string()); - metadata.oidc_issuer = oidc_issuer.clone(); - metadata.oidc_client_id = Some(oidc_client_id.clone()); - metadata.oidc_audience = Some(oidc_audience.clone()); - } else if let Ok(existing) = load_gateway_metadata(&name) { - metadata.auth_mode = existing.auth_mode; - metadata.oidc_issuer = existing.oidc_issuer; - metadata.oidc_client_id = existing.oidc_client_id; - metadata.oidc_audience = existing.oidc_audience; - metadata.oidc_scopes = existing.oidc_scopes; - } + let existing_metadata = if resume { + load_gateway_metadata(&name).ok() + } else { + None + }; + apply_oidc_gateway_metadata( + &mut metadata, + resume, + existing_metadata.as_ref(), + oidc_issuer.as_deref(), + &oidc_client_id, + &oidc_audience, + ); store_gateway_metadata(&name, &metadata)?; Ok(metadata) @@ -1280,4 +1309,82 @@ mod tests { ); } } + + #[test] + fn apply_oidc_gateway_metadata_sets_explicit_values() { + let mut metadata = GatewayMetadata::default(); + apply_oidc_gateway_metadata( + &mut metadata, + false, + None, + Some("http://issuer.test/realm"), + "openshell-cli", + "openshell-api", + ); + + assert_eq!(metadata.auth_mode.as_deref(), Some("oidc")); + assert_eq!( + metadata.oidc_issuer.as_deref(), + Some("http://issuer.test/realm") + ); + assert_eq!(metadata.oidc_client_id.as_deref(), Some("openshell-cli")); + assert_eq!(metadata.oidc_audience.as_deref(), Some("openshell-api")); + } + + #[test] + fn apply_oidc_gateway_metadata_preserves_existing_oidc_on_resume() { + let mut metadata = GatewayMetadata::default(); + let existing = GatewayMetadata { + auth_mode: Some("oidc".to_string()), + oidc_issuer: Some("http://issuer.test/realm".to_string()), + oidc_client_id: Some("openshell-cli".to_string()), + oidc_audience: Some("openshell-api".to_string()), + oidc_scopes: Some("sandbox:read".to_string()), + ..GatewayMetadata::default() + }; + + apply_oidc_gateway_metadata( + &mut metadata, + true, + Some(&existing), + None, + "ignored-client", + "ignored-audience", + ); + + assert_eq!(metadata.auth_mode.as_deref(), Some("oidc")); + assert_eq!( + metadata.oidc_issuer.as_deref(), + Some("http://issuer.test/realm") + ); + assert_eq!(metadata.oidc_client_id.as_deref(), Some("openshell-cli")); + assert_eq!(metadata.oidc_audience.as_deref(), Some("openshell-api")); + assert_eq!(metadata.oidc_scopes.as_deref(), Some("sandbox:read")); + } + + #[test] + fn apply_oidc_gateway_metadata_does_not_preserve_without_resume() { + let mut metadata = GatewayMetadata::default(); + let existing = GatewayMetadata { + auth_mode: Some("oidc".to_string()), + oidc_issuer: Some("http://issuer.test/realm".to_string()), + oidc_client_id: Some("openshell-cli".to_string()), + oidc_audience: Some("openshell-api".to_string()), + ..GatewayMetadata::default() + }; + + apply_oidc_gateway_metadata( + &mut metadata, + false, + Some(&existing), + None, + "ignored-client", + "ignored-audience", + ); + + assert!(metadata.auth_mode.is_none()); + assert!(metadata.oidc_issuer.is_none()); + assert!(metadata.oidc_client_id.is_none()); + assert!(metadata.oidc_audience.is_none()); + } } diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index d13884681..57c3e552e 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -106,15 +106,32 @@ impl tonic::service::Interceptor for SandboxSecretInterceptor { } type AuthenticatedClient = OpenShellClient>; +type AuthenticatedInferenceClient = + InferenceClient>; -/// Connect to the OpenShell server with sandbox secret authentication. -async fn connect(endpoint: &str) -> Result { - let channel = connect_channel(endpoint).await?; +fn sandbox_secret_interceptor() -> SandboxSecretInterceptor { let secret = std::env::var("OPENSHELL_SSH_HANDSHAKE_SECRET") .ok() .and_then(|s| s.parse().ok()); - let interceptor = SandboxSecretInterceptor { secret }; - Ok(OpenShellClient::with_interceptor(channel, interceptor)) + SandboxSecretInterceptor { secret } +} + +/// Connect to the OpenShell server with sandbox secret authentication. +async fn connect(endpoint: &str) -> Result { + let channel = connect_channel(endpoint).await?; + Ok(OpenShellClient::with_interceptor( + channel, + sandbox_secret_interceptor(), + )) +} + +/// Connect to the inference service with sandbox secret authentication. +async fn connect_inference(endpoint: &str) -> Result { + let channel = connect_channel(endpoint).await?; + Ok(InferenceClient::with_interceptor( + channel, + sandbox_secret_interceptor(), + )) } /// Fetch sandbox policy from OpenShell server via gRPC. @@ -356,8 +373,7 @@ impl CachedOpenShellClient { pub async fn fetch_inference_bundle(endpoint: &str) -> Result { debug!(endpoint = %endpoint, "Fetching inference route bundle"); - let channel = connect_channel(endpoint).await?; - let mut client = InferenceClient::new(channel); + let mut client = connect_inference(endpoint).await?; let response = client .get_inference_bundle(GetInferenceBundleRequest {}) @@ -366,3 +382,32 @@ pub async fn fetch_inference_bundle(endpoint: &str) -> Result Date: Tue, 21 Apr 2026 18:12:30 -0700 Subject: [PATCH 05/10] 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. --- Cargo.lock | 25 ++- crates/openshell-cli/Cargo.toml | 4 +- crates/openshell-cli/src/oidc_auth.rs | 255 ++++++++++---------------- 3 files changed, 120 insertions(+), 164 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f67e2e8e6..2e95c0ec4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3041,6 +3041,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "object" version = "0.37.3" @@ -3100,8 +3120,6 @@ dependencies = [ "crossterm 0.28.1", "dialoguer", "futures", - "getrandom 0.3.4", - "hex", "http-body-util", "hyper", "hyper-rustls", @@ -3109,6 +3127,7 @@ dependencies = [ "indicatif", "miette", "nix", + "oauth2", "openshell-bootstrap", "openshell-core", "openshell-policy", @@ -3123,7 +3142,6 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", - "sha2 0.10.9", "tar", "temp-env", "tempfile", @@ -6058,6 +6076,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index fea9dc331..1507cecef 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -59,10 +59,8 @@ anyhow = { workspace = true } tar = "0.4" # OIDC/Auth -sha2 = { workspace = true } +oauth2 = "5" base64 = { workspace = true } -hex = "0.4" -getrandom = { workspace = true } # WebSocket (Cloudflare tunnel proxy) tokio-tungstenite = { workspace = true } diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 17581d7f2..eadadfb3b 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -7,8 +7,6 @@ //! Client Credentials (CI/automation) OAuth2 grant types against a //! Keycloak-compatible OIDC provider. -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; use bytes::Bytes; use http_body_util::Full; use hyper::service::service_fn; @@ -16,9 +14,13 @@ use hyper::{Method, Response, StatusCode}; use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::server::conn::auto::Builder; use miette::{IntoDiagnostic, Result}; +use oauth2::basic::BasicClient; +use oauth2::{ + AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, + RefreshToken, Scope, TokenResponse, TokenUrl, +}; use openshell_bootstrap::oidc_token::OidcTokenBundle; use serde::Deserialize; -use sha2::{Digest, Sha256}; use std::convert::Infallible; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -36,16 +38,6 @@ struct OidcDiscovery { token_endpoint: String, } -/// Token endpoint response. -#[derive(Debug, Deserialize)] -struct TokenResponse { - access_token: String, - #[serde(default)] - refresh_token: Option, - #[serde(default)] - expires_in: Option, -} - /// Discover OIDC endpoints from the issuer's well-known configuration. /// /// Validates that the discovery document's `issuer` field matches the @@ -71,29 +63,32 @@ async fn discover(issuer: &str) -> Result { Ok(resp) } -/// Generate a random PKCE code verifier (43-128 unreserved chars). -fn generate_code_verifier() -> String { - let mut buf = [0u8; 32]; - csprng_fill(&mut buf); - URL_SAFE_NO_PAD.encode(buf) -} - -/// Compute the S256 code challenge from a code verifier. -fn compute_code_challenge(verifier: &str) -> String { - let digest = Sha256::digest(verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(digest) +fn http_client() -> reqwest::Client { + reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build HTTP client") } -/// Generate a random state parameter. -fn generate_state() -> String { - let mut buf = [0u8; 16]; - csprng_fill(&mut buf); - hex::encode(buf) +fn build_scopes(scopes: Option<&str>) -> Vec { + let mut result = vec![Scope::new("openid".to_string())]; + if let Some(s) = scopes { + for scope in s.split_whitespace() { + if scope != "openid" { + result.push(Scope::new(scope.to_string())); + } + } + } + result } -/// Fill a buffer with cryptographically secure random bytes from the OS. -fn csprng_fill(buf: &mut [u8]) { - getrandom::fill(buf).expect("OS RNG failed"); +fn build_ci_scopes(scopes: Option<&str>) -> Vec { + let Some(s) = scopes else { + return vec![]; + }; + s.split_whitespace() + .map(|scope| Scope::new(scope.to_string())) + .collect() } /// Run the OIDC Authorization Code + PKCE browser flow. @@ -108,47 +103,40 @@ pub async fn oidc_browser_auth_flow( ) -> Result { let discovery = discover(issuer).await?; - let code_verifier = generate_code_verifier(); - let code_challenge = compute_code_challenge(&code_verifier); - let state = generate_state(); - let listener = TcpListener::bind("127.0.0.1:0").await.into_diagnostic()?; let port = listener.local_addr().into_diagnostic()?.port(); let redirect_uri = format!("http://127.0.0.1:{port}/callback"); - let scope_value = match scopes { - Some(s) => { - let extra: Vec<&str> = s.split_whitespace().filter(|&sc| sc != "openid").collect(); - if extra.is_empty() { - "openid".to_string() - } else { - format!("openid {}", extra.join(" ")) - } - } - None => "openid".to_string(), - }; - let mut auth_url = format!( - "{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}&scope={}", - discovery.authorization_endpoint, - urlencoded(client_id), - urlencoded(&redirect_uri), - urlencoded(&code_challenge), - urlencoded(&state), - urlencoded(&scope_value), - ); - // Request a specific API audience when configured (needed for providers - // like Entra ID where the API audience differs from the client ID). + let client = BasicClient::new(ClientId::new(client_id.to_string())) + .set_auth_uri(AuthUrl::new(discovery.authorization_endpoint).into_diagnostic()?) + .set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?) + .set_redirect_uri(RedirectUrl::new(redirect_uri).into_diagnostic()?); + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let mut auth_request = client + .authorize_url(CsrfToken::new_random) + .set_pkce_challenge(pkce_challenge); + + for scope in build_scopes(scopes) { + auth_request = auth_request.add_scope(scope); + } + + let (mut auth_url, csrf_token) = auth_request.url(); + + // Append audience parameter for providers like Entra ID where the API + // audience differs from the client ID. if let Some(aud) = audience { - auth_url.push_str(&format!("&audience={}", urlencoded(aud))); + auth_url.query_pairs_mut().append_pair("audience", aud); } let (tx, rx) = oneshot::channel::(); - let expected_state = state.clone(); + let expected_state = csrf_token.secret().clone(); let server_handle = tokio::spawn(run_oidc_callback_server(listener, tx, expected_state)); eprintln!(" Opening browser for OIDC authentication..."); - if let Err(e) = crate::auth::open_browser_url(&auth_url) { + if let Err(e) = crate::auth::open_browser_url(auth_url.as_str()) { debug!(error = %e, "failed to open browser"); eprintln!("Could not open browser automatically."); eprintln!("Open this URL in your browser:"); @@ -173,17 +161,19 @@ pub async fn oidc_browser_auth_flow( server_handle.abort(); - // Exchange the authorization code for tokens. - let token_response = exchange_code( - &discovery.token_endpoint, - client_id, - &code, - &redirect_uri, - &code_verifier, - ) - .await?; + let http = http_client(); + let token_response = client + .exchange_code(AuthorizationCode::new(code)) + .set_pkce_verifier(pkce_verifier) + .request_async(&http) + .await + .map_err(|e| miette::miette!("token exchange failed: {e}"))?; - Ok(bundle_from_response(token_response, issuer, client_id)) + Ok(bundle_from_oauth2_response( + &token_response, + issuer, + client_id, + )) } /// Run the OIDC Client Credentials flow (for CI/automation). @@ -203,30 +193,29 @@ pub async fn oidc_client_credentials_flow( let discovery = discover(issuer).await?; - let mut params = vec![ - ("grant_type", "client_credentials"), - ("client_id", client_id), - ("client_secret", client_secret.as_str()), - ]; - if let Some(aud) = audience { - params.push(("audience", aud)); + let client = BasicClient::new(ClientId::new(client_id.to_string())) + .set_client_secret(ClientSecret::new(client_secret)) + .set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?); + + let mut request = client.exchange_client_credentials(); + for scope in build_ci_scopes(scopes) { + request = request.add_scope(scope); } - if let Some(s) = scopes { - params.push(("scope", s)); + if let Some(aud) = audience { + request = request.add_extra_param("audience", aud); } - let client = reqwest::Client::new(); - let resp: TokenResponse = client - .post(&discovery.token_endpoint) - .form(¶ms) - .send() - .await - .into_diagnostic()? - .json() + let http = http_client(); + let token_response = request + .request_async(&http) .await - .into_diagnostic()?; + .map_err(|e| miette::miette!("client credentials token exchange failed: {e}"))?; - Ok(bundle_from_response(resp, issuer, client_id)) + Ok(bundle_from_oauth2_response( + &token_response, + issuer, + client_id, + )) } /// Refresh an OIDC token using the refresh_token grant. @@ -242,25 +231,18 @@ pub async fn oidc_refresh_token(bundle: &OidcTokenBundle) -> Result Result { return Ok(bundle.access_token); } - // Token expired — try to refresh. debug!( gateway = gateway_name, "OIDC token expired, attempting refresh" @@ -295,67 +276,25 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str) -> Result { // ── Helpers ────────────────────────────────────────────────────────── -fn bundle_from_response(resp: TokenResponse, issuer: &str, client_id: &str) -> OidcTokenBundle { +fn bundle_from_oauth2_response( + resp: &oauth2::basic::BasicTokenResponse, + issuer: &str, + client_id: &str, +) -> OidcTokenBundle { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); OidcTokenBundle { - access_token: resp.access_token, - refresh_token: resp.refresh_token, - expires_at: resp.expires_in.map(|ei| now + ei), + access_token: resp.access_token().secret().to_string(), + refresh_token: resp.refresh_token().map(|rt| rt.secret().to_string()), + expires_at: resp.expires_in().map(|ei| now + ei.as_secs()), issuer: issuer.to_string(), client_id: client_id.to_string(), } } -async fn exchange_code( - token_endpoint: &str, - client_id: &str, - code: &str, - redirect_uri: &str, - code_verifier: &str, -) -> Result { - let params = [ - ("grant_type", "authorization_code"), - ("client_id", client_id), - ("code", code), - ("redirect_uri", redirect_uri), - ("code_verifier", code_verifier), - ]; - - let client = reqwest::Client::new(); - let resp: TokenResponse = client - .post(token_endpoint) - .form(¶ms) - .send() - .await - .into_diagnostic()? - .json() - .await - .into_diagnostic()?; - - Ok(resp) -} - -/// Minimal percent-encoding for URL query parameter values. -fn urlencoded(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char); - } - _ => { - out.push('%'); - out.push_str(&format!("{b:02X}")); - } - } - } - out -} - /// Percent-decode a URL query parameter value. fn percent_decode(s: &str) -> String { let mut out = Vec::with_capacity(s.len()); From b1628090055703f8a7de087eb95a4ad938a28253 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Tue, 21 Apr 2026 18:41:35 -0700 Subject: [PATCH 06/10] refactor(auth): move server auth modules into auth/ directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/openshell-server/src/{ => auth}/authz.rs | 4 ++-- .../src/{auth.rs => auth/http.rs} | 0 .../openshell-server/src/{ => auth}/identity.rs | 0 crates/openshell-server/src/auth/mod.rs | 16 ++++++++++++++++ crates/openshell-server/src/{ => auth}/oidc.rs | 2 +- crates/openshell-server/src/grpc/policy.rs | 2 +- crates/openshell-server/src/lib.rs | 11 ++++------- crates/openshell-server/src/multiplex.rs | 4 ++-- 8 files changed, 26 insertions(+), 13 deletions(-) rename crates/openshell-server/src/{ => auth}/authz.rs (99%) rename crates/openshell-server/src/{auth.rs => auth/http.rs} (100%) rename crates/openshell-server/src/{ => auth}/identity.rs (100%) create mode 100644 crates/openshell-server/src/auth/mod.rs rename crates/openshell-server/src/{ => auth}/oidc.rs (99%) diff --git a/crates/openshell-server/src/authz.rs b/crates/openshell-server/src/auth/authz.rs similarity index 99% rename from crates/openshell-server/src/authz.rs rename to crates/openshell-server/src/auth/authz.rs index 71cc5dfe4..67dccdba1 100644 --- a/crates/openshell-server/src/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -11,7 +11,7 @@ //! This separation follows RFC 0001's control-plane identity design: //! authentication is a driver concern, authorization is a gateway concern. -use crate::identity::Identity; +use super::identity::Identity; use tonic::Status; use tracing::debug; @@ -205,7 +205,7 @@ impl AuthzPolicy { #[cfg(test)] mod tests { use super::*; - use crate::identity::IdentityProvider; + use crate::auth::identity::IdentityProvider; fn default_policy() -> AuthzPolicy { AuthzPolicy { diff --git a/crates/openshell-server/src/auth.rs b/crates/openshell-server/src/auth/http.rs similarity index 100% rename from crates/openshell-server/src/auth.rs rename to crates/openshell-server/src/auth/http.rs diff --git a/crates/openshell-server/src/identity.rs b/crates/openshell-server/src/auth/identity.rs similarity index 100% rename from crates/openshell-server/src/identity.rs rename to crates/openshell-server/src/auth/identity.rs diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs new file mode 100644 index 000000000..8e4f332d8 --- /dev/null +++ b/crates/openshell-server/src/auth/mod.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Authentication and authorization for the gateway server. +//! +//! - `oidc`: JWT validation against OIDC providers (Keycloak, Entra ID, Okta) +//! - `authz`: Role-based and scope-based access control +//! - `identity`: Provider-agnostic identity representation +//! - `http`: HTTP endpoints for auth discovery and token exchange + +pub mod authz; +mod http; +pub mod identity; +pub mod oidc; + +pub use http::router; diff --git a/crates/openshell-server/src/oidc.rs b/crates/openshell-server/src/auth/oidc.rs similarity index 99% rename from crates/openshell-server/src/oidc.rs rename to crates/openshell-server/src/auth/oidc.rs index c634bd44c..8af6804b8 100644 --- a/crates/openshell-server/src/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -10,7 +10,7 @@ //! This module owns authentication (verifying who the caller is). //! Authorization (deciding what the caller can do) is in `authz.rs`. -use crate::identity::{Identity, IdentityProvider}; +use super::identity::{Identity, IdentityProvider}; use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; use openshell_core::OidcConfig; use reqwest::Client; diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 58fcbd8d5..58f80911c 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -11,7 +11,7 @@ #![allow(clippy::items_after_statements)] // DB_PORTS const inside function use crate::persistence::{DraftChunkRecord, PolicyRecord, Store}; -use crate::{ServerState, oidc}; +use crate::{ServerState, auth::oidc}; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; use openshell_core::proto::{ diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index dac5b4f02..32f1f56a9 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -20,15 +20,12 @@ //! [`compute::vm`]; keep this file driver-agnostic going forward. mod auth; -mod authz; pub mod cli; mod compute; mod grpc; mod http; -pub mod identity; mod inference; mod multiplex; -pub mod oidc; mod persistence; mod sandbox_index; mod sandbox_watch; @@ -95,7 +92,7 @@ pub struct ServerState { pub supervisor_sessions: Arc, /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. - pub oidc_cache: Option>, + pub oidc_cache: Option>, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -116,7 +113,7 @@ impl ServerState { sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, - oidc_cache: Option>, + oidc_cache: Option>, ) -> Self { Self { config, @@ -160,14 +157,14 @@ pub async fn run_server( let oidc_cache = if let Some(ref oidc) = config.oidc { // Validate RBAC configuration before starting. - let policy = authz::AuthzPolicy { + let policy = auth::authz::AuthzPolicy { admin_role: oidc.admin_role.clone(), user_role: oidc.user_role.clone(), scopes_enabled: !oidc.scopes_claim.is_empty(), }; policy.validate().map_err(|e| Error::config(e))?; - let cache = oidc::JwksCache::new(oidc) + let cache = auth::oidc::JwksCache::new(oidc) .await .map_err(|e| Error::config(format!("OIDC initialization failed: {e}")))?; info!("OIDC JWT validation enabled (issuer: {})", oidc.issuer); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index a4a8f070f..a6bfe18c1 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -30,8 +30,8 @@ use tower_http::trace::TraceLayer; use tracing::Span; use crate::{ - OpenShellService, ServerState, authz::AuthzPolicy, http_router, inference::InferenceService, - oidc, + OpenShellService, ServerState, auth::authz::AuthzPolicy, auth::oidc, http_router, + inference::InferenceService, }; /// Maximum inbound gRPC message size (1 MB). From 8b770bf6104f668b50537892e8a71e747bc97de8 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Wed, 22 Apr 2026 06:08:43 -0700 Subject: [PATCH 07/10] 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. --- crates/openshell-cli/src/oidc_auth.rs | 7 ++++--- crates/openshell-server/src/auth/mod.rs | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index eadadfb3b..ce87ce45e 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -16,8 +16,8 @@ use hyper_util::server::conn::auto::Builder; use miette::{IntoDiagnostic, Result}; use oauth2::basic::BasicClient; use oauth2::{ - AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, - RefreshToken, Scope, TokenResponse, TokenUrl, + AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, + RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl, }; use openshell_bootstrap::oidc_token::OidcTokenBundle; use serde::Deserialize; @@ -195,7 +195,8 @@ pub async fn oidc_client_credentials_flow( let client = BasicClient::new(ClientId::new(client_id.to_string())) .set_client_secret(ClientSecret::new(client_secret)) - .set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?); + .set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?) + .set_auth_type(AuthType::RequestBody); let mut request = client.exchange_client_credentials(); for scope in build_ci_scopes(scopes) { diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index 8e4f332d8..6d05128ec 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -14,3 +14,5 @@ pub mod identity; pub mod oidc; pub use http::router; +pub use identity::{Identity, IdentityProvider}; +pub use oidc::JwksCache; From fad27a69c1102ed7ef8e8a938aef65ce85ffddca Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Wed, 22 Apr 2026 15:49:32 -0700 Subject: [PATCH 08/10] 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 --- architecture/oidc-local-testing.md | 27 +++++++++++++++++---------- tasks/scripts/cluster-bootstrap.sh | 1 + 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/architecture/oidc-local-testing.md b/architecture/oidc-local-testing.md index 0f7e5fcc1..df303fcc4 100644 --- a/architecture/oidc-local-testing.md +++ b/architecture/oidc-local-testing.md @@ -193,9 +193,15 @@ issuer to the Helm chart so the gateway starts with JWT validation enabled. ```bash HOST_IP=$(hostname -I | awk '{print $1}') -OPENSHELL_OIDC_ISSUER="http://${HOST_IP}:8180/realms/openshell" mise run cluster +OPENSHELL_OIDC_ISSUER="http://${HOST_IP}:8180/realms/openshell" \ +OPENSHELL_OIDC_SCOPES="openshell:all" \ +mise run cluster ``` +Add `OPENSHELL_OIDC_SCOPES_CLAIM="scope"` to also enable scope enforcement. +The `OPENSHELL_OIDC_SCOPES` value is stored in gateway metadata so `gateway login` +requests these scopes automatically. + Wait for "Deploy complete!" and verify OIDC is active: ```bash @@ -428,18 +434,14 @@ openshell gateway add http://127.0.0.1:8080 \ --oidc-scopes "sandbox:read sandbox:write" ``` -Or for K3s testing: +Or for K3s testing, pass `OPENSHELL_OIDC_SCOPES` during bootstrap: ```bash HOST_IP=$(hostname -I | awk '{print $1}') OPENSHELL_OIDC_ISSUER="http://${HOST_IP}:8180/realms/openshell" \ OPENSHELL_OIDC_SCOPES_CLAIM="scope" \ +OPENSHELL_OIDC_SCOPES="sandbox:read sandbox:write" \ mise run cluster - -# Update gateway metadata with scopes -jq '.oidc_scopes = "sandbox:read sandbox:write"' \ - ~/.config/openshell/gateways/openshell/metadata.json > /tmp/meta.json \ - && mv /tmp/meta.json ~/.config/openshell/gateways/openshell/metadata.json ``` Then login and test: @@ -454,10 +456,15 @@ openshell provider list # should fail (no provider:read scope) ### 5f. Test openshell:all via CLI +For K3s, restart the cluster with `openshell:all`: + ```bash -jq '.oidc_scopes = "openshell:all"' \ - ~/.config/openshell/gateways/openshell/metadata.json > /tmp/meta.json \ - && mv /tmp/meta.json ~/.config/openshell/gateways/openshell/metadata.json +mise run cluster:stop +HOST_IP=$(hostname -I | awk '{print $1}') +OPENSHELL_OIDC_ISSUER="http://${HOST_IP}:8180/realms/openshell" \ +OPENSHELL_OIDC_SCOPES_CLAIM="scope" \ +OPENSHELL_OIDC_SCOPES="openshell:all" \ +mise run cluster openshell gateway login openshell sandbox list # should work diff --git a/tasks/scripts/cluster-bootstrap.sh b/tasks/scripts/cluster-bootstrap.sh index e12dace9c..7f4fcf175 100755 --- a/tasks/scripts/cluster-bootstrap.sh +++ b/tasks/scripts/cluster-bootstrap.sh @@ -280,6 +280,7 @@ if [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then [ -n "${OPENSHELL_OIDC_ADMIN_ROLE:-}" ] && DEPLOY_CMD+=(--oidc-admin-role "${OPENSHELL_OIDC_ADMIN_ROLE}") [ -n "${OPENSHELL_OIDC_USER_ROLE:-}" ] && DEPLOY_CMD+=(--oidc-user-role "${OPENSHELL_OIDC_USER_ROLE}") [ -n "${OPENSHELL_OIDC_SCOPES_CLAIM:-}" ] && DEPLOY_CMD+=(--oidc-scopes-claim "${OPENSHELL_OIDC_SCOPES_CLAIM}") + [ -n "${OPENSHELL_OIDC_SCOPES:-}" ] && DEPLOY_CMD+=(--oidc-scopes "${OPENSHELL_OIDC_SCOPES}") fi "${DEPLOY_CMD[@]}" From d7591c45d135e426ae787b6e831896a54a42ecae Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Wed, 22 Apr 2026 16:45:01 -0700 Subject: [PATCH 09/10] 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 --- e2e/python/oidc/conftest.py | 21 +++ e2e/python/oidc/oidc_auth_test.py | 279 ++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 e2e/python/oidc/conftest.py create mode 100644 e2e/python/oidc/oidc_auth_test.py diff --git a/e2e/python/oidc/conftest.py b/e2e/python/oidc/conftest.py new file mode 100644 index 000000000..889035799 --- /dev/null +++ b/e2e/python/oidc/conftest.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""OIDC e2e test fixtures. + +Overrides the parent conftest's session fixtures that assume unauthenticated +gRPC access, since the OIDC-enabled gateway requires Bearer tokens. +""" + +import pytest + + +@pytest.fixture(scope="session") +def sandbox_client(): + """Stub — OIDC tests manage their own authenticated gRPC connections.""" + pytest.skip("OIDC tests do not use the shared sandbox_client fixture") + + +@pytest.fixture(scope="session", autouse=True) +def ensure_sandbox_persistence_ready(): + """No-op — OIDC tests skip the unauthenticated persistence check.""" diff --git a/e2e/python/oidc/oidc_auth_test.py b/e2e/python/oidc/oidc_auth_test.py new file mode 100644 index 000000000..797507212 --- /dev/null +++ b/e2e/python/oidc/oidc_auth_test.py @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""End-to-end tests for OIDC authentication, RBAC, and scope enforcement. + +These tests require: +- A running K3s cluster with OIDC enabled (OPENSHELL_OIDC_ISSUER set) +- A running Keycloak instance with the openshell realm +- The cluster started with OPENSHELL_OIDC_SCOPES_CLAIM=scope + +Skip condition: set OPENSHELL_E2E_OIDC=1 to enable these tests. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import urllib.parse +import urllib.request +from pathlib import Path + +import grpc +import pytest + +from openshell._proto import datamodel_pb2, openshell_pb2, openshell_pb2_grpc + +KEYCLOAK_REALM = "openshell" + + +def _xdg_config_home() -> Path: + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + + +def _keycloak_url() -> str: + """Derive the Keycloak URL from the gateway's stored OIDC issuer. + + The server validates the issuer claim in JWTs, so the token must be + requested from the same base URL the server was configured with + (typically the host IP, not localhost). + """ + if url := os.environ.get("OPENSHELL_KEYCLOAK_URL"): + return url + cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") + metadata_path = ( + _xdg_config_home() / "openshell" / "gateways" / cluster_name / "metadata.json" + ) + if metadata_path.exists(): + metadata = json.loads(metadata_path.read_text()) + issuer = metadata.get("oidc_issuer", "") + if issuer: + # issuer is like "http://192.168.4.172:8180/realms/openshell" + # extract base URL before /realms/ + idx = issuer.find("/realms/") + if idx > 0: + return issuer[:idx] + return "http://localhost:8180" + + +TOKEN_ENDPOINT = ( + f"{_keycloak_url()}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token" +) + +pytestmark = pytest.mark.skipif( + os.environ.get("OPENSHELL_E2E_OIDC") != "1", + reason="OIDC e2e tests disabled (set OPENSHELL_E2E_OIDC=1)", +) + + +def _gateway_endpoint() -> tuple[str, bool]: + """Read the active gateway endpoint from metadata.""" + cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") + metadata_path = ( + _xdg_config_home() / "openshell" / "gateways" / cluster_name / "metadata.json" + ) + metadata = json.loads(metadata_path.read_text()) + endpoint = metadata["gateway_endpoint"] + is_tls = endpoint.startswith("https://") + return endpoint, is_tls + + +def _mtls_dir() -> Path: + cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell") + return _xdg_config_home() / "openshell" / "gateways" / cluster_name / "mtls" + + +def _token_request(data: dict[str, str]) -> str: + """POST to the Keycloak token endpoint and return the access token.""" + encoded = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(TOKEN_ENDPOINT, data=encoded) + with urllib.request.urlopen(req, timeout=10) as resp: + body = json.loads(resp.read()) + return body["access_token"] + + +def _get_token( + username: str, + password: str, + *, + client_id: str = "openshell-cli", + scopes: str | None = None, +) -> str: + """Get an access token from Keycloak via password grant.""" + data = { + "grant_type": "password", + "client_id": client_id, + "username": username, + "password": password, + } + if scopes: + data["scope"] = scopes + return _token_request(data) + + +def _get_ci_token( + *, + client_id: str = "openshell-ci", + client_secret: str = "ci-test-secret", +) -> str: + """Get an access token via client credentials grant.""" + return _token_request( + { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + ) + + +def _grpc_channel() -> grpc.Channel: + """Create a gRPC channel to the gateway with mTLS transport.""" + endpoint, is_tls = _gateway_endpoint() + parsed = urllib.parse.urlparse(endpoint) + host = parsed.hostname or "127.0.0.1" + port = parsed.port or (443 if is_tls else 80) + target = f"{host}:{port}" + + if is_tls: + mtls = _mtls_dir() + ca_cert = (mtls / "ca.crt").read_bytes() + client_cert = (mtls / "tls.crt").read_bytes() + client_key = (mtls / "tls.key").read_bytes() + creds = grpc.ssl_channel_credentials( + root_certificates=ca_cert, + private_key=client_key, + certificate_chain=client_cert, + ) + return grpc.secure_channel(target, creds) + return grpc.insecure_channel(target) + + +def _stub_with_token(token: str) -> openshell_pb2_grpc.OpenShellStub: + """Create a gRPC stub that injects a Bearer token.""" + channel = _grpc_channel() + return openshell_pb2_grpc.OpenShellStub(channel), [ + ("authorization", f"Bearer {token}") + ] + + +# ── RBAC Tests ──────────────────────────────────────────────────────── + + +class TestRbac: + """Test role-based access control.""" + + def test_admin_can_create_provider(self) -> None: + token = _get_token("admin@test", "admin", scopes="openid openshell:all") + stub, metadata = _stub_with_token(token) + req = openshell_pb2.CreateProviderRequest( + provider=datamodel_pb2.Provider( + name="e2e-oidc-admin-test", + type="claude", + credentials={"API_KEY": "test-value"}, + ) + ) + try: + stub.CreateProvider(req, metadata=metadata) + except grpc.RpcError as e: + if e.code() == grpc.StatusCode.ALREADY_EXISTS: + pass # fine, provider exists from a previous run + else: + raise + finally: + with contextlib.suppress(grpc.RpcError): + stub.DeleteProvider( + openshell_pb2.DeleteProviderRequest(name="e2e-oidc-admin-test"), + metadata=metadata, + ) + + def test_user_cannot_create_provider(self) -> None: + token = _get_token("user@test", "user", scopes="openid openshell:all") + stub, metadata = _stub_with_token(token) + req = openshell_pb2.CreateProviderRequest( + provider=datamodel_pb2.Provider( + name="e2e-oidc-user-blocked", + type="claude", + credentials={"API_KEY": "test-value"}, + ) + ) + with pytest.raises(grpc.RpcError) as exc_info: + stub.CreateProvider(req, metadata=metadata) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + assert "openshell-admin" in exc_info.value.details() + + def test_user_can_list_sandboxes(self) -> None: + token = _get_token("user@test", "user", scopes="openid openshell:all") + stub, metadata = _stub_with_token(token) + stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) + + def test_unauthenticated_request_rejected(self) -> None: + channel = _grpc_channel() + stub = openshell_pb2_grpc.OpenShellStub(channel) + with pytest.raises(grpc.RpcError) as exc_info: + stub.ListSandboxes(openshell_pb2.ListSandboxesRequest()) + assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED + + def test_health_does_not_require_auth(self) -> None: + channel = _grpc_channel() + stub = openshell_pb2_grpc.OpenShellStub(channel) + resp = stub.Health(openshell_pb2.HealthRequest()) + assert resp.status == openshell_pb2.SERVICE_STATUS_HEALTHY + + +# ── Scope Enforcement Tests ────────────────────────────────────────── + + +class TestScopes: + """Test scope-based fine-grained permissions. + + These tests require the server to be started with + OPENSHELL_OIDC_SCOPES_CLAIM=scope. + """ + + pytestmark = pytest.mark.skipif( + os.environ.get("OPENSHELL_E2E_OIDC_SCOPES") != "1", + reason="Scope e2e tests disabled (set OPENSHELL_E2E_OIDC_SCOPES=1)", + ) + + def test_sandbox_scoped_token_can_list_sandboxes(self) -> None: + token = _get_token( + "admin@test", "admin", scopes="openid sandbox:read sandbox:write" + ) + stub, metadata = _stub_with_token(token) + stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) + + def test_sandbox_scoped_token_cannot_list_providers(self) -> None: + token = _get_token( + "admin@test", "admin", scopes="openid sandbox:read sandbox:write" + ) + stub, metadata = _stub_with_token(token) + with pytest.raises(grpc.RpcError) as exc_info: + stub.ListProviders(openshell_pb2.ListProvidersRequest(), metadata=metadata) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + assert "provider:read" in exc_info.value.details() + + def test_openshell_all_grants_full_access(self) -> None: + token = _get_token("admin@test", "admin", scopes="openid openshell:all") + stub, metadata = _stub_with_token(token) + stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) + stub.ListProviders(openshell_pb2.ListProvidersRequest(), metadata=metadata) + + def test_no_openshell_scopes_denied(self) -> None: + token = _get_token("admin@test", "admin") + stub, metadata = _stub_with_token(token) + with pytest.raises(grpc.RpcError) as exc_info: + stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) + assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED + + +# ── Client Credentials Tests ───────────────────────────────────────── + + +class TestClientCredentials: + """Test CI/automation client credentials flow.""" + + def test_ci_token_can_list_sandboxes(self) -> None: + token = _get_ci_token() + stub, metadata = _stub_with_token(token) + stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata) From 13744ab7161c356e47ca5f2567bc2ccf4f4b50e6 Mon Sep 17 00:00:00 2001 From: Mrunal Patel Date: Fri, 24 Apr 2026 15:31:49 -0700 Subject: [PATCH 10/10] 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. --- architecture/oidc-auth.md | 2 ++ architecture/oidc-local-testing.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/architecture/oidc-auth.md b/architecture/oidc-auth.md index c759c933e..cfcf928a7 100644 --- a/architecture/oidc-auth.md +++ b/architecture/oidc-auth.md @@ -151,6 +151,7 @@ GET {jwks_uri} -> { keys: [...] } ``` Keys are cached in memory with a configurable TTL (default: 1 hour). A `refresh_mutex` serializes refresh operations so concurrent requests coalesce into a single HTTP fetch. The cache refreshes: + - When the TTL expires (on next request, re-checked under the mutex to avoid thundering herd). - Immediately when a JWT references a `kid` not in the cache (handles key rotation). @@ -192,6 +193,7 @@ These methods accept either an OIDC Bearer token (CLI users) or a sandbox secret | `OpenShell/GetSandboxConfig` | CLI reads effective sandbox policy and settings; sandbox callers may still use the shared secret | **Sandbox-secret restriction on `UpdateConfig`:** When a sandbox-secret-authenticated caller invokes `UpdateConfig`, the handler in `policy.rs` enforces strict scope limits via `validate_sandbox_secret_update()`. The caller: + - **Must** provide a sandbox `name` (sandbox-scoped only). - **Must** include a `policy` payload (policy sync only). - **May not** set `global = true` (no global config mutation). diff --git a/architecture/oidc-local-testing.md b/architecture/oidc-local-testing.md index df303fcc4..160636a9e 100644 --- a/architecture/oidc-local-testing.md +++ b/architecture/oidc-local-testing.md @@ -19,6 +19,7 @@ mise run keycloak Wait for "Keycloak is ready." The script prints connection info including test users. Verify: + ```bash curl -s http://localhost:8180/realms/openshell/.well-known/openid-configuration | jq .issuer # Expected: "http://localhost:8180/realms/openshell" @@ -37,6 +38,7 @@ cargo run -p openshell-server -- \ ``` You should see: + ``` OIDC JWT validation enabled (issuer: http://localhost:8180/realms/openshell) Server listening address=0.0.0.0:8080