Files
ruvnet--RuView/v2/crates/ruview-auth/src/pkce.rs
T
Dragan Spiridonov 347698b67c feat(auth): browser sign-in — /oauth/start, /oauth/callback, session cookie
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
2026-07-23 09:57:15 +02:00

84 lines
2.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 43128 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);
}
}