Authentication & Security¶
MCP HTTP Auth¶
When auth_token is set for a team in oduflow.toml, the MCP endpoint (/mcp) requires a Bearer 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:
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 metadataGET /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¶
- Go to Claude.ai Settings → Connectors → Add custom MCP
- 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) - In the OAuth fields, use the team's id as
Client IDand itsauth_tokenasClient Secret(theClient IDisteam_<N>for[team.N]— e.g.team_1for[team.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:
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:
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:
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
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.
- 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> ...).
- 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.
- Enter the current authenticator code in the CLI. Only a correct code enables 2FA. A failed or cancelled setup leaves the previous state unchanged.
- 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):
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_tokenis empty, the MCP endpoint has no team Bearer token - If
ui_passwordis 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:
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:
Git Credentials¶
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:
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:
passwordselects a top-level key,environment.OPENROUTER_API_KEYselects a nested key, andaccounts.0.tokenselects 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/tokenselect the same paths. In this syntax dots are literal:/environment.OPENROUTER_API_KEYselects 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:
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.
