Files
OpenShell/architecture/oidc-local-testing.md
Mrunal Patel 084505425b feat(auth): add OIDC/Keycloak authentication with RBAC and scope-based permissions (#935)
* feat(auth): add OIDC/Keycloak authentication with RBAC

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(auth): address branch review findings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(auth): forward OPENSHELL_OIDC_SCOPES through cluster bootstrap

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

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

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

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

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

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

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

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

Run with:

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

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

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

18 KiB

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

mise run keycloak

Wait for "Keycloak is ready." The script prints connection info including test users.

Verify:

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.

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)

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)

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

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

# 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

# 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

# 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

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:

# 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:

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

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.

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:

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:

openshell gateway login
# Login with: admin@test / admin
# Expected: ✓ Authenticated to gateway 'openshell' as admin@test

4c. Create and list sandboxes

# 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: <name>

# List sandboxes
openshell sandbox list
# Expected: shows the created sandbox

4d. Verify authentication enforcement

# 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.

# Wait 5+ minutes, then:
openshell sandbox list
# Expected: Unauthenticated: ExpiredSignature

# Re-login
openshell gateway login
openshell sandbox list
# Expected: success

4f. Verify RBAC

# 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:

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

# Login as admin to clean up
openshell gateway login
# Login with: admin@test / admin

openshell sandbox list
# Note sandbox names, then:
openshell sandbox delete <name>

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

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

# 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

# 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

# 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:

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:

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:

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:

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

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:

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
# 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

# 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 <JWT>

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://<HOST_IP>: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.