Skip to content

Authentication & Security

MCP HTTP Auth

When auth_token is set for a team in oduflow.toml, the MCP endpoint (/mcp) requires a Bearer token:

Authorization: Bearer <your-token>

Each team can have its own auth token:

[team.1]
hostname = "team-a.example.com"
auth_token = "secret-token-team-1"

[team.2]
hostname = "team-b.example.com"
auth_token = "secret-token-team-2"

The token is used to both authenticate and identify the team. The self-hosted OAuth provider also accepts it directly as a non-expiring Bearer credential.

Fresh configs get a generated auth_token for [team.1] on first startup. The value is stored in oduflow.toml (created with mode 0600) and never written to the log; read it from the file and use it as Authorization: Bearer <auth_token> when connecting HTTP MCP clients.

Server settings console

The Server settings console at /admin edits oduflow.toml, including every team's tokens and passwords, so it is not part of any team's dashboard. It has its own [admin] password (plus optional TOTP via oduflow ui-2fa setup --admin), its own 12-hour session cookie, and refuses team credentials; a console session does not open a team dashboard either. While the password is empty, /admin answers 404. Secrets reach the browser only through an explicit, logged Reveal.

Self-hosted OAuth (for Claude.ai and other MCP clients)

Oduflow can act as its own OAuth 2.1 Authorization Server, so MCP clients that require an OAuth flow (e.g. Claude.ai Remote MCP, MCP Inspector) can connect without any external identity provider.

The team's OAuth client_id is a non-secret identifier, team_<id> (e.g. team_1 for [team.1]); the client_secret is the team's auth_token. Only the client_id appears in the authorization URL — the secret is sent solely in the token request body, so it never leaks into logs or browser history. When the OAuth flow completes, Oduflow issues an independent, opaque access token that expires (with a refresh token to obtain a new one) — the client never receives the auth_token itself, so a compromised OAuth token has a bounded lifetime and can be revoked. The auth_token stays valid as a plain Bearer token for CLI clients (see Bearer-only mode).

Setup

The Authorization Server is enabled automatically whenever a team has an auth_token. It runs on each team's own hostname in both port and traefik mode: the OAuth issuer is derived per request from the incoming host after validating it against configured team hostnames. No separate OAuth configuration is needed:

[routing]
mode = "traefik"
acme_email = "admin@example.com"

[team.1]
hostname = "team-a.example.com"
auth_token = "secret-token-team-1"

In port mode behind Cloudflare Tunnel or another TLS proxy, publish the same hostname configured for the team and forward it to the HTTP listener:

[team.1]
hostname = "oduflow.example.com"
auth_token = "secret-token-team-1"

For direct LAN access while retaining that public hostname, use split DNS so oduflow.example.com resolves to the server's LAN address internally. Local Bearer clients may also connect by IP; OAuth discovery is intentionally served only for a recognized team hostname.

Either way, Oduflow exposes:

  • GET /.well-known/oauth-authorization-server — discovery metadata
  • GET /authorize — authorization endpoint (Authorization Code + PKCE)
  • POST /token — token endpoint (mints/rotates the access + refresh token pair)
  • POST /revoke — revoke a minted access or refresh token

Dynamic Client Registration (/register) is disabled — clients must use the preregistered credentials.

Connecting from Claude.ai

  1. Go to Claude.ai Settings → Connectors → Add custom MCP
  2. Enter your Oduflow URL: https://your-server.com/mcp (in traefik mode, the team's own hostname, e.g. https://team-a.example.com/mcp)
  3. In the OAuth fields, use the team's id as Client ID and its auth_token as Client Secret (the Client ID is team_<N> for [team.N] — e.g. team_1 for [team.1]):
Client ID     = team_1
Client Secret = secret-token-team-1
  1. Claude.ai performs the OAuth flow against your Oduflow instance, receives an access token, and connects.

