Skip to main content

max / makenotwork

ota: MNW OAuth (PKCE) auth for `mnw-cli ota publish` OTA publish now defaults to the interactive MNW OAuth2 PKCE flow instead of email/password, keeping the password out of env/argv and — critically — working for accounts with 2FA, which the password endpoint rejects outright. Password auth remains as an explicit fallback (--password/--email). - synckit-client: new `oauth` module with `generate_pkce()` (S256) + `generate_oauth_state()`, exported from the crate root. Reuses the existing `build_authorize_url` + `authenticate_with_code`. Adds `sha2` dep. The full OAuth surface now lives in the SDK so a future bento OtaBackend reuses it. - mnw-cli: `ota publish` drives the loopback flow — binds 127.0.0.1:<port>, opens the browser to /oauth/authorize, captures the redirect code (state- checked), exchanges via authenticate_with_code. email/password are now optional; OAuth is the default. - keystore stays warning-clean without the keychain feature. synckit-client 261 tests pass (+3 PKCE); no-default-features build clean; mnw-cli builds + 5 ota arg tests pass; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-08 03:02 UTC
Commit: 3befb8c20631caa53018c045831c2d9b51173741
Parent: 932ff76
6 files changed, +239 insertions, -15 deletions
@@ -3562,6 +3562,7 @@ dependencies = [
3562 3562 "reqwest",
3563 3563 "serde",
3564 3564 "serde_json",
3565 + "sha2 0.10.9",
3565 3566 "thiserror 2.0.18",
3566 3567 "tokio",
3567 3568 "tokio-stream",
M mnw-cli/src/ota.rs +145 -15
@@ -44,13 +44,17 @@ fn print_usage() {
44 44 \x20 --arch Architecture: {}\n\
45 45 \x20 --artifact Path to the built artifact file\n\
46 46 \n\
47 + \x20 --api-key SyncKit app API key (env MNW_OTA_API_KEY)\n\
48 + \x20 --key SyncKit SDK key (env MNW_OTA_KEY)\n\
49 + \n\
50 + Auth: defaults to MNW OAuth (opens a browser on this machine; required\n\
51 + for accounts with 2FA). Pass --password (+ --email) to use password auth.\n\
52 + \n\
47 53 Optional:\n\
48 54 \x20 --notes Release notes (default: empty)\n\
49 55 \x20 --signature Minisign signature for Tauri verification (REQUIRED for a working update)\n\
50 - \x20 --email MNW account email (env MNW_OTA_EMAIL)\n\
51 - \x20 --password MNW account password (env MNW_OTA_PASSWORD)\n\
52 - \x20 --api-key SyncKit app API key (env MNW_OTA_API_KEY)\n\
53 - \x20 --key SyncKit SDK key (env MNW_OTA_KEY)\n\
56 + \x20 --email MNW account email (env MNW_OTA_EMAIL; password auth only)\n\
57 + \x20 --password MNW account password (env MNW_OTA_PASSWORD; enables password auth)\n\
54 58 \x20 --server Server URL (env MNW_OTA_SERVER, default {DEFAULT_SERVER})",
55 59 ALLOWED_TARGETS.join(", "),
56 60 ALLOWED_ARCHS.join(", "),
@@ -65,8 +69,11 @@ struct PublishArgs {
65 69 artifact: PathBuf,
66 70 notes: String,
67 71 signature: String,
68 - email: String,
69 - password: String,
72 + // email + password drive the legacy password auth path. When absent, the
73 + // publisher uses the interactive MNW OAuth flow (the default; required for
74 + // accounts with 2FA, which the password endpoint rejects).
75 + email: Option<String>,
76 + password: Option<String>,
70 77 api_key: String,
71 78 key: String,
72 79 server: String,
@@ -85,7 +92,7 @@ impl std::fmt::Debug for PublishArgs {
85 92 .field("notes", &self.notes)
86 93 .field("signature", &self.signature)
87 94 .field("email", &self.email)
88 - .field("password", &"<redacted>")
95 + .field("password", &self.password.as_ref().map(|_| "<redacted>"))
89 96 .field("api_key", &"<redacted>")
90 97 .field("key", &"<redacted>")
91 98 .field("server", &self.server)
@@ -155,8 +162,8 @@ fn parse_args(flags: &[String]) -> Result<PublishArgs> {
155 162 artifact: artifact.ok_or_else(|| missing("--artifact"))?,
156 163 notes,
157 164 signature,
158 - email: email.ok_or_else(|| missing("--email / MNW_OTA_EMAIL"))?,
159 - password: password.ok_or_else(|| missing("--password / MNW_OTA_PASSWORD"))?,
165 + email, // optional: only used by the password auth path
166 + password,
160 167 api_key: api_key.ok_or_else(|| missing("--api-key / MNW_OTA_API_KEY"))?,
161 168 key: key.ok_or_else(|| missing("--key / MNW_OTA_KEY"))?,
162 169 server,
@@ -194,16 +201,31 @@ async fn publish(flags: &[String]) -> Result<()> {
194 201 api_key: args.api_key.clone(),
195 202 });
196 203
197 - print!(" authenticating... ");
198 - client
199 - .authenticate(&args.email, &args.password, &args.key)
200 - .await
201 - .context("authentication failed")?;
204 + // Default to MNW OAuth; fall back to password auth only when a password is
205 + // supplied. OAuth is required for accounts with 2FA (the password endpoint
206 + // rejects them) and keeps the password out of env/argv.
207 + match &args.password {
208 + Some(password) => {
209 + let email = args
210 + .email
211 + .as_deref()
212 + .context("--email / MNW_OTA_EMAIL is required with password auth")?;
213 + print!(" authenticating (password)... ");
214 + client
215 + .authenticate(email, password, &args.key)
216 + .await
217 + .context("authentication failed")?;
218 + println!("ok");
219 + }
220 + None => {
221 + authenticate_oauth(&client, &args.key).await?;
222 + }
223 + }
202 224 let app_id = client
203 225 .session_info()
204 226 .map(|s| s.app_id.to_string())
205 227 .unwrap_or_default();
206 - println!("ok (app {app_id})");
228 + println!(" authenticated (app {app_id})");
207 229
208 230 print!(" creating release v{}... ", args.version);
209 231 let release = client
@@ -265,6 +287,114 @@ async fn publish(flags: &[String]) -> Result<()> {
265 287 Ok(())
266 288 }
267 289
290 + /// Drive the MNW OAuth2 PKCE flow: bind a localhost redirect listener, open the
291 + /// browser to the authorize URL, capture the returned code, and exchange it for
292 + /// a session token. The browser must run on the same machine as this command
293 + /// (the redirect targets `http://127.0.0.1:<port>/`).
294 + async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> {
295 + let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
296 + .await
297 + .context("failed to bind a localhost listener for the OAuth redirect")?;
298 + let port = listener.local_addr()?.port();
299 +
300 + let pkce = synckit_client::generate_pkce();
301 + let state = synckit_client::generate_oauth_state();
302 + let url = client.build_authorize_url(port, &state, &pkce.challenge);
303 +
304 + println!("\n authenticating via MNW OAuth.");
305 + println!(" Open this URL in a browser on THIS machine and approve:");
306 + println!(" {url}");
307 + open_browser(&url);
308 + println!(" waiting for the authorization redirect (5 min timeout)...");
309 +
310 + let (code, got_state) =
311 + tokio::time::timeout(std::time::Duration::from_secs(300), wait_for_code(&listener))
312 + .await
313 + .context("timed out waiting for OAuth authorization")??;
314 +
315 + if got_state.as_deref() != Some(state.as_str()) {
316 + bail!("OAuth state mismatch — aborting (possible CSRF or a stale redirect)");
317 + }
318 +
319 + client
320 + .authenticate_with_code(&code, &pkce.verifier, port, key)
321 + .await
322 + .context("OAuth code exchange failed")?;
323 + Ok(())
324 + }
325 +
326 + /// Accept localhost connections until one carries an OAuth `code` (ignoring
327 + /// incidental requests like `/favicon.ico`), reply with a small page, and return
328 + /// `(code, state)`.
329 + async fn wait_for_code(
330 + listener: &tokio::net::TcpListener,
331 + ) -> Result<(String, Option<String>)> {
332 + use tokio::io::AsyncReadExt;
333 + loop {
334 + let (mut sock, _) = listener.accept().await.context("accept failed")?;
335 + let mut buf = [0u8; 4096];
336 + let n = sock.read(&mut buf).await.unwrap_or(0);
337 + let req = String::from_utf8_lossy(&buf[..n]);
338 + let target = req
339 + .lines()
340 + .next()
341 + .and_then(|line| line.split_whitespace().nth(1))
342 + .unwrap_or("");
343 + let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
344 +
345 + let (mut code, mut state, mut oauth_err) = (None, None, None);
346 + for pair in query.split('&') {
347 + match pair.split_once('=') {
348 + // `code` (hex) and `state` (base64url) are URL-safe — no decode needed.
349 + Some(("code", v)) => code = Some(v.to_string()),
350 + Some(("state", v)) => state = Some(v.to_string()),
351 + Some(("error", v)) => oauth_err = Some(v.to_string()),
352 + _ => {}
353 + }
354 + }
355 +
356 + if let Some(e) = oauth_err {
357 + let _ = respond(&mut sock, "Authorization failed. You can close this tab.").await;
358 + bail!("authorization was denied: {e}");
359 + }
360 + if let Some(c) = code {
361 + let _ = respond(
362 + &mut sock,
363 + "Authorization complete. You can close this tab and return to the terminal.",
364 + )
365 + .await;
366 + return Ok((c, state));
367 + }
368 + // Incidental request (favicon, etc.) — answer and keep waiting.
369 + let _ = respond(&mut sock, "Waiting for authorization...").await;
370 + }
371 + }
372 +
373 + /// Write a minimal HTML 200 response and close the connection.
374 + async fn respond(sock: &mut tokio::net::TcpStream, message: &str) -> std::io::Result<()> {
375 + use tokio::io::AsyncWriteExt;
376 + let body = format!(
377 + "<!doctype html><meta charset=utf-8><body style=\"font-family:system-ui;padding:2rem\"><p>{message}</p></body>"
378 + );
379 + let resp = format!(
380 + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
381 + body.len(),
382 + body
383 + );
384 + sock.write_all(resp.as_bytes()).await?;
385 + sock.flush().await
386 + }
387 +
388 + /// Best-effort browser open; the URL is also printed for manual use (e.g. over SSH).
389 + fn open_browser(url: &str) {
390 + let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
391 + let _ = std::process::Command::new(opener)
392 + .arg(url)
393 + .stdout(std::process::Stdio::null())
394 + .stderr(std::process::Stdio::null())
395 + .spawn();
396 + }
397 +
268 398 #[cfg(test)]
269 399 mod tests {
270 400 use super::*;
@@ -1445,6 +1445,17 @@ dependencies = [
1445 1445 ]
1446 1446
1447 1447 [[package]]
1448 + name = "sha2"
1449 + version = "0.10.9"
1450 + source = "registry+https://github.com/rust-lang/crates.io-index"
1451 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1452 + dependencies = [
1453 + "cfg-if",
1454 + "cpufeatures",
1455 + "digest",
1456 + ]
1457 +
1458 + [[package]]
1448 1459 name = "shlex"
1449 1460 version = "1.3.0"
1450 1461 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1519,6 +1530,7 @@ dependencies = [
1519 1530 "reqwest",
1520 1531 "serde",
1521 1532 "serde_json",
1533 + "sha2",
1522 1534 "thiserror",
1523 1535 "tokio",
1524 1536 "tokio-stream",
@@ -17,6 +17,9 @@ rand = "0.9"
17 17 base64 = "0.22"
18 18 zeroize = "1"
19 19
20 + # PKCE (OAuth2) challenge hashing
21 + sha2 = "0.10"
22 +
20 23 # HTTP
21 24 reqwest = { version = "0.12", features = ["json", "native-tls"] }
22 25 bytes = "1"
@@ -47,11 +47,13 @@ pub mod conflict;
47 47 pub mod crypto;
48 48 pub mod error;
49 49 pub mod keystore;
50 + pub mod oauth;
50 51 pub mod types;
51 52
52 53 // Re-exports for convenience
53 54 pub use client::{validate_api_key, SessionInfo, SyncKitClient, SyncKitConfig, SyncNotifyStream};
54 55 pub use client::{OtaArtifactUpload, OtaManifest, OtaRelease};
56 + pub use oauth::{generate_oauth_state, generate_pkce, Pkce};
55 57 pub use client::subscription::{
56 58 AccountInfo, AppPricing, BillingInterval, CheckoutResponse, PriceQuote, SubscriptionStatus,
57 59 };
@@ -0,0 +1,76 @@
1 + //! Client-side OAuth2 PKCE helpers (RFC 7636, S256).
2 + //!
3 + //! The MNW server's `/oauth/authorize` + `/oauth/token` flow requires PKCE with
4 + //! the `S256` method. A caller generates a [`Pkce`] pair up front, sends the
5 + //! `challenge` to `/oauth/authorize` (via
6 + //! [`build_authorize_url`](crate::SyncKitClient::build_authorize_url)), and sends
7 + //! the `verifier` to `/oauth/token` (via
8 + //! [`authenticate_with_code`](crate::SyncKitClient::authenticate_with_code)).
9 + //!
10 + //! Pair this with a localhost redirect listener and a browser to drive the full
11 + //! flow from a CLI.
12 +
13 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
14 + use rand::RngCore;
15 + use sha2::{Digest, Sha256};
16 +
17 + /// A PKCE verifier/challenge pair using the S256 method.
18 + pub struct Pkce {
19 + /// High-entropy secret kept by the client and sent at token-exchange time.
20 + pub verifier: String,
21 + /// `base64url(SHA256(verifier))`, sent to the authorize endpoint up front.
22 + pub challenge: String,
23 + }
24 +
25 + /// Generate a PKCE pair (S256).
26 + ///
27 + /// The verifier is 32 random bytes base64url-encoded (43 chars, well within
28 + /// RFC 7636's 43–128 range and using only the unreserved charset).
29 + pub fn generate_pkce() -> Pkce {
30 + let mut bytes = [0u8; 32];
31 + rand::rng().fill_bytes(&mut bytes);
32 + let verifier = URL_SAFE_NO_PAD.encode(bytes);
33 + let digest = Sha256::digest(verifier.as_bytes());
34 + let challenge = URL_SAFE_NO_PAD.encode(digest);
35 + Pkce { verifier, challenge }
36 + }
37 +
38 + /// Generate a random opaque `state` value for CSRF protection on the OAuth flow.
39 + pub fn generate_oauth_state() -> String {
40 + let mut bytes = [0u8; 16];
41 + rand::rng().fill_bytes(&mut bytes);
42 + URL_SAFE_NO_PAD.encode(bytes)
43 + }
44 +
45 + #[cfg(test)]
46 + mod tests {
47 + use super::*;
48 +
49 + #[test]
50 + fn verifier_is_43_chars_unreserved() {
51 + let p = generate_pkce();
52 + assert_eq!(p.verifier.len(), 43); // 32 bytes base64url-nopad
53 + assert!(
54 + p.verifier
55 + .chars()
56 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
57 + "verifier must use only the unreserved PKCE charset: {}",
58 + p.verifier
59 + );
60 + }
61 +
62 + #[test]
63 + fn challenge_is_s256_of_verifier() {
64 + let p = generate_pkce();
65 + let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(p.verifier.as_bytes()));
66 + assert_eq!(p.challenge, expected);
67 + // SHA256 digest is 32 bytes -> 43 base64url-nopad chars.
68 + assert_eq!(p.challenge.len(), 43);
69 + }
70 +
71 + #[test]
72 + fn pairs_and_states_are_unique() {
73 + assert_ne!(generate_pkce().verifier, generate_pkce().verifier);
74 + assert_ne!(generate_oauth_state(), generate_oauth_state());
75 + }
76 + }