This creates a frontend for opencode that provides single sign-on capabilities so that the agent web API can be protected with proper authentication
  • Go 96.9%
  • Dockerfile 2%
  • Shell 1.1%
Find a file
Eli Ribble e944ce0c42 Inject provider credentials into the opencode env
Makes it so users don't have to configure it
2026-07-03 14:49:32 -05:00
cmd Try to reuse boxen, stop on shutdown 2026-07-03 13:48:27 -05:00
contrib Add README information about using custom images. 2026-06-30 14:18:11 -05:00
internal Inject provider credentials into the opencode env 2026-07-03 14:49:32 -05:00
org/skills Add initial implementation of first 3 phases. 2026-06-23 12:01:36 -05:00
.gitignore Ignore osf-tool 2026-07-03 13:13:30 -05:00
config.example.toml Add initial Boxlite implementation 2026-06-28 13:49:37 -05:00
Containerfile Add initial Boxlite implementation 2026-06-28 13:49:37 -05:00
go.mod Add port forwarding logic for boxlite sandbox 2026-07-03 10:40:39 -05:00
go.sum Add port forwarding logic for boxlite sandbox 2026-07-03 10:40:39 -05:00
LICENSE Initial commit 2026-06-23 13:59:28 +00:00
README.md Add README information about using custom images. 2026-06-30 14:18:11 -05:00
start-container.sh Add verbose logging and sandbox output stream 2026-07-03 09:52:11 -05:00
start.sh Communicate directly over localhost with boxlite 2026-07-03 13:12:57 -05:00

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:

  1. Authenticating users via OIDC (Google, GitHub, Keycloak, Okta, etc.)
  2. Isolating sessions — each user gets their own opencode instance with separate databases, configs, and working directories
  3. Sandboxing agents — each opencode instance runs inside a boxlite micro-VM for hardware-level isolation
  4. Centralizing AI keys — provider API keys managed once at the organizational level, not per-user
  5. 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

  1. 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}"
    
  2. Admin writes skill files that know how to use those secrets. Skills are defined in the opencode config (via the skills and instructions fields) and reference secrets via environment variables or well-known config paths.

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

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

  1. Default values (built into the binary)
  2. Config file (--config / OSSOSSO_CONFIG)
  3. 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_port through opencode.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, launches opencode serve, and waits for the health check to pass.
  • Health check — polls GET /health on 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_timeout and 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)

  1. Start the BoxLite daemon on the host:

    boxlite serve
    # Starts on http://127.0.0.1:8080 by default
    
  2. 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
    
  3. 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:

  1. 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).
  2. 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:

  1. Provider keys — writes auth.json to <home>/data/auth.json with all API keys from [providers.*]
  2. 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).
  3. Org skills — each skill file listed in [[skills]] is read, ${ENV_VAR} references are resolved, and agent entries 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 GetOrCreate on the manager, builds a ReverseProxy targeting http://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-supplied Cookie, Authorization, and X-Forwarded-* headers
  • Hop-by-hop headers — stripped from proxied requests except Connection and Upgrade when a WebSocket upgrade is detected
  • WebSockethttputil.ReverseProxy handles WebSocket upgrades transparently; Upgrade/Connection headers 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_url or oidc.discovery_url (at least one is required)
  • Missing oidc.client_id, oidc.client_secret, oidc.redirect_url, or session.secret
  • session.secret is 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, or redirect_url
  • Invalid duration strings (session.max_age, opencode.idle_timeout, opencode.startup_timeout)
  • Invalid port ranges for opencode.base_port
  • opencode.max_instances less 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

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_SECRET must be a 64-character hex string (32 bytes) for AES-256 encryption. Generate it with openssl 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 /metrics for 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=0 for 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

  1. 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).

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

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

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

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

  6. OPENCODE_WORKSPACE_ID: The binary supports an OPENCODE_EXPERIMENTAL_WORKSPACES mode. If this proves sufficient for session isolation, we could simplify by using a single database with workspace IDs instead of per-user databases.

  7. Image caching: BoxLite pulls OCI images. For fast user startup, we should pre-pull the base image or use a local registry mirror.

  8. 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 state parameter in OIDC flow prevents CSRF on the callback
  • User data directories have filesystem permissions restricted to the service account (0700 per 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