The issued access token is an independent, expiring token bound to that team (not the auth_token), so each team's claude.ai connector ends up scoped to its own workspaces, templates, and credentials while Claude never stores the master secret. Claude.ai transparently uses its refresh token to obtain a new access token when the old one expires; the connection also survives an Oduflow restart because minted tokens are persisted.

Connecting from Claude Desktop

Claude Desktop does not use this OAuth flow — it can only start MCP servers as local processes. Connect it through the mcp-remote stdio bridge with the team's auth_token as a plain Bearer header; the full claude_desktop_config.json example is in Quick Start → Claude Desktop.

Bearer-only mode (CLI / automation)

For curl, IDE clients, or anything that doesn't need OAuth, simply send the auth_token as a Bearer header:

Authorization: Bearer secret-token-team-1

This uses the same team identity as the OAuth flow.

The built-in remote CLI uses the same Bearer authentication and live MCP tool schemas:

export ODUFLOW_MCP_URL="https://your-server.com/mcp"
export ODUFLOW_MCP_TOKEN="secret-token-team-1"

oduflow client list_environments

oduflow client does not use the dashboard's ui_password. For an automation that needs only one development environment, prefer its scoped /mcp/<env> URL and per-environment Secret Key instead of the team token. The server then hides team-wide tools and injects the environment target itself.

Scoped single-environment access (/mcp/<env>)

The team auth_token unlocks the full tool surface — create, delete, and stop environments, manage templates, services, and volumes. To hand an AI agent a confined handle to one environment only, Oduflow exposes a scoped endpoint:

https://your-server.com/mcp/<env>

On this endpoint only the in-environment tools are available — sync (pull_and_apply), install/upgrade modules, run tests, open the Odoo shell, run SQL, read and write records through the odoo_* ORM tools, read/write/search files, fetch logs and info, and restart. The ORM tools grant no new privilege: anything they can reach is already reachable through run_odoo_shell and run_db_query, which the endpoint has always exposed. Lifecycle and system tools (create/delete/stop/start/recreate, templates, services, volumes, listing other environments) are not exposed and cannot be called. The environment is taken from the URL, so the agent never passes — and cannot override — which environment it operates on.

Per-environment Secret Key

Every environment gets its own access token, minted at creation time and kept in the team's data directory (mcp_tokens.json, mode 0600), keyed by the environment name. It survives rename and recreate — an MCP client configured once keeps working for as long as an environment of that name exists — and is revoked when the environment is deleted. Use it as a Bearer token or as an OAuth client credential — exactly like a team auth_token, but it only unlocks its own /mcp/<env> endpoint:

Authorization: Bearer <environment-secret-key>

A per-environment token is rejected on the full /mcp endpoint and on any other environment's URL, so the credential itself is the boundary.

Getting the URL and Secret Key

In the web dashboard, open an environment's More → MCP Access. The dialog shows the /mcp/<env> URL and the Secret Key (with copy buttons) ready to paste into an agent's MCP configuration or the built-in remote CLI:

export ODUFLOW_MCP_URL="https://your-server.com/mcp/<env>"
export ODUFLOW_MCP_TOKEN="<environment-secret-key>"
oduflow client get_environment_info

