Added cli client SSO login support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s

This commit is contained in:
Ultradesu
2026-06-10 13:34:38 +01:00
parent 71d6556ba8
commit d9d0fbb7d1
3 changed files with 76 additions and 7 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.4.4" version = "0.4.5"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.4.5" version = "0.4.6"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+74 -5
View File
@@ -977,27 +977,54 @@ fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") { if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
return Some(value.to_owned()); return Some(value.to_owned());
} }
if is_loopback_http_redirect(&lower) {
return Some(value.to_owned());
}
None 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 { fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "code", code); 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( mobile_deep_link_page(
"success", "success",
"Sign-in complete", "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, None,
Some(code),
&deep_link, &deep_link,
) )
} }
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response { fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "error", error); 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( mobile_deep_link_page(
"error", "error",
"Sign-in failed", "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), Some(error),
None,
&deep_link, &deep_link,
) )
} }
@@ -1007,6 +1034,7 @@ fn mobile_deep_link_page(
title: &str, title: &str,
message: &str, message: &str,
detail: Option<&str>, detail: Option<&str>,
code: Option<&str>,
deep_link: &str, deep_link: &str,
) -> cot::response::Response { ) -> cot::response::Response {
let state_class = html_escape(state); let state_class = html_escape(state);
@@ -1015,6 +1043,15 @@ fn mobile_deep_link_page(
let detail_html = detail let detail_html = detail
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value))) .map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
.unwrap_or_default(); .unwrap_or_default();
let code_html = code
.map(|value| {
format!(
r#"<p class="hint">Signing in from a terminal? Paste this code there:</p>
<input class="code" readonly value="{}" onclick="this.select()">"#,
html_escape(value)
)
})
.unwrap_or_default();
let deep_link_html = html_escape(deep_link); let deep_link_html = html_escape(deep_link);
let deep_link_js = let deep_link_js =
serde_json::to_string(deep_link).expect("serializing URL string cannot fail"); serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
@@ -1095,6 +1132,19 @@ fn mobile_deep_link_page(
font-size: 13px; font-size: 13px;
color: #89847c; 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;
}}
</style> </style>
</head> </head>
<body> <body>
@@ -1105,15 +1155,13 @@ fn mobile_deep_link_page(
{detail_html} {detail_html}
<a href="{deep_link_html}">Open Furumi</a> <a href="{deep_link_html}">Open Furumi</a>
<p class="hint">If nothing happens, use the button above.</p> <p class="hint">If nothing happens, use the button above.</p>
{code_html}
</main> </main>
<script> <script>
const deepLink = {deep_link_js}; const deepLink = {deep_link_js};
window.setTimeout(() => {{ window.setTimeout(() => {{
window.location.href = deepLink; window.location.href = deepLink;
}}, 100); }}, 100);
window.setTimeout(() => {{
window.close();
}}, 1800);
</script> </script>
</body> </body>
</html>"#, </html>"#,
@@ -1230,4 +1278,25 @@ mod tests {
); );
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none()); 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());
}
} }