- Go 96.9%
- Dockerfile 2%
- Shell 1.1%
|
|
||
|---|---|---|
| cmd | ||
| contrib | ||
| internal | ||
| org/skills | ||
| .gitignore | ||
| config.example.toml | ||
| Containerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| README.md | ||
| start-container.sh | ||
| start.sh | ||
opencode-sso-frontend
A reverse proxy that protects the opencode web frontend with OpenID Connect (OIDC) authentication. Provides per-user session isolation, a secure agent sandbox (via boxlite), centralized AI provider keys, and organizational secret injection.
Motivation
OpenCode's built-in web server has no authentication mechanism beyond basic auth (username/password). Exposing it directly to the Internet is unsafe. opencode-sso-frontend solves this by:
- Authenticating users via OIDC (Google, GitHub, Keycloak, Okta, etc.)
- Isolating sessions — each user gets their own opencode instance with separate databases, configs, and working directories
- Sandboxing agents — each opencode instance runs inside a boxlite micro-VM for hardware-level isolation
- Centralizing AI keys — provider API keys managed once at the organizational level, not per-user
- Injecting org secrets — secrets for internal systems (ticketing, source control, messaging) are provided to agents via injected skills
Architecture
┌──────────────────────────┐
│ Identity Provider │
│ (OIDC: Google, etc.) │
└──────────┬───────────────┘
│ verify token
│
┌──────────┴───────────────┐
Browser ──(HTTPS)──▶ │ Caddy / nginx / etc. │ TLS termination,
│ (production reverse │ rate limiting,
│ proxy, not part of │ static assets
│ this project) │
└──────────┬───────────────┘
│ HTTP (plaintext, localhost)
│
┌──────────┴───────────────┐
│ opencode-sso-frontend │
│ ┌──────────────────────┐ │
│ │ OIDC Auth Middleware │ │
│ └──────────┬───────────┘ │
│ │ │
│ ┌──────────┴───────────┐ │
│ │ Session Router │ │
│ │ (user → port map) │ │
│ └──────────┬───────────┘ │
│ │ │
│ ┌──────────┴───────────┐ │
│ │ Process Manager │ │
│ │ (spawn/kill) │ │
│ └──────────┬───────────┘ │
└─────────────┼─────────────┘
│
┌───────────────────────┼───────────────────────┐
│ │ │
┌───────┴────────┐ ┌────────┴────────┐ ┌─────────┴───────┐
│ boxlite Box A │ │ boxlite Box B │ │ boxlite Box C │
│ (alice) │ │ (bob) │ │ (carol) │
│ ┌────────────┐ │ │ ┌────────────┐ │ │ ┌────────────┐ │
│ │ opencode │ │ │ │ opencode │ │ │ │ opencode │ │
│ │ :4097 │ │ │ │ :4098 │ │ │ │ :4099 │ │
│ │ isolated │ │ │ │ isolated │ │ │ │ isolated │ │
│ │ DB + conf │ │ │ │ DB + conf │ │ │ │ DB + conf │ │
│ └────────────┘ │ │ └────────────┘ │ │ └────────────┘ │
└────────────────┘ └─────────────────┘ └────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
┌─────────────────┴─────────────────┐
│ Host filesystem │
│ /var/lib/opencode-sso/ │
│ users/<id>/ (per-user data) │
│ org/ (shared keys, │
│ secrets, │
│ skills) │
└───────────────────────────────────┘
Component Overview
| Component | Role |
|---|---|
| Caddy/nginx (external) | TLS termination, rate limiting, static asset serving. Not part of this project. |
| OIDC Auth Middleware | Validates session cookies / JWT tokens. Redirects unauthenticated users to the identity provider's login page. |
| Session Router | Maps authenticated users to their dedicated opencode instance (by port). Maintains the user → instance assignment. |
| Process Manager | Spawns, monitors, and terminates opencode instances (each inside a boxlite Box). Handles idle timeout, crashes, and port allocation. |
| BoxLite Runtime | Embedded Go library that creates and manages micro-VMs for each user. Provides hardware isolation, resource limits, and network policy enforcement. |
User Isolation Strategy
OpenCode stores state across several filesystem paths and uses SQLite for session data. Without isolation, all users would share sessions, API keys, and state. We use two layers of isolation:
Layer 1: Process + Filesystem Isolation
Each user gets their own base directory. All XDG-relative opencode paths are redirected under it:
/var/lib/opencode-sso/users/<user-id>/
├── data/ (XDG_DATA_HOME → ~/.local/share/opencode)
│ ├── opencode.db # SQLite session database
│ ├── auth.json # AI provider keys (copied from org store)
│ └── log/opencode.log
├── config/ (XDG_CONFIG_HOME → ~/.config/opencode)
│ ├── opencode.jsonc # User-specific settings
│ └── agents/ # Injected org agents/skills
├── cache/ (XDG_CACHE_HOME → ~/.cache/opencode)
│ └── models.json
├── state/ (XDG_STATE_HOME → ~/.local/state/opencode)
│ └── locks/
├── repos/ # User's checked-out repositories
└── tmp/ (TMPDIR)
Environment variables set per instance:
| Variable | Value | Purpose |
|---|---|---|
HOME |
/var/lib/opencode-sso/users/<user-id>/home |
Root of XDG paths |
XDG_DATA_HOME |
.../data |
Session DB, auth keys, logs |
XDG_CONFIG_HOME |
.../config |
User config, plugins, agents |
XDG_CACHE_HOME |
.../cache |
Model cache, binaries |
XDG_STATE_HOME |
.../state |
Concurrency locks |
TMPDIR |
.../tmp |
Temporary files |
OPENCODE_DB |
.../data/opencode.db |
Explicit DB path |
OPENCODE_SERVER_PASSWORD |
random per-instance | Internal auth between proxy and opencode |
OPENCODE_SERVER_USERNAME |
opencode-sso |
Internal auth username |
OPENCODE_DISABLE_AUTOUPDATE |
1 |
Prevent auto-update on server |
OPENCODE_DISABLE_SHARE |
1 |
Prevent cross-user session sharing |
OPENCODE_PRINT_LOGS |
1 |
Log to stderr for service journal |
OPENCODE_DISABLE_EMBEDDED_WEB_UI |
1 |
Don't try to open a browser |
Layer 2: VM Isolation (BoxLite)
Each opencode instance runs inside its own boxlite Box — a lightweight micro-VM with its own kernel. This provides:
- Hardware-level isolation: Even if opencode or its plugins are compromised, the attacker cannot escape to the host or other users' VMs
- Resource limits: CPU and memory caps enforced at the hypervisor level
- Network policy: Restrict outbound access per user/role (e.g., allow API calls to approved services only)
- Filesystem isolation: Each VM sees only its own data directory via a volume mount
┌─────────────────────────────────┐
│ BoxLite Box (user: alice) │
│ ┌───────────────────────────┐ │
│ │ opencode serve │ │
│ │ --port 4097 │ │
│ │ OPENCODE_DB=.../data.db │ │
│ │ Seccomp + cgroups │ │
│ └───────────────────────────┘ │
│ Volume mounts: │
│ /home → host:.../alice/ │
│ Network: allow_net=internal │
│ CPU limit: 2 cores │
│ Memory limit: 4 GiB │
└─────────────────────────────────┘
Centralized Provider Keys
AI provider API keys (DeepSeek, Anthropic, OpenAI, etc.) are stored once at the
organizational level in the config file. When a user's opencode instance starts,
the proxy writes the shared keys into the instance's auth.json before
launching the process.
This means:
- One place to rotate keys — update the config and restart idle instances
- Unified cost tracking — all usage rolls up to the org's billing account
- No key distribution — users never see or handle raw API keys
Organizational Secrets & Skill Injection
Agents need access to internal systems — ticket management (Jira, Linear), source control (GitHub, GitLab), messaging (Slack, Teams). opencode-sso-frontend manages these secrets at the organizational level and injects them as environment variables and skills into each agent's runtime.
How It Works
-
Admin configures secrets in the TOML config:
[secrets.jira] url = "https://company.atlassian.net" email = "opencode-bot@company.com" token = "${JIRA_API_TOKEN}" # resolved from env var at startup [secrets.github] token = "${GITHUB_TOKEN}" base_url = "https://github.company.com/api/v3" [secrets.slack] webhook_url = "${SLACK_WEBHOOK_URL}" -
Admin writes skill files that know how to use those secrets. Skills are defined in the opencode config (via the
skillsandinstructionsfields) and reference secrets via environment variables or well-known config paths. -
At instance startup, the proxy:
- Injects each secret as an environment variable into the boxlite Box
(e.g.,
JIRA_TOKEN,GITHUB_TOKEN,SLACK_WEBHOOK_URL) - Writes the org-level agent/skill configurations into the user's
XDG_CONFIG_HOME - Merges org skills with any user-specific customizations
- Injects each secret as an environment variable into the boxlite Box
(e.g.,
Skill Example (Jira)
An org-provided skill that uses an injected secret:
// Injected into user's config as an agent definition
{
"agent": {
"jira": {
"description": "Interact with Jira for ticket management",
"instructions": [
"You have access to the Jira API at $JIRA_URL.",
"Authenticate using the API token in $JIRA_TOKEN.",
"Your email for Jira is $JIRA_EMAIL.",
"When asked to create, update, or search tickets, use the Jira REST API directly via curl commands."
],
"tools": ["bash", "read", "write"],
"skill": true
}
}
}
Configuration
Configuration is written in TOML. The config file path is specified via the
--config flag or the OSSOSSO_CONFIG environment variable.
# =============================================================================
# opencode-sso-frontend configuration
# =============================================================================
# Address and port the proxy listens on (plain HTTP — TLS handled externally)
listen = "127.0.0.1:8080"
# ---------------------------------------------------------------------------
# OIDC Authentication
# ---------------------------------------------------------------------------
[oidc]
issuer_url = "https://accounts.google.com"
client_id = "xxx.apps.googleusercontent.com"
client_secret = "${OIDC_CLIENT_SECRET}" # resolved from env var at startup
redirect_url = "https://opencode.company.com/oidc/callback"
scopes = ["openid", "profile", "email"]
# Optional: restrict access to specific users/domains
allowed_users = [] # explicit user IDs, or empty for all
allowed_domains = ["company.com"] # restrict by email domain
# ---------------------------------------------------------------------------
# Session Management
# ---------------------------------------------------------------------------
[session]
cookie_name = "opencode_sso_session"
max_age = "24h" # session lifetime
cookie_domain = "company.com"
# ---------------------------------------------------------------------------
# OpenCode Instance Management
# ---------------------------------------------------------------------------
[opencode]
binary_path = "/usr/local/bin/opencode"
base_port = 4100 # start of port range for user instances
data_dir = "/var/lib/opencode-sso/users"
idle_timeout = "30m" # kill instance after idle period
max_instances = 50 # cap on concurrent users
startup_timeout = "30s" # max wait for instance to become healthy
# ---------------------------------------------------------------------------
# BoxLite Sandbox
# ---------------------------------------------------------------------------
[boxlite]
enabled = true
image = "opencode-agent:latest" # custom image with opencode pre-installed (see contrib/boxlite-agent.Containerfile)
server_url = "http://host.containers.internal:8080" # or host.docker.internal
[boxlite.resources]
cpu_cores = 2
memory = "4GiB"
disk_size = "20GiB"
# Network policy for agent VMs
# By default, block all outbound. Add entries to allow specific targets.
[[boxlite.network.allow]]
domain = "*.company.com"
reason = "Internal services"
[[boxlite.network.allow]]
domain = "api.deepseek.com"
reason = "AI provider API"
[[boxlite.network.allow]]
domain = "api.anthropic.com"
reason = "AI provider API"
# ---------------------------------------------------------------------------
# Shared AI Provider Keys (used by ALL users)
# ---------------------------------------------------------------------------
[providers.deepseek]
key = "${DEEPSEEK_API_KEY}"
[providers.anthropic]
key = "${ANTHROPIC_API_KEY}"
[providers.openai]
key = "${OPENAI_API_KEY}"
# ---------------------------------------------------------------------------
# Organizational Secrets (injected as env vars into agent VMs)
# ---------------------------------------------------------------------------
[secrets.jira]
url = "https://company.atlassian.net"
email = "opencode-bot@company.com"
token = "${JIRA_API_TOKEN}"
[secrets.github]
token = "${GITHUB_TOKEN}"
base_url = "https://github.company.com/api/v3"
[secrets.slack]
webhook_url = "${SLACK_WEBHOOK_URL}"
[secrets.linear]
api_key = "${LINEAR_API_KEY}"
# Additional arbitrary secrets passed as env vars
[secrets.extra]
DEPLOY_KEY = "${DEPLOY_KEY}"
NPM_REGISTRY_TOKEN = "${NPM_REGISTRY_TOKEN}"
# ---------------------------------------------------------------------------
# Organizational Skills (injected into each user's opencode config)
# ---------------------------------------------------------------------------
[[skills]]
name = "jira"
path = "/var/lib/opencode-sso/org/skills/jira.jsonc"
description = "Jira ticket management"
[[skills]]
name = "github-enterprise"
path = "/var/lib/opencode-sso/org/skills/github.jsonc"
description = "GitHub Enterprise interactions"
[[skills]]
name = "slack-notify"
path = "/var/lib/opencode-sso/org/skills/slack.jsonc"
description = "Slack notifications"
Configuration Precedence
- Default values (built into the binary)
- Config file (
--config/OSSOSSO_CONFIG) - Environment variables prefixed with
OSSOSSO_(e.g.,OSSOSSO_LISTEN,OSSOSSO_OIDC_ISSUER_URL)
Secret Resolution
Values in the config file using ${ENV_VAR} syntax are resolved from the
process environment at startup. This avoids storing raw secrets in the config
file and allows integration with secret managers (Vault, AWS Secrets Manager,
etc.) via environment variable injection in the systemd unit.
Current Status
Phases 1-7 are implemented:
| Phase | Feature | Status |
|---|---|---|
| 1 | Project scaffolding, directory structure, stub files | Done |
| 2 | TOML config loading, ${ENV_VAR} interpolation, CLI/env overrides, validation |
Done |
| 3 | OIDC auth code flow, session cookies, middleware, logout, access control | Done |
| 4 | Sandbox abstraction, DirectSandbox (local process), BoxLiteSandbox (micro-VM via CLI) | Done |
| 5 | Process manager: port allocation, spawn, health check, idle reaper, graceful shutdown, state persistence | Done |
| 6 | Provider key injection (auth.json), secret env vars, org skill merging into opencode.jsonc |
Done |
| 7 | Reverse proxy with header stripping, basic auth injection, WebSocket passthrough | Done |
The server can be built with go build ./cmd/opencode-sso-frontend/ and proxies
all authenticated requests to per-user opencode instances. Phases 8-9
(production hardening, deployment) are not yet implemented.
Sandbox Architecture
Instances run inside a Sandbox abstraction. Two backends:
| Backend | Config | Description |
|---|---|---|
direct |
boxlite.enabled = false (default) |
Runs opencode serve as a local subprocess. User data dirs created under opencode.data_dir/<user-id>/. |
boxlite |
boxlite.enabled = true |
Creates a BoxLite micro-VM per user. Uses the Go SDK (when CGO_ENABLED=1) or the boxlite CLI (when CGO_ENABLED=0). Applies CPU/memory/disk limits, network policy, and volume mounts from config. |
Process Manager
The manager is initialized at startup and handles:
- Port allocation — assigns ports from
opencode.base_portthroughopencode.base_port + max_instances - 1. Checks TCP availability before allocating. - Instance creation — creates user directories (
data/,config/,cache/,state/,repos/,tmp/), generates a random per-instance password, sets XDG environment variables, launchesopencode serve, and waits for the health check to pass. - Health check — polls
GET /healthon the instance until it responds 200, with a configurable timeout (opencode.startup_timeout). - Idle reaper — background goroutine scans every 30s for instances idle longer than
opencode.idle_timeoutand stops/removes them. - Graceful shutdown — on SIGTERM, drains HTTP connections, saves port state to
opencode.data_dir/_state/instances.json, then stops all instances. - State persistence — ports from previous runs are recovered on restart to avoid reuse conflicts.
BoxLite Sandbox (Phase 4)
When boxlite.enabled = true, the sandbox runs opencode inside a micro-VM.
Two backend implementations are available, selected automatically by the Go
build tag cgo:
| Build | Backend | Details |
|---|---|---|
CGO_ENABLED=1 |
Go SDK (boxlite_sdk.go) |
Uses github.com/boxlite-ai/boxlite/sdks/go. Two modes:REST API (set boxlite.server_url): connects to a remote boxlite serve daemon. No KVM needed in the container.Embedded (default): creates boxes directly via runtime.Create(). Requires /dev/kvm. |
CGO_ENABLED=0 |
CLI (boxlite_cli.go) |
Calls the boxlite binary installed by sh.boxlite.ai. Uses boxlite run, boxlite stop, boxlite rm subcommands. Requires boxlite in $PATH. |
Box creation — the effective boxlite run command for the CLI backend:
boxlite run --name <name> --detach \
--cpus <n> --memory <mb> --disk-size-gb <gb> \
--volume <homeDir>:/home:rw \
--env KEY=VALUE ... \
--network enabled --network-allow <domain> ... \
--publish <port>:<port>:tcp \
<image> -- opencode serve --port <port>
The SDK backend uses the equivalent With*() option functions.
Box lifecycle:
| Operation | CLI backend | SDK backend |
|---|---|---|
| Create + start | boxlite run ... |
rt.Create(), box.Start() |
| Run opencode | inline with run |
box.StartExecution("opencode", ...) |
| Stop | boxlite stop <name> |
execution.Kill(), box.Stop() |
| Remove | boxlite rm <name> |
rt.ForceRemove(id) |
| IP discovery | boxlite inspect/exec |
box.Exec("hostname", "-I") |
Connectivity: The sandbox publishes the opencode port via --publish,
allowing the proxy to reach the instance at 127.0.0.1:<port>. If port
publishing is unavailable, the sandbox resolves the Box's internal IP via
boxlite inspect and connects directly.
Disk size: The config value disk_size = "20GiB" is parsed to extract the
numeric GB value (20).
Cleanup: On instance stop/idle reaping, the sandbox stops and removes the box.
Running the SDK backend without KVM
BoxLite's embedded runtime requires /dev/kvm for hardware virtualization.
Inside a container, there are two options:
Option A: Mount KVM into the container (simple, requires host KVM)
docker run --device /dev/kvm ...
If the host has KVM loaded (lsmod | grep kvm), this exposes it to the container.
The embedded runtime will use it automatically when server_url is not set.
Option B: Use the BoxLite REST API (recommended, no KVM in container needed)
-
Start the BoxLite daemon on the host:
boxlite serve # Starts on http://127.0.0.1:8080 by default -
Configure the proxy to connect to it:
[boxlite] enabled = true server_url = "http://host.containers.internal:8080" # or host.docker.internal api_key = "${BOXLITE_API_KEY}" # from boxlite serve output -
Or via environment variables:
OSSOSSO_BOXLITE_SERVER_URL="http://host.containers.internal:8080" \ BOXLITE_API_KEY="blk_..." \ ./opencode-sso-frontend --config config.toml
When server_url is set, the SDK uses boxlite.NewRest() instead of the
embedded runtime. Boxes are created and managed on the host through the REST
API. No KVM access needed inside the container.
If server_url is empty, the embedded runtime is used (requires /dev/kvm).
Agent VM Image
The boxlite VM runs the configured boxlite.image as the guest operating system.
The default image (debian:stable-slim) does not include the opencode binary.
You need to either provide a custom image with opencode pre-installed, or allow
the sandbox to install it at runtime (requires VM network access).
Option A: Custom image (recommended)
Build an image that includes opencode and configure the proxy to use it. A
sample Containerfile is provided at contrib/boxlite-agent.Containerfile:
# Build the agent image
podman build -t opencode-agent:latest -f contrib/boxlite-agent.Containerfile .
# Make it available to boxlite. boxlite pulls from the local image store
# when the reference matches — tag it so boxlite can find it:
podman tag opencode-agent:latest docker.io/library/opencode-agent:latest
Then configure the proxy:
[boxlite]
enabled = true
image = "opencode-agent:latest"
server_url = "http://host.containers.internal:8080"
The contrib/boxlite-agent.Containerfile installs ca-certificates, curl,
and the opencode binary. You can extend it to include additional tools (git,
build-essential, language runtimes) as needed by your agents.
Option B: Runtime install
If the custom image approach isn't used, the sandbox attempts to install
opencode inside the VM on first startup using wget or curl. This requires:
- The VM must have network access — either through
[[boxlite.network.allow]]entries or by allowing all outbound traffic (the default when no entries are configured). - The VM's network must be able to reach external hosts (requires proper gvproxy/NAT setup on the host).
The startup timeout (opencode.startup_timeout) should be increased to
accommodate the install time:
[opencode]
startup_timeout = "120s"
Secret & Skill Injection (Phase 6)
Before launching an opencode instance, the sandbox injects:
- Provider keys — writes
auth.jsonto<home>/data/auth.jsonwith all API keys from[providers.*] - Secret env vars — structured secrets (
[secrets.jira],[secrets.github], etc.) become prefixed env vars (JIRA_URL,JIRA_EMAIL,JIRA_TOKEN). The[secrets.extra]section passes keys through as-is (DEPLOY_KEY,NPM_REGISTRY_TOKEN). - Org skills — each skill file listed in
[[skills]]is read,${ENV_VAR}references are resolved, andagententries are merged into<home>/config/opencode.jsonc. Existing user agent configs are preserved (org skills only fill in missing entries).
Reverse Proxy (Phase 7)
After authentication, all requests (except /healthz, /oidc/login, /oidc/callback, /logout) are proxied to the user's opencode instance:
- Routing — extracts user ID from the session, calls
GetOrCreateon the manager, builds aReverseProxytargetinghttp://127.0.0.1:<port> - Auth injection — sets
Authorization: Basic <opencode-sso:password>on every proxied request using the per-instance random password - Header forwarding — injects
X-Forwarded-User,X-Forwarded-Email,X-Forwarded-Name; strips client-suppliedCookie,Authorization, andX-Forwarded-*headers - Hop-by-hop headers — stripped from proxied requests except
ConnectionandUpgradewhen a WebSocket upgrade is detected - WebSocket —
httputil.ReverseProxyhandles WebSocket upgrades transparently;Upgrade/Connectionheaders are preserved for WS connections - Error handling — backend failures return 502 with a logged error; instance creation failures return 503
Integration Testing Guide
What to Test
The following flows should work against any standard OIDC provider (Google, GitHub, Keycloak, Okta, Azure AD, Authentik, etc.):
1. OIDC Provider Discovery
The server supports two modes for locating the OIDC provider:
| Config field | How it works |
|---|---|
oidc.issuer_url |
Standard OIDC discovery: fetches {issuer_url}/.well-known/openid-configuration |
oidc.discovery_url or oidc.well_known_url |
Direct well-known URL: fetches the exact URL provided, extracts the issuer from the response, then continues with standard discovery |
When discovery_url is set, issuer_url is optional — the issuer is resolved
automatically from the well-known response.
Authentik example — Authentik exposes a per-application well-known URL:
[oidc]
discovery_url = "https://authentik.company.com/application/o/myapp/.well-known/openid-configuration"
client_id = "<authentik-client-id>"
client_secret = "${AUTHENTIK_CLIENT_SECRET}"
redirect_url = "https://opencode.company.com/oidc/callback"
scopes = ["openid", "profile", "email"]
Keycloak example — Keycloak uses the standard issuer path:
[oidc]
issuer_url = "https://keycloak.company.com/realms/my-realm"
client_id = "<keycloak-client-id>"
client_secret = "${KEYCLOAK_CLIENT_SECRET}"
redirect_url = "https://opencode.company.com/oidc/callback"
scopes = ["openid", "profile", "email"]
Both approaches result in the same behavior: the authorization endpoint, token endpoint, and JWKS URI are all discovered automatically from the well-known configuration. You never need to specify individual endpoints.
2. Server Startup
# Build
go build -o opencode-sso-frontend ./cmd/opencode-sso-frontend/
# Start with config
./opencode-sso-frontend --config config.toml
The server logs JSON to stdout. It will fail fast if the config is invalid.
3. Config Validation
The following config errors should produce clear, actionable messages:
- Missing
oidc.issuer_urloroidc.discovery_url(at least one is required) - Missing
oidc.client_id,oidc.client_secret,oidc.redirect_url, orsession.secret session.secretis not a valid hex string, or decodes to < 16 bytes (must be at least 32 hex chars)- Invalid URLs for
issuer_url,discovery_url,well_known_url, orredirect_url - Invalid duration strings (
session.max_age,opencode.idle_timeout,opencode.startup_timeout) - Invalid port ranges for
opencode.base_port opencode.max_instancesless than 1
4. Authentication Flow
| Step | URL | Expected |
|---|---|---|
| Visit any protected page | GET / |
302 redirect to GET /oidc/login with ?redirect=/ |
| Initiate login | GET /oidc/login?redirect=/some/page |
302 redirect to the IdP's authorization endpoint with correct client_id, redirect_uri, scope, state, nonce |
| IdP callback (happy path) | GET /oidc/callback?code=...&state=... |
Verifies ID token, sets opencode_sso_session cookie, 302 redirects to the original ?redirect= path |
| Protected page (authenticated) | GET / with valid cookie |
200 OK, renders user profile (sub, name, email, issuer) |
| Logout | GET /logout |
Clears session cookie, 302 redirect to / |
| IdP callback (bad state) | GET /oidc/callback?code=...&state=wrong |
400 Bad Request: "invalid state parameter" |
| IdP callback (missing state) | GET /oidc/callback?code=... |
400 Bad Request: "missing state parameter" |
| IdP callback (error) | GET /oidc/callback?error=access_denied&error_description=... |
400 Bad Request with the error description |
| Nonce mismatch | (hard to trigger manually, but verify nonce is in the auth request and verified in the callback) | |
| Expired session | Wait past session.max_age then visit a protected page |
302 redirect to login |
5. Access Control
Test with these config settings:
[oidc]
# Restrict to specific users
allowed_users = ["alice@company.com"]
# Or restrict to domains
allowed_domains = ["company.com"]
| Scenario | Expected |
|---|---|
| Allowed user/demain authenticates | 302 to target page |
| Disallowed user authenticates | 403 Forbidden: "access denied" |
| No restrictions set (default) | All users allowed |
6. Session Cookie Properties
Inspect the opencode_sso_session cookie after login:
| Property | Expected value |
|---|---|
HttpOnly |
true |
Path |
/ |
Secure |
true when redirect URL is HTTPS; false for localhost/HTTP |
SameSite |
Lax |
Max-Age |
matches session.max_age config |
The cookie value should be opaque (encrypted) — user claims like email and sub should not be readable in the cookie.
7. Config File Secret Interpolation
Create a config file using ${ENV_VAR} syntax:
oidc.client_secret = "${MY_OIDC_SECRET}"
session.secret = "${SESSION_SECRET}"
Then run:
MY_OIDC_SECRET=xxx SESSION_SECRET=yyy ./opencode-sso-frontend --config config.toml
Verify the secrets are correctly resolved from the environment at startup time (not baked into the config file).
8. Environment Variable Overrides
Config values can be overridden with OSSOSSO_-prefixed env vars:
# Override listen address
OSSOSSO_LISTEN="0.0.0.0:8080" ./opencode-sso-frontend --config config.toml
# Override OIDC discovery URL (Authentik, self-hosted)
OSSOSSO_OIDC_DISCOVERY_URL="https://authentik.company.com/application/o/myapp/.well-known/openid-configuration" \
./opencode-sso-frontend --config config.toml
# Override OIDC issuer URL (standard providers)
OSSOSSO_OIDC_ISSUER_URL="https://keycloak.company.com/realms/my-realm" \
./opencode-sso-frontend --config config.toml
Supported OSSOSSO_* env vars:
| Variable | Maps to config field |
|---|---|
OSSOSSO_LISTEN |
listen |
OSSOSSO_OIDC_ISSUER_URL |
oidc.issuer_url |
OSSOSSO_OIDC_DISCOVERY_URL |
oidc.discovery_url |
OSSOSSO_OIDC_WELL_KNOWN_URL |
oidc.well_known_url |
OSSOSSO_OIDC_CLIENT_ID |
oidc.client_id |
OSSOSSO_OIDC_CLIENT_SECRET |
oidc.client_secret |
OSSOSSO_OIDC_REDIRECT_URL |
oidc.redirect_url |
OSSOSSO_SESSION_COOKIE_NAME |
session.cookie_name |
OSSOSSO_SESSION_MAX_AGE |
session.max_age |
OSSOSSO_SESSION_COOKIE_DOMAIN |
session.cookie_domain |
OSSOSSO_SESSION_SECRET |
session.secret |
OSSOSSO_OPENCODE_BINARY_PATH |
opencode.binary_path |
OSSOSSO_OPENCODE_DATA_DIR |
opencode.data_dir |
Important:
OSSOSSO_SESSION_SECRETmust be a 64-character hex string (32 bytes) for AES-256 encryption. Generate it withopenssl rand -hex 32. Shorter values (e.g., 8 hex chars = 4 bytes) will be rejected at startup with "session.secret decodes to 4 bytes; AES requires at least 16 bytes".
Verify the override takes precedence over the config file value.
9. Health Check
curl http://localhost:8080/healthz
# → {"status":"ok","active_instances":0}
The health check is unauthenticated and returns immediately. active_instances
reflects the number of running opencode instances managed by the process manager.
10. Graceful Shutdown
Send SIGTERM to the process. Verify:
- "received signal, shutting down" appears in logs
- "server stopped" appears after clean shutdown
- No goroutine leaks or panics
Building & Running
Native Build
go build -o opencode-sso-frontend ./cmd/opencode-sso-frontend/
Container Build
# Build the image (requires Docker or podman)
docker build -f Containerfile -t opencode-sso-frontend:latest .
# Or with a tag
docker build -f Containerfile -t opencode-sso-frontend:$(git rev-parse --short HEAD) .
Container Runtime Parameters
docker run \
--rm \
-p 8080:8080 \
-e OSSOSSO_OIDC_ISSUER_URL="https://accounts.google.com" \
-e OSSOSSO_OIDC_CLIENT_ID="<your-client-id>" \
-e OSSOSSO_OIDC_CLIENT_SECRET="<your-client-secret>" \
-e OSSOSSO_OIDC_REDIRECT_URL="http://localhost:8080/oidc/callback" \
-e OSSOSSO_SESSION_SECRET="<64-char-hex-key>" \
-e OSSOSSO_LISTEN="0.0.0.0:8080" \
opencode-sso-frontend:latest
Or mount a config file:
docker run \
--rm \
-p 8080:8080 \
-v "$(pwd)/test-config.toml:/etc/opencode-sso/config.toml:ro" \
opencode-sso-frontend:latest \
--config /etc/opencode-sso/config.toml
| Parameter | Description |
|---|---|
-p <host>:8080 |
Map host port to container port (default listen is 127.0.0.1:8080, override with OSSOSSO_LISTEN=0.0.0.0:8080) |
--config <path> |
Path to a mounted TOML config file (optional; env vars work standalone) |
-e OSSOSSO_OIDC_ISSUER_URL |
Standard OIDC issuer URL (Google, Keycloak, Okta, etc.) |
-e OSSOSSO_OIDC_DISCOVERY_URL |
Direct well-known URL (Authentik, custom providers) |
OSSOSSO_* env vars |
Override any config value (see Environment Variable Overrides) |
--rm |
Remove container on exit |
All ${ENV_VAR} references in the config file are resolved against the container's
environment at startup, so secrets can be injected via -e or --env-file.
Container with Authentik (discovery URL)
docker run --rm -p 8080:8080 \
-e OSSOSSO_OIDC_DISCOVERY_URL="https://authentik.company.com/application/o/myapp/.well-known/openid-configuration" \
-e OSSOSSO_OIDC_CLIENT_ID="<authentik-client-id>" \
-e OSSOSSO_OIDC_CLIENT_SECRET="<authentik-client-secret>" \
-e OSSOSSO_OIDC_REDIRECT_URL="https://opencode.company.com/oidc/callback" \
-e OSSOSSO_SESSION_SECRET="<64-char-hex>" \
-e OSSOSSO_LISTEN="0.0.0.0:8080" \
opencode-sso-frontend:latest
Container with Env File
# secrets.env (mode 0600)
OIDC_CLIENT_SECRET=GOCSPX-xxx
# Generate with: openssl rand -hex 32
OSSOSSO_SESSION_SECRET=abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234
docker run --rm -p 8080:8080 \
--env-file secrets.env \
-e OSSOSSO_OIDC_ISSUER_URL="https://accounts.google.com" \
-e OSSOSSO_OIDC_CLIENT_ID="xxx.apps.googleusercontent.com" \
-e OSSOSSO_OIDC_REDIRECT_URL="http://localhost:8080/oidc/callback" \
-e OSSOSSO_LISTEN="0.0.0.0:8080" \
opencode-sso-frontend:latest
Minimal Test Config
Save this as test-config.toml and fill in your IdP details:
listen = "127.0.0.1:8080"
[oidc]
# Standard providers — use issuer_url (discovery is automatic at
# {issuer_url}/.well-known/openid-configuration)
issuer_url = "https://accounts.google.com" # or your IdP
# For Authentik or custom well-known paths, use discovery_url instead:
# discovery_url = "https://authentik.company.com/application/o/myapp/.well-known/openid-configuration"
client_id = "<your-client-id>"
client_secret = "<your-client-secret>"
redirect_url = "http://localhost:8080/oidc/callback"
scopes = ["openid", "profile", "email"]
[session]
cookie_name = "opencode_sso_session"
max_age = "24h"
cookie_domain = ""
# REQUIRED: AES encryption key for session cookies.
# Must be a 64-character hex string (32 bytes).
# Generate with:
# openssl rand -hex 32
# A shorter key (e.g., 8 hex chars = 4 bytes) will fail at startup with
# "session.secret decodes to N bytes; AES requires at least 16 bytes".
secret = "<64-char-hex-from-openssl-rand-hex-32>"
[opencode]
binary_path = "/usr/local/bin/opencode"
base_port = 4100
data_dir = "/var/lib/opencode-sso/users"
idle_timeout = "30m"
max_instances = 50
startup_timeout = "30s"
Implementation Plan
Phase 8: Production Hardening
- Health check endpoint (
GET /healthz) - Metrics endpoint (
GET /metricsfor Prometheus: instance count, active users, request latency, boxlite resource usage) - Structured logging (JSON to stdout for
journald) - Rate limiting (per-user and global)
- Graceful shutdown (drain connections, stop boxes, wait for cleanup)
- Resource monitoring (alert if host disk/memory is exhausted)
Phase 9: Deployment
- Build as a static Go binary (
CGO_ENABLED=0for proxy; boxlite SDK may require CGO + linking against boxlite native library) - systemd service unit:
[Unit] Description=OpenCode SSO Frontend After=network-online.target Wants=network-online.target [Service] Type=notify ExecStart=/usr/local/bin/opencode-sso-frontend \ --config /etc/opencode-sso/config.toml Restart=always RestartSec=5 User=opencode-sso Group=opencode-sso LimitNOFILE=65536 # Secrets injected via environment EnvironmentFile=/etc/opencode-sso/secrets.env [Install] WantedBy=multi-user.target /etc/opencode-sso/secrets.env(mode 0600, owned by service user):OIDC_CLIENT_SECRET=GOCSPX-xxx DEEPSEEK_API_KEY=sk-xxx ANTHROPIC_API_KEY=sk-ant-xxx OPENAI_API_KEY=sk-xxx JIRA_API_TOKEN=xxx GITHUB_TOKEN=ghp_xxx SLACK_WEBHOOK_URL=https://hooks.slack.com/... LINEAR_API_KEY=lin_api_xxx- Caddy reverse proxy in front:
opencode.company.com { tls internal reverse_proxy 127.0.0.1:8080 } - Nix package (to match opencode's own Nix packaging)
Open Questions & Investigation Needed
-
WebSocket protocol specifics: Need to inspect the actual WebSocket messages between the opencode web UI and server to ensure the proxy handles them correctly (binary vs text frames, custom sub-protocols).
-
BoxLite Go SDK maturity: The Go SDK is one of the newer client libraries. Need to verify stability, CGO requirements, and whether the embedded runtime works well in a long-running server process.
-
opencode inside a VM: Need to test that opencode runs correctly inside a boxlite Box — particularly filesystem operations, subprocess spawning, and any host-specific assumptions.
-
Plugin isolation: If users install custom plugins, those run inside the opencode process (inside the VM). BoxLite's hardware isolation should contain any malicious plugins, but we should test escape scenarios.
-
Concurrent browser tabs: A user opening multiple tabs should share the same backend instance. This should work naturally since routing is by user ID, not by connection.
-
OPENCODE_WORKSPACE_ID: The binary supports anOPENCODE_EXPERIMENTAL_WORKSPACESmode. If this proves sufficient for session isolation, we could simplify by using a single database with workspace IDs instead of per-user databases. -
Image caching: BoxLite pulls OCI images. For fast user startup, we should pre-pull the base image or use a local registry mirror.
-
BoxLite server vs embedded: BoxLite can run as an embedded library or as a REST server (
boxlite serve). The embedded approach is simpler for co-located instances; the server approach is better for distributed deployments.
Security Considerations
- All communication between the proxy and opencode instances stays on localhost — never exposed to the network
- Internal basic auth credentials are randomly generated per instance and never leave the server
- OIDC session cookies are signed, encrypted (
secure,httponly,samesite=lax) - The
stateparameter in OIDC flow prevents CSRF on the callback - User data directories have filesystem permissions restricted to the service
account (
0700per user directory) - BoxLite provides hardware-level isolation between users (KVM-based micro-VMs)
- Network policy restricts outbound access from agent VMs to only explicitly allowed domains
- Org secrets are never written to disk inside user VMs — they exist only as environment variables (visible only to the opencode process)
- Regular cleanup of idle instances prevents resource exhaustion
- AI provider keys are never exposed to end users; they're written server-side before instance launch