diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 7d7846c..c50209b 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3451,15 +3451,18 @@ version = "0.0.1" dependencies = [ "aho-corasick", "arc-swap", + "base64 0.22.1", "chrono", "criterion", "dotenvy", "httpmock", "keyring", + "rand 0.9.3", "reqwest", "rustc-hash", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-opener", @@ -3469,6 +3472,7 @@ dependencies = [ "tracing", "tracing-subscriber", "twitch_oauth2", + "url", "windows 0.62.2", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 09e555d..caba9f0 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -28,12 +28,16 @@ twitch_oauth2 = { version = "0.17", default-features = false, features = ["reqwe serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" -tokio = { version = "1", features = ["macros", "time"] } +tokio = { version = "1", features = ["macros", "time", "net", "io-util", "sync"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } aho-corasick = "1.1" arc-swap = "1.7" rustc-hash = "2.1" +sha2 = "0.10" +base64 = "0.22" +rand = "0.9" +url = "2" [dev-dependencies] criterion = "0.8" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2a34daf..2e3746c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,9 +1,11 @@ mod host; mod message; +pub mod oauth_pkce; pub mod ringbuf; mod sidecar_commands; mod sidecar_supervisor; pub mod twitch_auth; +pub mod youtube_auth; pub mod emote_index; @@ -53,6 +55,11 @@ pub fn run() { twitch_auth::commands::twitch_complete_login, twitch_auth::commands::twitch_cancel_login, twitch_auth::commands::twitch_logout, + youtube_auth::commands::youtube_auth_status, + youtube_auth::commands::youtube_start_login, + youtube_auth::commands::youtube_complete_login, + youtube_auth::commands::youtube_cancel_login, + youtube_auth::commands::youtube_logout, sidecar_commands::twitch_send_message, ]) .setup(setup) @@ -94,6 +101,40 @@ fn setup(app: &mut tauri::App) -> Result<(), Box { + let yt_auth = Arc::new( + YtAuthManager::builder(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) + .scope(SCOPE_YOUTUBE_READONLY) + .scope(SCOPE_YOUTUBE) + .build(YtKeychainStore, yt_http_client), + ); + let yt_wakeup = Arc::new(Notify::new()); + app.manage(YtAuthState::new(yt_auth, yt_wakeup)); + } + Err(err) => { + tracing::error!( + error = %err, + "failed to build reqwest client for YouTube; skipping YouTube auth wiring" + ); + } + } + } + let sender = sidecar_commands::SidecarCommandSender::default(); app.manage(sender.clone()); diff --git a/apps/desktop/src-tauri/src/oauth_pkce/errors.rs b/apps/desktop/src-tauri/src/oauth_pkce/errors.rs new file mode 100644 index 0000000..78b7fdd --- /dev/null +++ b/apps/desktop/src-tauri/src/oauth_pkce/errors.rs @@ -0,0 +1,57 @@ +//! PKCE error type. Variants exist where a caller actually branches. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PkceError { + /// TCP listener could not bind `127.0.0.1:0`. Practically this means + /// the loopback interface is firewalled in a way the OS itself + /// rejects — extremely rare on a developer/streamer machine, but + /// surfaced so the UI can tell the user instead of hanging. + #[error("failed to bind loopback listener: {0}")] + Bind(#[source] std::io::Error), + + /// OS RNG (`getrandom`) refused to fill the verifier/state buffer. + /// Practically unreachable on a desktop OS but surfaced rather + /// than panicked per docs/stability.md. + #[error("OS RNG unavailable: {0}")] + Rng(String), + + /// Listener bound but accepting / reading the inbound HTTP request + /// failed. + #[error("loopback I/O error: {0}")] + Io(#[source] std::io::Error), + + /// Inbound request did not look like the expected `GET /?...` from + /// the OAuth provider's redirect. Typically a probe (browser + /// pre-fetch, port scanner) — caller should keep waiting; the + /// listener only resolves on the *first valid* request. + #[error("malformed redirect request: {0}")] + BadRequest(&'static str), + + /// Redirect carried an `error=` query parameter from the provider. + /// The caller maps this to `UserDenied` / generic `OAuth` based on + /// the value (e.g. `access_denied`). + #[error("authorization endpoint returned error: {0}")] + Authorization(String), + + /// `state` parameter on the redirect did not match what we sent. + /// CSRF defense per RFC 6749 §10.12 — fail closed. + #[error("state mismatch on redirect")] + StateMismatch, + + /// Provider's token endpoint rejected the exchange. Body carries + /// `error_description` when available so the user / log sees why. + #[error("token endpoint error: {0}")] + TokenEndpoint(String), + + /// HTTP transport failure during code-for-token or refresh. + #[error("token endpoint HTTP error: {0}")] + Http(String), + + /// Token endpoint returned 200 but the JSON didn't decode into the + /// expected shape. Usually a sign the endpoint URL is wrong or the + /// provider changed their response format. + #[error("token response decode error: {0}")] + Decode(String), +} diff --git a/apps/desktop/src-tauri/src/oauth_pkce/exchange.rs b/apps/desktop/src-tauri/src/oauth_pkce/exchange.rs new file mode 100644 index 0000000..f21ab75 --- /dev/null +++ b/apps/desktop/src-tauri/src/oauth_pkce/exchange.rs @@ -0,0 +1,302 @@ +//! Generic POST-form helpers for OAuth code-for-token and refresh-token +//! exchanges. +//! +//! Per RFC 6749 §4.1.3 the token request is `application/x-www-form-urlencoded` +//! and the response is JSON. Provider-specific differences (extra +//! params, header requirements, error code vocabularies) are bridged +//! by the caller passing in the right `params` slice and classifying +//! the returned `error` string from `PkceError::TokenEndpoint`. + +use serde::Deserialize; + +use super::errors::PkceError; + +/// Subset of RFC 6749 §5.1 that we use. Providers add fields (Google +/// adds `id_token` for OpenID flows) that we ignore via serde's +/// permissive default. +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + /// Optional because refresh exchanges sometimes don't rotate the + /// refresh token — caller falls back to the previously stored value. + #[serde(default)] + pub refresh_token: Option, + /// Seconds until access_token expires. Caller converts to absolute + /// `expires_at_ms` so a sleeping process re-evaluates correctly. + pub expires_in: u64, + /// Space-delimited per RFC 6749 §3.3. Some providers omit this on + /// refresh; caller falls back to previously stored scopes. + #[serde(default)] + pub scope: Option, + /// Always `Bearer` in practice but present per spec. + #[serde(default)] + pub token_type: Option, +} + +/// Provider-specific error envelope (RFC 6749 §5.2). Both fields are +/// optional in practice — Google always sets both, but we don't want +/// to require them so a stripped-down provider response still surfaces +/// a meaningful message. +#[derive(Debug, Deserialize)] +struct ErrorResponse { + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +/// Exchange an authorization code for tokens. Caller supplies the full +/// `params` slice (provider-specific keys: `client_id`, `client_secret` +/// when required, `code`, `code_verifier`, `redirect_uri`, etc.) so this +/// helper stays provider-agnostic. +pub async fn exchange_code( + http: &reqwest::Client, + token_endpoint: &str, + params: &[(&str, &str)], +) -> Result { + post_form(http, token_endpoint, params).await +} + +/// Refresh-token exchange. Same shape as [`exchange_code`] but with +/// `grant_type=refresh_token` plus the stored `refresh_token` and +/// provider credentials. +pub async fn refresh_tokens( + http: &reqwest::Client, + token_endpoint: &str, + params: &[(&str, &str)], +) -> Result { + post_form(http, token_endpoint, params).await +} + +async fn post_form( + http: &reqwest::Client, + endpoint: &str, + params: &[(&str, &str)], +) -> Result { + let body = encode_form(params); + let resp = http + .post(endpoint) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .header(reqwest::header::ACCEPT, "application/json") + .body(body) + .send() + .await + .map_err(|e| PkceError::Http(e.to_string()))?; + + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| PkceError::Http(e.to_string()))?; + + if status.is_success() { + return serde_json::from_str(&body).map_err(|e| PkceError::Decode(e.to_string())); + } + + // Try to surface the provider's `error_description` first; fall + // back to `error`; fall back to the raw body so the caller's logs + // are useful even on a totally unknown shape. + let parsed: Option = serde_json::from_str(&body).ok(); + let msg = parsed + .as_ref() + .and_then(|e| e.error_description.clone().or_else(|| e.error.clone())) + .unwrap_or_else(|| body.trim().to_string()); + Err(PkceError::TokenEndpoint(msg)) +} + +/// Percent-encode an OAuth token-endpoint form body. We can't rely on +/// reqwest's `.form()` helper because it lives behind a feature flag +/// that's off in our minimal feature set, and pulling `serde_urlencoded` +/// in for two call sites would be wasteful — RFC 3986 unreserved set +/// is small and the encoding is mechanical. +fn encode_form(params: &[(&str, &str)]) -> String { + let mut out = String::new(); + for (i, (k, v)) in params.iter().enumerate() { + if i > 0 { + out.push('&'); + } + percent_encode_into(k, &mut out); + out.push('='); + percent_encode_into(v, &mut out); + } + out +} + +fn percent_encode_into(s: &str, out: &mut String) { + for b in s.as_bytes() { + match *b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(*b as char); + } + other => { + out.push('%'); + out.push(hex_nibble(other >> 4)); + out.push(hex_nibble(other & 0x0f)); + } + } + } +} + +fn hex_nibble(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + 10..=15 => (b'A' + n - 10) as char, + _ => unreachable!(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("reqwest client") + } + + #[tokio::test] + async fn exchange_code_decodes_minimal_response() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(httpmock::Method::POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"access_token":"at","expires_in":3600}"#); + }) + .await; + let url = format!("{}/token", server.base_url()); + let resp = exchange_code(&http_client(), &url, &[("foo", "bar")]) + .await + .unwrap(); + assert_eq!(resp.access_token, "at"); + assert_eq!(resp.expires_in, 3600); + assert!(resp.refresh_token.is_none()); + } + + #[tokio::test] + async fn exchange_code_returns_provider_error_description() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(httpmock::Method::POST).path("/token"); + then.status(400) + .header("content-type", "application/json") + .body(r#"{"error":"invalid_grant","error_description":"bad code"}"#); + }) + .await; + let url = format!("{}/token", server.base_url()); + let err = exchange_code(&http_client(), &url, &[("foo", "bar")]) + .await + .unwrap_err(); + match err { + PkceError::TokenEndpoint(msg) => assert_eq!(msg, "bad code"), + other => panic!("expected TokenEndpoint, got {other:?}"), + } + } + + #[tokio::test] + async fn exchange_code_falls_back_to_error_field_without_description() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(httpmock::Method::POST).path("/token"); + then.status(400) + .header("content-type", "application/json") + .body(r#"{"error":"invalid_request"}"#); + }) + .await; + let url = format!("{}/token", server.base_url()); + let err = exchange_code(&http_client(), &url, &[]).await.unwrap_err(); + match err { + PkceError::TokenEndpoint(msg) => assert_eq!(msg, "invalid_request"), + other => panic!("expected TokenEndpoint, got {other:?}"), + } + } + + #[tokio::test] + async fn exchange_code_falls_back_to_raw_body_on_unparsable_error() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(httpmock::Method::POST).path("/token"); + then.status(500).body("internal server error"); + }) + .await; + let url = format!("{}/token", server.base_url()); + let err = exchange_code(&http_client(), &url, &[]).await.unwrap_err(); + match err { + PkceError::TokenEndpoint(msg) => assert_eq!(msg, "internal server error"), + other => panic!("expected TokenEndpoint, got {other:?}"), + } + } + + #[tokio::test] + async fn refresh_tokens_decodes_with_rotated_refresh() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(httpmock::Method::POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{"access_token":"at2","refresh_token":"rt2","expires_in":1800,"scope":"a b","token_type":"Bearer"}"#, + ); + }) + .await; + let url = format!("{}/token", server.base_url()); + let resp = refresh_tokens(&http_client(), &url, &[]).await.unwrap(); + assert_eq!(resp.refresh_token.as_deref(), Some("rt2")); + assert_eq!(resp.scope.as_deref(), Some("a b")); + } + + #[tokio::test] + async fn exchange_code_decode_error_for_non_json_success() { + let server = httpmock::MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(httpmock::Method::POST).path("/token"); + then.status(200).body("not json"); + }) + .await; + let url = format!("{}/token", server.base_url()); + let err = exchange_code(&http_client(), &url, &[]).await.unwrap_err(); + assert!(matches!(err, PkceError::Decode(_))); + } + + #[test] + fn encode_form_round_trips_single_pair() { + let body = encode_form(&[("grant_type", "refresh_token")]); + assert_eq!(body, "grant_type=refresh_token"); + } + + #[test] + fn encode_form_joins_pairs_with_ampersand() { + let body = encode_form(&[("a", "1"), ("b", "2"), ("c", "3")]); + assert_eq!(body, "a=1&b=2&c=3"); + } + + #[test] + fn encode_form_percent_encodes_reserved_chars() { + // Space, slash, plus, equals must all encode. + let body = encode_form(&[("scope", "a b/c+d=e")]); + assert_eq!(body, "scope=a%20b%2Fc%2Bd%3De"); + } + + #[test] + fn encode_form_passes_through_unreserved_set() { + let body = encode_form(&[("k", "AZaz09-._~")]); + assert_eq!(body, "k=AZaz09-._~"); + } + + #[test] + fn encode_form_handles_empty_value() { + let body = encode_form(&[("k", "")]); + assert_eq!(body, "k="); + } +} diff --git a/apps/desktop/src-tauri/src/oauth_pkce/loopback.rs b/apps/desktop/src-tauri/src/oauth_pkce/loopback.rs new file mode 100644 index 0000000..72a577b --- /dev/null +++ b/apps/desktop/src-tauri/src/oauth_pkce/loopback.rs @@ -0,0 +1,462 @@ +//! One-shot HTTP listener for the OAuth redirect. +//! +//! RFC 8252 §7.3 specifies loopback IP redirect for native apps: +//! the app binds an ephemeral port on `127.0.0.1`, registers +//! `http://127.0.0.1:` as the redirect URI in the authorization +//! request, the OS browser delivers the redirect to that listener, +//! and the listener responds with a small HTML page telling the user +//! to switch back to the app. +//! +//! We deliberately bind `127.0.0.1` rather than `localhost` per the +//! same RFC: `localhost` resolves through the host's name resolver, +//! which on Windows can be configured to map to IPv6 `::1`, breaking +//! redirect_uri string-equality checks at the OAuth provider. +//! +//! Single-shot semantics: the listener accepts connections in a loop +//! but only resolves on the *first connection that carries a valid +//! OAuth redirect query*. Random probes (curl, port scans, browser +//! pre-fetch of the URL) get a `400 Bad Request` and the loop keeps +//! waiting. This avoids racing the user — the user's actual browser +//! redirect is what we want, not the first byte that hits the port. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +use super::errors::PkceError; + +/// Maximum bytes we'll read from a single inbound HTTP request before +/// declaring it malformed. The redirect request is `GET /? HTTP/1.1` +/// plus headers — Chrome/Edge/Firefox all stay under 4 KiB. 8 KiB is +/// generous; anything larger is a signal someone is fuzzing the port. +const MAX_REQUEST_BYTES: usize = 8 * 1024; + +/// Parsed OAuth redirect parameters. Either `code` + `state` (success) +/// or `error` (failure), per RFC 6749 §4.1.2 / §4.1.2.1. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RedirectParams { + pub code: Option, + pub state: Option, + pub error: Option, +} + +impl RedirectParams { + /// Returns `Err(PkceError::Authorization)` if the provider sent an + /// `error=` param, otherwise the `code` and `state` fields. Used by + /// the manager to short-circuit the state check on a denied flow. + pub fn into_code_and_state(self) -> Result<(String, String), PkceError> { + if let Some(err) = self.error { + return Err(PkceError::Authorization(err)); + } + let code = self + .code + .ok_or(PkceError::BadRequest("missing code parameter"))?; + let state = self + .state + .ok_or(PkceError::BadRequest("missing state parameter"))?; + Ok((code, state)) + } +} + +/// Loopback HTTP listener pre-bound to `127.0.0.1:0` (OS picks port). +/// Hold one of these for the duration of an in-flight authorization +/// flow; calling [`LoopbackServer::wait_for_redirect`] consumes it. +pub struct LoopbackServer { + listener: TcpListener, + port: u16, +} + +impl LoopbackServer { + /// Bind a fresh loopback listener. The caller reads `port` / + /// `redirect_uri` to construct the authorization URL before kicking + /// off the browser launch. + pub async fn bind() -> Result { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + let listener = TcpListener::bind(addr).await.map_err(PkceError::Bind)?; + let port = listener.local_addr().map_err(PkceError::Bind)?.port(); + Ok(Self { listener, port }) + } + + #[must_use] + pub fn port(&self) -> u16 { + self.port + } + + /// Full `http://127.0.0.1:` redirect URI. This is what the + /// caller passes as the `redirect_uri` query param on the + /// authorization request and again on the token exchange — the two + /// must match exactly per OAuth spec. + #[must_use] + pub fn redirect_uri(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + /// Block until a valid OAuth redirect lands on the listener. Probes + /// and malformed requests are answered with `400 Bad Request` and + /// then ignored — only the first request that parses as a real + /// OAuth redirect resolves the future. + /// + /// The caller is responsible for bounding wall time via + /// `tokio::time::timeout`. We don't do it here because the caller + /// often wants to cancel for unrelated reasons (UI close, logout) + /// and the timeout can vary by provider's authorization page UX. + pub async fn wait_for_redirect(self) -> Result { + loop { + let (mut stream, _peer) = self.listener.accept().await.map_err(PkceError::Io)?; + + let mut buf = Vec::with_capacity(1024); + // Read enough to parse the request line. We don't need the + // body and HTTP/1.1 GETs don't have one. Cap at MAX_REQUEST_BYTES + // so a slow loris can't pin us forever. + let mut chunk = [0u8; 1024]; + loop { + let n = stream.read(&mut chunk).await.map_err(PkceError::Io)?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + // Once we see the end of the request line + a blank + // line (header terminator) we have everything we need. + if find_double_crlf(&buf).is_some() { + break; + } + if buf.len() >= MAX_REQUEST_BYTES { + break; + } + } + + match parse_redirect(&buf) { + Ok(params) => { + write_success_page(&mut stream).await.ok(); + let _ = stream.shutdown().await; + return Ok(params); + } + Err(_) => { + write_400(&mut stream).await.ok(); + let _ = stream.shutdown().await; + // Keep waiting for the real request. + } + } + } + } +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +/// Parse the request line out of the inbound bytes and pull `code`, +/// `state`, `error` out of the query string. Returns +/// `PkceError::BadRequest` for anything that doesn't look like a +/// browser-issued GET to our redirect path. +fn parse_redirect(buf: &[u8]) -> Result { + let request_line_end = buf + .windows(2) + .position(|w| w == b"\r\n") + .ok_or(PkceError::BadRequest("no CRLF in request"))?; + let line = std::str::from_utf8(&buf[..request_line_end]) + .map_err(|_| PkceError::BadRequest("non-utf8 request line"))?; + + // Format: METHOD SP REQUEST-TARGET SP HTTP-VERSION + let mut parts = line.split(' '); + let method = parts + .next() + .ok_or(PkceError::BadRequest("missing method"))?; + let target = parts + .next() + .ok_or(PkceError::BadRequest("missing request target"))?; + + if method != "GET" { + return Err(PkceError::BadRequest("non-GET request")); + } + + // Target looks like `/?code=...&state=...` or `/favicon.ico`. + // Anything without a query is treated as a probe. + let query = match target.split_once('?') { + Some((_, q)) => q, + None => return Err(PkceError::BadRequest("no query string")), + }; + + let mut params = RedirectParams { + code: None, + state: None, + error: None, + }; + + for kv in query.split('&') { + let (k, v) = match kv.split_once('=') { + Some((k, v)) => (k, v), + None => continue, + }; + let decoded = percent_decode(v); + match k { + "code" => params.code = Some(decoded), + "state" => params.state = Some(decoded), + "error" => params.error = Some(decoded), + _ => {} + } + } + + if params.code.is_none() && params.error.is_none() { + return Err(PkceError::BadRequest( + "neither code nor error in query string", + )); + } + + // A `code` arriving without `state` cannot be the OAuth provider's + // redirect (RFC 6749 §4.1.2 mandates state echo when state was sent + // in the request). Treat it as a probe and keep waiting — otherwise + // a curl `?code=x` to the loopback would terminate the listener + // before the real browser redirect arrives. + if params.error.is_none() && params.code.is_some() && params.state.is_none() { + return Err(PkceError::BadRequest("code without state in query string")); + } + + Ok(params) +} + +/// Decode `%XX` sequences and `+`-as-space in a query-string value. +/// Hand-rolled rather than pulling a dependency for ~30 lines. +/// Invalid sequences pass through untouched — the OAuth provider's +/// `code` is opaque base64-ish text and unlikely to contain them, but +/// we'd rather see the raw value than 500 the redirect on a malformed +/// percent-escape. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let hi = hex_digit(bytes[i + 1]); + let lo = hex_digit(bytes[i + 2]); + if let (Some(h), Some(l)) = (hi, lo) { + out.push((h << 4) | l); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_digit(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// HTML body shown to the user after a successful redirect. Kept small +/// and self-contained — no external assets, no framework, no JS. The +/// tone matches the Twitch DCF flow's "switch back to the app" prompt. +const SUCCESS_PAGE: &str = include_str!("success_page.html"); + +async fn write_success_page(w: &mut W) -> std::io::Result<()> { + let body = SUCCESS_PAGE.as_bytes(); + let header = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: text/html; charset=utf-8\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + Cache-Control: no-store\r\n\ + \r\n", + body.len() + ); + w.write_all(header.as_bytes()).await?; + w.write_all(body).await?; + w.flush().await +} + +async fn write_400(w: &mut W) -> std::io::Result<()> { + let body = b"bad request\n"; + let header = format!( + "HTTP/1.1 400 Bad Request\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + body.len() + ); + w.write_all(header.as_bytes()).await?; + w.write_all(body).await?; + w.flush().await +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpStream; + + #[test] + fn parse_redirect_extracts_code_and_state() { + let raw = b"GET /?code=abc123&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + let p = parse_redirect(raw).unwrap(); + assert_eq!(p.code.as_deref(), Some("abc123")); + assert_eq!(p.state.as_deref(), Some("xyz")); + assert!(p.error.is_none()); + } + + #[test] + fn parse_redirect_extracts_error() { + let raw = b"GET /?error=access_denied HTTP/1.1\r\n\r\n"; + let p = parse_redirect(raw).unwrap(); + assert_eq!(p.error.as_deref(), Some("access_denied")); + assert!(p.code.is_none()); + } + + #[test] + fn parse_redirect_handles_percent_encoding() { + let raw = b"GET /?code=a%2Fb%3Dc&state=hello%20world HTTP/1.1\r\n\r\n"; + let p = parse_redirect(raw).unwrap(); + assert_eq!(p.code.as_deref(), Some("a/b=c")); + assert_eq!(p.state.as_deref(), Some("hello world")); + } + + #[test] + fn parse_redirect_handles_plus_as_space_in_value() { + let raw = b"GET /?code=hello+world&state=s HTTP/1.1\r\n\r\n"; + let p = parse_redirect(raw).unwrap(); + assert_eq!(p.code.as_deref(), Some("hello world")); + } + + #[test] + fn parse_redirect_rejects_non_get() { + let raw = b"POST /?code=abc HTTP/1.1\r\n\r\n"; + assert!(matches!( + parse_redirect(raw), + Err(PkceError::BadRequest("non-GET request")) + )); + } + + #[test] + fn parse_redirect_rejects_no_query() { + let raw = b"GET /favicon.ico HTTP/1.1\r\n\r\n"; + assert!(matches!( + parse_redirect(raw), + Err(PkceError::BadRequest("no query string")) + )); + } + + #[test] + fn parse_redirect_rejects_query_without_code_or_error() { + let raw = b"GET /?nonsense=1 HTTP/1.1\r\n\r\n"; + assert!(matches!(parse_redirect(raw), Err(PkceError::BadRequest(_)))); + } + + #[test] + fn parse_redirect_rejects_code_without_state() { + let raw = b"GET /?code=abc HTTP/1.1\r\n\r\n"; + assert!(matches!( + parse_redirect(raw), + Err(PkceError::BadRequest("code without state in query string")) + )); + } + + #[test] + fn into_code_and_state_propagates_authorization_error() { + let p = RedirectParams { + code: None, + state: None, + error: Some("access_denied".into()), + }; + assert!(matches!( + p.into_code_and_state(), + Err(PkceError::Authorization(s)) if s == "access_denied" + )); + } + + #[test] + fn into_code_and_state_requires_state_param() { + let p = RedirectParams { + code: Some("c".into()), + state: None, + error: None, + }; + assert!(matches!( + p.into_code_and_state(), + Err(PkceError::BadRequest("missing state parameter")) + )); + } + + #[test] + fn percent_decode_passes_through_invalid_escape() { + // Invalid % sequence — pass `%` through and continue. + assert_eq!(percent_decode("a%ZZb"), "a%ZZb"); + } + + #[test] + fn find_double_crlf_locates_terminator() { + let buf = b"GET / HTTP/1.1\r\nHost: x\r\n\r\nbody"; + let pos = find_double_crlf(buf).unwrap(); + assert_eq!(&buf[pos..pos + 4], b"\r\n\r\n"); + } + + #[tokio::test] + async fn loopback_returns_first_valid_redirect_after_probe() { + let server = LoopbackServer::bind().await.unwrap(); + let port = server.port(); + let server_task = tokio::spawn(server.wait_for_redirect()); + + // Send a probe that should be ignored. + let mut probe = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + probe + .write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: x\r\n\r\n") + .await + .unwrap(); + let mut sink = Vec::new(); + let _ = tokio::io::AsyncReadExt::read_to_end(&mut probe, &mut sink).await; + + // Now the real redirect. + let mut real = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + real.write_all(b"GET /?code=abc&state=xyz HTTP/1.1\r\nHost: x\r\n\r\n") + .await + .unwrap(); + let mut sink = Vec::new(); + let _ = tokio::io::AsyncReadExt::read_to_end(&mut real, &mut sink).await; + + let params = server_task.await.unwrap().unwrap(); + assert_eq!(params.code.as_deref(), Some("abc")); + assert_eq!(params.state.as_deref(), Some("xyz")); + } + + #[tokio::test] + async fn loopback_returns_error_param_without_code() { + let server = LoopbackServer::bind().await.unwrap(); + let port = server.port(); + let server_task = tokio::spawn(server.wait_for_redirect()); + + let mut s = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + s.write_all(b"GET /?error=access_denied HTTP/1.1\r\nHost: x\r\n\r\n") + .await + .unwrap(); + let mut sink = Vec::new(); + let _ = tokio::io::AsyncReadExt::read_to_end(&mut s, &mut sink).await; + + let params = server_task.await.unwrap().unwrap(); + assert_eq!(params.error.as_deref(), Some("access_denied")); + } + + #[tokio::test] + async fn redirect_uri_uses_127_0_0_1() { + let server = LoopbackServer::bind().await.unwrap(); + let uri = server.redirect_uri(); + assert!(uri.starts_with("http://127.0.0.1:")); + assert!(uri.contains(&server.port().to_string())); + } +} diff --git a/apps/desktop/src-tauri/src/oauth_pkce/mod.rs b/apps/desktop/src-tauri/src/oauth_pkce/mod.rs new file mode 100644 index 0000000..e32955b --- /dev/null +++ b/apps/desktop/src-tauri/src/oauth_pkce/mod.rs @@ -0,0 +1,31 @@ +//! Shared OAuth 2.0 Authorization Code + PKCE primitives. +//! +//! Implements the parts of RFC 8252 §7.3 that any platform using +//! loopback IP redirect needs: +//! +//! - PKCE code_verifier + S256 challenge generation (RFC 7636). +//! - CSRF `state` parameter generation. +//! - One-shot HTTP listener bound to `127.0.0.1:0` (OS-picked port) +//! that captures the redirect query and serves a tiny success page. +//! - Generic POST-form helper for the code-for-token and refresh-token +//! exchanges. +//! +//! Per-provider concerns (authorization endpoint URL, scope strings, +//! channel/user fetch, refresh-error classification) live in the +//! provider's own module — `youtube_auth` today, `kick_auth` next. +//! +//! Why a separate module: ADR 39 calls out that loopback + PKCE is the +//! shape both YouTube and Kick (write/mod path, ADR 38) need. Mirroring +//! the primitives in two parallel modules would invite drift; mirroring +//! the *flow control* (which scopes, which endpoints) is the part that +//! actually differs by provider. + +pub mod errors; +pub mod exchange; +pub mod loopback; +pub mod pkce; + +pub use errors::PkceError; +pub use exchange::{exchange_code, refresh_tokens, TokenResponse}; +pub use loopback::{LoopbackServer, RedirectParams}; +pub use pkce::{Pkce, State}; diff --git a/apps/desktop/src-tauri/src/oauth_pkce/pkce.rs b/apps/desktop/src-tauri/src/oauth_pkce/pkce.rs new file mode 100644 index 0000000..f533930 --- /dev/null +++ b/apps/desktop/src-tauri/src/oauth_pkce/pkce.rs @@ -0,0 +1,140 @@ +//! PKCE (RFC 7636) verifier + S256 challenge, and CSRF `state`. +//! +//! Verifier: 43–128 chars from the unreserved set `[A-Z][a-z][0-9]-._~`. +//! We generate 32 random bytes and base64url-encode them, yielding a +//! 43-char verifier — comfortably within spec and indistinguishable +//! from server-side practice. +//! +//! Challenge: `BASE64URL(SHA256(verifier))`. S256 only — `plain` is +//! permitted by RFC 7636 §4.2 but is downgrade-attack-vulnerable and +//! disallowed by every OAuth provider we target. +//! +//! State: 32 random bytes, base64url-encoded. Carried through the +//! authorization request and verified on the redirect (RFC 6749 +//! §10.12). Distinct from the verifier — the verifier is a secret +//! never sent until the token exchange; the state is sent in the clear +//! and is just an unguessable opaque token. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use rand::TryRngCore; +use sha2::{Digest, Sha256}; + +use super::errors::PkceError; + +/// Number of random bytes for both verifier and state. 32 → 256 bits +/// of entropy → 43 characters base64url-no-pad. Above the RFC 7636 +/// minimum (32 chars) and well below the maximum (128 chars). +const RANDOM_BYTES: usize = 32; + +/// PKCE verifier + S256 challenge pair. The verifier is what gets sent +/// at the token-exchange step; the challenge is what's published in +/// the authorization URL. +#[derive(Debug, Clone)] +pub struct Pkce { + pub verifier: String, + pub challenge: String, +} + +impl Pkce { + /// Generate a fresh verifier+challenge pair using the OS RNG. + /// + /// The OS RNG (`rand::rngs::OsRng`, a thin wrapper over `getrandom`) + /// is the only acceptable source: PKCE security relies on the + /// verifier being unguessable. Userspace PRNGs would not survive + /// a timing analysis of the SHA-256 challenge round-tripping the + /// network during the authorization phase. + pub fn generate() -> Result { + let mut bytes = [0u8; RANDOM_BYTES]; + rand::rngs::OsRng + .try_fill_bytes(&mut bytes) + .map_err(|e| PkceError::Rng(e.to_string()))?; + let verifier = URL_SAFE_NO_PAD.encode(bytes); + let challenge = challenge_for(&verifier); + Ok(Self { + verifier, + challenge, + }) + } +} + +/// Opaque CSRF token sent as `state` in the authorization request and +/// re-checked on the redirect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct State(pub String); + +impl State { + pub fn generate() -> Result { + let mut bytes = [0u8; RANDOM_BYTES]; + rand::rngs::OsRng + .try_fill_bytes(&mut bytes) + .map_err(|e| PkceError::Rng(e.to_string()))?; + Ok(Self(URL_SAFE_NO_PAD.encode(bytes))) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn challenge_for(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verifier_is_43_chars_base64url_no_pad() { + let p = Pkce::generate().unwrap(); + assert_eq!(p.verifier.len(), 43); + // base64url-no-pad alphabet: A-Z a-z 0-9 - _ + assert!( + p.verifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "verifier had non-base64url chars: {}", + p.verifier + ); + } + + #[test] + fn challenge_matches_s256_of_verifier() { + let p = Pkce::generate().unwrap(); + assert_eq!(p.challenge, challenge_for(&p.verifier)); + } + + #[test] + fn challenge_known_test_vector() { + // RFC 7636 Appendix B test vector. + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + assert_eq!(challenge_for(verifier), expected); + } + + #[test] + fn two_generates_produce_distinct_pairs() { + let a = Pkce::generate().unwrap(); + let b = Pkce::generate().unwrap(); + assert_ne!(a.verifier, b.verifier); + assert_ne!(a.challenge, b.challenge); + } + + #[test] + fn state_is_43_chars_base64url_no_pad() { + let s = State::generate().unwrap(); + assert_eq!(s.0.len(), 43); + assert!(s + .0 + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); + } + + #[test] + fn two_states_are_distinct() { + assert_ne!(State::generate().unwrap(), State::generate().unwrap()); + } +} diff --git a/apps/desktop/src-tauri/src/oauth_pkce/success_page.html b/apps/desktop/src-tauri/src/oauth_pkce/success_page.html new file mode 100644 index 0000000..fe3521b --- /dev/null +++ b/apps/desktop/src-tauri/src/oauth_pkce/success_page.html @@ -0,0 +1,46 @@ + + + + + Prismoid — signed in + + + +
+

You're signed in.

+

Switch back to Prismoid to continue. You can close this tab.

+
+ + diff --git a/apps/desktop/src-tauri/src/youtube_auth/auth_state.rs b/apps/desktop/src-tauri/src/youtube_auth/auth_state.rs new file mode 100644 index 0000000..791f1af --- /dev/null +++ b/apps/desktop/src-tauri/src/youtube_auth/auth_state.rs @@ -0,0 +1,353 @@ +//! Auth state and serializable view types shared by the YouTube +//! sign-in commands and the (future) sidecar supervisor. Mirrors the +//! shape of `twitch_auth::auth_state` so the frontend can use a +//! symmetric `SignIn` component layout. + +use std::sync::Arc; + +use serde::Serialize; +use tokio::sync::{Mutex, Notify}; + +use super::errors::AuthError; +use super::manager::{AuthManager, PendingLogin}; + +pub struct AuthState { + pub manager: Arc, + pub pending: Mutex>, + pub wakeup: Arc, + /// Notify shared with an in-flight `complete_login` call so that + /// `cancel_login` can actually unblock the loopback wait. A fresh + /// `Notify` is installed at every `start_login` so a stale cancel + /// from a previous attempt can't poison the next one. + cancel: Mutex>>, +} + +impl AuthState { + pub fn new(manager: Arc, wakeup: Arc) -> Self { + Self { + manager, + pending: Mutex::new(None), + wakeup, + cancel: Mutex::new(None), + } + } + + pub fn status(&self) -> Result { + let title = self.manager.peek_channel_title()?; + Ok(match title { + Some(t) => AuthStatus { + state: AuthStatusState::LoggedIn, + channel_title: Some(t), + }, + None => AuthStatus { + state: AuthStatusState::LoggedOut, + channel_title: None, + }, + }) + } + + pub async fn start_login(&self) -> Result { + let pending = self.manager.start_login().await?; + let view = PkceFlowView { + authorization_uri: pending.authorization_uri().to_string(), + }; + *self.pending.lock().await = Some(pending); + *self.cancel.lock().await = Some(Arc::new(Notify::new())); + Ok(view) + } + + pub async fn complete_login(&self) -> Result { + let pending = self.pending.lock().await.take().ok_or(AuthCommandError { + kind: "no_pending_flow", + message: "youtube_start_login has not been called".into(), + })?; + let cancel = self.cancel.lock().await.clone(); + + let outcome = match cancel { + Some(cancel) => tokio::select! { + biased; + _ = cancel.notified() => Err(AuthCommandError { + kind: "cancelled", + message: "youtube sign-in cancelled".into(), + }), + result = self.manager.complete_login(pending) => result.map_err(Into::into), + }, + None => self + .manager + .complete_login(pending) + .await + .map_err(Into::into), + }; + // Clear cancel slot regardless of which branch won; the next + // login attempt installs a fresh Notify in start_login. + *self.cancel.lock().await = None; + + let tokens = outcome?; + self.wakeup.notify_one(); + Ok(AuthStatus { + state: AuthStatusState::LoggedIn, + channel_title: Some(tokens.channel_title), + }) + } + + pub async fn cancel_login(&self) { + // Take the Arc out so a second cancel is a no-op, but notify + // first so an in-flight complete_login (which holds its own + // clone of the Arc) wakes up. + if let Some(cancel) = self.cancel.lock().await.take() { + cancel.notify_one(); + } + self.pending.lock().await.take(); + } + + pub async fn logout(&self) -> Result<(), AuthCommandError> { + self.manager.logout()?; + self.pending.lock().await.take(); + *self.cancel.lock().await = None; + self.wakeup.notify_one(); + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AuthStatus { + pub state: AuthStatusState, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_title: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AuthStatusState { + LoggedOut, + LoggedIn, +} + +/// Shape surfaced to the frontend after `start_login`. The frontend's +/// only job is to open `authorization_uri` in the system browser and +/// then call `complete_login`, which blocks on the loopback redirect. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PkceFlowView { + pub authorization_uri: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuthCommandError { + pub kind: &'static str, + pub message: String, +} + +impl From for AuthCommandError { + fn from(err: AuthError) -> Self { + let kind = match &err { + AuthError::NoTokens => "no_tokens", + AuthError::RefreshTokenInvalid => "refresh_invalid", + AuthError::UserDenied => "user_denied", + AuthError::LoopbackBind(_) => "loopback_bind", + AuthError::StateMismatch => "state_mismatch", + AuthError::Keychain(_) => "keychain", + AuthError::OAuth(_) => "oauth", + AuthError::Json(_) => "json", + AuthError::NoChannel => "no_channel", + AuthError::Timeout => "timeout", + }; + Self { + kind, + message: err.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::youtube_auth::storage::{MemoryStore, TokenStore}; + use crate::youtube_auth::tokens::YouTubeTokens; + use crate::youtube_auth::AuthManagerBuilder; + + fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("reqwest client") + } + + fn fixture_tokens() -> YouTubeTokens { + YouTubeTokens { + access_token: "at".into(), + refresh_token: "rt".into(), + expires_at_ms: i64::MAX, + scopes: vec!["scope-a".into()], + channel_id: "UC123".into(), + channel_title: "Test Channel".into(), + } + } + + fn build_state_with_store(store: MemoryStore) -> AuthState { + let manager = Arc::new(AuthManagerBuilder::new("c", "s").build(store, http_client())); + AuthState::new(manager, Arc::new(Notify::new())) + } + + #[test] + fn auth_command_error_maps_no_tokens() { + let mapped: AuthCommandError = AuthError::NoTokens.into(); + assert_eq!(mapped.kind, "no_tokens"); + } + + #[test] + fn auth_command_error_maps_refresh_invalid() { + let mapped: AuthCommandError = AuthError::RefreshTokenInvalid.into(); + assert_eq!(mapped.kind, "refresh_invalid"); + } + + #[test] + fn auth_command_error_maps_user_denied() { + let mapped: AuthCommandError = AuthError::UserDenied.into(); + assert_eq!(mapped.kind, "user_denied"); + } + + #[test] + fn auth_command_error_maps_loopback_bind() { + let mapped: AuthCommandError = AuthError::LoopbackBind("addr in use".into()).into(); + assert_eq!(mapped.kind, "loopback_bind"); + assert!(mapped.message.contains("addr in use")); + } + + #[test] + fn auth_command_error_maps_state_mismatch() { + let mapped: AuthCommandError = AuthError::StateMismatch.into(); + assert_eq!(mapped.kind, "state_mismatch"); + } + + #[test] + fn auth_command_error_maps_no_channel() { + let mapped: AuthCommandError = AuthError::NoChannel.into(); + assert_eq!(mapped.kind, "no_channel"); + } + + #[test] + fn auth_command_error_maps_timeout() { + let mapped: AuthCommandError = AuthError::Timeout.into(); + assert_eq!(mapped.kind, "timeout"); + } + + #[test] + fn auth_status_serializes_logged_out_without_channel() { + let s = AuthStatus { + state: AuthStatusState::LoggedOut, + channel_title: None, + }; + let v: serde_json::Value = serde_json::to_value(&s).unwrap(); + assert_eq!(v["state"], "logged_out"); + assert!(v.get("channel_title").is_none()); + } + + #[test] + fn auth_status_serializes_logged_in_with_channel() { + let s = AuthStatus { + state: AuthStatusState::LoggedIn, + channel_title: Some("Test".into()), + }; + let v: serde_json::Value = serde_json::to_value(&s).unwrap(); + assert_eq!(v["state"], "logged_in"); + assert_eq!(v["channel_title"], "Test"); + } + + #[tokio::test] + async fn status_returns_logged_in_when_tokens_present() { + let store = MemoryStore::default(); + store.save(&fixture_tokens()).unwrap(); + let state = build_state_with_store(store); + let status = state.status().unwrap(); + assert_eq!(status.state, AuthStatusState::LoggedIn); + assert_eq!(status.channel_title.as_deref(), Some("Test Channel")); + } + + #[tokio::test] + async fn status_returns_logged_out_when_no_tokens() { + let state = build_state_with_store(MemoryStore::default()); + let status = state.status().unwrap(); + assert_eq!(status.state, AuthStatusState::LoggedOut); + assert!(status.channel_title.is_none()); + } + + #[tokio::test] + async fn complete_login_without_pending_returns_no_pending_flow() { + let state = build_state_with_store(MemoryStore::default()); + let err = state.complete_login().await.unwrap_err(); + assert_eq!(err.kind, "no_pending_flow"); + } + + #[tokio::test] + async fn cancel_login_is_idempotent() { + let state = build_state_with_store(MemoryStore::default()); + state.cancel_login().await; + state.cancel_login().await; + assert!(state.pending.lock().await.is_none()); + } + + #[tokio::test] + async fn logout_when_empty_store_still_clears_and_notifies() { + let state = build_state_with_store(MemoryStore::default()); + let wakeup = state.wakeup.clone(); + state.logout().await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), wakeup.notified()) + .await + .expect("permit should be available"); + } + + #[tokio::test] + async fn logout_wipes_store_and_pending_and_notifies() { + let store = MemoryStore::default(); + store.save(&fixture_tokens()).unwrap(); + let state = build_state_with_store(store); + let wakeup = state.wakeup.clone(); + let waiter = tokio::spawn(async move { wakeup.notified().await }); + + state.logout().await.unwrap(); + + assert!(state.manager.peek_channel_title().unwrap().is_none()); + tokio::time::timeout(std::time::Duration::from_secs(1), waiter) + .await + .expect("waiter should be woken") + .expect("waiter task panicked"); + } + + #[tokio::test] + async fn start_login_stashes_pending_and_returns_authorization_uri() { + let state = build_state_with_store(MemoryStore::default()); + let view = state.start_login().await.unwrap(); + assert!(view + .authorization_uri + .starts_with("https://accounts.google.com/")); + assert!(view + .authorization_uri + .contains("code_challenge_method=S256")); + assert!(view + .authorization_uri + .contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A")); + assert!(state.pending.lock().await.is_some()); + // Drop the loopback listener so the test doesn't leak the port. + state.cancel_login().await; + } + + #[tokio::test] + async fn cancel_login_unblocks_in_flight_complete_login() { + let state = Arc::new(build_state_with_store(MemoryStore::default())); + state.start_login().await.unwrap(); + + let s = state.clone(); + let completion = tokio::spawn(async move { s.complete_login().await }); + + // Give the spawned task a tick to enter the select. + tokio::task::yield_now().await; + state.cancel_login().await; + + let err = tokio::time::timeout(std::time::Duration::from_secs(2), completion) + .await + .expect("complete_login should unblock once cancelled") + .expect("task panicked") + .expect_err("cancelled completion must error"); + assert_eq!(err.kind, "cancelled"); + } +} diff --git a/apps/desktop/src-tauri/src/youtube_auth/commands.rs b/apps/desktop/src-tauri/src/youtube_auth/commands.rs new file mode 100644 index 0000000..e04d26f --- /dev/null +++ b/apps/desktop/src-tauri/src/youtube_auth/commands.rs @@ -0,0 +1,40 @@ +//! Tauri command surface for the YouTube sign-in UI. +//! +//! Mirrors `twitch_auth::commands` shape — thin adapters over +//! [`AuthState`] which holds all the testable branchable logic. + +use tauri::State; + +use super::auth_state::{AuthCommandError, AuthState, AuthStatus, PkceFlowView}; + +#[tauri::command] +pub async fn youtube_auth_status( + state: State<'_, AuthState>, +) -> Result { + state.status() +} + +#[tauri::command] +pub async fn youtube_start_login( + state: State<'_, AuthState>, +) -> Result { + state.start_login().await +} + +#[tauri::command] +pub async fn youtube_complete_login( + state: State<'_, AuthState>, +) -> Result { + state.complete_login().await +} + +#[tauri::command] +pub async fn youtube_cancel_login(state: State<'_, AuthState>) -> Result<(), AuthCommandError> { + state.cancel_login().await; + Ok(()) +} + +#[tauri::command] +pub async fn youtube_logout(state: State<'_, AuthState>) -> Result<(), AuthCommandError> { + state.logout().await +} diff --git a/apps/desktop/src-tauri/src/youtube_auth/errors.rs b/apps/desktop/src-tauri/src/youtube_auth/errors.rs new file mode 100644 index 0000000..ee3ed7a --- /dev/null +++ b/apps/desktop/src-tauri/src/youtube_auth/errors.rs @@ -0,0 +1,144 @@ +//! Error type shared across the youtube_auth module. +//! +//! Mirrors the variants of `twitch_auth::AuthError` so the frontend's +//! error mapping (and the supervisor's branching) can stay symmetric +//! across providers — only the wire-format details and the underlying +//! flow change between Twitch DCF and YouTube Auth Code + PKCE. + +use thiserror::Error; + +use crate::oauth_pkce::PkceError; + +#[derive(Debug, Error)] +pub enum AuthError { + /// No tokens have been persisted. Caller's correct response is to + /// kick off the auth flow. + #[error("no tokens stored")] + NoTokens, + + /// Refresh exchange succeeded with the server but the server said + /// the refresh token is invalid (Google returns `invalid_grant` on + /// expired/revoked refresh tokens, on consent-revoked, and on the + /// 6-month-of-inactivity expiry). Per ADR 31 this surfaces a + /// re-auth UI; do not retry with the same refresh token. + #[error("refresh token rejected; user must re-authenticate")] + RefreshTokenInvalid, + + /// User explicitly denied the authorization request (Google sends + /// `error=access_denied` on the redirect). + #[error("user denied the authorization")] + UserDenied, + + /// Loopback listener bind failed — surfaces to the UI so the user + /// knows it's not their fault when no browser launches. + #[error("could not start local sign-in listener: {0}")] + LoopbackBind(String), + + /// CSRF state mismatch between what we sent and what the redirect + /// returned. Treated as a hard failure — never retry the same + /// flow; the user should start over. + #[error("CSRF state mismatch on redirect")] + StateMismatch, + + /// Keyring / OS credential store error. + #[error(transparent)] + Keychain(#[from] keyring::Error), + + /// Any other OAuth / HTTP failure. Carries the upstream message. + #[error("oauth error: {0}")] + OAuth(String), + + /// JSON (de)serialization of the persisted token blob failed. + #[error(transparent)] + Json(#[from] serde_json::Error), + + /// The /channels?mine=true call returned no items, meaning the + /// authenticated Google account has no YouTube channel attached. + /// Real failure mode: people with a Google account but no channel + /// (rare for streamers, common for general Google users). + #[error("Google account has no YouTube channel")] + NoChannel, + + /// `complete_login` waited longer than the configured ceiling for + /// the loopback redirect. Surfaces to the UI so the user can retry + /// instead of the command hanging until app exit. + #[error("timed out waiting for YouTube sign-in to complete")] + Timeout, +} + +impl From for AuthError { + fn from(err: PkceError) -> Self { + match err { + PkceError::Authorization(s) if s == "access_denied" => AuthError::UserDenied, + PkceError::StateMismatch => AuthError::StateMismatch, + PkceError::Bind(e) => AuthError::LoopbackBind(e.to_string()), + PkceError::Rng(s) => AuthError::OAuth(format!("OS RNG unavailable: {s}")), + // `invalid_grant` is Google's universal signal for a dead + // refresh token on the refresh path. We classify it the + // same way `twitch_auth` does — it triggers the re-auth UI. + PkceError::TokenEndpoint(s) if s.contains("invalid_grant") => { + AuthError::RefreshTokenInvalid + } + other => AuthError::OAuth(other.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_authorization_access_denied_maps_to_user_denied() { + let err: AuthError = PkceError::Authorization("access_denied".into()).into(); + assert!(matches!(err, AuthError::UserDenied)); + } + + #[test] + fn pkce_state_mismatch_maps_to_state_mismatch() { + let err: AuthError = PkceError::StateMismatch.into(); + assert!(matches!(err, AuthError::StateMismatch)); + } + + #[test] + fn pkce_bind_maps_to_loopback_bind() { + let io = std::io::Error::new(std::io::ErrorKind::AddrInUse, "in use"); + let err: AuthError = PkceError::Bind(io).into(); + assert!(matches!(err, AuthError::LoopbackBind(_))); + } + + #[test] + fn pkce_invalid_grant_maps_to_refresh_token_invalid() { + let err: AuthError = + PkceError::TokenEndpoint("Token has been expired or revoked: invalid_grant".into()) + .into(); + assert!(matches!(err, AuthError::RefreshTokenInvalid)); + } + + #[test] + fn pkce_other_token_endpoint_falls_through_to_oauth() { + let err: AuthError = PkceError::TokenEndpoint("rate limit exceeded".into()).into(); + match err { + AuthError::OAuth(msg) => assert!(msg.contains("rate limit")), + other => panic!("expected OAuth, got {other:?}"), + } + } + + #[test] + fn pkce_authorization_other_error_falls_through_to_oauth() { + let err: AuthError = PkceError::Authorization("server_error".into()).into(); + match err { + AuthError::OAuth(msg) => assert!(msg.contains("server_error")), + other => panic!("expected OAuth, got {other:?}"), + } + } + + #[test] + fn pkce_rng_maps_to_oauth() { + let err: AuthError = PkceError::Rng("entropy source unavailable".into()).into(); + match err { + AuthError::OAuth(msg) => assert!(msg.contains("OS RNG")), + other => panic!("expected OAuth, got {other:?}"), + } + } +} diff --git a/apps/desktop/src-tauri/src/youtube_auth/manager.rs b/apps/desktop/src-tauri/src/youtube_auth/manager.rs new file mode 100644 index 0000000..7037b91 --- /dev/null +++ b/apps/desktop/src-tauri/src/youtube_auth/manager.rs @@ -0,0 +1,647 @@ +//! `AuthManager` for YouTube — façade over the `oauth_pkce` building +//! blocks plus a [`TokenStore`] for keychain persistence. +//! +//! Mirrors `twitch_auth::AuthManager` shape but the underlying flow is +//! Authorization Code + PKCE (RFC 7636) over a loopback redirect (RFC +//! 8252 §7.3) instead of the Device Code Grant. Single-account per +//! ADR 30, proactive refresh per ADR 29, eager re-auth on +//! `invalid_grant` per ADR 31. + +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use serde::Deserialize; +use url::Url; + +use super::errors::AuthError; +use super::storage::TokenStore; +use super::tokens::YouTubeTokens; +use super::{ + GOOGLE_AUTH_ENDPOINT, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_TOKEN_ENDPOINT, + YOUTUBE_CHANNELS_ENDPOINT, +}; +use crate::oauth_pkce::{ + exchange_code, refresh_tokens as pkce_refresh, LoopbackServer, Pkce, State, TokenResponse, +}; + +/// Proactive refresh threshold per ADR 29. +pub const REFRESH_THRESHOLD_MS: i64 = 5 * 60 * 1000; + +/// Wall-clock ceiling on `complete_login` waiting for the loopback +/// redirect. Bounds stuck tasks if the user closes the browser tab +/// without completing the consent screen. +const LOGIN_TIMEOUT_SECS: u64 = 300; + +pub struct AuthManagerBuilder { + client_id: String, + client_secret: String, + auth_endpoint: String, + token_endpoint: String, + channels_endpoint: String, + scopes: Vec, +} + +impl AuthManagerBuilder { + pub fn new(client_id: impl Into, client_secret: impl Into) -> Self { + Self { + client_id: client_id.into(), + client_secret: client_secret.into(), + auth_endpoint: GOOGLE_AUTH_ENDPOINT.to_string(), + token_endpoint: GOOGLE_TOKEN_ENDPOINT.to_string(), + channels_endpoint: YOUTUBE_CHANNELS_ENDPOINT.to_string(), + scopes: Vec::new(), + } + } + + #[must_use] + pub fn scope(mut self, scope: impl Into) -> Self { + self.scopes.push(scope.into()); + self + } + + /// Override the OAuth endpoints. Tests use this to point the + /// manager at an httpmock instance; production uses defaults. + #[must_use] + pub fn endpoints( + mut self, + auth_endpoint: impl Into, + token_endpoint: impl Into, + channels_endpoint: impl Into, + ) -> Self { + self.auth_endpoint = auth_endpoint.into(); + self.token_endpoint = token_endpoint.into(); + self.channels_endpoint = channels_endpoint.into(); + self + } + + pub fn build( + self, + store: S, + http_client: reqwest::Client, + ) -> AuthManager { + AuthManager { + client_id: self.client_id, + client_secret: self.client_secret, + auth_endpoint: self.auth_endpoint, + token_endpoint: self.token_endpoint, + channels_endpoint: self.channels_endpoint, + scopes: self.scopes, + http_client, + store: Arc::new(store), + } + } +} + +pub struct AuthManager { + client_id: String, + client_secret: String, + auth_endpoint: String, + token_endpoint: String, + channels_endpoint: String, + scopes: Vec, + http_client: reqwest::Client, + store: Arc, +} + +impl AuthManager { + pub fn builder( + client_id: impl Into, + client_secret: impl Into, + ) -> AuthManagerBuilder { + AuthManagerBuilder::new(client_id, client_secret) + } + + /// Builder pre-seeded with the production Google constants. + pub fn google() -> AuthManagerBuilder { + AuthManagerBuilder::new(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) + } + + #[must_use] + pub fn http_client(&self) -> &reqwest::Client { + &self.http_client + } + + pub async fn load_or_refresh(&self) -> Result { + let Some(stored) = self.store.load()? else { + return Err(AuthError::NoTokens); + }; + + if !stored.needs_refresh(Utc::now().timestamp_millis(), REFRESH_THRESHOLD_MS) { + return Ok(stored); + } + + handle_refresh_result(self.refresh(&stored).await, self.store.as_ref()) + } + + /// Binds the loopback listener, generates a PKCE pair + CSRF state, + /// and returns a [`PendingLogin`] holding both — plus the + /// `authorization_uri` the caller should open in the user's + /// browser. The flow is consumed by [`AuthManager::complete_login`] + /// which awaits the redirect and exchanges the code. + pub async fn start_login(&self) -> Result { + let server = LoopbackServer::bind() + .await + .map_err(|e| AuthError::LoopbackBind(e.to_string()))?; + let pkce = Pkce::generate()?; + let state = State::generate()?; + let redirect_uri = server.redirect_uri(); + + let authorization_uri = build_authorization_uri( + &self.auth_endpoint, + &self.client_id, + &redirect_uri, + &self.scopes, + &pkce, + &state, + )?; + + Ok(PendingLogin { + server, + pkce, + state, + authorization_uri, + redirect_uri, + }) + } + + /// Awaits the loopback redirect, validates the CSRF state, exchanges + /// the authorization code for tokens, fetches the channel identity, + /// and persists the result. + pub async fn complete_login(&self, pending: PendingLogin) -> Result { + let PendingLogin { + server, + pkce, + state, + redirect_uri, + .. + } = pending; + // RFC 8252 doesn't pin a number; 5 minutes is generous for a + // user toggling to the browser, completing Google's account + // chooser + 2FA, and toggling back, while still bounding stuck + // tasks if the tab is closed without completing. + let redirect = tokio::time::timeout( + Duration::from_secs(LOGIN_TIMEOUT_SECS), + server.wait_for_redirect(), + ) + .await + .map_err(|_| AuthError::Timeout)??; + let (code, returned_state) = redirect.into_code_and_state()?; + if returned_state != state.as_str() { + return Err(AuthError::StateMismatch); + } + + let response = exchange_code( + &self.http_client, + &self.token_endpoint, + &[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("code", code.as_str()), + ("code_verifier", pkce.verifier.as_str()), + ("redirect_uri", redirect_uri.as_str()), + ("grant_type", "authorization_code"), + ], + ) + .await?; + + let identity = self.fetch_channel(&response.access_token).await?; + let tokens = tokens_from_response(&response, identity, None)?; + self.store.save(&tokens)?; + Ok(tokens) + } + + /// Returns the stored channel title without contacting Google. UI + /// uses this on every poll to render "Logged in as " + /// without paying for a network round-trip. + pub fn peek_channel_title(&self) -> Result, AuthError> { + Ok(self.store.load()?.map(|t| t.channel_title)) + } + + pub fn logout(&self) -> Result<(), AuthError> { + self.store.delete() + } + + async fn refresh(&self, stored: &YouTubeTokens) -> Result { + let response = pkce_refresh( + &self.http_client, + &self.token_endpoint, + &[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("refresh_token", stored.refresh_token.as_str()), + ("grant_type", "refresh_token"), + ], + ) + .await?; + tokens_from_response( + &response, + ChannelIdentity { + channel_id: stored.channel_id.clone(), + channel_title: stored.channel_title.clone(), + }, + Some(&stored.refresh_token), + ) + } + + async fn fetch_channel(&self, access_token: &str) -> Result { + // RequestBuilder::query() lives behind a reqwest feature we + // don't enable; build the URL ourselves with `url::Url`. + let mut url = Url::parse(&self.channels_endpoint) + .map_err(|e| AuthError::OAuth(format!("invalid channels endpoint: {e}")))?; + url.query_pairs_mut() + .append_pair("part", "snippet") + .append_pair("mine", "true"); + + let response = self + .http_client + .get(url.as_str()) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| AuthError::OAuth(format!("youtube channels request failed: {e}")))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| AuthError::OAuth(format!("youtube channels body read failed: {e}")))?; + + if !status.is_success() { + return Err(AuthError::OAuth(format!( + "youtube channels {status}: {body}" + ))); + } + + let parsed: ChannelsResponse = serde_json::from_str(&body)?; + let item = parsed + .items + .into_iter() + .next() + .ok_or(AuthError::NoChannel)?; + + Ok(ChannelIdentity { + channel_id: item.id, + channel_title: item.snippet.title, + }) + } +} + +/// Opaque handle returned by [`AuthManager::start_login`]. +pub struct PendingLogin { + server: LoopbackServer, + pkce: Pkce, + state: State, + authorization_uri: String, + redirect_uri: String, +} + +impl PendingLogin { + #[must_use] + pub fn authorization_uri(&self) -> &str { + &self.authorization_uri + } + + #[must_use] + pub fn redirect_uri(&self) -> &str { + &self.redirect_uri + } +} + +#[derive(Debug, Deserialize)] +struct ChannelsResponse { + #[serde(default)] + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct ChannelItem { + id: String, + snippet: ChannelSnippet, +} + +#[derive(Debug, Deserialize)] +struct ChannelSnippet { + title: String, +} + +struct ChannelIdentity { + channel_id: String, + channel_title: String, +} + +fn build_authorization_uri( + endpoint: &str, + client_id: &str, + redirect_uri: &str, + scopes: &[String], + pkce: &Pkce, + state: &State, +) -> Result { + let scope = scopes.join(" "); + let mut url = Url::parse(endpoint) + .map_err(|e| AuthError::OAuth(format!("invalid auth endpoint {endpoint:?}: {e}")))?; + url.query_pairs_mut() + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", &scope) + .append_pair("code_challenge", &pkce.challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", state.as_str()) + // `access_type=offline` is what makes Google return a + // refresh_token; without it the response is access-only. + .append_pair("access_type", "offline") + // `prompt=consent` forces re-issuance of the refresh_token even + // for users who've previously consented. Without it Google + // skips the refresh_token in the response on subsequent grants + // and our store ends up with no way to refresh. + .append_pair("prompt", "consent"); + Ok(url.into()) +} + +fn tokens_from_response( + response: &TokenResponse, + identity: ChannelIdentity, + fallback_refresh: Option<&str>, +) -> Result { + // Google sometimes omits `refresh_token` from refresh responses + // (it's only re-issued when the consent set changes), but it MUST + // be present on the initial code-for-token exchange when we sent + // `access_type=offline`. If neither the response nor the stored + // fallback carries one we'd save unusable credentials and trip + // refresh forever — fail closed instead. + let refresh_token = response + .refresh_token + .as_deref() + .filter(|t| !t.is_empty()) + .map(str::to_string) + .or_else(|| { + fallback_refresh + .filter(|t| !t.is_empty()) + .map(str::to_string) + }) + .ok_or_else(|| { + AuthError::OAuth( + "token response missing refresh_token and no stored token to fall back to" + .to_string(), + ) + })?; + + Ok(YouTubeTokens { + access_token: response.access_token.clone(), + refresh_token, + expires_at_ms: compute_expires_at_ms( + Utc::now().timestamp_millis(), + Duration::from_secs(response.expires_in), + ), + scopes: response + .scope + .as_deref() + .map(|s| s.split(' ').map(str::to_string).collect()) + .unwrap_or_default(), + channel_id: identity.channel_id, + channel_title: identity.channel_title, + }) +} + +/// Overflow-safe absolute expiry timestamp. Same rationale as the +/// twitch_auth equivalent — saturating beats panicking inside the +/// supervisor's hot path. +fn compute_expires_at_ms(now_ms: i64, expires_in: Duration) -> i64 { + let expires_in_ms = i64::try_from(expires_in.as_millis()).unwrap_or(i64::MAX); + now_ms.saturating_add(expires_in_ms) +} + +fn handle_refresh_result( + result: Result, + store: &dyn TokenStore, +) -> Result { + match result { + Ok(refreshed) => { + store.save(&refreshed)?; + Ok(refreshed) + } + Err(AuthError::RefreshTokenInvalid) => { + let _ = store.delete(); + Err(AuthError::RefreshTokenInvalid) + } + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::youtube_auth::storage::MemoryStore; + + fn test_http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("reqwest client") + } + + #[test] + fn build_authorization_uri_includes_required_params() { + let pkce = Pkce::generate().unwrap(); + let state = State::generate().unwrap(); + let uri = build_authorization_uri( + "https://accounts.google.com/o/oauth2/v2/auth", + "client123.apps.googleusercontent.com", + "http://127.0.0.1:54321", + &["a".into(), "b".into()], + &pkce, + &state, + ) + .unwrap(); + assert!(uri.contains("client_id=client123.apps.googleusercontent.com")); + assert!(uri.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A54321")); + assert!(uri.contains("response_type=code")); + assert!(uri.contains("scope=a+b")); + assert!(uri.contains(&format!("code_challenge={}", pkce.challenge))); + assert!(uri.contains("code_challenge_method=S256")); + assert!(uri.contains(&format!("state={}", state.as_str()))); + assert!(uri.contains("access_type=offline")); + assert!(uri.contains("prompt=consent")); + } + + #[test] + fn build_authorization_uri_returns_error_on_bad_endpoint() { + let pkce = Pkce::generate().unwrap(); + let state = State::generate().unwrap(); + let err = build_authorization_uri( + "not a url", + "client", + "http://127.0.0.1:1", + &[], + &pkce, + &state, + ) + .unwrap_err(); + assert!(matches!(err, AuthError::OAuth(_))); + } + + #[test] + fn compute_expires_at_ms_happy_path() { + let got = compute_expires_at_ms(1_000, Duration::from_secs(3600)); + assert_eq!(got, 1_000 + 3_600_000); + } + + #[test] + fn compute_expires_at_ms_saturates() { + let got = compute_expires_at_ms(i64::MAX - 10, Duration::from_secs(3600)); + assert_eq!(got, i64::MAX); + } + + fn sample_tokens() -> YouTubeTokens { + YouTubeTokens { + access_token: "at".into(), + refresh_token: "rt".into(), + expires_at_ms: 1_000_000, + scopes: vec!["scope-a".into()], + channel_id: "UC123".into(), + channel_title: "Test".into(), + } + } + + #[test] + fn handle_refresh_result_persists_on_success() { + let store = MemoryStore::default(); + let fresh = sample_tokens(); + handle_refresh_result(Ok(fresh.clone()), &store).unwrap(); + assert_eq!(store.load().unwrap().unwrap(), fresh); + } + + #[test] + fn handle_refresh_result_evicts_on_invalid_grant() { + let store = MemoryStore::default(); + store.save(&sample_tokens()).unwrap(); + let err = handle_refresh_result(Err(AuthError::RefreshTokenInvalid), &store).unwrap_err(); + assert!(matches!(err, AuthError::RefreshTokenInvalid)); + assert!(store.load().unwrap().is_none()); + } + + #[test] + fn handle_refresh_result_keeps_store_on_transient_error() { + let store = MemoryStore::default(); + store.save(&sample_tokens()).unwrap(); + let err = + handle_refresh_result(Err(AuthError::OAuth("network".into())), &store).unwrap_err(); + assert!(matches!(err, AuthError::OAuth(_))); + assert!(store.load().unwrap().is_some()); + } + + #[test] + fn tokens_from_response_uses_response_refresh_when_present() { + let response = TokenResponse { + access_token: "at".into(), + refresh_token: Some("rt-new".into()), + expires_in: 3600, + scope: Some("a b".into()), + token_type: Some("Bearer".into()), + }; + let got = tokens_from_response( + &response, + ChannelIdentity { + channel_id: "UC1".into(), + channel_title: "T".into(), + }, + Some("rt-old"), + ) + .unwrap(); + assert_eq!(got.refresh_token, "rt-new"); + assert_eq!(got.scopes, vec!["a", "b"]); + } + + #[test] + fn tokens_from_response_falls_back_to_stored_refresh_when_missing() { + // Google often omits refresh_token from refresh responses. + let response = TokenResponse { + access_token: "at-fresh".into(), + refresh_token: None, + expires_in: 3600, + scope: None, + token_type: None, + }; + let got = tokens_from_response( + &response, + ChannelIdentity { + channel_id: "UC1".into(), + channel_title: "T".into(), + }, + Some("rt-stored"), + ) + .unwrap(); + assert_eq!( + got.refresh_token, "rt-stored", + "must keep the previous refresh token when Google omits it" + ); + } + + #[test] + fn tokens_from_response_errors_when_no_refresh_anywhere() { + // Initial code-for-token path with `access_type=offline` should + // always include refresh_token; if Google ever drops it we must + // fail closed rather than persist credentials we can't refresh. + let response = TokenResponse { + access_token: "at".into(), + refresh_token: None, + expires_in: 3600, + scope: None, + token_type: None, + }; + let err = tokens_from_response( + &response, + ChannelIdentity { + channel_id: "UC1".into(), + channel_title: "T".into(), + }, + None, + ) + .unwrap_err(); + assert!(matches!(err, AuthError::OAuth(msg) if msg.contains("refresh_token"))); + } + + #[tokio::test] + async fn load_or_refresh_returns_no_tokens_when_store_empty() { + let mgr = AuthManager::builder("c", "s").build(MemoryStore::default(), test_http_client()); + match mgr.load_or_refresh().await { + Err(AuthError::NoTokens) => {} + Ok(_) => panic!("expected NoTokens, got Ok"), + Err(_) => panic!("expected NoTokens variant"), + } + } + + #[tokio::test] + async fn load_or_refresh_returns_fresh_tokens_verbatim() { + let store = Arc::new(MemoryStore::default()); + + struct Handle(Arc); + impl TokenStore for Handle { + fn load(&self) -> Result, AuthError> { + self.0.load() + } + fn save(&self, t: &YouTubeTokens) -> Result<(), AuthError> { + self.0.save(t) + } + fn delete(&self) -> Result<(), AuthError> { + self.0.delete() + } + } + + let mgr = AuthManager::builder("c", "s").build(Handle(store.clone()), test_http_client()); + let fresh = YouTubeTokens { + access_token: "at".into(), + refresh_token: "rt".into(), + expires_at_ms: Utc::now().timestamp_millis() + 60 * 60 * 1000, + scopes: vec!["x".into()], + channel_id: "UC".into(), + channel_title: "T".into(), + }; + store.save(&fresh).unwrap(); + + let got = mgr.load_or_refresh().await.unwrap(); + assert_eq!(got, fresh); + } +} diff --git a/apps/desktop/src-tauri/src/youtube_auth/mod.rs b/apps/desktop/src-tauri/src/youtube_auth/mod.rs new file mode 100644 index 0000000..f9c5642 --- /dev/null +++ b/apps/desktop/src-tauri/src/youtube_auth/mod.rs @@ -0,0 +1,80 @@ +//! YouTube OAuth + keychain integration. +//! +//! Implements ADR 39 (YouTube OAuth 2.0 via Authorization Code + PKCE +//! with loopback IP redirect, tokens via `keyring-rs`), ADR 29 +//! (proactive refresh 5 min before expiry), and ADR 31 (re-auth path +//! on refresh failure). The flow: +//! +//! 1. At startup the supervisor calls [`AuthManager::load_or_refresh`]. +//! If fresh, the access token is handed to the sidecar via +//! `youtube_connect`. If stale, the manager refreshes transparently +//! and persists the rotated refresh token. +//! 2. On [`AuthError::NoTokens`] (first run) or +//! [`AuthError::RefreshTokenInvalid`] (Google's refresh tokens +//! silently expire after 6 months of inactivity, plus other invalid +//! states), the frontend kicks [`AuthManager::start_login`] → +//! [`AuthManager::complete_login`]. +//! +//! The module is pure-logic and async-only; wiring into the supervisor +//! lives alongside the Twitch supervisor in `lib.rs::setup`. + +pub mod auth_state; +pub mod commands; +pub mod errors; +pub mod manager; +pub mod storage; +pub mod tokens; + +pub use auth_state::{AuthCommandError, AuthState, AuthStatus, AuthStatusState, PkceFlowView}; +pub use commands::{ + youtube_auth_status, youtube_cancel_login, youtube_complete_login, youtube_logout, + youtube_start_login, +}; +pub use errors::AuthError; +pub use manager::{AuthManager, AuthManagerBuilder, PendingLogin, REFRESH_THRESHOLD_MS}; +pub use storage::{KeychainStore, MemoryStore, TokenStore, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE}; +pub use tokens::YouTubeTokens; + +/// OAuth `client_id` for the registered Prismoid Google application. +/// +/// Replace with the real value from your Google Cloud Console "OAuth +/// 2.0 Client IDs → Desktop app" credential before shipping. The +/// surrounding code paths (start_login → complete_login → exchange) +/// will all return `AuthError::OAuth("invalid_client")` until this is +/// set to a registered Desktop client. +/// +/// Per RFC 8252 §8.4 and Google's own docs, this `client_id` is a +/// public identifier — it appears in browser URLs during the +/// authorization flow and is bundled in source the same way the +/// Twitch DCF flow handles `TWITCH_CLIENT_ID` (see ADR 37). +pub const GOOGLE_CLIENT_ID: &str = "REPLACE_ME.apps.googleusercontent.com"; + +/// OAuth `client_secret` for the registered Prismoid Google application. +/// +/// Google issues a `client_secret` for "Desktop app" credentials and +/// requires it on the token-exchange POST, but their own +/// [installed-app docs](https://developers.google.com/identity/protocols/oauth2/native-app) +/// note: *"In this context, the client secret is obviously not treated +/// as a secret."* PKCE S256 is what cryptographically protects the +/// flow on a public client; this string is included on the wire only +/// because Google's endpoint won't accept the request without it. +pub const GOOGLE_CLIENT_SECRET: &str = "REPLACE_ME"; + +/// Google OAuth 2.0 authorization endpoint. Hard-coded to the v2 +/// endpoint per Google's [installed-app guide](https://developers.google.com/identity/protocols/oauth2/native-app#step-2-send-a-request-to-googles-oauth-20-server). +pub const GOOGLE_AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth"; + +/// Google OAuth 2.0 token endpoint. Hard-coded to the v4 endpoint per +/// the same guide. +pub const GOOGLE_TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token"; + +/// YouTube Data API v3 channels endpoint, used post-token-exchange to +/// fetch the authenticated user's channel id + title. +pub const YOUTUBE_CHANNELS_ENDPOINT: &str = "https://www.googleapis.com/youtube/v3/channels"; + +/// Read-only YouTube Data API scope. Required to read live chat. +pub const SCOPE_YOUTUBE_READONLY: &str = "https://www.googleapis.com/auth/youtube.readonly"; + +/// Full YouTube scope (read + write + moderation). Required to send +/// messages and ban/timeout/delete on YouTube live chat. +pub const SCOPE_YOUTUBE: &str = "https://www.googleapis.com/auth/youtube"; diff --git a/apps/desktop/src-tauri/src/youtube_auth/storage.rs b/apps/desktop/src-tauri/src/youtube_auth/storage.rs new file mode 100644 index 0000000..e167591 --- /dev/null +++ b/apps/desktop/src-tauri/src/youtube_auth/storage.rs @@ -0,0 +1,140 @@ +//! Persistence layer for [`YouTubeTokens`]. +//! +//! Single-account per ADR 30: one blob per app under a fixed +//! `(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)` pair. Mirrors +//! `twitch_auth::storage` shape; the only difference is the service +//! name (`prismoid.youtube` instead of `prismoid.twitch`) so the two +//! providers' entries don't collide in the OS credential store. + +use std::sync::Mutex; + +use keyring::Entry; + +use super::errors::AuthError; +use super::tokens::YouTubeTokens; + +pub const KEYCHAIN_SERVICE: &str = "prismoid.youtube"; +pub const KEYCHAIN_ACCOUNT: &str = "active"; + +pub trait TokenStore: Send + Sync { + fn load(&self) -> Result, AuthError>; + fn save(&self, tokens: &YouTubeTokens) -> Result<(), AuthError>; + fn delete(&self) -> Result<(), AuthError>; +} + +#[derive(Default, Debug)] +pub struct KeychainStore; + +impl TokenStore for KeychainStore { + fn load(&self) -> Result, AuthError> { + let entry = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?; + match entry.get_password() { + Ok(blob) => { + let tokens: YouTubeTokens = serde_json::from_str(&blob)?; + Ok(Some(tokens)) + } + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(AuthError::Keychain(e)), + } + } + + fn save(&self, tokens: &YouTubeTokens) -> Result<(), AuthError> { + let entry = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?; + let blob = serde_json::to_string(tokens)?; + entry.set_password(&blob)?; + Ok(()) + } + + fn delete(&self) -> Result<(), AuthError> { + let entry = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(AuthError::Keychain(e)), + } + } +} + +#[derive(Default, Debug)] +pub struct MemoryStore { + inner: Mutex>, +} + +impl TokenStore for MemoryStore { + fn load(&self) -> Result, AuthError> { + let guard = self.inner.lock().expect("MemoryStore mutex poisoned"); + Ok(guard.clone()) + } + + fn save(&self, tokens: &YouTubeTokens) -> Result<(), AuthError> { + let mut guard = self.inner.lock().expect("MemoryStore mutex poisoned"); + *guard = Some(tokens.clone()); + Ok(()) + } + + fn delete(&self) -> Result<(), AuthError> { + let mut guard = self.inner.lock().expect("MemoryStore mutex poisoned"); + *guard = None; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> YouTubeTokens { + YouTubeTokens { + access_token: "at".into(), + refresh_token: "rt".into(), + expires_at_ms: 1_000_000, + scopes: vec!["https://www.googleapis.com/auth/youtube.readonly".into()], + channel_id: "UC123".into(), + channel_title: "Test".into(), + } + } + + #[test] + fn memory_store_load_missing_returns_none() { + let store = MemoryStore::default(); + assert!(store.load().unwrap().is_none()); + } + + #[test] + fn memory_store_save_then_load_returns_same() { + let store = MemoryStore::default(); + let t = sample(); + store.save(&t).unwrap(); + assert_eq!(store.load().unwrap().unwrap(), t); + } + + #[test] + fn memory_store_save_overwrites() { + let store = MemoryStore::default(); + let mut t = sample(); + store.save(&t).unwrap(); + t.access_token = "at2".into(); + store.save(&t).unwrap(); + assert_eq!(store.load().unwrap().unwrap(), t); + } + + #[test] + fn memory_store_delete_removes_entry() { + let store = MemoryStore::default(); + store.save(&sample()).unwrap(); + store.delete().unwrap(); + assert!(store.load().unwrap().is_none()); + } + + #[test] + fn memory_store_delete_missing_is_noop() { + let store = MemoryStore::default(); + store.delete().unwrap(); + } + + #[test] + fn keychain_service_is_distinct_from_twitch() { + // Sanity: if these collide a single keychain entry is shared + // and the two providers stomp each other's tokens. + assert_ne!(KEYCHAIN_SERVICE, crate::twitch_auth::KEYCHAIN_SERVICE); + } +} diff --git a/apps/desktop/src-tauri/src/youtube_auth/tokens.rs b/apps/desktop/src-tauri/src/youtube_auth/tokens.rs new file mode 100644 index 0000000..d04065b --- /dev/null +++ b/apps/desktop/src-tauri/src/youtube_auth/tokens.rs @@ -0,0 +1,108 @@ +//! Persistable token DTO for YouTube. +//! +//! Mirrors `twitch_auth::TwitchTokens` shape with the YouTube-specific +//! identity fields (`channel_id`, `channel_title`) substituted for +//! Twitch's `user_id`/`login`. Same redacted Debug pattern. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct YouTubeTokens { + pub access_token: String, + pub refresh_token: String, + /// Unix milliseconds at which `access_token` expires. Absolute time + /// is captured at save, not expires_in, so a sleeping process still + /// evaluates freshness correctly on wake. + pub expires_at_ms: i64, + /// Scopes granted, parsed from the token endpoint's space-delimited + /// `scope` response field. + pub scopes: Vec, + /// YouTube channel id (the `UC...` ID) of the authenticated user, + /// fetched once from `youtube.channels?mine=true` after the token + /// exchange. Used as `liveChatId` resolver input and as the + /// "Logged in as" display anchor. + pub channel_id: String, + /// Channel display name from `snippet.title`. UI display only. + pub channel_title: String, +} + +impl std::fmt::Debug for YouTubeTokens { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("YouTubeTokens") + .field("access_token", &"[redacted]") + .field("refresh_token", &"[redacted]") + .field("expires_at_ms", &self.expires_at_ms) + .field("scopes", &self.scopes) + .field("channel_id", &self.channel_id) + .field("channel_title", &self.channel_title) + .finish() + } +} + +impl YouTubeTokens { + /// Returns true if the access token is either already expired or + /// within `threshold_ms` of expiring. ADR 29 pins the threshold to + /// 5 min. + #[must_use] + pub fn needs_refresh(&self, now_ms: i64, threshold_ms: i64) -> bool { + now_ms + threshold_ms >= self.expires_at_ms + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(expires_at_ms: i64) -> YouTubeTokens { + YouTubeTokens { + access_token: "at".into(), + refresh_token: "rt".into(), + expires_at_ms, + scopes: vec!["https://www.googleapis.com/auth/youtube.readonly".into()], + channel_id: "UC123".into(), + channel_title: "Test Channel".into(), + } + } + + #[test] + fn needs_refresh_fresh_token_returns_false() { + let t = sample(1_000_000); + assert!(!t.needs_refresh(0, 300_000)); + } + + #[test] + fn needs_refresh_at_threshold_returns_true() { + let t = sample(1_000_000); + assert!(t.needs_refresh(700_000, 300_000)); + } + + #[test] + fn needs_refresh_already_expired_returns_true() { + let t = sample(500_000); + assert!(t.needs_refresh(1_000_000, 0)); + } + + #[test] + fn roundtrip_json_preserves_all_fields() { + let t = sample(1_234_567); + let blob = serde_json::to_string(&t).unwrap(); + let back: YouTubeTokens = serde_json::from_str(&blob).unwrap(); + assert_eq!(t, back); + } + + #[test] + fn debug_impl_redacts_secrets() { + let t = YouTubeTokens { + access_token: "super-secret-access-xyz".into(), + refresh_token: "super-secret-refresh-xyz".into(), + expires_at_ms: 1_234_567, + scopes: vec![], + channel_id: "UCabc".into(), + channel_title: "Title".into(), + }; + let dbg = format!("{t:?}"); + assert!(!dbg.contains("super-secret")); + assert!(dbg.contains("[redacted]")); + assert!(dbg.contains("UCabc")); + } +} diff --git a/apps/desktop/src/components/Header.tsx b/apps/desktop/src/components/Header.tsx index 4a2730e..2d84ed2 100644 --- a/apps/desktop/src/components/Header.tsx +++ b/apps/desktop/src/components/Header.tsx @@ -10,6 +10,7 @@ import { type SidecarState, type SidecarStatus, } from "../lib/sidecarStatus"; +import YouTubeSignIn from "./YouTubeSignIn"; export interface HeaderProps { login: string; @@ -89,6 +90,7 @@ const Header: Component = (props) => { {props.login} + ); diff --git a/apps/desktop/src/components/YouTubeSignIn.tsx b/apps/desktop/src/components/YouTubeSignIn.tsx new file mode 100644 index 0000000..7b65391 --- /dev/null +++ b/apps/desktop/src/components/YouTubeSignIn.tsx @@ -0,0 +1,281 @@ +// YouTube connection UI rendered inside the header. +// +// Three states drive what's shown: +// - logged_out: button "Connect YouTube" → starts the PKCE flow +// - logged_out + busy: button shows "Waiting for browser…" with cancel +// - logged_in: channel title + dropdown to Disconnect +// +// The flow itself: clicking the button opens the system browser at the +// Google authorization URL and immediately calls completeLogin() which +// blocks on the loopback redirect (see oauth_pkce::loopback). When the +// user authorizes in the browser, the loopback fires and the promise +// resolves. A `generation` counter guards against races between Cancel +// and a late-resolving completeLogin(). + +import { Component, Show, createSignal, onCleanup, onMount } from "solid-js"; +import { + cancelLogin, + completeLogin, + getAuthStatus, + logout, + openAuthorizationUri, + startLogin, + type AuthCommandError, + type AuthStatus, +} from "../lib/youtubeAuth"; + +function isAuthError(e: unknown): e is AuthCommandError { + return ( + typeof e === "object" && + e !== null && + typeof (e as { kind?: unknown }).kind === "string" + ); +} + +const YouTubeSignIn: Component = () => { + const [status, setStatus] = createSignal(null); + const [busy, setBusy] = createSignal(false); + const [error, setError] = createSignal(null); + const [menuOpen, setMenuOpen] = createSignal(false); + let generation = 0; + + onMount(() => { + void cancelLogin().catch(() => {}); + void getAuthStatus() + .then(setStatus) + .catch(() => setStatus({ state: "logged_out" })); + }); + + onCleanup(() => { + generation += 1; + void cancelLogin().catch(() => {}); + }); + + const beginFlow = async () => { + generation += 1; + const gen = generation; + setError(null); + setBusy(true); + try { + const view = await startLogin(); + if (gen !== generation) return; + try { + await openAuthorizationUri(view.authorization_uri); + } catch (e) { + // Browser launch failed (allowlist rejected, no opener, etc.). + // Tear down the pending backend flow so it doesn't sit on the + // loopback waiting for a redirect that will never arrive. + if (gen === generation) { + await cancelLogin().catch(() => {}); + } + throw e; + } + if (gen !== generation) return; + + const next = await completeLogin(); + if (gen !== generation) return; + setStatus(next); + } catch (e) { + if (gen !== generation) return; + if (isAuthError(e)) { + // A user-initiated cancel surfaces as a backend error; don't + // flash that as a red error message. + if (e.kind !== "cancelled") setError(authErrorMessage(e)); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + } finally { + if (gen === generation) setBusy(false); + } + }; + + const cancel = async () => { + generation += 1; + setBusy(false); + setError(null); + await cancelLogin().catch(() => {}); + }; + + const disconnect = async () => { + setMenuOpen(false); + try { + await logout(); + setStatus({ state: "logged_out" }); + } catch (e) { + if (isAuthError(e)) setError(authErrorMessage(e)); + else setError(e instanceof Error ? e.message : String(e)); + } + }; + + return ( +
+ + { + const s = status(); + return s?.state === "logged_in" ? s : null; + })()} + fallback={ + + Connect YouTube + + } + > + + Waiting for browser… + + + + } + > + {(loggedIn) => ( + <> + + +
+ +
+
+ + )} +
+ + {(e) => ( + + {e()} + + )} + +
+ ); +}; + +const buttonStyle = { + padding: "4px 10px", + "font-size": "12px", + background: "#cc0000", + color: "white", + border: "none", + "border-radius": "4px", + cursor: "pointer", +} as const; + +const subtleButton = { + padding: "4px 8px", + "font-size": "12px", + background: "transparent", + color: "#888", + border: "1px solid #333", + "border-radius": "4px", + cursor: "pointer", +} as const; + +const YouTubeBadge: Component<{ connected: boolean }> = (props) => ( + + + + + +); + +function authErrorMessage(e: AuthCommandError): string { + switch (e.kind) { + case "user_denied": + return "Authorization was denied. Try again to grant access."; + case "loopback_bind": + return `Could not start local listener: ${e.message}`; + case "state_mismatch": + return "Sign-in failed a security check. Try again."; + case "no_channel": + return "This Google account has no YouTube channel."; + case "keychain": + return `Could not access the OS credential store: ${e.message}`; + case "oauth": + return `Google rejected the request: ${e.message}`; + case "no_pending_flow": + return "Sign-in flow lost its state. Try again."; + case "timeout": + return "Sign-in took too long. Try again."; + case "cancelled": + return "Sign-in cancelled."; + default: + return e.message; + } +} + +export default YouTubeSignIn; diff --git a/apps/desktop/src/lib/youtubeAuth.test.ts b/apps/desktop/src/lib/youtubeAuth.test.ts new file mode 100644 index 0000000..3c7dff8 --- /dev/null +++ b/apps/desktop/src/lib/youtubeAuth.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn().mockResolvedValue(undefined), +})); + +import { + cancelLogin, + completeLogin, + getAuthStatus, + logout, + openAuthorizationUri, + startLogin, +} from "./youtubeAuth"; + +afterEach(() => { + invokeMock.mockReset(); +}); + +describe("youtubeAuth commands", () => { + it("getAuthStatus invokes youtube_auth_status", async () => { + invokeMock.mockResolvedValueOnce({ state: "logged_out" }); + await getAuthStatus(); + expect(invokeMock).toHaveBeenCalledWith("youtube_auth_status"); + }); + + it("startLogin invokes youtube_start_login", async () => { + invokeMock.mockResolvedValueOnce({ authorization_uri: "https://x" }); + await startLogin(); + expect(invokeMock).toHaveBeenCalledWith("youtube_start_login"); + }); + + it("completeLogin invokes youtube_complete_login", async () => { + invokeMock.mockResolvedValueOnce({ + state: "logged_in", + channel_title: "T", + }); + await completeLogin(); + expect(invokeMock).toHaveBeenCalledWith("youtube_complete_login"); + }); + + it("cancelLogin invokes youtube_cancel_login", async () => { + invokeMock.mockResolvedValueOnce(undefined); + await cancelLogin(); + expect(invokeMock).toHaveBeenCalledWith("youtube_cancel_login"); + }); + + it("logout invokes youtube_logout", async () => { + invokeMock.mockResolvedValueOnce(undefined); + await logout(); + expect(invokeMock).toHaveBeenCalledWith("youtube_logout"); + }); +}); + +describe("openAuthorizationUri", () => { + it("allows accounts.google.com over https", async () => { + await expect( + openAuthorizationUri( + "https://accounts.google.com/o/oauth2/v2/auth?client_id=x", + ), + ).resolves.toBeUndefined(); + }); + + it("rejects non-Google hosts", async () => { + await expect( + openAuthorizationUri("https://evil.com/phish"), + ).rejects.toThrow("authorization URL not on a Google domain"); + }); + + it("rejects http URLs", async () => { + await expect( + openAuthorizationUri("http://accounts.google.com/o/oauth2/v2/auth"), + ).rejects.toThrow("authorization URL not on a Google domain"); + }); + + it("rejects invalid URLs", async () => { + await expect(openAuthorizationUri("not-a-url")).rejects.toThrow( + "invalid authorization URL", + ); + }); +}); diff --git a/apps/desktop/src/lib/youtubeAuth.ts b/apps/desktop/src/lib/youtubeAuth.ts new file mode 100644 index 0000000..9dc02d1 --- /dev/null +++ b/apps/desktop/src/lib/youtubeAuth.ts @@ -0,0 +1,77 @@ +// YouTube sign-in flow client. Mirrors the Tauri command surface in +// apps/desktop/src-tauri/src/youtube_auth/commands.rs. +// +// Flow shape differs from Twitch's DCF: there's no user_code to display +// — `start_login` returns an `authorization_uri` we open in the system +// browser, and `complete_login` blocks on the loopback HTTP redirect. +// The frontend's only job in between is to open the URL. + +import { invoke } from "@tauri-apps/api/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; + +export type AuthStatusState = "logged_out" | "logged_in"; + +export type AuthStatus = + | { state: "logged_out" } + | { state: "logged_in"; channel_title: string }; + +export interface PkceFlowView { + authorization_uri: string; +} + +export interface AuthCommandError { + kind: + | "no_tokens" + | "refresh_invalid" + | "user_denied" + | "loopback_bind" + | "state_mismatch" + | "keychain" + | "oauth" + | "json" + | "no_channel" + | "no_pending_flow" + | "cancelled" + | "timeout"; + message: string; +} + +export function getAuthStatus(): Promise { + return invoke("youtube_auth_status"); +} + +export function startLogin(): Promise { + return invoke("youtube_start_login"); +} + +export function completeLogin(): Promise { + return invoke("youtube_complete_login"); +} + +export function cancelLogin(): Promise { + return invoke("youtube_cancel_login"); +} + +export function logout(): Promise { + return invoke("youtube_logout"); +} + +const ALLOWED_HOSTS = ["accounts.google.com"]; + +export function openAuthorizationUri(uri: string): Promise { + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + return Promise.reject(new Error("invalid authorization URL")); + } + if ( + parsed.protocol !== "https:" || + !ALLOWED_HOSTS.includes(parsed.hostname) + ) { + return Promise.reject( + new Error("authorization URL not on a Google domain"), + ); + } + return openUrl(uri); +} diff --git a/docs/adr.md b/docs/adr.md index b9b3d20..45293b2 100644 --- a/docs/adr.md +++ b/docs/adr.md @@ -42,20 +42,21 @@ Decisions that have been discussed, evaluated, and locked. Don't revisit unless ## Operational Decisions (locked during planning) -| # | Decision | Options Considered | Rationale | -| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 24 | Emote cache eviction: LRU at 500MB | Time-based, size-based (various limits), no eviction | Generous for thousands of emotes without bloating disk | -| 25 | Emote set diff: on channel join + every 5 min | On join only, continuous polling, WebSocket subscription | Catches mid-stream emote additions without hammering APIs | -| 26 | Cache warming: serve SQLite, diff in background | Fresh fetch on switch, SQLite only | Instant emotes on channel switch, background diff catches changes silently | -| 27 | API rate limiting: token bucket per provider in Go | Global limiter, per-endpoint limiter, no limiter | Standard approach, isolates each platform's limits | -| 28 | Global emote/badge TTL: 1 hour in-memory | 15 min, 1 hour, 6 hours | These change rarely. 1 hour balances freshness and API efficiency | -| 29 | OAuth refresh: proactive, 5 min before expiry | Reactive (on 401), proactive | Avoids dropped messages during refresh window | -| 30 | One account per platform in v1 | Multi-account, single account | Brief doesn't require multi-account. Keep simple for v1 | -| 31 | Refresh token failure: re-auth UI, graceful degradation | Auto-retry, force logout, silent fail | Other platforms keep working. User re-authenticates the failed one | -| 32 | SQLite WAL mode | Default journal, WAL | Standard for concurrent read/write. UI never blocks on disk | -| 33 | Schema migrations from day one | No migrations, manual schema, migration framework | Prevents manual DB management for end users. Embedded in binary | -| 34 | Logging: tracing (Rust), zerolog (Go), console (frontend) | log4rs, slog, winston | Industry standard structured logging for each language | -| 35 | License: GPL v3 | MIT, Apache 2.0, GPL v3, BSL 1.1, AGPL | Open source, prevents closed-source forks. Can relicense later as sole copyright holder (with CLA for contributors) | -| 36 | Windows-first development | All platforms equal, macOS first | Most streamers are on Windows. CI, tooling, and testing prioritize Windows | -| 37 | Twitch OAuth via Device Code Grant (public client), tokens stored via `keyring-rs` (native credential store per OS) as a single serde-JSON blob per broadcaster. Browser launched to `verification_uri` via `tauri_plugin_shell::open`. Refresh rotation (Twitch rotates refresh tokens on every use) handled by persisting the new refresh token atomically. YouTube and Kick get their own ADRs when those platforms land; Twitch-specific constraints do not generalize. | Authorization Code Grant with client secret, Authorization Code + PKCE, Device Code Grant (public client), Implicit Grant. Storage: keyring-rs, direct Win32 CredMan/macOS Keychain/Secret-Service bindings, encrypted file on disk. | Twitch's Authorization Code Grant requires `client_secret` in the token exchange per their docs ("This flow is meant for apps that use a server, can securely store a client secret, and can make server-to-server requests to the Twitch API"), and a desktop binary cannot safely hold a client secret regardless of code signing. Twitch does not support PKCE as a client-secret substitute on their Authorization Code endpoint; the documented flow hard-requires `client_secret`. Twitch's docs explicitly recommend Device Code Grant with public client type "if your application is on a more open platform (such as windows)", which is exactly our primary target (ADR 36). DCF for public clients issues access tokens (4h expiry) plus refresh tokens (one-time use, 30-day inactive expiry) with no secret required, and matches ADR 29 (proactive 5-min refresh) and ADR 31 (re-auth UI on refresh failure) without modification. Implicit Grant is ruled out by RFC 9700 and issues no refresh token. On storage, `keyring-rs` is the de-facto Rust cross-platform credential store — it wraps Windows Credential Manager, macOS Keychain Services, Linux Secret Service / keyutils, and FreeBSD/OpenBSD, exposing a single `Entry::new(service, user)` API; every token lives in the OS's own secret store with OS-level access control, matching how 1Password, GitHub CLI, and git-credential-manager store secrets. Direct platform APIs were rejected as unnecessary reinvention. Encrypted-file was rejected because there is no key the app can hold that the OS cannot already hold better. Tokens are serialized as a single JSON blob `{access_token, refresh_token, expires_at, scopes}` keyed by `prismoid.twitch.`, not per-field entries — atomic read/write and one keychain prompt per operation. DCF user UX is "app opens browser to verification URL with the device code pre-filled in query string, user clicks Authorize, app polls token endpoint until approved" — Twitch's `verification_uri` already includes the `device-code=` query parameter, so the flow is effectively one click once the browser lands on the page. | -| 38 | Kick chat read via Pusher WebSocket (public channel, no auth for read-only). Official API (OAuth 2.1 Authorization Code + PKCE via `id.kick.com`) used for write and moderation operations only. Platform tag byte (`0x01` Twitch, `0x02` Kick, `0x03` YouTube) prepended to each message in the Go sidecar's out channel so the Rust host can dispatch to the correct parser without trying multiple deserializers. | Official webhooks for all events, Pusher WebSocket for all events, official API polling | Kick's official event system (docs.kick.com) is webhook-only and requires a publicly reachable URL, which a desktop app cannot provide without tunneling. Kick's own web client reads chat via Pusher WebSocket on `chatrooms.{id}.v2` channels, which are public (no auth required for subscribe). This is the only viable path for a desktop chat reader. Official OAuth 2.1 + PKCE is used for write/mod operations (`POST /public/v1/chat`, `POST/DELETE /public/v1/moderation/bans`, `DELETE /public/v1/chat/{message_id}`) since those endpoints require `chat:write` and `moderation:*` scopes. The platform tag byte approach was chosen over trying multiple JSON parsers (fragile, O(n) parsers per message) or wrapping in a JSON envelope (unnecessary allocation and re-encoding). One byte per message is zero-cost and extends cleanly to YouTube (tag `0x03`). | +| # | Decision | Options Considered | Rationale | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 24 | Emote cache eviction: LRU at 500MB | Time-based, size-based (various limits), no eviction | Generous for thousands of emotes without bloating disk | +| 25 | Emote set diff: on channel join + every 5 min | On join only, continuous polling, WebSocket subscription | Catches mid-stream emote additions without hammering APIs | +| 26 | Cache warming: serve SQLite, diff in background | Fresh fetch on switch, SQLite only | Instant emotes on channel switch, background diff catches changes silently | +| 27 | API rate limiting: token bucket per provider in Go | Global limiter, per-endpoint limiter, no limiter | Standard approach, isolates each platform's limits | +| 28 | Global emote/badge TTL: 1 hour in-memory | 15 min, 1 hour, 6 hours | These change rarely. 1 hour balances freshness and API efficiency | +| 29 | OAuth refresh: proactive, 5 min before expiry | Reactive (on 401), proactive | Avoids dropped messages during refresh window | +| 30 | One account per platform in v1 | Multi-account, single account | Brief doesn't require multi-account. Keep simple for v1 | +| 31 | Refresh token failure: re-auth UI, graceful degradation | Auto-retry, force logout, silent fail | Other platforms keep working. User re-authenticates the failed one | +| 32 | SQLite WAL mode | Default journal, WAL | Standard for concurrent read/write. UI never blocks on disk | +| 33 | Schema migrations from day one | No migrations, manual schema, migration framework | Prevents manual DB management for end users. Embedded in binary | +| 34 | Logging: tracing (Rust), zerolog (Go), console (frontend) | log4rs, slog, winston | Industry standard structured logging for each language | +| 35 | License: GPL v3 | MIT, Apache 2.0, GPL v3, BSL 1.1, AGPL | Open source, prevents closed-source forks. Can relicense later as sole copyright holder (with CLA for contributors) | +| 36 | Windows-first development | All platforms equal, macOS first | Most streamers are on Windows. CI, tooling, and testing prioritize Windows | +| 37 | Twitch OAuth via Device Code Grant (public client), tokens stored via `keyring-rs` (native credential store per OS) as a single serde-JSON blob per broadcaster. Browser launched to `verification_uri` via `tauri_plugin_shell::open`. Refresh rotation (Twitch rotates refresh tokens on every use) handled by persisting the new refresh token atomically. YouTube and Kick get their own ADRs when those platforms land; Twitch-specific constraints do not generalize. | Authorization Code Grant with client secret, Authorization Code + PKCE, Device Code Grant (public client), Implicit Grant. Storage: keyring-rs, direct Win32 CredMan/macOS Keychain/Secret-Service bindings, encrypted file on disk. | Twitch's Authorization Code Grant requires `client_secret` in the token exchange per their docs ("This flow is meant for apps that use a server, can securely store a client secret, and can make server-to-server requests to the Twitch API"), and a desktop binary cannot safely hold a client secret regardless of code signing. Twitch does not support PKCE as a client-secret substitute on their Authorization Code endpoint; the documented flow hard-requires `client_secret`. Twitch's docs explicitly recommend Device Code Grant with public client type "if your application is on a more open platform (such as windows)", which is exactly our primary target (ADR 36). DCF for public clients issues access tokens (4h expiry) plus refresh tokens (one-time use, 30-day inactive expiry) with no secret required, and matches ADR 29 (proactive 5-min refresh) and ADR 31 (re-auth UI on refresh failure) without modification. Implicit Grant is ruled out by RFC 9700 and issues no refresh token. On storage, `keyring-rs` is the de-facto Rust cross-platform credential store — it wraps Windows Credential Manager, macOS Keychain Services, Linux Secret Service / keyutils, and FreeBSD/OpenBSD, exposing a single `Entry::new(service, user)` API; every token lives in the OS's own secret store with OS-level access control, matching how 1Password, GitHub CLI, and git-credential-manager store secrets. Direct platform APIs were rejected as unnecessary reinvention. Encrypted-file was rejected because there is no key the app can hold that the OS cannot already hold better. Tokens are serialized as a single JSON blob `{access_token, refresh_token, expires_at, scopes}` keyed by `prismoid.twitch.`, not per-field entries — atomic read/write and one keychain prompt per operation. DCF user UX is "app opens browser to verification URL with the device code pre-filled in query string, user clicks Authorize, app polls token endpoint until approved" — Twitch's `verification_uri` already includes the `device-code=` query parameter, so the flow is effectively one click once the browser lands on the page. | +| 38 | Kick chat read via Pusher WebSocket (public channel, no auth for read-only). Official API (OAuth 2.1 Authorization Code + PKCE via `id.kick.com`) used for write and moderation operations only. Platform tag byte (`0x01` Twitch, `0x02` Kick, `0x03` YouTube) prepended to each message in the Go sidecar's out channel so the Rust host can dispatch to the correct parser without trying multiple deserializers. | Official webhooks for all events, Pusher WebSocket for all events, official API polling | Kick's official event system (docs.kick.com) is webhook-only and requires a publicly reachable URL, which a desktop app cannot provide without tunneling. Kick's own web client reads chat via Pusher WebSocket on `chatrooms.{id}.v2` channels, which are public (no auth required for subscribe). This is the only viable path for a desktop chat reader. Official OAuth 2.1 + PKCE is used for write/mod operations (`POST /public/v1/chat`, `POST/DELETE /public/v1/moderation/bans`, `DELETE /public/v1/chat/{message_id}`) since those endpoints require `chat:write` and `moderation:*` scopes. The platform tag byte approach was chosen over trying multiple JSON parsers (fragile, O(n) parsers per message) or wrapping in a JSON envelope (unnecessary allocation and re-encoding). One byte per message is zero-cost and extends cleanly to YouTube (tag `0x03`). | +| 39 | YouTube OAuth 2.0 via Authorization Code + PKCE (RFC 8252 §7.3 loopback IP redirect, S256 challenge, CSRF `state`). Single shared `oauth_pkce` Rust module hosts the loopback `TcpListener` (binds `127.0.0.1:0`, OS picks port), browser launch via `tauri_plugin_shell::open`, and code-for-token exchange. Tokens persisted via `keyring-rs` under service `prismoid.youtube`, account `active` (single-account per ADR 30) as a serde-JSON blob `{access_token, refresh_token, expires_at_ms, scopes, channel_id, channel_title}`. Channel identifiers fetched once post-exchange via `GET https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true`. Refresh: proactive 5-minute threshold (ADR 29) via `POST https://oauth2.googleapis.com/token` with `grant_type=refresh_token`. Re-auth on `invalid_grant` (ADR 31). | Device Code Grant (Google supports for limited-input devices only, scopes restricted; YouTube Live Streaming scope is not on the allowlist), Implicit Grant, embedded user-agent (RFC 8252 §8.12 forbids), custom URL scheme registration. | Google's OAuth 2.0 docs explicitly recommend loopback IP redirect for installed/native apps on Windows, macOS, and Linux desktops; it is the only flow that issues refresh tokens for `https://www.googleapis.com/auth/youtube.readonly` and `https://www.googleapis.com/auth/youtube` from a public client. Device Code Grant is rejected because Google restricts it to "limited-input devices" and the YouTube scopes are not in the allowed-scopes list — attempting it returns `invalid_scope`. Implicit Grant is rejected per RFC 9700 (no refresh token, fragment-based delivery). Custom URL scheme registration was rejected because it requires a per-OS installer step (Windows registry, macOS `LSCFBundleURLTypes`) and the OWASP-recommended modern pattern for desktop is loopback. Embedded WebViews are forbidden by RFC 8252 §8.12 and Google explicitly blocks them with `disallowed_useragent` on `accounts.google.com`. Google issues a `client_secret` to "Desktop app" credentials but their own docs note it is "not actually treated as a secret"; PKCE S256 is what cryptographically binds the auth code to this device, and the secret is included in the token exchange only because Google's endpoint requires it as a form field. Loopback `127.0.0.1` (not `localhost`) is RFC 8252's stated preference because some hosts file resolutions on Windows redirect `localhost` to IPv6 `::1` and the OAuth provider's redirect URI matcher is string-equality. The shared `oauth_pkce` module sits at `apps/desktop/src-tauri/src/oauth_pkce/` so when ADR 38's Kick write/mod path is implemented it reuses the same loopback + PKCE primitives without duplication. |