# 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](https://codna.me/docs/adr/0013-no-administrators.md)).
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.
