# MCP Authentication & Bio-ID Identity

## Overview

JCI-MCP uses Bio-ID as its authorization server. All identity — `bioId`, `orgSlug`, and
`roles` — flows from a Bio-ID JWT. That JWT drives which JCI role a user gets, which
pipeline audiences they can access, and how jci-brain scopes returned data.

There are two credential paths:

| Path | Credential | Use case |
|------|-----------|----------|
| **OAuth popup** | Bio-ID Bearer JWT (auto-managed) | claude.ai, Cursor, Windsurf, any MCP-OAuth client |
| **JCI API key** | `jci_*` static key | Claude Code stdio, CLI, programmatic tools |


## Hostnames — which URL actually exists

Verified 2026-10-02 against DNS and each host's `/health` and
`/.well-known/oauth-protected-resource`.

| URL | Status | What it is |
|-----|--------|------------|
| `https://mcp.askjefferson.com` | **live** — use this | Public entry, served by the **JCI plane** (Kubernetes, 2 pods, Envoy Gateway; Cloudflare-proxied). Publishes the OAuth discovery documents; `POST /` and `POST /mcp` are the MCP transport; `/mcp/jci-mcp/<user>` is the URL-username path |
| `https://jci-mcp.tawa.pro` | **live** | Alias of mcp.askjefferson.com: A record straight to the plane load balancer (DNS-only), same pods. Discovery documents name this host when you arrive here |
| `https://jefferson.daryx.com/mcp/jci-mcp/<user>` | live, legacy | Old path-prefix URL, not on the plane. No `.well-known` documents, so no OAuth discovery |

### Where the code runs, and how a change reaches it

Since the 2026-09-26 cutover (insureco/jci-plane, `docs/cutover-jci-mcp.md`), public MCP traffic
runs on the plane, not on jefferson. jefferson keeps a PM2 copy of jci-mcp, and it is still part
of the path:

```
./deploy.sh  →  jefferson PM2 jci-mcp (:9002)
             →  plane-image-sync timer on jefferson (every 5 min, jci-plane#48)
                builds registry.digitalocean.com/insureco/jci-mcp:<sha>, merges the tag into jci-plane
             →  Argo CD rolls the plane
```

- **A green `deploy.sh` proves jefferson only.** The change is live for clients when the public
  health check shows its commit:
  `curl -s https://mcp.askjefferson.com/health | grep -o '"commit":"[^"]*"'`
- `curl localhost:9002/health` on jefferson is the break-glass copy, not what clients hit.
- **Never stop PM2 `jci-mcp` on jefferson.** It is the commit `plane-image-sync` ships, and the
  standing break-glass if the plane fails (jci-plane `docs/runbook-break-glass.md`, decision
  2026-09-27).

> **Correction (2026-10-02):** this table said `jci-mcp.tawa.pro` was "an A record straight to
> jefferson … same nginx proxy to the same process". That stopped being true at the plane
> cutover. Acting on it, an agent verified a fix on jefferson's `localhost:9002`, called it live
> while clients still ran the previous commit, and proposed stopping the jefferson process the
> deploy pipeline depends on.

> **Correction (2026-09-08):** the Claude Code examples below pointed at
> `https://jci-mcp.tawa.pro/mcp` while that host did not exist — no DNS record, no
> ingress. It was brought up the same day as a true alias, so the URL now works, but
> `mcp.askjefferson.com` stays the canonical name and the one connectors are registered
> under. Separately, `POST /mcp` rejected `initialize` until insureco/jci-mcp#59 — a
> client pointed at `/mcp` before that fix would fail its handshake on either host.

---

## Path 1 — OAuth (claude.ai, Cursor, Windsurf)

MCP clients that implement the MCP OAuth spec discover the authorization server automatically
via two well-known endpoints that jci-mcp exposes:

```
GET /.well-known/oauth-protected-resource
  → { authorization_servers: ["https://bio.tawa.pro"], bearer_methods_supported: ["header"], ... }

GET /.well-known/oauth-authorization-server
  → { issuer: "https://bio.tawa.pro",
      authorization_endpoint: "https://bio.tawa.pro/oauth/authorize",
      token_endpoint: "https://bio.tawa.pro/api/oauth/token",
      code_challenge_methods_supported: ["S256"], ... }
```

### What the client does automatically

1. User adds the MCP server URL: `https://mcp.askjefferson.com`
2. Client fetches `/.well-known/oauth-protected-resource` → discovers Bio-ID is the AS
3. Client fetches Bio-ID's OAuth metadata from `/.well-known/oauth-authorization-server`
4. Client opens the Bio-ID login popup (PKCE authorization code flow)
5. User authenticates with Bio-ID
6. Client receives and stores the access token
7. All subsequent MCP requests carry `Authorization: Bearer <bio-id-jwt>`

The user does not configure any client ID or secret manually. The MCP client handles the
full PKCE flow with Bio-ID directly.

### Token verification in jci-mcp

