From d9d0fbb7d1826e0a8a241cf7036b45076c6bc3e4 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 10 Jun 2026 13:34:38 +0100 Subject: [PATCH] Added cli client SSO login support --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/oidc.rs | 79 +++++++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73bd94f..5b07d48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 4040cb2..ded4ada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.4.5" +version = "0.4.6" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/oidc.rs b/src/oidc.rs index 4a9acf8..c98835e 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -977,27 +977,54 @@ fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option { if lower.starts_with("furumi://") || lower.starts_with("furumusic://") { return Some(value.to_owned()); } + if is_loopback_http_redirect(&lower) { + return Some(value.to_owned()); + } None } +/// RFC 8252 ยง7.3: native apps without a custom URL scheme (the CLI client) +/// receive the callback on a loopback listener with an ephemeral port. +fn is_loopback_http_redirect(lower: &str) -> bool { + let Some(rest) = lower.strip_prefix("http://") else { + return false; + }; + let host_port = rest.split(['/', '?', '#']).next().unwrap_or(""); + let Some((host, port)) = host_port.rsplit_once(':') else { + return false; + }; + matches!(host, "127.0.0.1" | "localhost" | "[::1]") + && !port.is_empty() + && port.len() <= 5 + && port.bytes().all(|b| b.is_ascii_digit()) +} + fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response { let deep_link = append_query_param(app_redirect_uri, "code", code); + if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) { + return auth::redirect(&deep_link); + } mobile_deep_link_page( "success", "Sign-in complete", - "Furumi should open automatically. You can close this window after the app opens.", + "Furumi should open automatically. If it doesn't, use the button or copy the code below.", None, + Some(code), &deep_link, ) } fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response { let deep_link = append_query_param(app_redirect_uri, "error", error); + if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) { + return auth::redirect(&deep_link); + } mobile_deep_link_page( "error", "Sign-in failed", - "Furumi should open automatically and show the sign-in error. You can close this window after the app opens.", + "Furumi should open automatically and show the sign-in error.", Some(error), + None, &deep_link, ) } @@ -1007,6 +1034,7 @@ fn mobile_deep_link_page( title: &str, message: &str, detail: Option<&str>, + code: Option<&str>, deep_link: &str, ) -> cot::response::Response { let state_class = html_escape(state); @@ -1015,6 +1043,15 @@ fn mobile_deep_link_page( let detail_html = detail .map(|value| format!(r#"

Reason: {}

"#, html_escape(value))) .unwrap_or_default(); + let code_html = code + .map(|value| { + format!( + r#"

Signing in from a terminal? Paste this code there:

+ "#, + html_escape(value) + ) + }) + .unwrap_or_default(); let deep_link_html = html_escape(deep_link); let deep_link_js = serde_json::to_string(deep_link).expect("serializing URL string cannot fail"); @@ -1095,6 +1132,19 @@ fn mobile_deep_link_page( font-size: 13px; color: #89847c; }} + .code {{ + width: 100%; + margin-top: 8px; + padding: 10px 12px; + box-sizing: border-box; + border: 1px solid #3a3c42; + border-radius: 8px; + background: #1a1c20; + color: #e8d8a8; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + text-align: center; + }} @@ -1105,15 +1155,13 @@ fn mobile_deep_link_page( {detail_html} Open Furumi

If nothing happens, use the button above.

+ {code_html} "#, @@ -1230,4 +1278,25 @@ mod tests { ); assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none()); } + + #[test] + fn mobile_oidc_redirect_uri_allows_loopback_http() { + assert_eq!( + safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(), + Some("http://127.0.0.1:8753/callback") + ); + assert_eq!( + safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(), + Some("http://localhost:1234/callback") + ); + assert_eq!( + safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(), + Some("http://[::1]:1234/callback") + ); + // Non-loopback hosts, missing ports and https stay rejected. + assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none()); + assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none()); + assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none()); + assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none()); + } }