diff --git a/Cargo.lock b/Cargo.lock
index 0e59eb64f..f0f7e6f8c 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",
@@ -3350,6 +3387,7 @@ dependencies = [
"hyper-rustls",
"hyper-util",
"ipnet",
+ "jsonwebtoken",
"metrics",
"metrics-exporter-prometheus",
"miette",
@@ -4949,6 +4987,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"
@@ -6057,6 +6107,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..746a6a519
--- /dev/null
+++ b/architecture/oidc-auth.md
@@ -0,0 +1,542 @@
+# 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
+
+```mermaid
+graph LR
+ CLI -->|"Bearer token
(gRPC metadata)"| Gateway["Gateway
Server"]
+ Gateway -->|response| CLI
+ Gateway -->|"JWKS (cached)"| Keycloak["Keycloak /
OIDC Provider"]
+ Browser -->|"Auth Code + PKCE"| Gateway
+ Keycloak -->|"Token exchange"| CLI
+ Keycloak -->|"Token exchange"| Browser
+```
+
+## 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 0f7129470..c18c938aa 100644
--- a/crates/openshell-bootstrap/src/docker.rs
+++ b/crates/openshell-bootstrap/src/docker.rs
@@ -506,6 +506,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);
@@ -784,6 +790,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 5f38a1251..5d2a17afd 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.clone_from(&existing.auth_mode);
+ metadata.oidc_issuer.clone_from(&existing.oidc_issuer);
+ metadata.oidc_client_id.clone_from(&existing.oidc_client_id);
+ metadata.oidc_audience.clone_from(&existing.oidc_audience);
+ metadata.oidc_scopes.clone_from(&existing.oidc_scopes);
+ }
}
#[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.
@@ -457,6 +528,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;
@@ -557,14 +634,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)
@@ -1216,4 +1308,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 41e75e811..beadcbeac 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()
}
}
@@ -460,9 +478,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();
@@ -551,13 +567,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);
}
@@ -571,9 +582,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..19c6cabaa
--- /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 a5ae991df..8eb421535 100644
--- a/crates/openshell-cli/src/auth.rs
+++ b/crates/openshell-cli/src/auth.rs
@@ -135,7 +135,7 @@ pub async fn browser_auth_flow(gateway_endpoint: &str) -> Result {
std::io::stdin().read_line(&mut input).ok();
drop(input);
- 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:");
@@ -167,7 +167,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/bootstrap.rs b/crates/openshell-cli/src/bootstrap.rs
index d6245a760..fcb7744ab 100644
--- a/crates/openshell-cli/src/bootstrap.rs
+++ b/crates/openshell-cli/src/bootstrap.rs
@@ -191,13 +191,22 @@ pub async fn run_bootstrap(
// Deploy the gateway. The deploy flow auto-resumes from existing state
// when it finds one. If that fails, fall back to a full recreate.
- let handle = match deploy_gateway_with_panel(build_options(false), &gateway_name, location)
- .await
+ let handle = match Box::pin(deploy_gateway_with_panel(
+ build_options(false),
+ &gateway_name,
+ location,
+ ))
+ .await
{
Ok(handle) => handle,
Err(resume_err) => {
tracing::warn!("auto-bootstrap resume failed, falling back to recreate: {resume_err}");
- deploy_gateway_with_panel(build_options(true), &gateway_name, location).await?
+ Box::pin(deploy_gateway_with_panel(
+ build_options(true),
+ &gateway_name,
+ location,
+ ))
+ .await?
}
};
let server = handle.gateway_endpoint().to_string();
diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs
index c8b5c82a3..d5d9a0a88 100644
--- a/crates/openshell-cli/src/completers.rs
+++ b/crates/openshell-cli/src/completers.rs
@@ -177,12 +177,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 57f3dbc84..ccad7a099 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
@@ -1739,6 +1839,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()]
@@ -1757,6 +1865,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?;
}
@@ -1786,6 +1902,10 @@ async fn main() -> Result<()> {
remote,
ssh_key,
local,
+ oidc_issuer,
+ oidc_client_id,
+ oidc_audience,
+ oidc_scopes,
} => {
run::gateway_add(
&endpoint,
@@ -1793,6 +1913,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?;
}
@@ -1808,6 +1932,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)?;
}
@@ -1869,7 +2005,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());
@@ -1967,7 +2103,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 {
@@ -1995,7 +2131,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,
@@ -2040,7 +2176,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,
@@ -2148,7 +2284,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 } => {
@@ -2202,7 +2338,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)?;
@@ -2255,7 +2391,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,
@@ -2395,7 +2531,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
@@ -2456,7 +2592,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() {
@@ -2492,7 +2628,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:{} -> {}",
@@ -2507,7 +2643,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 { .. }
@@ -2597,7 +2733,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 {
@@ -2652,7 +2788,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?;
}
@@ -2684,7 +2820,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?;
}
@@ -2703,7 +2839,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).
@@ -2839,12 +2975,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()
}
}
@@ -3263,7 +3395,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(
@@ -3274,7 +3406,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..fd742a418
--- /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.clone_from(&bundle.refresh_token);
+ }
+ 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().clone(),
+ refresh_token: resp.refresh_token().map(|rt| rt.secret().clone()),
+ 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(u8::try_from(h * 16 + l).unwrap_or(b'%'));
+ } 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