Docs Decisions
0012 · MCP authenticates via OAuth against your IdP; humans use passwords for now
Status: Accepted · 2026-09-23
Context
CoDNA has two kinds of caller and they are not alike.
An agent connects over MCP on behalf of a person and must inherit exactly
that person's visibility. A person opens the web UI. Both must resolve to
the same Actor, because the permission policy consults nothing else.
MCP's authorization spec is OAuth 2.1 for HTTP transports, and claude.ai's
connector UI expects it. The Go SDK takes a plain TokenVerifier function and
ships ProtectedResourceMetadataHandler for RFC 9728, so the credential format
is a detail behind one seam rather than a decision that reaches through the
codebase.
Decision
MCP: CoDNA is an OAuth 2.0 resource server, never an authorization server.
It validates JWTs issued by the deployment's own identity provider against that
provider's JWKS, and publishes /.well-known/oauth-protected-resource naming
it. CoDNA issues no tokens, runs no consent screen, and stores no client
secrets.
Humans: email and password, with OIDC login added later. Self-contained evaluation matters more at this stage than avoiding a password store.
Identity is linked on (issuer, subject), never on email. An identities
table maps an IdP subject to a CoDNA user. First sight of an unknown subject
links to an existing account by verified email, or provisions a new one when
CODNA_OIDC_AUTO_PROVISION is set.
Local and evaluation deployments run Dex from the compose file under a
dev profile.
Consequences
- An enterprise security review gets the answer it wants: SSO, central revocation, MFA and offboarding all stay with the IdP. Disabling someone there disables their agent.
- No IdP means no MCP. That is why Dex is in the compose file: development and production exercise the same verifier, so OIDC bugs surface on a laptop rather than in somebody's cluster, and there is no bypass to leave switched on by accident.
- claude.ai one-click works only when the IdP supports Dynamic Client Registration. Okta, Auth0, Keycloak, Authentik and Dex do; Google Workspace does not. Those deployments configure the client by hand, or use a client that accepts a bearer token directly.
- Matching identities on email would be a real vulnerability, not a
shortcut. IdP addresses are mutable and get reassigned: give a departed
employee's address to a new hire and email-matching hands the newcomer every
document the previous holder owned. Hence
(issuer, subject). - Two credential paths means two revocation stories, and password hashing becomes dead weight in any deployment that later moves to SSO. Accepted for now; see the migration note below.
- CoDNA never holds an OAuth client secret or refresh token for a user.
Alternatives rejected
Personal access tokens. Nearly free -- the api_tokens table already
exists -- and they work with every client that can set a header. Rejected as
the primary mechanism because long-lived bearer tokens in a knowledge base are
what a security review objects to, and because offboarding someone would then
require revoking credentials in two systems.
A built-in OAuth 2.1 authorization server. One-click install from claude.ai for everyone, no IdP required. Rejected because it is a large, security-critical subsystem -- authorization endpoint, PKCE, dynamic client registration, consent, refresh rotation, key management -- and it makes a knowledge base partly an identity product. Reconsider only if IdP-less teams turn out to be the market.
A development-mode static token. Zero infrastructure. Rejected because it is an authentication bypass guarded by a single environment variable, and the failure mode when it escapes into a production image is total.
Migration note
When OIDC login lands for the web UI, users.password_hash becomes nullable
dead weight rather than a schema change: accounts that authenticate only through
an IdP simply have none, which the column already allows.
Amendment
2026-09-24 — Service tokens now exist, for operators' tooling: a provisioner, a backup
job, a monitoring console. An admin issues one (POST /api/tokens, or codna bootstrap --service-token on a fresh instance); it acts as that admin, is stored hashed, can be scoped
read or admin, and dies with the account.
This does not reverse the rejection above. That rejection was about agents holding long-lived
credentials on behalf of people, and it stands: service tokens are accepted by the REST API only,
and /mcp refuses them by shape before looking anything up. Offboarding a person still happens
in one place — the identity provider — because no person authenticates with a token.
The "api_tokens table already exists" remark above was wrong when written; no such table was
ever in the schema. The table is service_tokens.
Amendment
2026-10-06 — The web UI can sign in through the identity provider. "OIDC login added later"
is now: with CODNA_OIDC_WEB_CLIENT_ID set, the login page offers "Continue with SSO", an
authorization-code flow with PKCE, a state and a nonce, ending in the same session cookie a
password login sets. The ID token is verified for the web client's audience and then mapped by
the same (issuer, subject) rules as an MCP token, so linking, auto-provisioning and deactivation
behave identically on both paths, and a token minted for one audience is refused by the other.
The "stores no client secrets" line stands for the recommended setup: the web client is registered
as a public client, and PKCE proves that whoever redeems the code started the flow. A provider
that refuses public clients can be given CODNA_OIDC_WEB_CLIENT_SECRET; that is the one secret
CoDNA may then hold, and only by the deployer's choice. CoDNA still issues no tokens and runs no
consent screen. Accounts made with codna bootstrap --sso-only -- the hosted flow -- now have a
way into the web UI, which until today they did not.
Amendment
2026-10-07 — Service tokens no longer act as a person (0013).
The 2026-09-24 amendment's "acts as that admin, can be scoped read or admin, and dies with the
account" no longer holds. Every token is a manage token that acts as the built-in management
principal: it runs the instance — people, groups, tokens, status — and never reads or writes what
a document says. Migration 00002 revoked the read and admin tokens and neither can be
issued again. A token ends when it is revoked or expires, not with an account. The first comes from
the host, with codna tokens create, or codna bootstrap --service-token on a fresh instance.
The rest of that amendment stands: tokens are accepted by the REST API only, /mcp refuses them by
shape, and offboarding a person still happens in one place, the identity provider.