1use html_escape::encode_safe;
2
3pub 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
28pub 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 "'<script>"&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("<script>"));
312 assert!(html.contains("""));
313 assert!(!html.contains(r#"a="onclick"#));
314 }
315}