# Authentication (/docs/auth/authentication)



Authentication answers one question: &#x2A;*who is calling?** Ductor's API server
accepts four credential types, each backed by a **ClaimMapper** that validates the
credential and returns a verified `authn.Principal`. This page covers how each one
works and how to turn it on.

<Callout title="How the credential is read">
  On every request the authn layer checks the `X-API-Key` header first, then falls
  back to `Authorization: Bearer <token>`. Over gRPC the equivalents are the
  `x-api-key` and `authorization` metadata keys. The **same** header carries both
  API keys and JWTs — the ClaimMapper chain decides which validator claims it.
</Callout>

## At a glance [#at-a-glance]

| Method                | Credential        | Header                          | Turned on by                            | Identity source                 |
| --------------------- | ----------------- | ------------------------------- | --------------------------------------- | ------------------------------- |
| **OIDC / JWT**        | Signed JWT        | `Authorization: Bearer <jwt>`   | `api.oidc_issuer` + `api.oidc_audience` | Your IdP (via JWKS)             |
| **DB-backed API key** | `duk_…` secret    | `X-API-Key` or `Bearer`         | `auth.api_key_enabled` + key store      | `tenant_api_key` table          |
| **SAML session**      | HS256 session JWT | `Authorization: Bearer <token>` | `identity.saml.enabled`                 | Per-tenant IdP → minted session |
| **Service token**     | HS256 token       | `Authorization: Bearer <token>` | `auth.service_token_secret`             | Internal transport plane        |

## OIDC / JWT [#oidc--jwt]

The primary mechanism for human and machine callers alike. Ductor validates bearer
JWTs against your identity provider's published keys — it never holds a shared
secret for this path.

### Configure [#configure]

```bash
export DUCTOR_AUTH_ENABLED=true
export DUCTOR_API_OIDC_ISSUER="https://your-idp.example.com/"
export DUCTOR_API_OIDC_AUDIENCE="ductor-api"
```

Both issuer **and** audience are required; setting only one leaves OIDC inert (the
JWT mapper is not registered). Clients then send:

```http
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6...
```

### How validation works [#how-validation-works]

* **Keys (JWKS).** On startup Ductor fetches the issuer's JWKS. By default the
  endpoint is derived as `<issuer>/.well-known/jwks.json`; explicit JWKS URLs can
  be supplied in config. Keys are cached and **refreshed every 5 minutes** in the
  background.
* **Algorithms.** Only asymmetric signatures are accepted — `RS256` and `ES256` by
  default. The verifier pins the allowed methods, which blocks the classic
  algorithm-downgrade attack (an `HS256` token signed with the public key as the
  secret is rejected).
* **Claims → Principal.** `sub` becomes `Subject` (required), `tenant_id` →
  `TenantID`, `org_id` → `OrgID`, `client_id`/`azp` → `ClientID`, the
  space-delimited `scope` → `Scopes`, and the **permissions claim** (default name
  `permissions`) → `Roles`.
* **Expiry.** `exp` is honored with a default **1 minute** of clock-skew tolerance.

### Failure handling: the JWKS grace window [#failure-handling-the-jwks-grace-window]

The subtlety worth seeing: an IdP outage does *not* break auth immediately.
Tokens keep validating against cached keys right up to the edge of the grace
window — and then flip to rejecting everything at once:

```mermaid
stateDiagram-v2
  [*] --> Fresh
  Fresh --> Stale: refresh fails
  Stale --> Fresh: refresh succeeds
  Stale --> FailClosed: grace window elapsed
  FailClosed --> Fresh: IdP recovers
  note right of Stale
    cached keys still accepted
  end note
  note right of FailClosed
    cache cleared, all tokens rejected
  end note
```

If the IdP's JWKS endpoint becomes unreachable, Ductor keeps trusting its cached
keys for a bounded &#x2A;*grace window (default 1 hour)**. Past that window without a
successful refresh, the provider &#x2A;*clears its cache and rejects all tokens
(fail-closed)** until the IdP recovers.

<Callout title="Watch the JWKS health signal">
  A stale cache emits an error-level log (`jwks cache stale: rejecting tokens`)
  and increments a `stale` refresh counter exactly once per episode. Wire the
  provider's health into your readiness probe so operators see degraded auth
  *before* users report `401`s. See
  [Observability](/docs/operations/observability).
</Callout>

## DB-backed API keys [#db-backed-api-keys]

Durable, revocable, per-tenant credentials for service-to-service traffic — minted
and managed through the [Tenant API Key API](/docs/auth/api-keys). Enable them
with:

```bash
export DUCTOR_AUTH_API_KEY_ENABLED=true
```

Callers present the raw key in either header:

```http
X-API-Key: duk_live_9f2c…            # preferred
Authorization: Bearer duk_live_9f2c… # also accepted
```

### How validation works [#how-validation-works-1]

* Ductor keys always begin with the `duk_` prefix. The first **8 characters** are
  stored as a lookup prefix, so a presented key is resolved to at most one or two
  candidate rows — the expensive bcrypt comparison runs against that tiny set, not
  every tenant key.
* The stored **bcrypt hash** is compared in constant time. A token that carries
  the `duk_` prefix but is too short is rejected up front, so an attacker cannot
  turn a short key into a broad lookup prefix (a CPU-amplification guard).
* On success the Principal is built from the key: `Subject` is `apikey:<id>`,
  `TenantID`/`Roles`/`Scopes`/environment scope come from the row, and a
  fire-and-forget update stamps `last_used_at`.
