How to set up integration client credentials
Overview
The integration services (soap-hub, inrest, modbus) authenticate against the Energyworx
platform API with the OAuth2 client credentials grant (RFC 6749 §4.4) on the
identity service. This replaces the earlier jwt-config mechanism, where each service
signed its own platform JWT with a pre-registered private key.
One client credential (a client_id/client_secret pair) is shared by all integration
services per target environment. At runtime a service exchanges the credentials for
a short-lived access token at the identity service token endpoint and caches it until
it (nearly) expires.
1. Provision the client credentials
Credentials are created by the intelligence bootstrap, gated behind an opt-in flag so environments without integration services are unaffected:
python super_bootstrap.py --full-provision \
--datastore-project-id <gcp-project> \
--with-integrations-credentials \
...
This creates the integrations ClientCredentials entity in the identity service,
whitelists the credential's account, and seeds the per-namespace users the legacy
authorizer requires. The generated client_id and client_secret are printed
exactly once at the end of the run and are not stored anywhere — capture them
immediately for step 2. To rotate or re-run with known values, pass
--integrations-client-id / --integrations-client-secret.
2. Store the secret (SOPS in ewx-config)
The credentials are committed SOPS-encrypted to the ewx-config repository — never in plaintext in config, source, or Terraform state. From the ewx-config repo root:
mkdir -p secrets/<TF_WORKSPACE>
cp secrets/integrations.enc.yaml.example secrets/<TF_WORKSPACE>/integrations.enc.yaml
# fill in the printed values, then encrypt in place:
sops --encrypt --in-place secrets/<TF_WORKSPACE>/integrations.enc.yaml
The file maps target GCP project ids to credentials, because one deployment can call the APIs of several environments (inrest in particular):
integration_credentials:
ewx-example-project:
client_id: ...
client_secret: ...
# token_url is optional; defaults to
# https://identity.<gcp_project>.ewxapis.com/oauth2/v1/token
See secrets/README.md in ewx-config for the SOPS/KMS runbook and the pre-commit and
CI guards that reject unencrypted files.
3. Deploy
Deploy the integrations scope from the ewx-root monorepo:
mise run tf-apply-integrations -p <env>
Each service's terragrunt.hcl decrypts the environment's integrations.enc.yaml and
Terraform injects the map into the pods as a Kubernetes Secret. The secret feeds the
DYNACONF_INTEGRATION_CREDENTIALS environment variable (all integration services share
the DYNACONF_ Dynaconf prefix), carrying the JSON map with Dynaconf's @json cast
prefix so it lands in settings as the INTEGRATION_CREDENTIALS mapping.
An environment without the file still plans and applies — the services then fail
authentication only if they actually need a token.
4. Runtime token flow
The shared OAuth2ClientCredentialsService (in the ewx-integrations common library)
implements the token lifecycle:
- On each API request the service asks the token service for a bearer token for the target GCP project.
- A cached token that is still valid (with a 60-second early-refresh margin) is reused.
- Otherwise the service POSTs
{"grant_type": "client_credentials", "client_id": ..., "client_secret": ...}tohttps://identity.<target-project>.ewxapis.com/oauth2/v1/tokenand caches the returnedaccess_tokentogether with its expiry (fromexpires_in).
There is no refresh token for this grant: refreshing simply means exchanging the client credentials again.
The token service itself is configuration-agnostic: each service resolves the
INTEGRATION_CREDENTIALS mapping from its own Dynaconf settings and passes it in at
construction, along with the target project id. The token URL template, expiry skew,
and request timeouts are constructor parameters with sensible defaults.
Troubleshooting
No client credentials configured for target project ...— the environment'sintegrations.enc.yamlis missing an entry for that target project (or the prefixed*_INTEGRATION_CREDENTIALSenvironment variable didn't reach the pod). Add the entry and redeploy.401 unauthorized_clientfrom the token endpoint — the credentials don't match the ClientCredentials entity (rotated? typo?), or the entity expired. Re-run the bootstrap with--with-integrations-credentialsand known values to rotate.- API calls return 403/500 after tokens are issued — the credential's account is likely missing whitelisting or per-namespace users; re-run the bootstrap, which seeds both.