Connectors

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 typeValueWhat it is
NonenoneNo credential — used for public or in-process providers.
Secret textsecret_textA single opaque secret (or a small set of fields defined by the provider's CredentialSchema).
BasicbasicHTTP basic auth (username + password).
OAuth2oauth2Authorization-code OAuth2 with refresh; tokens managed by Ductor.
OAuth2 client credentialsoauth2_client_credentialsMachine-to-machine OAuth2, no user redirect.
Two-steptwo_stepExchange stored credentials for a short-lived token at dispatch (application/connector/twostep/).
OAuth1oauth1OAuth 1.0a request signing (application/connector/oauth1/).
JWTjwtMint a signed JWT per request, optionally exchanged for an access token (application/connector/jwtauth/).
MCP OAuth2mcp_oauth2OAuth2 for Model Context Protocol servers, with RFC 8414 metadata discovery and RFC 7591 dynamic client registration overlaid on the base OAuth2 flow.
CustomcustomProvider-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; pendingErrConnectionPending; expired / revoked / erroredErrConnectionExpired.

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 stamp Secret[two_step_token].
  • jwt → sign a JWT (with optional token exchange) and stamp Secret[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/rand per message, and the Poly1305 tag is 16 bytes.
  • Tenant-bound AAD. The additional authenticated data binds each ciphertext to tenantID and providerKey. Decryption with the wrong tenant or provider fails loudly — a ciphertext cannot be replayed across tenants.
  • Plaintext never persists. The connector_connection row holds only CredentialsEnc (and OAuthEnc for OAuth tokens). Decryption happens in-process, at dispatch, into the throwaway AuthContext.

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:

FormatMagicKey derivation
v0 (legacy)noneCipher key is the raw master key.
v10xF1Cipher key = HKDF-SHA256(master, info=Purpose). Connector credentials use purpose ductor/connector-credentials/v1.
v20xF2Per-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:

  1. BYOK — when keyProvider.enabled is set, the base64 wrappedMaster is unwrapped through the external key provider (kp.Decrypt(keyRef, wrapped)) and must yield exactly 32 bytes. No silent fallback.
  2. Static keyconnector.encryption_key, or the environment variable DUCTOR_CONNECTOR_ENCRYPTION_KEY. The value is standard base64 that must decode to exactly 32 bytes.
  3. Development fallback — only if the key is unset and DUCTOR_ENV=development and 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:

  • TenantKEKCache is an expirable LRU (default 512 entries, 15-minute TTL) with singleflight loading.
  • TenantKEKResolver loads 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.
  • EvictingTenantKEKStore evicts the cache on Upsert/SoftDelete/Destroy and can broadcast the eviction to other pods over Redis. A Destroy (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