mockforge_http/handlers/
consent.rs1use axum::{
7 extract::{Query, State},
8 http::StatusCode,
9 response::{Html, Json},
10};
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::sync::Arc;
14
15use crate::auth::risk_engine::{RiskAction, RiskEngine};
16use crate::handlers::oauth2_server::OAuth2ServerState;
17
18#[derive(Debug, Deserialize)]
20pub struct ConsentRequest {
21 pub client_id: String,
23 pub scope: Option<String>,
25 pub state: Option<String>,
27 pub code: Option<String>,
29}
30
31#[derive(Debug, Deserialize)]
33pub struct ConsentDecisionRequest {
34 pub client_id: String,
36 pub state: Option<String>,
38 pub approved: bool,
40 pub scopes: Vec<String>,
42}
43
44#[derive(Clone)]
46pub struct ConsentState {
47 pub oauth2_state: OAuth2ServerState,
49 pub risk_engine: Arc<RiskEngine>,
51}
52
53pub async fn get_consent_screen(
55 State(state): State<ConsentState>,
56 Query(params): Query<ConsentRequest>,
57) -> Result<Html<String>, StatusCode> {
58 let risk_factors = HashMap::new();
62 let risk_assessment = state.risk_engine.assess_risk("user-default", &risk_factors).await;
63
64 if risk_assessment.recommended_action == RiskAction::Block {
66 return Ok(Html(blocked_login_html()));
67 }
68
69 let scopes = params
71 .scope
72 .as_ref()
73 .map(|s| s.split(' ').map(|s| s.to_string()).collect::<Vec<_>>())
74 .unwrap_or_else(Vec::new);
75
76 let html = generate_consent_screen_html(¶ms.client_id, &scopes, params.state.as_deref());
78 Ok(Html(html))
79}
80
81pub async fn submit_consent(
83 State(state): State<ConsentState>,
84 Json(request): Json<ConsentDecisionRequest>,
85) -> Result<Json<serde_json::Value>, StatusCode> {
86 if !request.approved {
87 return Ok(Json(serde_json::json!({
88 "approved": false,
89 "message": "Consent denied"
90 })));
91 }
92
93 Ok(Json(serde_json::json!({
96 "approved": true,
97 "scopes": request.scopes,
98 "message": "Consent approved"
99 })))
100}
101
102fn generate_consent_screen_html(client_id: &str, scopes: &[String], state: Option<&str>) -> String {
104 let scope_items = scopes
105 .iter()
106 .map(|scope| {
107 let description = get_scope_description(scope);
108 format!(
109 r#"
110 <div class="scope-item">
111 <label class="scope-toggle">
112 <input type="checkbox" name="scope" value="{}" checked>
113 <span class="scope-name">{}</span>
114 </label>
115 <p class="scope-description">{}</p>
116 </div>
117 "#,
118 scope, scope, description
119 )
120 })
121 .collect::<String>();
122
123 let state_param = state
124 .map(|s| format!(r#"<input type="hidden" name="state" value="{}">"#, s))
125 .unwrap_or_default();
126
127 format!(
128 r#"
129<!DOCTYPE html>
130<html lang="en">
131<head>
132 <meta charset="UTF-8">
133 <meta name="viewport" content="width=device-width, initial-scale=1.0">
134 <title>Authorize Application - MockForge</title>
135 <style>
136 * {{
137 margin: 0;
138 padding: 0;
139 box-sizing: border-box;
140 }}
141 body {{
142 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
143 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
144 min-height: 100vh;
145 display: flex;
146 align-items: center;
147 justify-content: center;
148 padding: 20px;
149 }}
150 .consent-container {{
151 background: white;
152 border-radius: 16px;
153 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
154 max-width: 500px;
155 width: 100%;
156 padding: 40px;
157 animation: slideUp 0.3s ease-out;
158 }}
159 @keyframes slideUp {{
160 from {{
161 opacity: 0;
162 transform: translateY(20px);
163 }}
164 to {{
165 opacity: 1;
166 transform: translateY(0);
167 }}
168 }}
169 .app-icon {{
170 width: 64px;
171 height: 64px;
172 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
173 border-radius: 16px;
174 margin: 0 auto 24px;
175 display: flex;
176 align-items: center;
177 justify-content: center;
178 font-size: 32px;
179 color: white;
180 }}
181 h1 {{
182 font-size: 24px;
183 font-weight: 600;
184 text-align: center;
185 margin-bottom: 8px;
186 color: #1a1a1a;
187 }}
188 .client-id {{
189 text-align: center;
190 color: #666;
191 font-size: 14px;
192 margin-bottom: 32px;
193 }}
194 .permissions-title {{
195 font-size: 16px;
196 font-weight: 600;
197 margin-bottom: 16px;
198 color: #1a1a1a;
199 }}
200 .scope-item {{
201 padding: 16px;
202 border: 1px solid #e5e5e5;
203 border-radius: 8px;
204 margin-bottom: 12px;
205 transition: all 0.2s;
206 }}
207 .scope-item:hover {{
208 border-color: #667eea;
209 background: #f8f9ff;
210 }}
211 .scope-toggle {{
212 display: flex;
213 align-items: center;
214 cursor: pointer;
215 margin-bottom: 8px;
216 }}
217 .scope-toggle input[type="checkbox"] {{
218 width: 20px;
219 height: 20px;
220 margin-right: 12px;
221 cursor: pointer;
222 accent-color: #667eea;
223 }}
224 .scope-name {{
225 font-weight: 500;
226 color: #1a1a1a;
227 }}
228 .scope-description {{
229 font-size: 13px;
230 color: #666;
231 margin-left: 32px;
232 line-height: 1.5;
233 }}
234 .buttons {{
235 display: flex;
236 gap: 12px;
237 margin-top: 32px;
238 }}
239 button {{
240 flex: 1;
241 padding: 14px 24px;
242 border: none;
243 border-radius: 8px;
244 font-size: 16px;
245 font-weight: 500;
246 cursor: pointer;
247 transition: all 0.2s;
248 }}
249 .btn-approve {{
250 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
251 color: white;
252 }}
253 .btn-approve:hover {{
254 transform: translateY(-2px);
255 box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
256 }}
257 .btn-deny {{
258 background: #f5f5f5;
259 color: #666;
260 }}
261 .btn-deny:hover {{
262 background: #e5e5e5;
263 }}
264 .privacy-link {{
265 text-align: center;
266 margin-top: 24px;
267 font-size: 13px;
268 color: #666;
269 }}
270 .privacy-link a {{
271 color: #667eea;
272 text-decoration: none;
273 }}
274 .privacy-link a:hover {{
275 text-decoration: underline;
276 }}
277 </style>
278</head>
279<body>
280 <div class="consent-container">
281 <div class="app-icon">🔐</div>
282 <h1>Authorize Application</h1>
283 <p class="client-id">{}</p>
284
285 <p class="permissions-title">This application is requesting the following permissions:</p>
286
287 <form id="consent-form" method="POST" action="/consent/decision">
288 <input type="hidden" name="client_id" value="{}">
289 {}
290 <div class="scopes">
291 {}
292 </div>
293
294 <div class="buttons">
295 <button type="submit" class="btn-approve" name="approved" value="true">
296 Approve
297 </button>
298 <button type="button" class="btn-deny" onclick="denyConsent()">
299 Deny
300 </button>
301 </div>
302 </form>
303
304 <div class="privacy-link">
305 By approving, you agree to our <a href="/privacy">Privacy Policy</a> and <a href="/terms">Terms of Service</a>.
306 </div>
307 </div>
308
309 <script>
310 function denyConsent() {{
311 document.getElementById('consent-form').innerHTML += '<input type="hidden" name="approved" value="false">';
312 document.getElementById('consent-form').submit();
313 }}
314 </script>
315</body>
316</html>
317 "#,
318 client_id, client_id, state_param, scope_items
319 )
320}
321
322fn get_scope_description(scope: &str) -> &str {
324 match scope {
325 "openid" => "Access your basic profile information",
326 "profile" => "Access your profile information including name and picture",
327 "email" => "Access your email address",
328 "address" => "Access your address information",
329 "phone" => "Access your phone number",
330 "offline_access" => "Access your information while you're offline",
331 _ => "Access to this permission",
332 }
333}
334
335fn blocked_login_html() -> String {
337 r#"
338<!DOCTYPE html>
339<html lang="en">
340<head>
341 <meta charset="UTF-8">
342 <meta name="viewport" content="width=device-width, initial-scale=1.0">
343 <title>Login Blocked - MockForge</title>
344 <style>
345 * {
346 margin: 0;
347 padding: 0;
348 box-sizing: border-box;
349 }
350 body {
351 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
352 background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
353 min-height: 100vh;
354 display: flex;
355 align-items: center;
356 justify-content: center;
357 padding: 20px;
358 }
359 .blocked-container {
360 background: white;
361 border-radius: 16px;
362 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
363 max-width: 400px;
364 width: 100%;
365 padding: 40px;
366 text-align: center;
367 }
368 .icon {
369 font-size: 64px;
370 margin-bottom: 24px;
371 }
372 h1 {
373 font-size: 24px;
374 font-weight: 600;
375 margin-bottom: 16px;
376 color: #1a1a1a;
377 }
378 p {
379 color: #666;
380 line-height: 1.6;
381 margin-bottom: 24px;
382 }
383 </style>
384</head>
385<body>
386 <div class="blocked-container">
387 <div class="icon">🚫</div>
388 <h1>Login Blocked</h1>
389 <p>Your login attempt has been blocked due to security concerns. Please contact support if you believe this is an error.</p>
390 </div>
391</body>
392</html>
393 "#.to_string()
394}
395
396pub fn consent_router(state: ConsentState) -> axum::Router {
398 use axum::routing::{get, post};
399
400 axum::Router::new()
401 .route("/consent", get(get_consent_screen))
402 .route("/consent/decision", post(submit_consent))
403 .with_state(state)
404}