CoDNA Request access
Contents

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.