diff --git a/Cargo.lock b/Cargo.lock index 967cce7f9..2e95c0ec4 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" @@ -3026,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" @@ -3078,6 +3113,7 @@ name = "openshell-cli" version = "0.0.0" dependencies = [ "anyhow", + "base64 0.22.1", "bytes", "clap", "clap_complete", @@ -3091,6 +3127,7 @@ dependencies = [ "indicatif", "miette", "nix", + "oauth2", "openshell-bootstrap", "openshell-core", "openshell-policy", @@ -3330,6 +3367,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "ipnet", + "jsonwebtoken", "metrics", "metrics-exporter-prometheus", "miette", @@ -4918,6 +4956,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" @@ -6026,6 +6076,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] 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..cfcf928a7 --- /dev/null +++ b/architecture/oidc-auth.md @@ -0,0 +1,549 @@ +# 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 | +|---|---| +| `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 + +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 | +| `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). +- **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-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 | +| 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). + +**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. + +### 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, GetSandboxConfig, 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 + +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 | +| `--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. + +### 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 | +| `--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). + +### Helm Values + +```yaml +server: + oidc: + issuer: "https://keycloak.example.com/realms/openshell" + audience: "openshell-cli" + jwksTtl: 3600 + scopesClaim: "scope" # enable scope enforcement (Keycloak) +``` + +### 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 --> Check scopes (if enabled) --> 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..160636a9e --- /dev/null +++ b/architecture/oidc-local-testing.md @@ -0,0 +1,575 @@ +# 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 an empty bundle when no routes are configured) +grpcurl -plaintext -import-path proto -proto inference.proto \ + -H "x-sandbox-secret: test" \ + 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 inference.proto \ + -H "x-sandbox-secret: wrong" \ + 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 inference.proto \ + 127.0.0.1:8080 openshell.inference.v1.Inference/GetInferenceBundle +# 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) + +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) +``` + +### 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" \ +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 +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) + +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 + +```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. 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, 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 +``` + +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 + +For K3s, restart the cluster with `openshell:all`: + +```bash +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 +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 +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`. + +**"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 65482739f..2091889e3 100644 --- a/crates/openshell-bootstrap/src/docker.rs +++ b/crates/openshell-bootstrap/src/docker.rs @@ -501,6 +501,12 @@ 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>, + oidc_scopes_claim: Option<&str>, ) -> Result { let container_name = container_name(name); @@ -782,6 +788,25 @@ 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}")); + } + if let Some(claim) = oidc_scopes_claim { + env_vars.push(format!("OIDC_SCOPES_CLAIM={claim}")); + } + } + 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..33c9b8637 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -5,6 +5,7 @@ pub mod build; pub mod edge_token; pub mod errors; pub mod image; +pub mod oidc_token; pub mod constants; mod docker; @@ -123,6 +124,20 @@ 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, + /// OIDC scopes claim path. When set, the server enforces scope-based permissions. + pub oidc_scopes_claim: Option, } impl DeployOptions { @@ -139,6 +154,13 @@ 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, + oidc_scopes_claim: None, } } @@ -208,6 +230,48 @@ 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 + } +} + +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)] @@ -272,6 +336,13 @@ 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; + 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. @@ -458,6 +529,12 @@ 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(), + oidc_scopes_claim.as_deref(), ) .await?; let port = actual_port; @@ -558,14 +635,29 @@ where wait_for_gateway_ready(&target_docker, &name, &mut gateway_log).await?; } - // Create and store gateway metadata. - let metadata = create_gateway_metadata_with_host( + // 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(), port, ssh_gateway_host.as_deref(), disable_tls, ); + 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) @@ -1217,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-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index 8e6b8a070..e53260974 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,25 @@ 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, + + /// 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 { @@ -134,8 +153,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 +479,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 +568,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 +583,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..1507cecef 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -58,6 +58,10 @@ anyhow = { workspace = true } # File archiving (tar-over-SSH sync) tar = "0.4" +# OIDC/Auth +oauth2 = "5" +base64 = { 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..cb4a32429 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -122,17 +122,52 @@ 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 +856,40 @@ 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, + + /// 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). @@ -897,9 +966,29 @@ 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, + + /// 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 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 +999,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 +1825,14 @@ 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, + oidc_scopes, + oidc_scopes_claim, } => { let gpu = if gpu { vec!["auto".to_string()] @@ -1743,6 +1851,14 @@ 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(), + oidc_scopes.as_deref(), + oidc_scopes_claim.as_deref(), ) .await?; } @@ -1772,6 +1888,10 @@ async fn main() -> Result<()> { remote, ssh_key, local, + oidc_issuer, + oidc_client_id, + oidc_audience, + oidc_scopes, } => { run::gateway_add( &endpoint, @@ -1779,6 +1899,10 @@ async fn main() -> Result<()> { remote.as_deref(), ssh_key.as_deref(), local, + oidc_issuer.as_deref(), + &oidc_client_id, + oidc_audience.as_deref(), + oidc_scopes.as_deref(), ) .await?; } @@ -1794,6 +1918,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 +1991,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 +2090,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 +2118,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 +2163,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 +2271,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 +2325,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 +2378,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 +2503,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 +2561,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 +2596,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 +2611,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 +2691,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 +2746,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 +2778,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 +2797,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 +2933,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 +3353,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 +3364,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..ce87ce45e --- /dev/null +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -0,0 +1,438 @@ +// 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 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 oauth2::basic::BasicClient; +use oauth2::{ + AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, + RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl, +}; +use openshell_bootstrap::oidc_token::OidcTokenBundle; +use serde::Deserialize; +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, +} + +/// 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) +} + +fn http_client() -> reqwest::Client { + reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("failed to build HTTP client") +} + +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 +} + +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. +/// +/// 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>, + scopes: Option<&str>, +) -> Result { + let discovery = discover(issuer).await?; + + 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 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.query_pairs_mut().append_pair("audience", aud); + } + + let (tx, rx) = oneshot::channel::(); + 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.as_str()) { + 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(); + + 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_oauth2_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>, + scopes: 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 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_auth_type(AuthType::RequestBody); + + let mut request = client.exchange_client_credentials(); + for scope in build_ci_scopes(scopes) { + request = request.add_scope(scope); + } + if let Some(aud) = audience { + request = request.add_extra_param("audience", aud); + } + + let http = http_client(); + let token_response = request + .request_async(&http) + .await + .map_err(|e| miette::miette!("client credentials token exchange failed: {e}"))?; + + Ok(bundle_from_oauth2_response( + &token_response, + 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 client = BasicClient::new(ClientId::new(bundle.client_id.clone())) + .set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?); + + let http = http_client(); + let token_response = client + .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string())) + .request_async(&http) + .await + .map_err(|e| miette::miette!("token refresh failed: {e}"))?; + + let mut refreshed = + bundle_from_oauth2_response(&token_response, &bundle.issuer, &bundle.client_id); + 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); + } + + 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_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().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(), + } +} + +/// 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..8ef2238bc 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,10 @@ 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>, + 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 @@ -1044,6 +1047,92 @@ 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), + oidc_scopes: oidc_scopes.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, + oidc_scopes, + ) + .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, + oidc_scopes, + ) + .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 +1188,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 +1212,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 +1246,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 +1257,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 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, scopes) + .await? + } else { + crate::oidc_auth::oidc_browser_auth_flow(issuer, client_id, audience, scopes) + .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) +} - eprintln!("{} Authenticated to gateway '{name}'", "✓".green().bold(),); +/// 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" + ) + })?; + + 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 +1592,14 @@ 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>, + oidc_scopes: Option<&str>, + oidc_scopes_claim: Option<&str>, ) -> Result<()> { let location = if remote.is_some() { "remote" } else { "local" }; @@ -1501,9 +1666,35 @@ 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()); + } + 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. @@ -5512,12 +5703,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 +6086,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 +6116,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,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) - .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"); @@ -6010,6 +6197,10 @@ mod tests { None, None, true, + None, + "openshell-cli", + None, + 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..924d95121 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,64 @@ 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, + + /// 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 { + 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 +291,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 +444,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..57c3e552e 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,54 @@ 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>; +type AuthenticatedInferenceClient = + InferenceClient>; + +fn sandbox_secret_interceptor() -> SandboxSecretInterceptor { + let secret = std::env::var("OPENSHELL_SSH_HANDSHAKE_SECRET") + .ok() + .and_then(|s| s.parse().ok()); + 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::new(channel)) + 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. @@ -106,7 +151,7 @@ pub async fn fetch_policy(endpoint: &str, sandbox_id: &str) -> Result, + client: &mut AuthenticatedClient, sandbox_id: &str, ) -> Result> { let response = client @@ -130,7 +175,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 +265,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 +284,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() } @@ -329,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 {}) @@ -339,3 +382,32 @@ pub async fn fetch_inference_bundle(endpoint: &str) -> Result 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 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(()); + } + + let required_scope = SCOPED_METHODS + .iter() + .find(|(m, _)| *m == method) + .map(|(_, s)| *s) + .unwrap_or(SCOPE_ALL); + + 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" + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::identity::IdentityProvider; + + fn default_policy() -> AuthzPolicy { + 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, + } + } + + 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(), + 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, + } + } + + #[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(), + scopes_enabled: false, + }; + 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(), + scopes_enabled: false, + }; + 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(), + scopes_enabled: false, + }; + 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(), + scopes_enabled: false, + }; + 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(), + 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 get_sandbox_config_requires_config_read_scope() { + let policy = scoped_policy(); + let id = identity_with_roles_and_scopes(&["openshell-user"], &["config:read"]); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/GetSandboxConfig") + .is_ok() + ); + + let wrong_scope = identity_with_roles_and_scopes(&["openshell-user"], &["sandbox:read"]); + let err = policy + .check(&wrong_scope, "/openshell.v1.OpenShell/GetSandboxConfig") + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(err.message().contains("config:read")); + } + + #[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/auth.rs b/crates/openshell-server/src/auth/http.rs similarity index 96% rename from crates/openshell-server/src/auth.rs rename to crates/openshell-server/src/auth/http.rs index b896d062c..3f45f1814 100644 --- a/crates/openshell-server/src/auth.rs +++ b/crates/openshell-server/src/auth/http.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/auth/identity.rs b/crates/openshell-server/src/auth/identity.rs new file mode 100644 index 000000000..fc06c4776 --- /dev/null +++ b/crates/openshell-server/src/auth/identity.rs @@ -0,0 +1,44 @@ +// 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, + + /// OAuth2 scopes granted to this identity. Empty when scope enforcement is disabled. + pub scopes: 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/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs new file mode 100644 index 000000000..6d05128ec --- /dev/null +++ b/crates/openshell-server/src/auth/mod.rs @@ -0,0 +1,18 @@ +// 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; +pub use identity::{Identity, IdentityProvider}; +pub use oidc::JwksCache; diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs new file mode 100644 index 000000000..8af6804b8 --- /dev/null +++ b/crates/openshell-server/src/auth/oidc.rs @@ -0,0 +1,603 @@ +// 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 super::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/ReportPolicyStatus", + "/openshell.v1.OpenShell/PushSandboxLogs", + "/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). `OpenShell/GetSandboxConfig` serves CLI settings reads while +/// remaining compatible with sandbox-secret-authenticated callers. +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 { + 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, +} + +const STANDARD_OIDC_SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"]; + +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(); + } + } + + /// 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 { + /// 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); + + 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, + }) + } +} + +#[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.sandbox.v1.SandboxService/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.inference.v1.Inference/GetInferenceBundle" + )); + } + + #[test] + fn openshell_get_sandbox_config_is_dual_auth() { + assert!(!is_sandbox_secret_method( + "/openshell.v1.OpenShell/GetSandboxConfig" + )); + assert!(is_dual_auth_method( + "/openshell.v1.OpenShell/GetSandboxConfig" + )); + } + + #[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()); + } + + #[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/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 2e6e2823b..2507a1f2b 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -188,6 +188,51 @@ 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, + + /// 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 { @@ -304,6 +349,18 @@ 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, + scopes_claim: args.oidc_scopes_claim, + }); + } + 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..58f80911c 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; use crate::persistence::{DraftChunkRecord, PolicyRecord, Store}; +use crate::{ServerState, auth::oidc}; use openshell_core::proto::policy_merge_operation; use openshell_core::proto::setting_value; use openshell_core::proto::{ @@ -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/lib.rs b/crates/openshell-server/src/lib.rs index 4a5ca55bd..32f1f56a9 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -90,6 +90,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 +113,7 @@ impl ServerState { sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, + oidc_cache: Option>, ) -> Self { Self { config, @@ -122,6 +126,7 @@ impl ServerState { ssh_connections_by_sandbox: Mutex::new(HashMap::new()), settings_mutex: tokio::sync::Mutex::new(()), supervisor_sessions, + oidc_cache, } } } @@ -150,6 +155,24 @@ 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 = 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 = auth::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 +194,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..a6bfe18c1 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, http_router, inference::InferenceService}; +use crate::{ + OpenShellService, ServerState, auth::authz::AuthzPolicy, auth::oidc, http_router, + inference::InferenceService, +}; /// Maximum inbound gRPC message size (1 MB). /// @@ -61,7 +64,17 @@ 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(), + scopes_enabled: !oidc.scopes_claim.is_empty(), + }); + 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 +167,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-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..9287adc48 100644 --- a/deploy/docker/cluster-entrypoint.sh +++ b/deploy/docker/cluster-entrypoint.sh @@ -506,6 +506,25 @@ 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" + 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. # 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..86f6dc3ed 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -104,6 +104,30 @@ 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 }} + {{- if .Values.server.oidc.scopesClaim }} + - name: OPENSHELL_OIDC_SCOPES_CLAIM + value: {{ .Values.server.oidc.scopesClaim | 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..18e671375 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -110,6 +110,26 @@ 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: "" + # 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 a09e0f300..81743746a 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -38,6 +38,14 @@ 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__" + scopesClaim: "__OIDC_SCOPES_CLAIM__" tls: certSecretName: openshell-server-tls clientCaSecretName: openshell-server-client-ca 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) 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..7c5234c25 --- /dev/null +++ b/scripts/keycloak-realm.json @@ -0,0 +1,350 @@ +{ + "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"], + "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", + "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, + "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", + "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, + "defaultClientScopes": ["openid", "profile", "email", "roles", "web-origins", "acr", "openshell:all"] + } + ], + "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..7f4fcf175 100755 --- a/tasks/scripts/cluster-bootstrap.sh +++ b/tasks/scripts/cluster-bootstrap.sh @@ -273,6 +273,16 @@ 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}") + [ -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[@]}" # 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..e7f4d224d 100755 --- a/tasks/scripts/cluster-deploy-fast.sh +++ b/tasks/scripts/cluster-deploy-fast.sh @@ -428,6 +428,24 @@ 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 + 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} \ --namespace openshell \ --set image.repository=${IMAGE_REPO_BASE}/gateway \ @@ -438,6 +456,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."