* Only **active**, non-expired keys authenticate. Revoked or expired keys are
  rejected immediately.

See the [API keys deep dive](/docs/auth/api-keys) for minting, roles, scopes,
environment scope, expiry, storage, and rotation.

<Callout title="Use database-backed API keys">
  The v2 API supports OIDC, database-backed API keys, SAML, and service tokens.
  The legacy `auth.api_keys` and `DUCTOR_AUTH_API_KEYS` settings are not supported
  for request authentication. Set `auth.api_key_enabled=true` and mint keys through the
  [Tenant API Key API](/docs/auth/api-keys).
</Callout>

## SAML browser sessions [#saml-browser-sessions]

For enterprise SSO, Ductor runs a SAML 2.0 Service Provider. It is **off by
default**; when disabled, no SAML route is mounted and behavior is byte-identical
to a build without it.

```bash
export DUCTOR_IDENTITY_SAML_ENABLED=true
export DUCTOR_IDENTITY_SAML_SP_ENTITY_ID="https://app.example.com/saml/{tenant}/metadata"
export DUCTOR_IDENTITY_SAML_ACS_BASE_URL="https://app.example.com"   # must be HTTPS
```

### The flow [#the-flow]

One SP serves all tenants via per-tenant path scoping, with per-tenant IdP config
managed out of band (CLI: `ductor saml-idp set/get/disable`). After a successful
assertion at the ACS endpoint, Ductor **mints an HS256 session JWT** for the
`(tenant, NameID)` pair. That token carries a fixed audience (`saml_session`) and
issuer (`ductor`), the user's email and roles, a unique `jti`, and a **default
8-hour TTL**.

The browser then presents that session token as a normal `Authorization: Bearer`
credential; the SAML session mapper verifies it. Its signing key is a per-tenant
secret (minimum 32 bytes) stored AEAD-encrypted at rest.

### Single Logout (SLO) and revocation [#single-logout-slo-and-revocation]

With `identity.saml.slo_enabled=true`, the `/saml/{tenant}/slo` endpoint is
mounted. Both IdP-initiated and SP-initiated logout &#x2A;*revoke the session's `jti`**
(recorded in `saml_session_revocation`), and an `sso.logout` audit event is
emitted. Session verification then consults the revocation store and rejects a
logged-out session even before its `exp`.

<Callout title="SLO enforcement is mandatory when enabled">
  If `slo_enabled=true` but no revocation checker is wired, **startup fails** — the
  system refuses to silently downgrade logout to plain bearer validation with no
  revocation lookup. On a revocation-store outage, verification **fails closed**
  (treats the session as invalid) rather than admitting a possibly-logged-out
  session.
</Callout>

## Service tokens (internal plane) [#service-tokens-internal-plane]

For coordinator-to-coordinator and worker-to-coordinator traffic on the internal
transport plane, Ductor validates HS256 service tokens signed with a shared
secret:

```bash
export DUCTOR_AUTH_SERVICE_TOKEN_SECRET="<32+ byte high-entropy secret>"
```

An empty secret disables service tokens. These carry the internal-only `service`
role, whose scope bundle is read access plus the workflow-control surface; the
internal RPC surface is additionally gated by an internal-only method option, not
just by scope. Do **not** assign the `service` role to human identities.

## Selecting and composing mappers [#selecting-and-composing-mappers]

By default the API composes **API key → JWT** (with SAML inserted between them when
enabled). To pin or compose explicitly, set `auth.claim_mapper`:

```bash
# Single validator
export DUCTOR_AUTH_CLAIM_MAPPER="jwt"

# Explicit chain (tried left to right; ErrMissingToken falls through)
export DUCTOR_AUTH_CLAIM_MAPPER="apikey,jwt"
```

Built-in factory names are `jwt`, `apikey`, and `service-token` (plus the SAML
session factory when SAML is enabled). Operators can register custom factories via
`fx.Decorate(*claimmapper.Registry)` and select them here by name.

## Config reference [#config-reference]

| Env var                            | Config key                  | Purpose                                                    |
| ---------------------------------- | --------------------------- | ---------------------------------------------------------- |
| `DUCTOR_AUTH_ENABLED`              | `auth.enabled`              | Master switch for authentication                           |
| `DUCTOR_AUTH_ALLOW_ANONYMOUS`      | `auth.allow_anonymous`      | Dev-only escape hatch (requires `environment=development`) |
| `DUCTOR_API_OIDC_ISSUER`           | `api.oidc_issuer`           | OIDC issuer URL                                            |
| `DUCTOR_API_OIDC_AUDIENCE`         | `api.oidc_audience`         | Expected JWT `aud`                                         |
| `DUCTOR_AUTH_API_KEY_ENABLED`      | `auth.api_key_enabled`      | Enable DB-backed API keys                                  |
| `DUCTOR_AUTH_CLAIM_MAPPER`         | `auth.claim_mapper`         | Select/compose the mapper(s)                               |
| `DUCTOR_AUTH_SERVICE_TOKEN_SECRET` | `auth.service_token_secret` | HS256 service-token secret                                 |
| `DUCTOR_IDENTITY_SAML_ENABLED`     | `identity.saml.enabled`     | Enable the SAML SP                                         |

<Callout title="Next">
  Ready to issue credentials? Go to the [API keys](/docs/auth/api-keys) deep dive.
  Want to control what an authenticated caller may do? See
  [Authorization](/docs/auth/authorization).
</Callout>
