mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +00:00
31fb3d53f6
Phase 1 could verify a token and phase 3 could gate on one, but there was no way for a user to OBTAIN one. This closes that: sign in with a Cognitum account and get a token a RuView sensing server accepts, instead of everyone sharing one static RUVIEW_API_TOKEN string. Lives in `ruview-auth` behind a non-default `login` feature rather than in the CLI, so the Tauri desktop app can reuse it instead of growing a second copy. A server built with default features still gets the verifier and nothing else — no reqwest, no tokio net, no browser launcher. (This amends ADR-271's "no login flow in this crate" note; the reason for that line was to keep the server lean, and a feature gate achieves it without duplication.) Ported from meta-proxy's src/oauth/, cross-checked against musica's cognitum_provider.rs — two independent implementations against this same AS. Where they agree, this follows both: redirect path EXACTLY /oauth/callback, 60-second refresh skew, OOB fallback on SSH/CONTAINER//.dockerenv. Refresh is the part with teeth. Identity rotates refresh tokens with reuse detection, so presenting a spent one revokes the whole session family. Both obvious implementations are wrong: refreshing concurrently looks like replay, and retrying a failed refresh with the same token IS the replay. So `Session::ensure_fresh` holds an async mutex across the await, re-checks expiry after acquiring it (the waiter usually finds the work already done), persists the rotated token BEFORE returning it, and never retries. A missing expires_at counts as expired rather than being given a guessed default. Least scope by default: `login` requests `sensing:read`. `--admin` adds `sensing:admin` explicitly, and requests both because there is no scope hierarchy server-side. A session that streams poses should not casually hold the capability to delete the model it streams through. Credentials are written atomically and 0600 (temp file, chmod BEFORE rename) — the same discipline the seed applies to its cloud key. `logout` is local-only and says so: it makes this machine unable to act as you, but revoking the session everywhere is an account-level action. Also `whoami`, which reports whether the stored token is live — an expired-looking session is the most common reason a command starts 401ing, and it should be visible directly rather than inferred from a failure elsewhere. Verified against PRODUCTION, not just locally: authorize URLs built by this exact code path return HTTP 200 from auth.cognitum.one for both `sensing:read` and `sensing:read sensing:admin`, which exercises the real client_id, scope encoding, PKCE parameters and redirect_uri shape. Tests: 74 with --features login (51 unit + 21 verifier matrix + 2 doctests), including the RFC 7636 Appendix B vector, multi-scope URL encoding (a space that is hand-formatted rather than encoded silently truncates the request), a real TCP callback round-trip, callback timeout, 0600 permissions asserted on disk, atomic-save leaving no temp file, and refresh-window boundaries. Unchanged: 43 with default features, 501 in the sensing server. Co-Authored-By: Ruflo & AQE
58 lines
2.6 KiB
TOML
58 lines
2.6 KiB
TOML
[package]
|
|
name = "ruview-auth"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
description = "Cognitum OAuth access-token verification for RuView (ADR-271)"
|
|
publish = false
|
|
|
|
[dependencies]
|
|
# Same major as the service that ISSUES these tokens
|
|
# (cognitum-one/dashboard `services/identity`, workspace `jsonwebtoken = "9"`).
|
|
# Signature math is delegated to this crate; nothing here hand-rolls crypto.
|
|
jsonwebtoken = "9"
|
|
|
|
# `ureq`, not `reqwest`: `wifi-densepose-sensing-server` — the first consumer —
|
|
# deliberately chose ureq as "the smallest" HTTP client (see its Cargo.toml).
|
|
# Adding reqwest here would silently reverse that decision for the whole
|
|
# dependency graph. Optional so a caller can supply its own transport via
|
|
# `JwksFetcher` and take no HTTP dependency at all.
|
|
ureq = { version = "2", default-features = false, features = ["tls", "json"], optional = true }
|
|
|
|
serde = { workspace = true }
|
|
serde_json = { workspace = true }
|
|
thiserror = { workspace = true }
|
|
tracing = { workspace = true }
|
|
|
|
# --- `login` feature only (ADR-271 phase 2) -------------------------------
|
|
# The login flow is an interactive client concern: a browser, a loopback
|
|
# listener, a token exchange. The sensing server needs none of it and must not
|
|
# pay for it, so every dependency here is optional and off by default. A server
|
|
# built with default features gets the verifier and nothing more.
|
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
|
tokio = { workspace = true, optional = true }
|
|
rand = { version = "0.8", optional = true }
|
|
sha2 = { workspace = true, optional = true }
|
|
base64 = { version = "0.21", optional = true }
|
|
url = { version = "2", optional = true }
|
|
|
|
[features]
|
|
default = ["ureq-transport"]
|
|
ureq-transport = ["dep:ureq"]
|
|
# Interactive OAuth login: PKCE, loopback callback, OOB paste fallback,
|
|
# credential storage, single-flight refresh. Opt in from a CLI or desktop app.
|
|
login = ["dep:reqwest", "dep:tokio", "dep:rand", "dep:sha2", "dep:base64", "dep:url"]
|
|
|
|
[dev-dependencies]
|
|
# Test-only: sign real ES256 tokens so the negative matrix exercises the same
|
|
# code path production does, rather than asserting against hand-built strings.
|
|
jsonwebtoken = "9"
|
|
serde_json = { workspace = true }
|
|
|
|
# Keypairs are GENERATED AT TEST RUNTIME, never committed. A checked-in
|
|
# `-----BEGIN PRIVATE KEY-----` is inert here but it trains scanners and readers
|
|
# to treat committed key material as normal, and this repo has no such
|
|
# precedent (zero tracked `.pem` files). Generating also makes the matrix
|
|
# self-contained: no fixture can drift out of sync with the JWKS it is served by.
|
|
p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }
|
|
base64 = "0.21"
|