Codumentor logo Codumentor

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:

ProviderValueDescription
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 the local provider with a single admin user 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, plus CODUMENTOR_JWT_SECRET, before starting:

export CODUMENTOR_ADMIN_PASSWORD='<choose one>'
export CODUMENTOR_JWT_SECRET='<random string>'

Regardless of provider, set jwt_secret in 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.provider applies 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 and static_key for OpenAI-compatible API clients — set ui_provider and/or api_provider (see Static API Key). When api_provider is omitted it inherits ui_provider (not provider alone), so hardening the UI also protects the API unless you explicitly set api_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:

  1. Configure a provider — local with a ${ENV_VAR} password is the smallest step.
  2. Keep api.host: 127.0.0.1 and publish the instance through a reverse proxy that authenticates.
  3. 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
SettingRequiredDefaultDescription
base_urlYes--Base URL of the Gitea instance
token_cache_ttl_minutesNo30How 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

  1. The client sends a Gitea personal access token in the Authorization header.
  2. Codumentor calls the Gitea API (/api/v1/user) to validate the token.
  3. On success, the result is cached for token_cache_ttl_minutes to avoid repeated API calls.
  4. 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"]
FieldRequiredDefaultDescription
usernameYes--Login name. Must be unique across the users list (duplicates fail config validation).
passwordYes--Plaintext or a pbkdf2_sha256$... hash (see below). Supports ${ENV_VAR} substitution so the real secret stays out of the committed file.
emailNonullUsed for identity matching and notifications.
display_nameNonullShown in the UI. Falls back to username.
rolesNo["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:

  1. 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.
  2. Seeds the at-rest encryption key (Fernet) for the per-user stores written by the user_secrets, model_profiles, and oauth plugins. 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

SettingRequiredDefaultDescription
server_urlYes--LDAP server URL. Use ldaps:// for SSL or ldap:// for plain/STARTTLS.
bind_dnYes--Distinguished name of the service account used for searching.
bind_passwordYes--Password for the bind DN. Use ${LDAP_BIND_PASSWORD} to read from an environment variable.
base_dnYes--Base DN for all LDAP searches.

Server Type

The server_type setting applies sensible defaults for common directory servers:

Typeusername_attributeuser_search_filterNotes
active_directorysAMAccountName(&(objectClass=user)(sAMAccountName={username}))Uses AD-specific nested group resolution
openldapuid(&(objectClass=inetOrgPerson)(uid={username}))Standard LDAP schema, no nested group resolution
genericuid(&(objectClass=person)(uid={username}))Minimal defaults, configure manually

You can override any of the defaults by specifying the corresponding setting explicitly.

Security Mode

ModeDescription
ldapsSSL/TLS on connect (port 636). Recommended for production.
starttlsUpgrade plain connection to TLS after connect (port 389).
noneNo encryption. Only use for testing on a trusted network.

User Search Settings

SettingDefaultDescription
user_search_filterDepends on server_typeLDAP filter to locate the user. {username} is replaced with the login name.
user_search_baseValue of base_dnSubtree to search for users.
username_attributeDepends on server_typeLDAP attribute containing the username.
email_attributemailLDAP attribute containing the email address.
display_name_attributedisplayNameLDAP attribute containing the display name.

Group and Role Mapping

SettingDefaultDescription
group_search_baseValue of base_dnSubtree to search for groups.
group_member_attributememberOfLDAP 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_groupnullIf set, only users who are members of this group can authenticate. All others are rejected.

SSL Certificate Settings

SettingDefaultDescription
validate_certtrueWhether to validate the server's TLS certificate. Set to false only for testing.
ca_cert_filenullPath 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:

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"]
FieldRequiredDefaultDescription
api_keyYes--The bearer token API clients must present. Supports ${ENV_VAR} substitution — use it, the file is often committed.
usernameNoapi-serviceIdentity recorded for every API call made with this key (audit log, conversation ownership).
rolesNo["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:

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_key with no static_key: block started successfully — with the API surface defaulting to none, where any bearer token authenticated as the anonymous user. If your config has that shape, the API was open; add the block (or drop api_provider so the API inherits your UI provider) rather than looking for a way back to the old behaviour.

See Also