10 KiB
ADR 020-1: Zitadel OIDC and OpenBao Integration for the Config Store
Author: Jean-Gabriel Gill-Couture
Date: 2026-03-18
Status
Proposed
Context
ADR 020 defines a unified harmony_config crate with a ConfigStore trait. The default team-oriented backend is OpenBao, which provides encrypted storage, versioned KV, audit logging, and fine-grained access control.
OpenBao requires authentication. The question is how developers authenticate without introducing new credentials to manage.
The goals are:
- Zero new credentials. Developers log in with their existing corporate identity (Google Workspace, GitHub, or Microsoft Entra ID / Azure AD).
- Headless compatibility. The flow must work over SSH, inside containers, and in CI — environments with no browser or localhost listener.
- Minimal friction. After a one-time login, authentication should be invisible for weeks of active use.
- Centralized offboarding. Revoking a user in the identity provider must immediately revoke their access to the config store.
Decision
Developers authenticate to OpenBao through a two-step process: first, they obtain an OIDC token from Zitadel (sso.nationtech.io) using the OAuth 2.0 Device Authorization Grant (RFC 8628); then, they exchange that token for a short-lived OpenBao client token via OpenBao's JWT auth method.
The authentication flow
Step 1: Trigger
The ConfigManager attempts to resolve a value via the StoreSource. The StoreSource checks for a cached OpenBao token in ~/.local/share/harmony/session.json. If the token is missing or expired, authentication begins.
Step 2: Device Authorization Request
Harmony sends a POST to Zitadel's device authorization endpoint:
POST https://sso.nationtech.io/oauth/v2/device_authorization
Content-Type: application/x-www-form-urlencoded
client_id=<harmony_client_id>&scope=openid email profile offline_access
Zitadel responds with:
{
"device_code": "dOcbPeysDhT26ZatRh9n7Q",
"user_code": "GQWC-FWFK",
"verification_uri": "https://sso.nationtech.io/device",
"verification_uri_complete": "https://sso.nationtech.io/device?user_code=GQWC-FWFK",
"expires_in": 300,
"interval": 5
}
Step 3: User prompt
Harmony prints the code and URL to the terminal:
[Harmony] To authenticate, open your browser to:
https://sso.nationtech.io/device
and enter code: GQWC-FWFK
Or visit: https://sso.nationtech.io/device?user_code=GQWC-FWFK
If a desktop environment is detected, Harmony also calls open / xdg-open to launch the browser automatically. The verification_uri_complete URL pre-fills the code, so the user only needs to click "Confirm" after logging in.
There is no localhost HTTP listener. The CLI does not need to bind a port or receive a callback. This is what makes the device flow work over SSH, in containers, and through corporate firewalls — unlike the oc login approach which spins up a temporary web server to catch a redirect.
Step 4: User login
The developer logs in through Zitadel's web UI using one of the configured identity providers:
- Google Workspace — for teams using Google as their corporate identity.
- GitHub — for open-source or GitHub-centric teams.
- Microsoft Entra ID (Azure AD) — for enterprise clients, particularly common in Quebec and the broader Canadian public sector.
Zitadel federates the login to the chosen provider. The developer authenticates with their existing corporate credentials. No new password is created.
Step 5: Polling
While the user is authenticating in the browser, Harmony polls Zitadel's token endpoint at the interval specified in the device authorization response (typically 5 seconds):
POST https://sso.nationtech.io/oauth/v2/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=dOcbPeysDhT26ZatRh9n7Q
&client_id=<harmony_client_id>
Before the user completes login, Zitadel responds with authorization_pending. Once the user consents, Zitadel returns:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"id_token": "eyJhbGciOiJSUzI1NiIs..."
}
The scope=offline_access in the initial request is what causes Zitadel to issue a refresh_token.
Step 6: OpenBao JWT exchange
Harmony sends the id_token (a JWT signed by Zitadel) to OpenBao's JWT auth method:
POST https://secrets.nationtech.io/v1/auth/jwt/login
Content-Type: application/json
{
"role": "harmony-developer",
"jwt": "eyJhbGciOiJSUzI1NiIs..."
}
OpenBao validates the JWT:
- It fetches Zitadel's public keys from
https://sso.nationtech.io/oauth/v2/keys(the JWKS endpoint). - It verifies the JWT signature.
- It reads the claims (
email,groups, and any custom claims mapped from the upstream identity provider, such as Azure AD tenant or Google Workspace org). - It evaluates the claims against the
bound_claimsandbound_audiencesconfigured on theharmony-developerrole. - If validation passes, OpenBao returns a client token:
{
"auth": {
"client_token": "hvs.CAES...",
"policies": ["harmony-dev"],
"metadata": { "role": "harmony-developer" },
"lease_duration": 14400,
"renewable": true
}
}
Harmony caches the OpenBao token, the OIDC refresh token, and the token expiry timestamps to ~/.local/share/harmony/session.json with 0600 file permissions.
OpenBao storage structure
All configuration and secret state is stored in an OpenBao Versioned KV v2 engine.
Path taxonomy:
harmony/<organization>/<project>/<environment>/<key>
Examples:
harmony/nationtech/my-app/staging/PostgresConfig
harmony/nationtech/my-app/production/PostgresConfig
harmony/nationtech/my-app/local-shared/PostgresConfig
The ConfigClass (Standard vs. Secret) can influence OpenBao policy structure — for example, Secret-class paths could require stricter ACLs or additional audit backends — but the path taxonomy itself does not change. This is an operational concern configured in OpenBao policies, not a structural one enforced by path naming.
Token lifecycle and silent refresh
The system manages three tokens with different lifetimes:
| Token | TTL | Max TTL | Purpose |
|---|---|---|---|
| OpenBao client token | 4 hours | 24 hours | Read/write config store |
| OIDC ID token | 1 hour | — | Exchange for OpenBao token |
| OIDC refresh token | 90 days absolute, 30 days inactivity | — | Obtain new ID tokens silently |
The refresh flow, from the developer's perspective:
- Same session (< 4 hours since last use). The cached OpenBao token is still valid. No network call to Zitadel. Fastest path.
- Next day (OpenBao token expired, refresh token valid). Harmony uses the OIDC
refresh_tokento request a newid_tokenfrom Zitadel's token endpoint (grant_type=refresh_token). It then exchanges the newid_tokenfor a fresh OpenBao token. This happens silently. The developer sees no prompt. - OpenBao token near max TTL (approaching 24 hours of cumulative renewals). Instead of renewing, Harmony re-authenticates using the refresh token to get a completely fresh OpenBao token. Transparent to the user.
- After 30 days of inactivity. The OIDC refresh token expires. Harmony falls back to the device flow (Step 2 above) and prompts the user to re-authenticate in the browser. This is the only scenario where a returning developer sees a login prompt.
- User offboarded. An administrator revokes the user's account or group membership in Zitadel. The next time the refresh token is used, Zitadel rejects it. The device flow also fails because the user can no longer authenticate. Access is terminated without any action needed on the OpenBao side.
OpenBao token renewal uses the /auth/token/renew-self endpoint with the X-Vault-Token header. Harmony renews proactively at ~75% of the TTL to avoid race conditions.
OpenBao role configuration
The OpenBao JWT auth role for Harmony developers:
bao write auth/jwt/config \
oidc_discovery_url="https://sso.nationtech.io" \
bound_issuer="https://sso.nationtech.io"
bao write auth/jwt/role/harmony-developer \
role_type="jwt" \
bound_audiences="<harmony_client_id>" \
user_claim="email" \
groups_claim="urn:zitadel:iam:org:project:roles" \
policies="harmony-dev" \
ttl="4h" \
max_ttl="24h" \
token_type="service"
The bound_audiences claim ties the role to the specific Harmony Zitadel application. The groups_claim allows mapping Zitadel project roles to OpenBao policies for per-team or per-project access control.
Self-hosted deployments
For organizations running their own infrastructure, the same architecture applies. The operator deploys Zitadel and OpenBao using Harmony's existing ZitadelScore and OpenbaoScore. The only configuration needed is three environment variables (or their equivalents in the bootstrap config):
HARMONY_SSO_URL— the Zitadel instance URL.HARMONY_SECRETS_URL— the OpenBao instance URL.HARMONY_SSO_CLIENT_ID— the Zitadel application client ID.
None of these are secrets. They can be committed to an infrastructure repository or distributed via any convenient channel.
Consequences
Positive
- Developers authenticate with existing corporate credentials. No new passwords, no static tokens to distribute.
- The device flow works in every environment: local terminal, SSH, containers, CI runners, corporate VPNs.
- Silent token refresh keeps developers authenticated for weeks without any manual intervention.
- User offboarding is a single action in Zitadel. No OpenBao token rotation or manual revocation required.
- Azure AD / Microsoft Entra ID support addresses the enterprise and public sector market.
Negative
- The OAuth state machine (device code polling, token refresh, error handling) adds implementation complexity compared to a static token approach.
- Developers must have network access to
sso.nationtech.ioandsecrets.nationtech.ioto pull or push configuration state. True offline work falls back to the local file store, which does not sync with the team. - The first login per machine requires a browser interaction. Fully headless first-run scenarios (e.g., a fresh CI runner with no pre-seeded tokens) must use
EnvSourceoverrides or a service account JWT.