mirror of
https://github.com/ruvnet/RuView
synced 2026-08-04 19:31:42 +00:00
347698b67c
Closes the gap adversarial review found: `wifi-densepose login` writes ~/.ruview/credentials.json, which a BROWSER CANNOT READ. The UI therefore had no way to obtain a Cognitum token at all, and the WebSocket ticket mechanism ADR-272 built "for browsers" was only exercisable with the legacy static shared secret OAuth was meant to replace. The ADRs described a browser story that did not exist. Ported from cognitum-one/freetokens (src/auth/oauth.ts, live at freetokens.cognitum.one), whose shape is not the obvious one and is the whole point: THE BROWSER NEVER HOLDS AN OAUTH TOKEN. The server generates the PKCE verifier and state, keeps them in an HMAC-signed cookie, performs the code exchange itself, verifies the token, and issues its OWN session cookie carrying an assertion — subject, account, scope, expiry — not a credential. So the access token cannot be read by an XSS, cannot sit in localStorage, and cannot leak through a URL. A stolen session cookie is useless against Cognitum or any sibling service. GET /oauth/start -> 302 to auth.cognitum.one + signed transaction cookie GET /oauth/callback -> constant-time state check, exchange, verify, session GET /oauth/logout -> clears the local session (not the Cognitum session) Verified against the running binary: /oauth/start returns the same 302 + HttpOnly/SameSite=Lax/Max-Age=600 shape freetokens does live; a forged `state` is refused 400; a forged session cookie is refused 401. DELIBERATE DEVIATION from freetokens: no `__Host-` cookie prefix. That prefix REQUIRES `Secure`, and RuView is routinely reached at http://localhost or over plain HTTP on a LAN, where such a cookie is never sent and sign-in would fail silently. `Secure` is set only when the request actually arrived over TLS (direct or via x-forwarded-proto). Every other attribute matches; the HMAC is what protects the value. Other decisions worth stating: - The callback verifies through the SAME `verify_access_token` every other request uses — signature, audience (client_id), typ, expiry, scope. A sign-in path must not be a softer path. - The session cookie is checked LAST in the middleware, after bearer and ticket: it is the weakest-bound credential, so a presented bearer should win. - A browser session requests `sensing:read` only. Admin work goes through the CLI's explicit `--admin`. - The token exchange runs in `spawn_blocking` — `ureq` is blocking, and parking an async worker is the mistake this codebase just had to fix in jwks.rs. - `/oauth/*` sits outside `/api/v1/*` on purpose: gating the routes you use to obtain a credential would deadlock. PKCE moved out from behind the `login` feature into its own light `pkce` feature (rand + sha2 + base64, no HTTP stack), so the server can build an authorize URL without pulling in the client-side login machinery. `login` now implies `pkce`. Tests: 13 new browser_session unit tests — signature round-trip, tampered payload, wrong secret, malformed cookie values, HttpOnly/SameSite/Secure attributes, exact scope matching with no implied escalation, a cookie name that merely ends with the target not matching, multi-scope URL encoding, and the core property that a session cookie never contains the access token. Totals: 547 sensing-server lib + 5 wiring integration, 87 ruview-auth. Co-Authored-By: Ruflo & AQE
84 lines
2.5 KiB
Rust
84 lines
2.5 KiB
Rust
//! OAuth 2.0 PKCE (RFC 7636) generation.
|
||
//!
|
||
//! Ported from `cognitum-one/meta-proxy` `src/oauth/pkce.rs`, itself ported from
|
||
//! `dashboard/apps/cli`. Kept byte-compatible on purpose: a verifier generated
|
||
//! here has to validate against the same `services/identity` code every other
|
||
//! Cognitum client already talks to.
|
||
|
||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||
use base64::Engine;
|
||
use rand::RngCore;
|
||
use sha2::{Digest, Sha256};
|
||
|
||
/// One login attempt's PKCE pair plus its CSRF `state`.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PkceRequest {
|
||
pub state: String,
|
||
pub code_verifier: String,
|
||
pub code_challenge: String,
|
||
}
|
||
|
||
fn random_url_safe_token(byte_len: usize) -> String {
|
||
let mut bytes = vec![0u8; byte_len];
|
||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||
URL_SAFE_NO_PAD.encode(bytes)
|
||
}
|
||
|
||
pub fn challenge_from_verifier(verifier: &str) -> String {
|
||
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
|
||
}
|
||
|
||
/// Fresh `state` + verifier/challenge for one login attempt.
|
||
///
|
||
/// 32 random bytes each: base64url-encodes to 43 characters, comfortably inside
|
||
/// RFC 7636 §4.1's 43–128 range without padding.
|
||
pub fn generate() -> PkceRequest {
|
||
let state = random_url_safe_token(32);
|
||
let code_verifier = random_url_safe_token(32);
|
||
let code_challenge = challenge_from_verifier(&code_verifier);
|
||
PkceRequest {
|
||
state,
|
||
code_verifier,
|
||
code_challenge,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn matches_the_rfc7636_appendix_b_worked_example() {
|
||
// The spec's own vector. If this drifts, our S256 is not S256 and the
|
||
// server will reject every exchange — worth pinning to the standard
|
||
// rather than to our own output.
|
||
assert_eq!(
|
||
challenge_from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
|
||
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn verifier_length_is_within_rfc7636_bounds() {
|
||
let r = generate();
|
||
assert!(
|
||
r.code_verifier.len() >= 43 && r.code_verifier.len() <= 128,
|
||
"len {}",
|
||
r.code_verifier.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn challenge_is_derived_from_the_verifier_it_ships_with() {
|
||
let r = generate();
|
||
assert_eq!(challenge_from_verifier(&r.code_verifier), r.code_challenge);
|
||
}
|
||
|
||
#[test]
|
||
fn separate_attempts_share_nothing() {
|
||
let (a, b) = (generate(), generate());
|
||
assert_ne!(a.state, b.state);
|
||
assert_ne!(a.code_verifier, b.code_verifier);
|
||
}
|
||
}
|