Skip to main content

wasm_oidc_plugin/
html.rs

1use html_escape::encode_safe;
2
3/// Generate provider card HTML
4///
5/// ## Arguments
6///
7/// * `url` - URL to redirect to
8/// * `name` - Name of the provider
9/// * `logo` - URL to the logo of the provider
10pub fn provider_card(url: &str, name: &str, logo: &str) -> String {
11    let url = encode_safe(url);
12    let name = encode_safe(name);
13    let logo = encode_safe(logo);
14    format!(
15        r#"
16                <a href="{url}" class="provider-link">
17                    <div class="provider-card">
18                        <div class="logo-container">
19                            <img src="{logo}" alt="{name}" class="provider-logo">
20                        </div>
21                        <h2 class="provider-name">{name}</h2>
22                    </div>
23                </a>
24            "#,
25    )
26}
27
28/// Generate the HTML for the authentication page
29///
30/// ## Arguments
31///
32/// * `provider_cards` - HTML of the provider cards
33pub fn auth_page_html(provider_cards: String) -> String {
34    let version = env!("CARGO_PKG_VERSION");
35    format!(
36        r#"
37            <!DOCTYPE html>
38            <html lang="en">
39            <head>
40                <meta charset="UTF-8">
41                <meta name="viewport" content="width=device-width, initial-scale=1.0">
42                <title>Select Authentication Provider</title>
43                <style>
44                    :root {{
45                        --bg-color: #f0f2f5;
46                        --text-color: #333;
47                        --card-bg: #ffffff;
48                        --card-text: #333;
49                        --card-footer-bg: #f8f9fa;
50                        --card-border: #e9ecef;
51                        --toggle-bg: #e2e8f0;
52                        --toggle-border: #cbd5e1;
53                        --hover-border: #3498db;
54                        --footer-text: #888;
55                    }}
56                    .dark-mode {{
57                        --bg-color: #1a1a1a;
58                        --text-color: #ffffff;
59                        --card-bg: #2c2c2c;
60                        --card-text: #ffffff;
61                        --card-footer-bg: #383838;
62                        --card-border: #4a4a4a;
63                        --toggle-bg: #4a5568;
64                        --toggle-border: #2d3748;
65                        --hover-border: #3498db;
66                        --footer-text: #888;
67                    }}
68                    body {{
69                        font-family: Helvetica, sans-serif;
70                        display: flex;
71                        flex-direction: column;
72                        justify-content: center;
73                        align-items: center;
74                        min-height: 100vh;
75                        margin: 0;
76                        background-color: var(--bg-color);
77                        color: var(--text-color);
78                        transition: background-color 0.3s ease, color 0.3s ease;
79                    }}
80                    .container {{
81                        text-align: center;
82                        padding: 20px;
83                        max-width: 800px;
84                        width: 100%;
85                    }}
86                    h1 {{
87                        margin-bottom: 30px;
88                    }}
89                    .provider-grid {{
90                        display: grid;
91                        grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
92                        gap: 20px;
93                        justify-content: center;
94                        padding: 20px;
95                    }}
96                    .provider-card {{
97                        background-color: var(--card-bg);
98                        border-radius: 12px;
99                        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
100                        transition: all 0.3s ease;
101                        overflow: hidden;
102                        position: relative;
103                        display: flex;
104                        flex-direction: column;
105                        height: 200px;
106                    }}
107                    .provider-card::before {{
108                        content: '';
109                        position: absolute;
110                        top: 0;
111                        left: 0;
112                        right: 0;
113                        bottom: 0;
114                        border-radius: 12px;
115                        border: 2px solid transparent;
116                        transition: border-color 0.3s ease;
117                    }}
118                    .provider-link:hover .provider-card {{
119                        transform: translateY(-5px);
120                        box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
121                    }}
122                    .provider-link:hover .provider-card::before {{
123                        border-color: var(--hover-border);
124                    }}
125                    .provider-link {{
126                        display: block;
127                        text-decoration: none;
128                        color: var(--card-text);
129                    }}
130                    .logo-container {{
131                        flex-grow: 1;
132                        display: flex;
133                        align-items: center;
134                        justify-content: center;
135                        padding: 30px;
136                        background-color: var(--card-bg);
137                        height: 140px;
138                    }}
139                    .provider-logo {{
140                        max-width: 80%;
141                        max-height: 80px;
142                        width: auto;
143                        height: auto;
144                        object-fit: contain;
145                        mix-blend-mode: darken;
146                        transition: opacity 0.2s ease, mix-blend-mode 0.2s ease;
147                    }}
148                    .dark-mode .provider-logo {{
149                        mix-blend-mode: lighten;
150                    }}
151                    .provider-name {{
152                        font-size: 18px;
153                        margin: 0;
154                        padding: 15px;
155                        background-color: var(--card-footer-bg);
156                        border-top: 1px solid var(--card-border);
157                        flex-shrink: 0;
158                    }}
159                    .dark-mode-toggle {{
160                        position: fixed;
161                        top: 20px;
162                        right: 20px;
163                    }}
164                    .toggle-switch {{
165                        position: relative;
166                        display: inline-block;
167                        width: 60px;
168                        height: 34px;
169                    }}
170                    .toggle-switch input {{
171                        opacity: 0;
172                        width: 0;
173                        height: 0;
174                    }}
175                    .toggle-slider {{
176                        position: absolute;
177                        cursor: pointer;
178                        top: 0;
179                        left: 0;
180                        right: 0;
181                        bottom: 0;
182                        background-color: var(--toggle-bg);
183                        border: 2px solid var(--toggle-border);
184                        transition: .4s;
185                        border-radius: 34px;
186                    }}
187                    .toggle-slider:before {{
188                        position: absolute;
189                        content: "☀️";
190                        display: flex;
191                        align-items: center;
192                        justify-content: center;
193                        height: 26px;
194                        width: 26px;
195                        left: 4px;
196                        bottom: 2px;
197                        background-color: white;
198                        transition: .4s;
199                        border-radius: 50%;
200                    }}
201                    input:checked + .toggle-slider {{
202                        background-color: var(--toggle-bg);
203                    }}
204                    input:checked + .toggle-slider:before {{
205                        transform: translateX(26px);
206                        content: "🌙";
207                        background-color: #2c3e50;
208                    }}
209                    .footer {{
210                        position: fixed;
211                        bottom: 20px;
212                        margin-top: 20px;
213                        color: var(--footer-text);
214                        font-size: 14px;
215                    }}
216                    .footer a {{
217                        color: var(--footer-text);
218                        text-decoration: none;
219                    }}
220                    .footer a:hover {{
221                        text-decoration: underline;
222                    }}
223                </style>
224            </head>
225            <body>
226                <div class="dark-mode-toggle">
227                    <label class="toggle-switch">
228                        <input type="checkbox" id="darkModeToggle">
229                        <span class="toggle-slider"></span>
230                    </label>
231                </div>
232                <div class="container">
233                    <h1>Select a provider to authenticate with</h1>
234                    <div class="provider-grid">
235                        {provider_cards}
236                    </div>
237                </div>
238                <div class="footer">
239                    <a href="https://github.com/antonengelhardt/wasm-oidc-plugin" target="_blank" rel="noopener noreferrer">wasm-oidc-plugin</a> v{version}
240                </div>
241                <script>
242                    const darkModeToggle = document.getElementById('darkModeToggle');
243                    const body = document.body;
244
245                    function getDarkModePreference() {{
246                        try {{
247                            return localStorage.getItem('darkMode');
248                        }} catch (error) {{
249                            console.warn('localStorage is not available', error);
250                            return null;
251                        }}
252                    }}
253
254                    function setDarkModePreference(value) {{
255                        try {{
256                            if (value === null) {{
257                                localStorage.removeItem('darkMode');
258                            }} else {{
259                                localStorage.setItem('darkMode', value);
260                            }}
261                        }} catch (error) {{
262                            console.warn('localStorage is not available', error);
263                        }}
264                    }}
265
266                    darkModeToggle.addEventListener('change', () => {{
267                        body.classList.toggle('dark-mode');
268                    }});
269
270                    // Check for saved dark mode preference
271                    if (getDarkModePreference() === 'enabled') {{
272                        body.classList.add('dark-mode');
273                        darkModeToggle.checked = true;
274                    }}
275
276                    // Save dark mode preference
277                    darkModeToggle.addEventListener('change', () => {{
278                        if (body.classList.contains('dark-mode')) {{
279                            setDarkModePreference('enabled');
280                        }} else {{
281                            setDarkModePreference(null);
282                        }}
283                    }});
284                </script>
285            </body>
286            </html>
287            "#
288    )
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn escape_html_escapes_special_characters() {
297        assert_eq!(
298            encode_safe(r#"'<script>"&xss"#),
299            "&#x27;&lt;script&gt;&quot;&amp;xss"
300        );
301    }
302
303    #[test]
304    fn provider_card_escapes_interpolated_values() {
305        let html = provider_card(
306            r#"/x?a="onclick"#,
307            r#"Evil<script>"#,
308            r#"https://example.com/a"onerror="alert(1)"#,
309        );
310        assert!(!html.contains("<script>"));
311        assert!(html.contains("&lt;script&gt;"));
312        assert!(html.contains("&quot;"));
313        assert!(!html.contains(r#"a="onclick"#));
314    }
315}