Action Authentication & Credentials
Every connector auth type, how AuthContext is resolved at dispatch, and how credentials are AEAD-encrypted at rest with XChaCha20-Poly1305, BYOK, and key rotation.
Connector authentication has two halves: what kind of credential a provider needs, and how that credential is stored and turned into a usable token at dispatch time. This page covers both, plus the encryption that protects every stored secret.
Auth types
A provider declares the auth types it supports in Provider.AuthTypes; a
connection is created with exactly one. The AuthType enum
(domain/connector/auth.go) has ten values:
| Auth type | Value | What it is |
|---|---|---|
| None | none | No credential — used for public or in-process providers. |
| Secret text | secret_text | A single opaque secret (or a small set of fields defined by the provider's CredentialSchema). |
| Basic | basic | HTTP basic auth (username + password). |
| OAuth2 | oauth2 | Authorization-code OAuth2 with refresh; tokens managed by Ductor. |
| OAuth2 client credentials | oauth2_client_credentials | Machine-to-machine OAuth2, no user redirect. |
| Two-step | two_step | Exchange stored credentials for a short-lived token at dispatch (application/connector/twostep/). |
| OAuth1 | oauth1 | OAuth 1.0a request signing (application/connector/oauth1/). |
| JWT | jwt | Mint a signed JWT per request, optionally exchanged for an access token (application/connector/jwtauth/). |
| MCP OAuth2 | mcp_oauth2 | OAuth2 for Model Context Protocol servers, with RFC 8414 metadata discovery and RFC 7591 dynamic client registration overlaid on the base OAuth2 flow. |
| Custom | custom | Provider-specific scheme handled entirely by provider hooks. |
For the secret-text / basic / custom types the provider supplies a
CredentialSchema — a list of CredentialFields (name, label, type,
Required, Sensitive, validation, placeholder). Fields marked Sensitive are
encrypted and never returned by the API.
AuthContext resolution at dispatch
When an action is dispatched, ConnectionService.Resolve(ctx, tenantID, connID)
(application/connector/connection_resolve.go) produces a request-scoped
AuthContext. This is the only place stored credentials are decrypted, and the
resulting context is explicitly forbidden from being logged, stored, serialized,
or passed across process boundaries.
The flow:
Cache + singleflight. An LRU cache (default max-age ~60s) short-circuits
repeat resolves; concurrent resolves of the same connection collapse via
singleflight keyed on tenantID/connID.
Load and status-gate. store.Get returns the connection. An active
connection proceeds; pending → ErrConnectionPending; expired / revoked
/ errored → ErrConnectionExpired.
Decrypt. crypto.DecryptContext(ctx, ciphertext, tenantID, providerKey)
decrypts the credential blob into AuthContext.Secret. Non-secret JSONB maps
(ConnectionConfig, Tags, Metadata) are hydrated separately.
Per-auth decoration. A switch on AuthType runs the right decorator:
two_step→ POST the provider's token URL and stampSecret[two_step_token].jwt→ sign a JWT (with optional token exchange) and stampSecret[jwt_token].oauth1→ attach the provider's OAuth1 config; signing happens at egress.none/secret_text/basic/oauth2/oauth2_client_credentials/mcp_oauth2/custom→ no decorator.
OAuth2 inline refresh. For oauth2, if the token expires within 60s, the
service refreshes it under a per-connection singleflight (refresh/<key>),
preferring the provider's Hooks.RefreshToken and falling back to a standard
OAuth2 refresh. A successful refresh is persisted atomically
(BumpAndUpdateOAuthTokens) and recorded as a lifecycle event; an
invalid_grant marks the connection reauth-required.
AuthContext.BearerToken() resolves the usable token in priority order: the
OAuth2 access token, then access_token, two_step_token, or jwt_token from
the secret map. OAuth1 intentionally returns an empty bearer — its requests are
signed by the HTTP client at egress instead.
Where each auth mode is wired
The two-step, JWT, OAuth1, and MCP-OAuth handlers are wired as default-off fx
modules in cmd/ductor/ (fx_connector_twostep.go, fx_connector_jwtauth.go,
fx_connector_oauth1.go, fx_connector_mcpoauth.go). They are constructed at
startup but only fire when a connection of the matching auth type is resolved,
so a deployment that uses only API keys pays nothing for them.
Credential encryption at rest
Every stored credential is sealed with authenticated encryption before it touches the database.
- Algorithm: XChaCha20-Poly1305 (the 24-byte-nonce X variant, not plain
12-byte ChaCha20-Poly1305). Keys are 32 bytes, nonces are 24 bytes drawn from
crypto/randper message, and the Poly1305 tag is 16 bytes. - Tenant-bound AAD. The additional authenticated data binds each ciphertext
to
tenantIDandproviderKey. Decryption with the wrong tenant or provider fails loudly — a ciphertext cannot be replayed across tenants. - Plaintext never persists. The
connector_connectionrow holds onlyCredentialsEnc(andOAuthEncfor OAuth tokens). Decryption happens in-process, at dispatch, into the throwawayAuthContext.
Envelope formats
The crypto layer supports three on-the-wire envelope formats, dispatched on the first byte, so key derivation and rotation can evolve without a data migration:
| Format | Magic | Key derivation |
|---|---|---|
| v0 (legacy) | none | Cipher key is the raw master key. |
| v1 | 0xF1 | Cipher key = HKDF-SHA256(master, info=Purpose). Connector credentials use purpose ductor/connector-credentials/v1. |
| v2 | 0xF2 | Per-message ephemeral data-encryption key, wrapped under a per-tenant key-encryption key (KEK). Enables BYOK and crypto-shredding. |
Configuring the key
The master key is loaded once at startup by provideConnectorCrypto
(cmd/ductor/fx_connector.go). Resolution order:
- BYOK — when
keyProvider.enabledis set, the base64wrappedMasteris unwrapped through the external key provider (kp.Decrypt(keyRef, wrapped)) and must yield exactly 32 bytes. No silent fallback. - Static key —
connector.encryption_key, or the environment variableDUCTOR_CONNECTOR_ENCRYPTION_KEY. The value is standard base64 that must decode to exactly 32 bytes. - Development fallback — only if the key is unset and
DUCTOR_ENV=developmentand the security profile isn't enterprise, a deterministic dev key is derived. In every other environment a missing key is a startup error.
Protect the encryption key
DUCTOR_CONNECTOR_ENCRYPTION_KEY decrypts every stored credential. Treat it
like a database password: inject it from your secret manager, never commit it,
and prefer a KMS-backed BYOK setup in production — the loader even logs a
warning that an env-supplied key is visible in /proc/<pid>/environ. Losing
the key means losing access to every stored connection; leaking it exposes all
of them.
Key rotation
Rotation is decrypt-with-many, encrypt-with-one. The ConnectorCryptoConfig
carries an ActiveKeyID (written into every new ciphertext) plus a Keyring of
older keys retained for decryption only. Each ciphertext records the key id it
was sealed with (connector.encryption_key_id for the active key,
connector.rotation_keys for the retired ones), so during a rotation window both
old and new ciphertexts decrypt correctly and reads transparently re-seal under
the active key on the next write.
Two subtleties matter when rotating:
- The key id is on the wire; the HKDF purpose is not. The purpose label must stay stable across a rotation, or existing ciphertext becomes undecryptable.
- Retired keys stay in the keyring until you're confident no ciphertext still references them. Rotating the active key does not re-encrypt existing rows eagerly — it happens lazily on write.
BYOK and crypto-shredding (envelope v2)
For per-tenant key isolation, envelope v2 wraps a fresh data key per message under a tenant KEK:
TenantKEKCacheis an expirable LRU (default 512 entries, 15-minute TTL) withsingleflightloading.TenantKEKResolverloads a tenant's wrapped KEK from the KEK store and unwraps it through the external key provider; an un-provisioned or soft-deleted tenant returns an error rather than auto-provisioning.EvictingTenantKEKStoreevicts the cache onUpsert/SoftDelete/Destroyand can broadcast the eviction to other pods over Redis. ADestroy(irreversible key overwrite — a crypto-shred) broadcasts fail-closed: every credential for that tenant becomes permanently undecryptable.
Credential lifecycle and health
Beyond raw storage, connections carry a projected health signal
(domain/connector/credential_lifecycle.go) so routing can avoid connections
that will fail. A CredentialHealthGrade is one of unknown, healthy,
degraded, unhealthy, or reauth_required, projected from connection state:
a revoked or expired connection, exhausted refreshes, or missing scopes grade
reauth_required; an errored connection grades unhealthy; recent refresh
failures grade degraded. A connection is readyForRouting only when its
lifecycle policy is active, the connection is active, and it is healthy (or
degraded when the policy allows routing to unhealthy connections). Lifecycle
transitions (oauth_refresh_succeeded, auth_failed, reauth_required,
connection_revoked, …) are emitted as CredentialLifecycleEvents.
Where to go next
- Connections — creating connections and the OAuth start/callback API.
- Architecture — where resolution sits in the dispatch flow.
- Policies, Quotas & Audit — tenant-level BYO OAuth overrides.
Connector Architecture
The registries, the ConnectionService, action dispatch, action semantics, and the durable attempt and async-job execution models.
Action Connections
Creating and resolving connections, direct vs. route mode, connect sessions, and the management API with real request/response payloads.