use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use serde::Serialize;
use crate::ctx::AuthCtx;
use super::types::UpstreamProvider;
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct PublicUpstream {
pub slug: String,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}
pub fn filter_to_public(
rows: Vec<UpstreamProvider>,
mut registered: impl FnMut(&str) -> bool,
) -> Vec<PublicUpstream> {
rows.into_iter()
.filter(|r| r.enabled)
.filter(|r| registered(r.slug.as_str()))
.map(|r| PublicUpstream {
slug: r.slug,
display_name: r.display_name,
icon_url: r.icon_url.and_then(sanitize_icon_url),
})
.collect()
}
fn sanitize_icon_url(raw: String) -> Option<String> {
let parsed = url::Url::parse(&raw).ok()?;
if parsed.scheme() == "https" {
Some(raw)
} else {
None
}
}
pub async fn list_public(State(ctx): State<AuthCtx>) -> Response {
let provider = match ctx.oidc_provider.as_ref() {
Some(p) => p,
None => {
return (StatusCode::OK, Json(Vec::<PublicUpstream>::new())).into_response();
}
};
let rows = match provider.upstream.list().await {
Ok(rows) => rows,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "list upstream").into_response(),
};
let registry = ctx.oidc.as_ref();
let body = filter_to_public(rows, |slug| {
registry
.map(|reg| reg.client(slug).is_some())
.unwrap_or(false)
});
(StatusCode::OK, Json(body)).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn provider(slug: &str, enabled: bool) -> UpstreamProvider {
UpstreamProvider {
slug: slug.into(),
issuer: "https://accounts.example.com".into(),
client_id: "id".into(),
client_secret: "secret".into(),
display_name: slug.into(),
icon_url: None,
enabled,
scopes: vec![],
auth_params: Default::default(),
}
}
fn with_icon(mut p: UpstreamProvider, icon: &str) -> UpstreamProvider {
p.icon_url = Some(icon.into());
p
}
#[test]
fn disabled_rows_filtered() {
let rows = vec![provider("google", true), provider("github", false)];
let out = filter_to_public(rows, |_| true);
assert_eq!(out.len(), 1);
assert_eq!(out[0].slug, "google");
}
#[test]
fn unregistered_rows_filtered() {
let rows = vec![provider("google", true), provider("orphan", true)];
let out = filter_to_public(rows, |slug| slug == "google");
assert_eq!(out.len(), 1);
assert_eq!(out[0].slug, "google");
}
#[test]
fn secrets_never_appear_in_serialized_form() {
let row = provider("google", true);
let out = filter_to_public(vec![row], |_| true);
let json = serde_json::to_string(&out).unwrap();
assert!(!json.contains("secret"));
assert!(!json.contains("client_id"));
assert!(!json.contains("auth_params"));
assert!(!json.contains("\"issuer\""));
}
#[test]
fn https_icon_url_passes_through() {
let row = with_icon(provider("google", true), "https://cdn.example.com/g.svg");
let out = filter_to_public(vec![row], |_| true);
assert_eq!(
out[0].icon_url.as_deref(),
Some("https://cdn.example.com/g.svg")
);
}
#[test]
fn javascript_data_and_http_icon_urls_stripped() {
for bad in [
"javascript:alert(1)",
"data:image/svg+xml;base64,PHN2Zw==",
"http://cdn.example.com/g.svg", "//cdn.example.com/g.svg",
"not a url",
] {
let row = with_icon(provider("google", true), bad);
let out = filter_to_public(vec![row], |_| true);
assert_eq!(out[0].icon_url, None, "must strip {bad:?}");
}
}
}