Skip to main content

dbx_tools_auth/
oauth_template.rs

1use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
2
3const BRAND_YAML: &str = include_str!("../assets/brand.yaml");
4const DEFAULT_CALLBACK_IMAGE: &[u8] = include_bytes!("../assets/logo-light.svg");
5
6/// Build the default callback logo as an embedded SVG data URI.
7pub fn default_callback_image_src() -> String {
8    format!(
9        "data:image/svg+xml;base64,{}",
10        BASE64_STANDARD.encode(DEFAULT_CALLBACK_IMAGE),
11    )
12}
13
14/// Branding and content renderer for the browser OAuth callback.
15#[derive(Clone, Debug)]
16pub struct OAuthTemplate {
17    brand: CallbackBrand,
18    image_src: String,
19}
20
21#[derive(Clone, Debug)]
22struct CallbackBrand {
23    name: &'static str,
24    tagline: &'static str,
25    primary: &'static str,
26    primary_hover: &'static str,
27    foreground: &'static str,
28    background: &'static str,
29    surface: &'static str,
30    muted: &'static str,
31    border: &'static str,
32    font_family: &'static str,
33}
34
35/// Values displayed by the browser OAuth callback.
36pub struct OAuthTemplateContext<'a> {
37    /// Provider host linked from a successful callback.
38    pub host: Option<&'a str>,
39    /// OAuth error identifier displayed by an unsuccessful callback.
40    pub error: Option<&'a str>,
41    /// OAuth error detail displayed by an unsuccessful callback.
42    pub error_description: Option<&'a str>,
43}
44
45impl OAuthTemplate {
46    /// Create a callback renderer with an optional logo URL or data URI.
47    pub fn new(image_src: Option<String>) -> Self {
48        Self {
49            brand: CallbackBrand::load(),
50            image_src: image_src.unwrap_or_else(default_callback_image_src),
51        }
52    }
53
54    /// Build the complete callback HTML with escaped dynamic values.
55    pub fn render(&self, context: OAuthTemplateContext<'_>) -> String {
56        let page_title = context
57            .error
58            .map(title)
59            .unwrap_or_else(|| "Success".to_owned());
60        let result = if context.error.is_some() {
61            format!(
62                r#"<div class="title">{}</div><div class="content">{}</div>"#,
63                escape_html(&page_title),
64                escape_html(context.error_description.unwrap_or_default()),
65            )
66        } else {
67            let host = context.host.map_or_else(String::new, |host| {
68                let host = escape_html(host);
69                format!(r#"<div class="content">Go to <a href="{host}">{host}</a></div>"#)
70            });
71            format!(r#"<div class="title">Authenticated</div>{host}"#)
72        };
73        let image_src = escape_html(&self.image_src);
74        let page_title = escape_html(&page_title);
75        let brand_name = escape_html(self.brand.name);
76        let tagline = escape_html(self.brand.tagline);
77        let primary = self.brand.primary;
78        let primary_hover = self.brand.primary_hover;
79        let foreground = self.brand.foreground;
80        let background = self.brand.background;
81        let surface = self.brand.surface;
82        let muted = self.brand.muted;
83        let border = self.brand.border;
84        let font_family = self.brand.font_family;
85
86        format!(
87            r#"<!doctype html>
88<html lang="en">
89  <head>
90    <meta charset="utf-8">
91    <meta name="viewport" content="width=device-width, initial-scale=1">
92    <title>{page_title}</title>
93    <style>
94      html, body {{ height: 100%; }}
95      body {{
96        margin: 0;
97        background: {surface};
98        color: {foreground};
99        font-family: {font_family};
100      }}
101      .root-container {{
102        display: flex;
103        min-height: 100%;
104        align-items: center;
105        justify-content: center;
106      }}
107      .info-container {{
108        display: flex;
109        width: min(320px, calc(100vw - 96px));
110        flex-direction: column;
111        align-items: center;
112        gap: 24px;
113        padding: 48px;
114        border: 1px solid {border};
115        border-radius: 12px;
116        background: {background};
117        box-shadow: 0 8px 25px rgba(27, 49, 57, 0.12);
118        text-align: center;
119      }}
120      .brand {{ display: block; width: 360px; max-width: calc(100vw - 96px); height: auto; margin: 0 auto; transform: translateX(8%); }}
121      .tagline {{ color: {muted}; font-size: 13px; line-height: 18px; }}
122      .title {{ color: {primary}; font-size: 24px; font-weight: 700; line-height: 28px; }}
123      .content {{ width: 100%; color: {muted}; font-size: 14px; line-height: 20px; }}
124      a {{ color: {primary}; }}
125      a:hover {{ color: {primary_hover}; }}
126    </style>
127  </head>
128  <body>
129    <main class="root-container">
130      <section class="info-container">
131        <img class="brand" src="{image_src}" alt="{brand_name}">
132        <div class="tagline">{tagline}</div>
133        {result}
134        <div class="content">You can close this tab.</div>
135      </section>
136    </main>
137  </body>
138</html>"#
139        )
140    }
141}
142
143impl Default for OAuthTemplate {
144    fn default() -> Self {
145        Self::new(None)
146    }
147}
148
149impl CallbackBrand {
150    fn load() -> Self {
151        Self {
152            name: brand_value("name").unwrap_or("dbx tools"),
153            tagline: brand_value("tagline").unwrap_or("Practical tools for Databricks builders."),
154            primary: brand_value("primary").unwrap_or("#1B3139"),
155            primary_hover: brand_value("primaryHover").unwrap_or("#0E538B"),
156            foreground: brand_value("foreground").unwrap_or("#1B3139"),
157            background: brand_value("background").unwrap_or("#FFFFFF"),
158            surface: brand_value("surface").unwrap_or("#F9F7F4"),
159            muted: brand_value("muted").unwrap_or("#618794"),
160            border: brand_value("border").unwrap_or("#E4E2DD"),
161            font_family: brand_value("sans")
162                .unwrap_or("'DM Sans', ui-sans-serif, system-ui, sans-serif"),
163        }
164    }
165}
166
167fn brand_value(key: &str) -> Option<&'static str> {
168    BRAND_YAML.lines().find_map(|line| {
169        let (candidate, value) = line.trim().split_once(':')?;
170        if candidate != key {
171            return None;
172        }
173        let value = value
174            .split_once(" #")
175            .map_or(value, |(value, _)| value)
176            .trim()
177            .trim_matches('"');
178        (!value.is_empty()).then_some(value)
179    })
180}
181
182fn title(value: &str) -> String {
183    value
184        .split(['_', ' '])
185        .filter(|word| !word.is_empty())
186        .map(|word| {
187            let mut characters = word.chars();
188            match characters.next() {
189                Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
190                None => String::new(),
191            }
192        })
193        .collect::<Vec<_>>()
194        .join(" ")
195}
196
197fn escape_html(value: &str) -> String {
198    let mut escaped = String::with_capacity(value.len());
199    for character in value.chars() {
200        match character {
201            '&' => escaped.push_str("&amp;"),
202            '<' => escaped.push_str("&lt;"),
203            '>' => escaped.push_str("&gt;"),
204            '"' => escaped.push_str("&quot;"),
205            '\'' => escaped.push_str("&#x27;"),
206            _ => escaped.push(character),
207        }
208    }
209    escaped
210}
211
212#[cfg(test)]
213mod tests {
214    use super::{brand_value, default_callback_image_src, OAuthTemplate, OAuthTemplateContext};
215
216    #[test]
217    fn renders_default_branding_and_success() {
218        let html = OAuthTemplate::default().render(OAuthTemplateContext {
219            host: Some("https://example.com/?a=1&b=2"),
220            error: None,
221            error_description: None,
222        });
223
224        assert!(html.contains(&default_callback_image_src()));
225        assert!(default_callback_image_src().starts_with("data:image/svg+xml;base64,"));
226        assert_eq!(brand_value("primary"), Some("#1B3139"));
227        assert!(html.contains("Practical tools for Databricks builders."));
228        assert!(html.contains("Authenticated"));
229        assert!(html.contains("https://example.com/?a=1&amp;b=2"));
230    }
231
232    #[test]
233    fn renders_custom_branding_and_escaped_error() {
234        let html = OAuthTemplate::new(Some("data:image/svg+xml,&custom".to_owned())).render(
235            OAuthTemplateContext {
236                host: None,
237                error: Some("access_denied"),
238                error_description: Some("<denied>"),
239            },
240        );
241
242        assert!(html.contains(r#"src="data:image/svg+xml,&amp;custom""#));
243        assert!(html.contains("Access Denied"));
244        assert!(html.contains("&lt;denied&gt;"));
245        assert!(!html.contains("Authenticated"));
246    }
247}