Authentication
Codumentor supports multiple authentication providers to control access to the API. Authentication is configured through the auth section of codumentor.yaml.
Overview
The auth.provider setting selects which authentication backend to use:
| Provider | Value | Description |
|---|---|---|
| Local | "local" | Default. Username/password users defined directly in the config file. A generated config ships one admin account whose password comes from CODUMENTOR_ADMIN_PASSWORD. |
| Gitea | "gitea" | Authenticate using Gitea personal access tokens. |
| LDAP | "ldap" | Authenticate against an LDAP or Active Directory server. |
| No authentication | "none" | No credentials required. Opt-in, and only servable on a loopback bind — see No Authentication. |
auth:
provider: "local" # "local", "gitea", "ldap", or "none"
What a fresh install does.
codumentor init(and first-run scaffolding) writes thelocalprovider with a singleadminuser whose password is${CODUMENTOR_ADMIN_PASSWORD}— no secret is written to the file. Until you export that variable the account is disabled and every login is rejected; the server logs an error saying so at startup. Export it, plusCODUMENTOR_JWT_SECRET, before starting:export CODUMENTOR_ADMIN_PASSWORD='<choose one>' export CODUMENTOR_JWT_SECRET='<random string>'
Regardless of provider, set
jwt_secretin any deployment that uses persistent login sessions or the per-user secrets / model-profiles / OAuth plugins — without it sessions reset on restart and those stores are not encrypted at rest.
Separate UI and API providers (advanced).
auth.providerapplies to both the web UI and the API when no per-surface overrides are set. To use different methods for each — e.g. LDAP for interactive UI login andstatic_keyfor OpenAI-compatible API clients — setui_providerand/orapi_provider(see Static API Key). Whenapi_provideris omitted it inheritsui_provider(notprovideralone), so hardening the UI also protects the API unless you explicitly setapi_provider(including to"none"). The per-provider config blocks (ldap:,local:, …) are shared between them.
No Authentication (provider: "none")
All requests are accepted without credentials, as an anonymous user. This is appropriate for a single-user machine where the API is bound to 127.0.0.1, and nowhere else — it is not the default and has to be asked for.
auth:
provider: "none"
The server enforces that. With authentication off it refuses to start on any bind address other than loopback, because every request would be accepted:
Refusing to start: authentication is disabled (auth.ui_provider=none, auth.api_provider=none)
but the server would bind '0.0.0.0', which is reachable from the network. …
Three ways out, in preference order:
- Configure a provider —
localwith a${ENV_VAR}password is the smallest step. - Keep
api.host: 127.0.0.1and publish the instance through a reverse proxy that authenticates. - If another boundary already restricts who can reach the port (a container network, a VPN, an authenticating proxy), state that explicitly:
``yaml``
auth:
provider: "none"
allow_unauthenticated_network_access: true # I have another boundary
Codumentor cannot verify such a boundary, so this flag is taken at face value; the startup log keeps a warning that access is open. Do not set it to silence the error on a machine that is actually reachable.
The check covers both surfaces independently: an api_provider: "none" behind an LDAP UI is refused on a network bind just the same. See the Security guide for the rest of the deployment posture.
Gitea (provider: "gitea")
Users authenticate by providing a Gitea personal access token. Codumentor validates the token against the Gitea API on each request and caches successful validations to reduce API calls.
Configuration
auth:
provider: "gitea"
gitea:
base_url: "https://gitea.example.com"
token_cache_ttl_minutes: 30
| Setting | Required | Default | Description |
|---|---|---|---|
base_url | Yes | -- | Base URL of the Gitea instance |
token_cache_ttl_minutes | No | 30 | How long (in minutes) to cache a successful token validation. Set to 0 to disable caching. Recommended range for production: 15--120 minutes. |
How It Works
- The client sends a Gitea personal access token in the
Authorizationheader. - Codumentor calls the Gitea API (
/api/v1/user) to validate the token. - On success, the result is cached for
token_cache_ttl_minutesto avoid repeated API calls. - Invalid tokens are never cached.
Generating a Token
Users create personal access tokens in their Gitea account under Settings > Applications > Generate New Token. The token only needs the read:user scope.
Local (provider: "local")
A fixed set of username/password users defined directly in codumentor.yaml. This is the simplest way to give a small team real logins without standing up Gitea or LDAP — it is the shipped default and what the self-hosted reference configs use.
A user whose password is empty, or still the literal ${VAR} placeholder because the variable is not set, is dropped at startup with an error naming the account. A misconfiguration therefore disables the login rather than creating one with a guessable password; if no account survives, the provider logs that every login will be rejected and tells you which variable to export.
Configuration
auth:
provider: "local"
jwt_secret: "${CODUMENTOR_JWT_SECRET}" # see below — needed for persistent sessions
local:
users:
- username: admin
password: "${CODUMENTOR_ADMIN_PASSWORD}"
email: admin@example.com
display_name: "Site Admin"
roles: ["admin", "user", "power_user"]
- username: dev
password: "${CODUMENTOR_DEV_PASSWORD}"
roles: ["user"]
| Field | Required | Default | Description |
|---|---|---|---|
username | Yes | -- | Login name. Must be unique across the users list (duplicates fail config validation). |
password | Yes | -- | Plaintext or a pbkdf2_sha256$... hash (see below). Supports ${ENV_VAR} substitution so the real secret stays out of the committed file. |
email | No | null | Used for identity matching and notifications. |
display_name | No | null | Shown in the UI. Falls back to username. |
roles | No | ["user"] | Codumentor roles granted on login. Common roles: user, power_user, admin. |
Passwords: env injection and hashing
Never commit a plaintext password. Use ${ENV_VAR} substitution and provide the value at runtime (systemd EnvironmentFile, .env, secrets manager):
export CODUMENTOR_ADMIN_PASSWORD='s3cret'
export CODUMENTOR_DEV_PASSWORD='…'
For defence in depth you can store a PBKDF2 hash instead of a plaintext value (the provider accepts either, auto-detected by the pbkdf2_sha256$ prefix). Generate one with:
python -c "from codumentor.auth.providers.local import hash_password; print(hash_password('s3cret'))"
Paste the resulting pbkdf2_sha256$<iterations>$<salt>$<hash> string as the password value (this can be committed safely).
Security note. As with every provider, do not expose an instance on a public network without a reverse proxy and TLS. See Security. Login attempts are rate-limited and locked out server-side (per IP and per username, independent of the provider), but that is a backstop, not a substitute for TLS and a proxy.
Session Persistence and At-Rest Encryption (jwt_secret)
auth.jwt_secret is a single deployment-wide secret that does two things:
- Signs login session tokens. Without it, sessions are signed with an ephemeral key generated at startup — so every restart invalidates all logins and forces users to sign in again.
- Seeds the at-rest encryption key (Fernet) for the per-user stores written by the
user_secrets,model_profiles, andoauthplugins. Without it, those credentials are stored unencrypted on disk.
auth:
jwt_secret: "${CODUMENTOR_JWT_SECRET}"
# Generate a strong value once and keep it stable for the life of the deployment:
export CODUMENTOR_JWT_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
If the referenced environment variable is unset, Codumentor refuses to use the literal ${CODUMENTOR_JWT_SECRET} placeholder as the key (it would be a guessable, committed value). It drops the secret, logs a prominent ERROR, and degrades to ephemeral sessions + unencrypted secret stores. Treat that log line as a misconfiguration to fix, not a warning to ignore.
Rotation. Changing jwt_secret logs everyone out and makes existing encrypted rows unreadable — unless you let the affected plugins decrypt under the old key while re-encrypting under the new one. Pass the prior value(s) to each store plugin via its previous_jwt_secrets arg; rotated rows are re-encrypted lazily on next read:
plugins:
- module: codumentor.plugins.user_secrets
class: UserSecretsPlugin
args:
enabled: true
previous_jwt_secrets: ["${CODUMENTOR_JWT_SECRET_OLD}"]
(The same previous_jwt_secrets arg is accepted by the model_profiles and oauth plugins.)
LDAP (provider: "ldap")
For enterprise environments, Codumentor can authenticate users against an LDAP directory or Active Directory server. This provider supports user search, group membership checks, and role mapping.
Configuration
auth:
provider: "ldap"
ldap:
# Connection (required)
server_url: "ldaps://ldap.example.com"
bind_dn: "cn=codumentor-svc,ou=services,dc=example,dc=com"
bind_password: "${LDAP_BIND_PASSWORD}"
base_dn: "dc=example,dc=com"
# Server type: "active_directory", "openldap", or "generic"
server_type: "openldap"
# Security: "ldaps", "starttls", or "none"
security_mode: "ldaps"
# User search
user_search_filter: "(&(objectClass=inetOrgPerson)(uid={username}))"
user_search_base: "ou=users,dc=example,dc=com"
username_attribute: "uid"
email_attribute: "mail"
display_name_attribute: "displayName"
# Group settings
group_search_base: "ou=groups,dc=example,dc=com"
group_member_attribute: "memberOf"
# Role mapping
group_role_mapping:
"cn=admins,ou=groups,dc=example,dc=com": ["admin"]
"cn=developers,ou=groups,dc=example,dc=com": ["developer"]
default_roles: ["user"]
# Access restriction (optional)
require_group: "cn=codumentor-users,ou=groups,dc=example,dc=com"
# SSL certificate validation
validate_cert: true
ca_cert_file: "/etc/ssl/certs/ca-certificates.crt"
Connection Settings
| Setting | Required | Default | Description |
|---|---|---|---|
server_url | Yes | -- | LDAP server URL. Use ldaps:// for SSL or ldap:// for plain/STARTTLS. |
bind_dn | Yes | -- | Distinguished name of the service account used for searching. |
bind_password | Yes | -- | Password for the bind DN. Use ${LDAP_BIND_PASSWORD} to read from an environment variable. |
base_dn | Yes | -- | Base DN for all LDAP searches. |
Server Type
The server_type setting applies sensible defaults for common directory servers:
| Type | username_attribute | user_search_filter | Notes |
|---|---|---|---|
active_directory | sAMAccountName | (&(objectClass=user)(sAMAccountName={username})) | Uses AD-specific nested group resolution |
openldap | uid | (&(objectClass=inetOrgPerson)(uid={username})) | Standard LDAP schema, no nested group resolution |
generic | uid | (&(objectClass=person)(uid={username})) | Minimal defaults, configure manually |
You can override any of the defaults by specifying the corresponding setting explicitly.
Security Mode
| Mode | Description |
|---|---|
ldaps | SSL/TLS on connect (port 636). Recommended for production. |
starttls | Upgrade plain connection to TLS after connect (port 389). |
none | No encryption. Only use for testing on a trusted network. |
User Search Settings
| Setting | Default | Description |
|---|---|---|
user_search_filter | Depends on server_type | LDAP filter to locate the user. {username} is replaced with the login name. |
user_search_base | Value of base_dn | Subtree to search for users. |
username_attribute | Depends on server_type | LDAP attribute containing the username. |
email_attribute | mail | LDAP attribute containing the email address. |
display_name_attribute | displayName | LDAP attribute containing the display name. |
Group and Role Mapping
| Setting | Default | Description |
|---|---|---|
group_search_base | Value of base_dn | Subtree to search for groups. |
group_member_attribute | memberOf | LDAP attribute on the user entry listing group membership. |
group_role_mapping | {} | Dictionary mapping LDAP group DNs to lists of Codumentor roles. |
default_roles | ["user"] | Roles assigned when the user does not match any group mapping. |
require_group | null | If set, only users who are members of this group can authenticate. All others are rejected. |
SSL Certificate Settings
| Setting | Default | Description |
|---|---|---|
validate_cert | true | Whether to validate the server's TLS certificate. Set to false only for testing. |
ca_cert_file | null | Path to a custom CA certificate file. If not set, the system certificate store is used. |
Full LDAP Example (Active Directory)
auth:
provider: "ldap"
ldap:
server_url: "ldaps://dc01.corp.example.com"
bind_dn: "CN=Codumentor Service,OU=Service Accounts,DC=corp,DC=example,DC=com"
bind_password: "${LDAP_BIND_PASSWORD}"
base_dn: "DC=corp,DC=example,DC=com"
server_type: "active_directory"
security_mode: "ldaps"
user_search_base: "OU=Employees,DC=corp,DC=example,DC=com"
# sAMAccountName and AD-specific filter are applied automatically
group_search_base: "OU=Groups,DC=corp,DC=example,DC=com"
group_member_attribute: "memberOf"
group_role_mapping:
"CN=Codumentor Admins,OU=Groups,DC=corp,DC=example,DC=com": ["admin"]
"CN=Engineering,OU=Groups,DC=corp,DC=example,DC=com": ["developer"]
default_roles: ["user"]
require_group: "CN=Codumentor Users,OU=Groups,DC=corp,DC=example,DC=com"
validate_cert: true
ca_cert_file: "/etc/ssl/certs/corp-ca.pem"
Full LDAP Example (OpenLDAP)
auth:
provider: "ldap"
ldap:
server_url: "ldaps://ldap.example.com"
bind_dn: "cn=codumentor,ou=services,dc=example,dc=com"
bind_password: "${LDAP_BIND_PASSWORD}"
base_dn: "dc=example,dc=com"
server_type: "openldap"
security_mode: "ldaps"
user_search_filter: "(&(objectClass=inetOrgPerson)(uid={username}))"
user_search_base: "ou=people,dc=example,dc=com"
username_attribute: "uid"
email_attribute: "mail"
display_name_attribute: "cn"
group_search_base: "ou=groups,dc=example,dc=com"
group_member_attribute: "memberOf"
group_role_mapping:
"cn=admins,ou=groups,dc=example,dc=com": ["admin"]
"cn=dev-team,ou=groups,dc=example,dc=com": ["developer"]
default_roles: ["user"]
validate_cert: true
Multiple LDAP Directories
When users live in more than one independent directory (separate domains, an
employee directory plus a contractor directory, partner organisations, etc.),
configure them as a list under ldap_directories instead of the single
ldap: block. The two forms are mutually exclusive.
auth:
provider: "ldap"
ldap_directories:
- name: "corp"
server_url: "ldaps://corp.example.com"
bind_dn: "cn=svc,dc=corp,dc=example,dc=com"
bind_password: "${LDAP_CORP_BIND_PASSWORD}"
base_dn: "dc=corp,dc=example,dc=com"
server_type: "active_directory"
group_role_mapping:
"CN=Codumentor Admins,OU=Groups,DC=corp,DC=example,DC=com": ["admin"]
- name: "contractors"
server_url: "ldaps://contractors.example.com"
bind_dn: "cn=svc,dc=contractors,dc=example,dc=com"
bind_password: "${LDAP_CONTRACTORS_BIND_PASSWORD}"
base_dn: "dc=contractors,dc=example,dc=com"
server_type: "openldap"
# No admin mapping — contractors get the default role only.
Each entry accepts the full set of LDAP options listed above. Each entry must
have a unique, non-empty name.
How login routing works
On every login Codumentor tries each directory in declared order and stops at
the first directory that authenticates the user successfully:
- If a directory responds with user not found, wrong password, disabled account, or not in the required group, the next directory is tried.
- If a directory is unreachable (network error, bind failure), the next directory is tried and a warning is logged. A single dead replica does not lock everyone out.
- If all directories return a connection-level failure, the user sees the generic "service unavailable, try again" response.
- If any directory responded but none accepted the credentials, the user sees the generic "invalid username or password" response. The login form does not reveal which directory rejected the user.
The first directory whose bind succeeds wins, so if the same username exists
in two directories, the one listed first authenticates. Order matters.
Identity across directories
User.provider remains "ldap" regardless of which directory handled the
login. With the default trust_email behaviour, a user who appears in two
directories with the same verified email is treated as the same person — they
land on a single user record.
Audit logs
Each per-directory attempt is logged with provider="ldap:<name>" (e.g.
ldap:corp, ldap:contractors) so administrators can see which directory
authenticated a given login or which one rejected it.
Static API Key (api_provider: "static_key")
A single pre-configured key for the OpenAI-compatible API (/v1/chat/completions), for clients that can only send a bearer token — the OpenAI SDK, LibreChat, scripts. It is an API-surface provider only: there is no interactive login, so it cannot be used for provider or ui_provider, and a typical deployment pairs it with a real UI provider.
auth:
ui_provider: "local" # or "ldap" — how people log in to the web UI
api_provider: "static_key" # how OpenAI-compatible clients authenticate
jwt_secret: "${CODUMENTOR_JWT_SECRET}"
local:
users:
- username: admin
password: "${CODUMENTOR_ADMIN_PASSWORD}"
roles: ["admin", "user"]
static_key:
api_key: "${CODUMENTOR_API_KEY}" # export it; see below
username: "api-service" # identity all API calls are attributed to
roles: ["user", "api"]
| Field | Required | Default | Description |
|---|---|---|---|
api_key | Yes | -- | The bearer token API clients must present. Supports ${ENV_VAR} substitution — use it, the file is often committed. |
username | No | api-service | Identity recorded for every API call made with this key (audit log, conversation ownership). |
roles | No | ["user", "api"] | Roles granted to that identity. |
Clients send it as Authorization: Bearer <api_key> (the token <api_key> form is accepted too).
When the key or the block is missing
api_key is the whole credential, so an unusable one is never turned into a provider that accepts something. The key is refused, and the static key provider is not registered, when it is empty or still the literal ${VAR} placeholder because the variable was not exported — the same fail-closed rule as local passwords.
The consequence is scoped to the surface that broke:
- The server starts and the web UI works normally. A missing or unusable
auth.static_keyblock used to abort startup withRefusing to start rather than falling back to unauthenticated access, taking a perfectly good UI down with it. - The OpenAI-compatible API is disabled: every request to it gets
503with a message pointing back atauth.api_provider, never200. It does not fall back to unauthenticated access. - The startup log says which block to fix, at
ERRORlevel.
A broken ui_provider still refuses to start, because that provider is also the default the middleware authenticates every route against — there is nothing safe left to serve.
Upgrading from 0.3.2 or earlier. Before the surface-hardening release,
api_provider: static_keywith nostatic_key:block started successfully — with the API surface defaulting tonone, where any bearer token authenticated as the anonymous user. If your config has that shape, the API was open; add the block (or dropapi_providerso the API inherits your UI provider) rather than looking for a way back to the old behaviour.
See Also
- Configuration -- Full configuration reference including the
authsection - Security -- Security best practices for production deployments