ADR-007: Authentication Model¶
Status: Accepted
Date: 2026-01-29 (amended 2026-07-09: gateway JWT now requires Ed25519
proof-of-possession, not gateway_id alone, see Option D)
Decision: enrollment-gated JWT for gateways, bound to an Ed25519
device-identity keypair; built-in users today with account lockout and
server-side logout invalidation; optional OAuth/OIDC later
Context¶
Two types of identities need authentication: 1. Gateways - machines connecting to Control Plane 2. Users - humans accessing UI and API
Each has different requirements for credential lifecycle and management.
Options Considered¶
Gateway Authentication¶
Option A: API Keys¶
- Static keys per gateway
- Simple to implement
Pros: Simple
Cons: No expiry, revocation requires key rotation, no identity claims
Option B: mTLS (Mutual TLS)¶
- Certificate-based authentication
- Gateway presents client certificate
Pros: Strong security, no shared secrets
Cons: Certificate management complexity, rotation overhead
Option C: JWT Tokens¶
- Gateway receives JWT on registration
- Token includes gateway identity and permissions
Pros: Standard, contains claims, expirable, revocable
Cons: Token must be stored securely
Option D: JWT + Ed25519 proof-of-possession (chosen, added 2026-07-09)¶
- Gateway generates an Ed25519 keypair on first use; the private key never
leaves the device. The public key is bound to the
gateway_idat enrollment (trust-on-first-enroll, not a CA-backed identity). - Every
/tokenand/token/renewrequest must include a fresh signature overgateway_id|purpose|timestamp|nonce, verified against the stored public key, with a clock-skew window and single-last-nonce reuse check.
Pros: Closes the actual gap in Option C as shipped (a JWT was mintable by
anyone who merely knew an approved gateway_id, which is not a secret,
it's visible in the UI, audit logs, and hostnames); no shared secret to leak
or rotate; no PKI/CA operational weight.
Cons: TOFU, not CA-backed, a compromised enrollment token used before
the legitimate device enrolls can still bind the wrong key (mitigated by:
the first key bound to a gateway_id sticks, and re-enrollment with a
different key is rejected with 409 rather than silently re-keying).
User Authentication¶
Option A: Built-in Only¶
- Username/password stored in Control Plane DB
- Session tokens
Pros: No external dependencies
Cons: Another password to manage, no SSO
Option B: OAuth/OIDC Only¶
- Delegate to external identity provider
- No local user management
Pros: SSO, enterprise-ready
Cons: Requires external IdP, complex for small deployments
Option C: Built-in + Optional OAuth¶
- Default: built-in username/password
- Optional: integrate with OAuth2/OIDC providers
Pros: Works out of box, scales to enterprise
Cons: Two auth paths to maintain
Decision¶
- Gateways: admin-managed enrollment token creates a pending gateway;
operator approval gates long-lived JWT issuance; the gateway additionally
proves possession of an Ed25519 private key (bound to the
gateway_idat first enrollment, TOFU) on every token issue/renew, Option D above. - Users: built-in authentication today; optional OAuth2/OIDC remains the
enterprise direction. Account lockout (5 failed attempts / 15 minutes) and
server-side logout invalidation (
token_valid_afterwatermark) are shipped.
Rationale¶
Gateway JWT¶
- Long validity: 1-year tokens reduce renewal frequency for remote gateways
- Auto-renew: Gateway renews before expiry when connected
- Revocable: Control Plane can invalidate tokens immediately
- Claims: Token contains gateway ID, permissions, tenant (future)
- Bound to device identity: knowledge of
gateway_idalone was never meant to be sufficient; it isn't a secret. Requiring an Ed25519 signature closes that gap without full mTLS/PKI operational weight.
User Auth¶
- Built-in for demo: Works immediately, no setup required
- OAuth for enterprise: Integrate with existing identity providers
- Progressive complexity: Start simple, add SSO when needed
Gateway Registration Flow¶
1. Operator creates a gateway enrollment token in the UI / control-plane API
{
"enrollment_id": "enroll-factory-north",
"token": "qre_..."
}
2. Gateway boots with CONTROL_PLANE_ENROLLMENT_TOKEN and its stable gateway ID,
generating (or loading) its Ed25519 keypair, private key never leaves
the device
3. Gateway calls POST /api/v1/gateways/enroll
{
"gateway_id": "gw-factory-north",
"enrollment_token": "qre_...",
"hostname": "gateway-01.local",
"public_key": "<base64 Ed25519 public key>"
}
Control Plane stores the public key against the gateway (or rejects with
409 if a different key is already on file for this gateway_id)
4. Control Plane records the gateway as pending
5. Operator approves the pending gateway
6. Gateway calls POST /api/v1/gateways/token, signing
"gw-factory-north|token|<timestamp>|<nonce>" with its private key:
{
"gateway_id": "gw-factory-north",
"timestamp": 1782000000,
"nonce": "<random>",
"signature": "<base64 Ed25519 signature>"
}
Control Plane verifies the signature against the stored public key,
the timestamp is within a 120s clock-skew window, and the nonce doesn't
match the immediately preceding request, before issuing a token.
7. Control Plane returns JWT:
{
"token": "eyJhbGc...",
"expires_at": "2027-01-29T00:00:00Z",
"gateway_id": "gw-factory-north"
}
8. Gateway stores token securely
9. All subsequent API calls include: Authorization: Bearer <token>
Token Renewal¶
The implemented refresh cadence is proactive and frequent rather than timed against expiry, and it re-issues rather than renews in place:
While online, checked roughly every 24 hours:
1. If the in-memory token is more than ~1 day old, the gateway calls
POST /api/v1/gateways/token again (the same enrollment-flow endpoint,
not a dedicated /renew path) and receives a fresh ~365-day token.
2. Gateway replaces the old token in memory.
This is a rolling refresh, not an expiry-driven one: at the moment of
refresh the previous token typically still has ~364 days of validity left.
The dedicated POST /api/v1/gateways/token/renew endpoint exists
server-side but is not currently called by the gateway runtime.
If a refresh attempt fails:
- Gateway continues operating on its existing (still-valid) token
- Retries on the next ~24-hour check
- Gateway tracks consecutive refresh failures and reports the count in its
regular heartbeat metrics (the same channel CPU/memory pressure already
use), chosen because a refresh failure specifically doesn't block other
authenticated calls, so the still-working heartbeat path can carry the
signal even while refresh itself is failing
- Control Plane raises `alarm_type = "gateway.token_refresh_failing"` once
consecutive failures exceed 3, and clears it the moment a refresh
succeeds, implemented, closed remediation ledger item D3
This was a deliberate choice, not drift: daily refresh is simple and self-healing (a missed refresh just retries the next day, with no risk since the token isn't near expiry), and switching to a near-expiry-only renewal would trade that resilience margin for fewer control-plane round-trips. Both are reasonable; this ADR documents the cadence that is actually running.
Consequences¶
Positive¶
- Gateways work reliably with long-lived tokens
- Auto-renewal prevents expiry issues
- Built-in auth enables quick demos
- OAuth enables enterprise integration
Negative¶
- Token storage on gateway must be secure
- Two auth systems to maintain
Mitigations¶
- Store tokens with restricted file permissions
- Document secure token storage
- Clear separation between auth paths in code