| 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 |
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 |
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 |
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 |
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 |
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::*;
|