Some checks failed
Run Check Script / check (pull_request) Failing after 10s
234 lines
10 KiB
Markdown
234 lines
10 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```bash
|
|
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.
|