Environments created before this feature carry no Secret Key (Docker labels can't be added to a live container); recreate the environment to issue one. Recreating an environment also rotates its token.

Shared single-environment dashboard (/env/<name>)

The dashboard equivalent of /mcp/<env>: a link that opens the dashboard reduced to one environment, for a client or collaborator who has no team password.

Open More → Share UI on an environment card and Oduflow mints

https://your-server.com/env/<env>?key=<share-secret>

Opening it trades the key for a signed, HTTP-only, SameSite=Strict cookie and moves on to the clean /env/<env>, so the key does not linger in the address bar or browser history. The move is a navigation started by a small landing page rather than an HTTP redirect: when the link is opened from another site, such as a webmail button, the browser treats a redirect chain as cross-site and withholds the Strict cookie, while a navigation started by the landing page is same-site. The same modal regenerates or revokes the link; both take effect immediately, including for sessions already opened with it, because the cookie carries a fingerprint of the secret it was minted from. Links have no expiry of their own; a session cookie lasts seven days and re-opening the link renews it.

A shared session sees that environment's card, logs, storage and status; can start, stop, restart and sync it, install and upgrade modules, open its Odoo shell and psql consoles, use Connect As and its Agent Chat, and read its /mcp/<env> URL and Secret Key. Everything else is refused server-side by a default-deny allowlist re-checked on every request: the full dashboard, any other environment, all team-wide surfaces (templates, services, volumes, extra addons, credentials, productions, host statistics, license), the provisioning actions on the environment itself (create, delete, update, recreate, switch branch, protect, save as template), the share routes themselves, and Agent CLI.

Agent Chat runs in the per-team agent container, but as this environment's own Linux user: it reaches only this environment's checkout, attachments and HOME, and only this environment's scoped MCP token. Other environments' checkouts, conversations and tokens are closed to it (see Coding Agent → Security model). What it still shares with the rest of the team is the git credential store and deploy key (below), the team-wide provider credentials in [team.X.agent_env], and the container network. Agent CLI stays excluded from share links. Share with people you would let work in the environment, and revoke when they are done.

Share secrets live in the team's data directory (shares.json, mode 0600), not in a container label, so environments that already exist can be shared, and a link survives recreating the environment. Deleting an environment drops its share; renaming one carries it over.

Web Dashboard Auth

The browser login form checks the team's ui_password and, when enabled, its TOTP authenticator code. It creates a signed, seven-day HTTP-only session cookie for the dashboard, UI REST API, and WebSocket handshakes. Opening the dashboard does not extend that expiry. HTTP Basic authentication is no longer accepted.

The UI password is independent from the MCP Bearer token (auth_token). Use oduflow client for remote automation; it does not need the UI password or TOTP. Password comparisons use hmac.compare_digest. State-changing cookie-auth requests and WebSocket handshakes have a same-origin Origin/Referer check; the login POST also rejects cross-origin submissions.

Fresh configs get a generated ui_password for [team.1] on first startup. Older HTTP configs with an empty ui_password are also auto-filled on startup and written back to oduflow.toml, so an upgrade does not expose the dashboard.

Enable authenticator-app 2FA

UI 2FA is optional and uses standard six-digit TOTP codes from Google Authenticator, Microsoft Authenticator, or another compatible app. It protects the full operator UI. Shared environment links keep their separate, restricted access without OTP; MCP clients and import/webhook authentication are unchanged. Odoo's own login is separate.

  1. On the Oduflow server, run the command as the same OS user as the Oduflow service, with its existing configuration and persistent data directory:
oduflow ui-2fa setup --team 1
# For a non-default configuration:
ODUFLOW_TOML=/path/to/oduflow.toml oduflow ui-2fa setup --team 1

For Docker installations, run the command inside the running Oduflow container using an interactive terminal (docker exec -it <container> ...).

  1. Scan the QR code printed in the terminal with your authenticator. A manual setup key is printed as a fallback. Both contain the secret: do not put them in logs, tickets, screenshots, or source control. QR generation is local.
  2. Enter the current authenticator code in the CLI. Only a correct code enables 2FA. A failed or cancelled setup leaves the previous state unchanged.
  3. Wait for the next code, then sign in to the dashboard with the existing password and the Authenticator code field. The setup code is already consumed. If 2FA is disabled, leave that field empty.

No server restart is needed. Enabling 2FA revokes existing full UI cookies; shared-link cookies are unaffected. One secret belongs to the team, matching its existing shared UI password; this is not a personal-user account system. A code can be accepted only once, including simultaneous requests. Keep the server and phone clocks synchronized; verification tolerates one 30-second step in either direction. Failed logins are limited by IP, and ten failed TOTP attempts within five minutes lock further attempts for the team until that window clears. Team attempts and consumed time steps survive server restarts.

Lost phone or replacing an authenticator

Use the local server CLI (over SSH if necessary):

oduflow ui-2fa reset --team 1
oduflow ui-2fa setup --team 1

Reset asks for confirmation, disables the factor, and revokes full UI cookies. Until setup completes again, the UI accepts the team password alone. There is no web or MCP reset endpoint and no recovery-code system in this version. Revocation is checked on subsequent HTTP requests and new WebSocket handshakes; it does not disconnect an already established terminal connection.

The secret, revocation generation, replay counter, and failed attempts live in <team-data-dir>/.ui_totp.json (permissions 0600). Updates are locked across processes and atomically replaced. Keep this file on the persistent data volume and protect backups as credentials. An unreadable or malformed file blocks full UI authentication; restore it or use CLI reset. Do not delete the file to reset 2FA: absence represents a team that has never enrolled, whereas CLI reset keeps a fresh generation so old cookies remain revoked.

Migrating scripts from HTTP Basic

Use oduflow client create_environment ... and oduflow client pull_and_apply ... instead of the removed scripts/create_env.py and scripts/sync_env.py helpers. Other scripts using Basic against /api/ must migrate to the corresponding MCP tools. The browser continues to use these API routes with its session cookie. The session format change signs out existing operators once on upgrade, even for teams without 2FA; shared links keep working.

When auth is disabled

MCP auth and Web UI auth are configured independently per team:

  • If auth_token is empty, the MCP endpoint has no team Bearer token
  • If ui_password is empty, the web dashboard has no login password

In HTTP mode, Oduflow refuses to start with an unauthenticated MCP endpoint or dashboard unless the operator explicitly sets:

[server]
allow_insecure_http = true

Use that only behind your own authenticating proxy. In normal fresh HTTP deployments, auth_token and ui_password are generated automatically and startup logs show auth as enabled:

INFO  [team.1] http://localhost:8000/ (MCP token ON, OAuth ON (self-hosted), UI auth ON)

Git Credentials

Credentials Management

Private repository credentials are stored in the git credential store at {team_data_dir}/.git-credentials (per-team) via the setup_repo_auth tool. The clean URL (without credentials) is always used in Docker labels and logs — credentials are never exposed.

Managing credentials via MCP

# Store a personal access token for a git host (verified with git ls-remote against repo_url)
oduflow call setup_repo_auth '{"repo_url": "https://github.com/owner/private-repo.git", "token": "ghp_..."}'

# Host only — verified against the provider API (GitHub, GitLab, Bitbucket)
oduflow call setup_repo_auth '{"host": "github.com", "token": "ghp_..."}'

# Legacy inline form
oduflow call setup_repo_auth https://user:PAT@github.com/owner/private-repo.git

Git matches stored credentials by host and username, not by repository, so a single token covers every repository on that host. username is optional (it defaults to x-access-token; GitHub, GitLab and Azure DevOps accept any name with a token) and only has to be the real account name for Bitbucket app passwords. Use different usernames to keep several tokens for one host; storing again with the same username replaces the token.

Managing credentials via REST API and Web Dashboard

The Web Dashboard and REST API provide full credential lifecycle management:

Action REST API
List all stored credentials GET /api/credentials
Add a credential for a git host POST /api/credentials/add (body: token, host = github.com, optional username, optional repo_url to verify against; legacy: repo_url with inline user:PAT@)
Delete a stored credential POST /api/credentials/delete (body: host, username)
Validate a credential against the provider POST /api/credentials/validate (body: host, username)

Validation checks the credential against the provider's API (GitHub, GitLab, Bitbucket). For other hosts, it reports "valid" if the credential exists. Tokens are always masked in API responses (e.g. ghp_****).

SSH deploy key

As an alternative to tokens, each team has an SSH deploy key: an ed25519 keypair generated automatically at server start and stored at {team_data_dir}/ssh/id_ed25519 with owner-only permissions. The dashboard's Credentials tab, GET /api/ssh-key, and the get_ssh_public_key MCP tool expose only the public key. Register it with your git hosting (repository deploy key or machine-user key) and SSH repository URLs (git@github.com:owner/repo.git) work for environments, extra addon repos and productions.

Like the team's git credential store, the private key is also provisioned into the team's coding-agent container (a copy in every environment's HOME) so agent-side clones work over SSH. Anyone who can drive that agent — including a visitor holding a scoped environment share link, via Agent Chat — can therefore read it. Treat the deploy key as a team-level credential: prefer registering it read-only and per-repository, and regenerate it when a share should no longer grant repo access.

Git runs SSH with BatchMode=yes (it can never block on a prompt) and StrictHostKeyChecking=accept-new with a per-team known_hosts file, so a host key is pinned on first contact and a later change is refused. POST /api/ssh-key/generate with {"force": true} regenerates the keypair; the old key stops working everywhere it was registered.

Secrets for Environment Variables

Environment variables on services and environments are visible to coding agents through get_service_info, list_services, get_environment_info and the dashboard — so putting a password or API key directly into env_vars leaks it into every agent conversation that inspects the resource.

Secrets are team-scoped named values that avoid this. A human operator creates them in the dashboard (Credentials tab → Secrets); values are write-only — they can be replaced or deleted, but no MCP tool or REST endpoint ever returns a stored value. Agents can list the names with list_secrets.

The secret form starts with Name, followed by the independent Hide value and JSON value switches. Hide value masks the ordinary text value and the individual key's Value by default. In JSON mode, Replace Data is always visible, even with masking enabled, and validates JSON while you type. Its warning makes clear that saving replaces all data in the secret. Text and JSON replacement drafts stay separate when switching modes. JSON secrets also undergo server-side validation before saving; invalid input leaves the previous value intact. The stored type (text or json) persists across replacements. Secrets saved before types existed are typed by their stored value: a JSON object or array is json, anything else (including scalars such as 123) is text; an upgrade records that type once. JSON values retain their original formatting and may contain any valid JSON value, including arrays and scalars; NaN and Infinity are rejected.

To use one, set the env-var value to a reference:

oduflow call create_service '{
  "name": "meili",
  "image": "getmeili/meilisearch:v1.6",
  "port": 7700,
  "env_vars": "MEILI_MASTER_KEY=secret:meili-master-key,MEILI_ENV=production"
}'

The real value is substituted only into the container's environment at creation time. Everything that stores or displays the configuration — the service preset, the environment's Docker label, template metadata, get_service_info/get_environment_info output — keeps the secret:<name> reference. Because only the reference travels, secrets migrate automatically when a service is restored from a preset, an environment is renamed, or an environment is saved as a template and new environments are created from it.

A dangling reference (secret deleted or never created) fails the create/update with a clear error before anything is touched; running containers keep their resolved value until recreated. After replacing a secret's value, recreate the services, environments and productions that use it (update_service / update_environment / reconfigure_production): a rotated value counts as a config change, so update_service and reconfigure_production recreate the container even when the image and every other setting are unchanged. restart_production and restart_environment keep the old value.

The store lives at {team_data_dir}/secrets.json with owner-only (0600) file permissions, like the other credential stores. Note the boundary: code running inside a container can always read its own environment — secrets protect the MCP/REST/dashboard read surfaces, not the container itself.

Updating one JSON element

In the dashboard, choose Edit secret on an existing JSON secret. Above Replace Data, enter a path in Update Key (for example, environment.OPENROUTER_API_KEY) and the new non-empty text in Value, then click Save key. The value is saved as a string exactly as entered, without JSON quotes or automatic conversion of text such as true or 123. Hide value controls masking for this field independently of the visible replacement JSON.

Update Key and Value stay disabled for a secret stored as text, even with JSON value checked. Paste the full JSON into Replace Data and click Replace data once; the form closes and the secret is stored as JSON. Open Edit secret again to update individual keys.

After saving, the form stays open and displays Key created or Key updated with the submitted path. The value field is cleared, and the path remains for reuse. The result comes from the server, which checks whether the key existed under the same lock as the write. Editing the key or value clears the previous result. Stored secret values are never displayed.

Save key and Replace data are separate actions: saving a key never submits or clears the replacement draft, and replacing data never submits the key fields. Individual key saves are disabled for new or text secrets until they have been saved as JSON.

To save a field through the API, send an authenticated request to POST /api/secrets/{name}/update-json:

{"path": "environment.OPENROUTER_API_KEY", "value": "new-api-key"}

The secret must already have type json. A missing final object key is created; an existing key or array element is replaced. All parent containers must exist: environment.OPENROUTER_API_KEY requires an existing environment object. Text secrets return HTTP 400 even when their contents happen to be valid JSON; the method never changes a secret's type. A missing secret returns HTTP 404.

path accepts either syntax:

  • Dot notation: password selects a top-level key, environment.OPENROUTER_API_KEY selects a nested key, and accounts.0.token selects a field in the first array element. Segments must be non-empty; leading/trailing dots, consecutive dots and slashes are rejected.
  • JSON Pointer: /password, /environment/OPENROUTER_API_KEY, and /accounts/0/token select the same paths. In this syntax dots are literal: /environment.OPENROUTER_API_KEY selects one top-level key whose name contains a dot. Escape / within a key as ~1, and ~ as ~0. / selects an empty-string key.

Array indices start at zero and cannot have leading zeros. Empty paths, missing parents, scalar parents, out-of-range array indices and array append (-) are rejected with HTTP 400 without modifying the secret. Arrays are never extended.

value is the new JSON value itself, not a JSON-encoded string. Strings, numbers, booleans, objects, arrays, and null are supported; null replaces the element with null rather than deleting it. Replacing an object or array replaces that entire selected subtree. Other elements retain their values, though the stored JSON is reformatted with two-space indentation and non-ASCII characters are escaped as \uXXXX sequences (surrogate pairs where needed). JSON parsing restores the same characters. NaN and Infinity are rejected.

The server performs the read/modify/write under the same team lock as other secret writes. It preserves created_at and updates updated_at. Responses contain only the secret name and creation/update flags, never secret values. created: true means a new key was added; created: false means an existing element was replaced (updated is the inverse). For example, an update returns:

{"ok": true, "result": {"name": "my-secret", "created": false, "updated": true}}

Recreate consumers with update_service / update_environment to apply the updated value to their containers.

iptables rule

On startup, an iptables ACCEPT rule is automatically added for the oduflow-net Docker bridge interface. This ensures that containers on the shared network can communicate with the host (required for Traefik host.docker.internal routing and PostgreSQL access). If iptables is not available, the rule is skipped with a warning.

Odoo security defaults

The bundled odoo.conf template includes these security settings:

  • list_db = False (hides database selector)
  • without_demo = True (no demo data)
  • max_cron_threads = 0 (disables cron in dev environments)

A repository that ships its own .oduflow/odoo.conf replaces the template entirely and is responsible for these settings itself. Either way, the generated conf always sets http_interface = 0.0.0.0: Odoo 20 otherwise listens only on 127.0.0.1, which neither Traefik nor a published port can reach.

The master password (admin_passwd)

Oduflow never writes admin_passwd, so Odoo's compiled-in default (the literal admin) is in effect. What keeps that from mattering is list_db = False: Odoo gates every database management function on it, so /web/database/* is unreachable regardless of the password.

Productions make the pair explicit. list_db = True is refused unless the same override set (or the team odoo.prod.conf) also provides an admin_passwd of at least 12 characters that is not admin, and a list_db value other than True/False is refused because Odoo would read it as a truthy string and enable database management. See odoo.conf overrides.

Development environments keep list_db = False from the bundled template and are not exposed to the public internet by default; a repository that ships its own .oduflow/odoo.conf owns that setting — if it enables list_db, set a master password there too.