On each request, jci-mcp's `bioAuthMiddleware` verifies the Bearer token against Bio-ID's
JWKS endpoint (`https://bio.tawa.pro/.well-known/jwks.json`). The JWKS response is cached
and refreshed automatically on key rotation. No shared secret is needed — tokens are RS256.

```
Request arrives with Authorization: Bearer <jwt>
  → bioAuthMiddleware verifies via JWKS
  → extracts { bioId, orgSlug, roles, email } from JWT payload
  → sets req.userContext
  → request proceeds to tool handler
```

---

## Path 2 — JCI API Key (Claude Code, CLI, programmatic)

JCI API keys are generated from the askJefferson admin portal. They carry a pre-assigned
role and pipeline audience set and are stored in jci-brain as SHA-256 hashes (the raw key
is shown once at generation time).

**Key format:** `jci_<12-char-prefix>_<36-char-random>`

The key is sent in the Authorization header:
```
Authorization: Bearer jci_abc123456789_<rest-of-key>
```

Or in the `x-api-key` header:
```
x-api-key: jci_abc123456789_<rest-of-key>
```

jci-brain detects the `jci_` prefix and routes to API key verification instead of JWT
verification. The key is hashed on arrival and looked up against the `jci_api_keys`
collection. Each key stores:

- `bio_id` — the Bio-ID of the key owner
- `role` — the JCI role (`developer`, `devops`, `business`, `admin`)
- `pipeline_audiences` — the audiences this key can access
- `persona_id` — which persona (context profile) this key belongs to
- `last_used` — updated on each successful verification

---

## Identity Flow: Bio-ID JWT → JCI Context

### From a Bio-ID JWT

```
decoded.sub (or decoded.bioId)  →  jciUser.bio_id
decoded.orgSlug                 →  jciUser.org_slug  (company scoping)
decoded.roles[]                 →  mapped to JCI role (see table below)
decoded.email                   →  jciUser.email
```

After JWT verification, jci-brain looks up the user in `jci_users` by `bio_id` to find
their active persona. The persona carries the definitive `role` and `pipeline_audiences`
for this session. If no persona exists yet, defaults to `role: business` /
`pipeline_audiences: ['business', 'public']`.

### From a JCI API key

Role and pipeline audiences are stored directly on the key record — no persona lookup
needed. Identity is the key's `bio_id`.

---

## Role Mapping

