Files
harmony/docs/adr/020-1-zitadel-openbao-secure-config-store.md
Jean-Gabriel Gill-Couture 64582caa64
Some checks failed
Run Check Script / check (pull_request) Failing after 10s
docs: Major rehaul of documentation
2026-03-19 22:38:55 -04:00

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:

  1. It fetches Zitadel's public keys from https://sso.nationtech.io/oauth/v2/keys (the JWKS endpoint).
  2. It verifies the JWT signature.
  3. 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).
  4. It evaluates the claims against the bound_claims and bound_audiences configured on the harmony-developer role.
  5. 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:

  1. Same session (< 4 hours since last use). The cached OpenBao token is still valid. No network call to Zitadel. Fastest path.
  2. Next day (OpenBao token expired, refresh token valid). Harmony uses the OIDC refresh_token to request a new id_token from Zitadel's token endpoint (grant_type=refresh_token). It then exchanges the new id_token for a fresh OpenBao token. This happens silently. The developer sees no prompt.
  3. 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.
  4. 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.
  5. 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.io and secrets.nationtech.io to 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 EnvSource overrides or a service account JWT.