docs(adr): ADR-271 — RuView as a Cognitum OAuth resource server
The previous commit referenced ADR-271 in five places without the ADR existing. This writes it. Records the decision and, more importantly, the direction — RuView verifies tokens users present, it does not obtain tokens to call Cognitum. Every other Cognitum OAuth integration in the org is the client side of a plane RuView does not have; the sole relevant precedent is meta-llm's oauthBearer.ts. Covers: why offline verification is a requirement rather than an optimisation (Pi-class hosts lose WAN, and no introspection endpoint exists); why the accept-rule is a port and not a design; why long-lived setup/workload credentials are refused (no database to check revocation); why scope — not `aud`, not `client_id` — is the capability boundary; and the alternatives, including the `cog_`-minting approach that was tried org-wide and found broken against production. Co-Authored-By: Ruflo & AQE
This commit is contained in:
parent
499cec7914
commit
92cbeb0c34
|
|
@ -0,0 +1,209 @@
|
||||||
|
# ADR-271: RuView as a Cognitum OAuth resource server
|
||||||
|
|
||||||
|
- **Status**: accepted
|
||||||
|
- **Date**: 2026-07-22
|
||||||
|
- **Deciders**: RuView maintainers
|
||||||
|
- **Tags**: auth, oauth, cognitum, security, sensing-server
|
||||||
|
- **Related**: ADR-055 (integrated sensing server), ADR-102 (edge module registry), ADR-066 (ESP32 seed pairing), cognitum-one/dashboard ADR-060 (OAuth scopes beyond `inference`), cognitum-one/meta-llm ADR-045 (Bearer at completions)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`/api/v1/*` on `wifi-densepose-sensing-server` is gated by `RUVIEW_API_TOKEN`
|
||||||
|
(`bearer_auth.rs`): a single shared secret, compared in constant time, with no
|
||||||
|
expiry, no rotation and no per-user attribution. `homecore-api` has a second,
|
||||||
|
unrelated scheme (`LongLivedTokenStore` over `HOMECORE_TOKENS`) whose own doc
|
||||||
|
comment describes it as "no expiry, no rotation, no per-user attribution yet".
|
||||||
|
|
||||||
|
That is proportionate for the ADR-055 topology — server bundled in the desktop
|
||||||
|
app, spawned as a child, localhost only. It is not proportionate for the other
|
||||||
|
deployment RuView actually has: a sensing server on a Pi or hub, reachable on a
|
||||||
|
LAN, potentially serving more than one person, exposing live presence, pose,
|
||||||
|
breathing and heart-rate data plus destructive operations (model training,
|
||||||
|
model delete, recording delete).
|
||||||
|
|
||||||
|
Cognitum operates a live OAuth 2.1 authorization server at `auth.cognitum.one`.
|
||||||
|
Users of RuView are already Cognitum account holders. The obvious question is
|
||||||
|
whether RuView can accept that identity instead of a shared string.
|
||||||
|
|
||||||
|
### The direction of the integration is the thing most likely to be misread
|
||||||
|
|
||||||
|
Every existing Cognitum OAuth integration in the org — meta-proxy, musica,
|
||||||
|
metaharness, the dashboard CLI — is an OAuth **client**: it obtains a token so
|
||||||
|
the application can *call* a Cognitum service (the completions plane).
|
||||||
|
|
||||||
|
RuView is the opposite. It makes **no authenticated calls to any Cognitum API**.
|
||||||
|
Its only outbound Cognitum dependency is the ADR-102 registry fetch, which is an
|
||||||
|
anonymous GET against a public GCS bucket. What RuView wants is to be a
|
||||||
|
**resource server**: a user signs in to their *own* RuView instance with their
|
||||||
|
Cognitum identity, and RuView verifies the token they present.
|
||||||
|
|
||||||
|
So the client-side prior art in the org, while useful for a future `ruview
|
||||||
|
login` command, addresses a plane RuView does not have. The only relevant
|
||||||
|
precedent is `meta-llm/src/auth/oauthBearer.ts` (ADR-045) — the org's sole
|
||||||
|
resource-server-side verifier of these tokens. It is TypeScript; **RuView is the
|
||||||
|
first Rust one.**
|
||||||
|
|
||||||
|
### Facts about the tokens, verified against a live production token
|
||||||
|
|
||||||
|
- **ES256 JWT**, signed by a single P-256 key published at
|
||||||
|
`https://auth.cognitum.one/.well-known/jwks.json`.
|
||||||
|
- **15-minute lifetime**, with an opaque refresh token that **rotates with reuse
|
||||||
|
detection** (presenting a spent one ends the session).
|
||||||
|
- Claims: `typ`, `sub`, `account_id`, `org_id`, `workspace_id`, `client_id`,
|
||||||
|
`scope`, `family_id`, `jti`, `iat`, `exp`, `setup`, `workload`.
|
||||||
|
- **No `aud` claim.** No `/oauth/introspect`. No `/userinfo`. It is an OAuth 2.1
|
||||||
|
authorization server, not an OpenID Provider, deliberately.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Verify Cognitum access tokens **offline**, in a new `ruview-auth` crate, and
|
||||||
|
gate RuView's own API surface on the **scope** they carry.
|
||||||
|
|
||||||
|
### 1. Offline verification is a requirement, not an optimisation
|
||||||
|
|
||||||
|
RuView runs on Pi-class hardware that loses WAN, and there is no introspection
|
||||||
|
endpoint to call even when the network is up. Verification is therefore an
|
||||||
|
ES256 signature check against a `kid`-indexed JWKS cache. Two consequences we
|
||||||
|
accept explicitly:
|
||||||
|
|
||||||
|
- **Revocation window = token lifetime.** A compromised access token stays
|
||||||
|
usable until `exp`. This is the same position meta-llm takes, for the same
|
||||||
|
reason, and it is why §3 refuses long-lived credentials.
|
||||||
|
- **A JWKS refetch failure is survivable while a key set is cached.** A key that
|
||||||
|
verified a minute ago has not stopped being valid because the network blipped;
|
||||||
|
failing closed there would log every user out of their own sensing server
|
||||||
|
whenever their internet wobbled. We fail closed in exactly one case: no key
|
||||||
|
set has *ever* been fetched.
|
||||||
|
|
||||||
|
### 2. The accept-rule is ported from meta-llm, not designed
|
||||||
|
|
||||||
|
```
|
||||||
|
typ == "access" AND NOT setup AND NOT workload
|
||||||
|
AND account_id is a non-empty string
|
||||||
|
AND exp is in the future
|
||||||
|
AND iss matches the configured issuer verbatim
|
||||||
|
AND the scope required by the route is held
|
||||||
|
```
|
||||||
|
|
||||||
|
Divergence from `oauthBearer.ts` would be a bug rather than a preference: a
|
||||||
|
token meta-llm rejects must not be one RuView accepts. The algorithm is **fixed
|
||||||
|
to ES256 by our code** — the header's `alg` is only ever compared against that
|
||||||
|
allowlist, never used to select an algorithm.
|
||||||
|
|
||||||
|
### 3. Long-lived setup and workload credentials are refused outright
|
||||||
|
|
||||||
|
Identity also issues 365-day *setup* and machine *workload* credentials. Their
|
||||||
|
revocation state lives in identity's `oauth_setup_tokens` table. RuView — like
|
||||||
|
meta-llm — has no database and no way to check it, so accepting one would mean
|
||||||
|
honouring a credential that may already have been revoked. A 15-minute token
|
||||||
|
needs no revocation round-trip because it expires faster than revocation
|
||||||
|
propagates; a 365-day one does.
|
||||||
|
|
||||||
|
### 4. Scope is the capability boundary, because nothing else can be
|
||||||
|
|
||||||
|
Tokens carry no `aud`, so RuView cannot verify a token was minted *for* RuView.
|
||||||
|
`client_id` cannot substitute: clients borrow each other's registrations when
|
||||||
|
their own has not been deployed (musica ships `DEFAULT_CLIENT_ID = "meta-proxy"`).
|
||||||
|
|
||||||
|
This is not a defect to route around. Cross-product **identity** is intended —
|
||||||
|
one Cognitum account, every Cognitum product. Cross-product **capability** is
|
||||||
|
not, and scope is what carries the difference.
|
||||||
|
|
||||||
|
RuView registers two scopes (dashboard ADR-060, identity migration `0016`):
|
||||||
|
|
||||||
|
| Scope | Grants |
|
||||||
|
|---|---|
|
||||||
|
| `sensing:read` | sensing/pose streams, one-shot inference, reading model and recording metadata |
|
||||||
|
| `sensing:admin` | `POST /api/v1/train/*`, `DELETE /api/v1/models/{id}`, `DELETE /api/v1/recording/{id}` |
|
||||||
|
|
||||||
|
**No hierarchy**: `sensing:admin` does not imply `sensing:read`. Consent means
|
||||||
|
exactly what it said, and a token needing both must have consented to both.
|
||||||
|
`client_id` is retained on the principal for logging and attribution only —
|
||||||
|
never as an authorization input.
|
||||||
|
|
||||||
|
### 5. Additive and fail-closed, never a silent downgrade
|
||||||
|
|
||||||
|
`RUVIEW_API_TOKEN` and `HOMECORE_TOKENS` deployments keep working unchanged.
|
||||||
|
OAuth is opt-in; with it unconfigured, behaviour is byte-identical to today.
|
||||||
|
When OAuth *is* configured but unusable (JWKS unreachable at boot, required
|
||||||
|
scope not registered), the server must refuse to serve `/api/v1/*` rather than
|
||||||
|
fall through to an open or single-secret state.
|
||||||
|
|
||||||
|
### 6. `ureq`, and a transport seam
|
||||||
|
|
||||||
|
`wifi-densepose-sensing-server` deliberately chose `ureq` as "the smallest" HTTP
|
||||||
|
client. Introducing `reqwest` for a JWKS fetch would silently reverse that for
|
||||||
|
the whole dependency graph. The fetch sits behind a `JwksFetcher` trait — the
|
||||||
|
`ureq` implementation is a default-on feature, and a host may supply its own and
|
||||||
|
take no HTTP dependency at all.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Requests become attributable: `sub`, `account_id`, `org_id`, `workspace_id`,
|
||||||
|
`jti`. This closes the gap `homecore-api`'s `tokens.rs` has been deferring as
|
||||||
|
"P3", using claims rather than new RuView machinery.
|
||||||
|
- Destructive operations can be separated from observation for the first time.
|
||||||
|
- **The 15-minute lifetime is the main operational cost.** A long-running client
|
||||||
|
must refresh, and because refresh tokens rotate with reuse detection, a
|
||||||
|
concurrent or naively retried refresh **ends the session** — single-flight is a
|
||||||
|
correctness requirement, not an optimisation. This lands with the login flow,
|
||||||
|
not this crate.
|
||||||
|
- Hosts without a battery-backed clock will fail `exp`/`iat` until NTP lands.
|
||||||
|
The verifier reports that distinguishably so it is diagnosable rather than
|
||||||
|
presenting as a generic 401.
|
||||||
|
- A new dependency, `jsonwebtoken` — the same crate, same major version, that
|
||||||
|
identity itself uses to sign these tokens.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Keep `RUVIEW_API_TOKEN` only.** Zero work, and adequate for a single-user
|
||||||
|
localhost install. Rejected because it cannot express who did what, cannot be
|
||||||
|
revoked without a restart, and cannot separate "watch the stream" from "delete
|
||||||
|
the model" — all of which matter the moment the server is on a LAN.
|
||||||
|
|
||||||
|
**Exchange the OAuth token for a `cog_` key.** The pattern ADR-316 (meta-proxy)
|
||||||
|
and ADR-119 (metaharness) originally described. Rejected: it cannot work.
|
||||||
|
`/v1/me/keys` requires a *Firebase* ID token, not an OAuth token — meta-proxy
|
||||||
|
hit the resulting 401 in production, replaced the approach with Bearer-direct
|
||||||
|
under ADR-045, and deleted `mint.rs` as dead code.
|
||||||
|
|
||||||
|
**Call identity to introspect each token.** Rejected: no introspection endpoint
|
||||||
|
exists, and a network round-trip per request would be wrong for an edge sensing
|
||||||
|
server regardless.
|
||||||
|
|
||||||
|
**Wait for an `aud` claim before shipping.** Rejected as sequencing. `aud` would
|
||||||
|
touch every issued token and every verifier in the org; scope is additive and
|
||||||
|
independently correct. Tracked separately; adding `aud` later strengthens this
|
||||||
|
design rather than invalidating it.
|
||||||
|
|
||||||
|
**Use OAuth for the ESP32 device plane too.** Rejected as a category error.
|
||||||
|
Devices have no browser, no user and no human present; they already pair with a
|
||||||
|
`seed_token` bearer (ADR-066) plus a device-bound PSK. Cognitum OAuth is for the
|
||||||
|
API plane only.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
`v2/crates/ruview-auth` — `jwks` (fetch, TTL cache, `kid` index, one
|
||||||
|
rate-limited forced refetch on an unknown `kid` so rotation is picked up without
|
||||||
|
waiting out the TTL), `verify` (the §2 accept-rule), `principal` (the verified
|
||||||
|
caller and its scopes).
|
||||||
|
|
||||||
|
41 tests pass under both `cargo test --no-default-features` (the repo's
|
||||||
|
canonical gate) and default features. The matrix signs real ES256 tokens with a
|
||||||
|
runtime-generated key — no key material is committed — and covers `alg:none`,
|
||||||
|
forged signatures, spliced payloads, unknown `kid`, expiry on both sides of the
|
||||||
|
leeway, issuer mismatch including a trailing-slash-only difference, `typ`
|
||||||
|
confusion, `setup`/`workload` smuggled onto a `typ=access` token, missing and
|
||||||
|
empty `account_id`, and scope escalation.
|
||||||
|
|
||||||
|
The load-bearing case is
|
||||||
|
`g2_a_genuinely_valid_token_from_another_cognitum_product_cannot_reach_the_sensing_surface`:
|
||||||
|
a correctly signed, unexpired, right-issuer, right-`typ` token bearing
|
||||||
|
`client_id=meta-proxy` and `scope=inference` is rejected. Nothing about its
|
||||||
|
signature or identity claims distinguishes it — only scope does. A naive
|
||||||
|
verifier accepts it, and an `inference` token becomes a key to someone's home
|
||||||
|
sensor.
|
||||||
|
|
||||||
|
**Not in this crate**: the login flow (PKCE, loopback, OOB paste), wiring into
|
||||||
|
`bearer_auth.rs`, WebSocket authentication (ADR-272), and any outbound Cognitum
|
||||||
|
call. This is verification only.
|
||||||
Loading…
Reference in New Issue