**jci `admin` means platform admin, and nothing weaker** (insureco/jci-brain#396, #400).
Platform admin uses Bio-ID's own definition: the JWT `roles` claim includes `super_admin`
(iec-bio `lib/is-platform-admin.ts`). `roles` is the user's platform role list. An org
admin's role is in `orgRoles` and never counts, and a bare `admin` in `roles` is a Bio-ID
*product* role, not an admin.

| Bio-ID token | JCI role | Pipeline audiences |
|-------------|----------|--------------------|
| `super_admin` in `roles` (platform admin) | `admin` | `business`, `developer`, `infra`, `internal`, `public` |
| `developer` in `roles` | `developer` | `developer`, `public` |
| `devops` in `roles` | `devops` | `developer`, `infra`, `public` |
| anything else, **including `admin` in `roles` and every org role** | `business` | `business`, `public` |

The mapping is performed by jci-brain's `authMiddleware._verifyBioJwt` (`bioRolesToJciRole`).
Note: for Bio-ID JWT logins, `persona_id` is set to `null`, because the middleware does not
look up a persona on JWT authentication.

### One invariant, on every auth path

`AuthMiddleware._attach` applies `capRole` after the verifier runs, whatever the credential:
role `admin` and the `internal` audience require the **owner** or a **platform admin**. Any
other caller carrying them is served as `business` / `business, public`. That covers:

- a JCI API key whose stored `role` is `admin`, when its owner is not the owner or a platform
  admin (the stored record is not rewritten, and it regains nothing);
- a persona role on the legacy URL path.

A person also cannot create an `admin` persona unless they are a platform admin
(`POST /api/identity/me/personas` → 403). A personal key copies its persona's role.

Service-identity API keys are an explicit allowlist (`src/utils/serviceIdentity.js`:
`SERVICE-JCI-MCP`, `SERVICE-ADMIN-CHATBOT`). They keep their stored role and audiences, and pass
platform gates only if an operator hand-set `platform_admin` on the key (jci-brain#407). Only an
allow-listed service may name another person in `x-bio-id` (jci-brain#403).

### Header-trusted routes are gated (jci-brain#402)

`/api/intelligence/{tasks,review,schedule,calendar,emails,team,outbound}` and the general
`/api/intelligence` router sit behind `trustedCaller`. It accepts three kinds of caller:

- iec-portal's shared secret, on `/tasks` only;
- a genuinely local caller with no `jci_` credential (loopback, closable with
  `INTELLIGENCE_ROUTES_ALLOW_LOCAL=false`);
- any credential `authenticate()` accepts.

A person's identity headers and `user_id`-style parameters are rebuilt from `req.jciUser`, so a
person cannot name anyone else. The URL-username path (`/mcp/jci-brain/:username`) was removed in
jci-brain#403. Use a Bio-ID JWT or a JCI API key.

### Gates use `isPlatformAdmin`, never `role === 'admin'`

Every gate over other people's data (the roster, identity edits, `/api/admin/*`, service
keys, dev projects, the unfiltered pipeline view) checks `isPlatformAdmin(req.jciUser)`
(`src/utils/platformAdmin.js`): `is_owner`, or `platform_admin` from a verified JWT. A role is
a label a caller can arrive with, so it is never the gate. Org admins keep org-scoped powers
only.

---

## Pipeline Audience Scoping

Every data record in jci-brain carries one or more `pipeline_audiences` tags. Queries are
filtered to only return records whose audience overlaps with the authenticated user's
`pipeline_audiences`.

| JCI role | Sees |
|----------|------|
| `admin` (platform admin only) | All data (business + developer + infra + internal + public) |
| `developer` | Developer + public data |
| `devops` | Developer + infra + public data |
| `business` | Business + public data |

This means a `business` user querying pipeline logs will only see records tagged
`business` or `public` — they cannot see developer build logs or infra metrics.

---

## Environment Variables

| Variable | Required by | Purpose |
|----------|------------|---------|
| `BIO_ID_URL` | jci-mcp, jci-brain | Bio-ID base URL. Default: `https://bio.tawa.pro` |
| `MCP_SERVER_URL` | jci-mcp | Included in `oauth-protected-resource` response as `resource`. The plane answers with the host the request arrived on (`https://mcp.askjefferson.com` or `https://jci-mcp.tawa.pro`) |
| `BIO_AUTH_REQUIRED` | jci-mcp | Set `true` to reject requests without a valid JWT (disables legacy fallback) |
| `OWNER_BIO_ID` | jci-brain | bio_id that gets `is_owner: true` (admin bypass) |

---

## Client Configuration Examples

### claude.ai (web)

1. Go to **Settings → Integrations → Add MCP Server**
2. Enter the server URL: `https://mcp.askjefferson.com`
3. Claude.ai detects the Bio-ID OAuth requirement and shows a login popup
4. Authenticate with your Bio-ID account
5. Done — claude.ai manages token refresh automatically

### Cursor / Windsurf

In `.cursor/mcp.json` or the MCP settings panel:

```json
{
  "mcpServers": {
    "jci-mcp": {
      "url": "https://mcp.askjefferson.com",
      "transport": "http"
    }
  }
}
```

Cursor will pop the Bio-ID OAuth flow on first connection.

### Claude Code (stdio with JCI API key)

In `~/.claude/settings.json` or the project `.claude/settings.json`:

```json
{
  "mcpServers": {
    "jci-mcp": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.askjefferson.com/mcp"],
      "env": {
        "BEARER_TOKEN": "jci_your_api_key_here"
      }
    }
  }
}
```

Or using environment headers with `mcp-remote`:

```bash
MCP_SERVER_HEADERS='{"Authorization":"Bearer jci_your_key"}' \
  npx mcp-remote https://mcp.askjefferson.com
```

### Claude Code (remote OAuth — claude.ai SDK)

Claude Code in remote mode will follow the same OAuth popup flow as claude.ai when the
server URL is used directly without a pre-configured key:

```json
{
  "mcpServers": {
    "jci-mcp": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.askjefferson.com/mcp"]
    }
  }
}
```

---

## Authentication Priority Order

jci-brain's `authMiddleware` checks credentials in this order:

1. **Bearer JWT** starting with `jci_` → JCI API key path
2. **Bearer JWT** (other) → Bio-ID RS256 JWT verification
3. **`x-api-key` header** starting with `jci_` → JCI API key path
4. ~~URL-based username~~ (removed in jci-brain#403)
5. **Dev headers** (`x-bio-id`, `x-role`) → localhost only, development mode

In production with `BIO_AUTH_REQUIRED=true`, steps 3-5 are rejected with 401.

---

## Key Facts

- Bio-ID tokens are RS256-signed. jci-mcp and jci-brain verify via JWKS — no shared secret.
- `BIO_ID_URL` defaults to `https://bio.tawa.pro` — no env var needed in standard deployments.
- JCI API keys are hashed (SHA-256) in storage — the raw key cannot be recovered.
- `orgSlug` from the JWT becomes the `company_id` for data scoping in jci-brain.
- The `iecHash` field on InsureBio entities is the Vault chain address for insurance entities
  — it is a separate concept from JCI user identity (`bioId`). Do not conflate the two.
- Personas allow one `bio_id` to carry different roles in different contexts (e.g., a user who
  acts as both `developer` and `business`). The default persona is used unless a specific
  `persona_id` is passed.

## Common Mistakes

- Putting a Bio-ID access token in the `x-api-key` header → only `jci_*` keys are accepted there
- Confusing `iecHash` (InsureBio Vault entity address) with `bioId` (JCI user identity)
- Expecting `orgSlug` in the JWT without the user having an org in Bio-ID
- Using `BIO_AUTH_REQUIRED=true` in dev before you have a JCI API key configured
- Adding `internalDependencies: bio-id` in catalog-info.yaml to enable OAuth — wrong; use `spec.auth: mode: sso`
