1use axum::{
7 extract::{Query, State},
8 http::{HeaderMap, 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 pub redirect_uri: Option<String>,
31}
32
33#[derive(Debug, Deserialize)]
35pub struct ConsentDecisionRequest {
36 pub client_id: String,
38 pub state: Option<String>,
40 pub approved: bool,
42 pub scopes: Vec<String>,
44 pub redirect_uri: Option<String>,
46}
47
48#[derive(Clone)]
50pub struct ConsentState {
51 pub oauth2_state: OAuth2ServerState,
53 pub risk_engine: Arc<RiskEngine>,
55}
56
57pub async fn get_consent_screen(
59 State(state): State<ConsentState>,
60 headers: HeaderMap,
61 Query(params): Query<ConsentRequest>,
62) -> Result<Html<String>, StatusCode> {
63 let mut risk_factors: HashMap<String, f64> = HashMap::new();
65
66 if let Some(ip) = headers
68 .get("x-forwarded-for")
69 .or_else(|| headers.get("x-real-ip"))
70 .and_then(|h| h.to_str().ok())
71 {
72 let ip_trimmed = ip.split(',').next().unwrap_or(ip).trim();
74 let ip_risk = if ip_trimmed.starts_with("127.")
75 || ip_trimmed == "::1"
76 || ip_trimmed.starts_with("10.")
77 || ip_trimmed.starts_with("192.168.")
78 {
79 0.1
80 } else {
81 0.3
82 };
83 risk_factors.insert("ip_risk".to_string(), ip_risk);
84 }
85
86 if let Some(ua) = headers.get("user-agent").and_then(|h| h.to_str().ok()) {
88 let ua_risk = if ua.contains("bot") || ua.contains("curl") || ua.contains("wget") {
89 0.5
90 } else {
91 0.1
92 };
93 risk_factors.insert("user_agent_risk".to_string(), ua_risk);
94 } else {
95 risk_factors.insert("user_agent_risk".to_string(), 0.6);
96 }
97
98 let risk_assessment = state.risk_engine.assess_risk("user-default", &risk_factors).await;
99
100 if risk_assessment.recommended_action == RiskAction::Block {
102 return Ok(Html(blocked_login_html()));
103 }
104
105 let scopes = params
107 .scope
108 .as_ref()
109 .map(|s| s.split(' ').map(|s| s.to_string()).collect::<Vec<_>>())
110 .unwrap_or_else(Vec::new);
111
112 let html = generate_consent_screen_html(
114 ¶ms.client_id,
115 &scopes,
116 params.state.as_deref(),
117 params.redirect_uri.as_deref(),
118 );
119 Ok(Html(html))
120}
121
122pub async fn submit_consent(
124 State(state): State<ConsentState>,
125 Json(request): Json<ConsentDecisionRequest>,
126) -> Result<Json<serde_json::Value>, StatusCode> {
127 if !request.approved {
128 return Ok(Json(serde_json::json!({
129 "approved": false,
130 "message": "Consent denied"
131 })));
132 }
133
134 let code = uuid::Uuid::new_v4().to_string();
136 let expires_at = chrono::Utc::now().timestamp() + 600; let code_info = crate::handlers::oauth2_server::AuthorizationCodeInfo {
139 client_id: request.client_id.clone(),
140 redirect_uri: request.redirect_uri.clone().unwrap_or_default(),
141 scopes: request.scopes.clone(),
142 user_id: "consent-user".to_string(),
143 state: request.state.clone(),
144 expires_at,
145 tenant_context: None,
146 };
147
148 {
150 let mut auth_codes = state.oauth2_state.auth_codes.write().await;
151 auth_codes.insert(code.clone(), code_info);
152 }
153
154 Ok(Json(serde_json::json!({
155 "approved": true,
156 "scopes": request.scopes,
157 "code": code,
158 "message": "Consent approved"
159 })))
160}
161
162fn generate_consent_screen_html(
164 client_id: &str,
165 scopes: &[String],
166 state: Option<&str>,
167 redirect_uri: Option<&str>,
168) -> String {
169 let scope_items = scopes
170 .iter()
171 .map(|scope| {
172 let description = get_scope_description(scope);
173 format!(
174 r#"
175 <div class="scope-item">
176 <label class="scope-toggle">
177 <input type="checkbox" name="scope" value="{}" checked>
178 <span class="scope-name">{}</span>
179 </label>
180 <p class="scope-description">{}</p>
181 </div>
182 "#,
183 scope, scope, description
184 )
185 })
186 .collect::<String>();
187
188 let state_param = state
189 .map(|s| format!(r#"<input type="hidden" name="state" value="{}">"#, s))
190 .unwrap_or_default();
191
192 let redirect_uri_param = redirect_uri
193 .map(|u| format!(r#"<input type="hidden" name="redirect_uri" value="{}">"#, u))
194 .unwrap_or_default();
195
196 format!(
197 r#"
198<!DOCTYPE html>
199<html lang="en">
200<head>
201 <meta charset="UTF-8">
202 <meta name="viewport" content="width=device-width, initial-scale=1.0">
203 <title>Authorize Application - MockForge</title>
204 <style>
205 * {{
206 margin: 0;
207 padding: 0;
208 box-sizing: border-box;
209 }}
210 body {{
211 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
212 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
213 min-height: 100vh;
214 display: flex;
215 align-items: center;
216 justify-content: center;
217 padding: 20px;
218 }}
219 .consent-container {{
220 background: white;
221 border-radius: 16px;
222 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
223 max-width: 500px;
224 width: 100%;
225 padding: 40px;
226 animation: slideUp 0.3s ease-out;
227 }}
228 @keyframes slideUp {{
229 from {{
230 opacity: 0;
231 transform: translateY(20px);
232 }}
233 to {{
234 opacity: 1;
235 transform: translateY(0);
236 }}
237 }}
238 .app-icon {{
239 width: 64px;
240 height: 64px;
241 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
242 border-radius: 16px;
243 margin: 0 auto 24px;
244 display: flex;
245 align-items: center;
246 justify-content: center;
247 font-size: 32px;
248 color: white;
249 }}
250 h1 {{
251 font-size: 24px;
252 font-weight: 600;
253 text-align: center;
254 margin-bottom: 8px;
255 color: #1a1a1a;
256 }}
257 .client-id {{
258 text-align: center;
259 color: #666;
260 font-size: 14px;
261 margin-bottom: 32px;
262 }}
263 .permissions-title {{
264 font-size: 16px;
265 font-weight: 600;
266 margin-bottom: 16px;
267 color: #1a1a1a;
268 }}
269 .scope-item {{
270 padding: 16px;
271 border: 1px solid #e5e5e5;
272 border-radius: 8px;
273 margin-bottom: 12px;
274 transition: all 0.2s;
275 }}
276 .scope-item:hover {{
277 border-color: #667eea;
278 background: #f8f9ff;
279 }}
280 .scope-toggle {{
281 display: flex;
282 align-items: center;
283 cursor: pointer;
284 margin-bottom: 8px;
285 }}
286 .scope-toggle input[type="checkbox"] {{
287 width: 20px;
288 height: 20px;
289 margin-right: 12px;
290 cursor: pointer;
291 accent-color: #667eea;
292 }}
293 .scope-name {{
294 font-weight: 500;
295 color: #1a1a1a;
296 }}
297 .scope-description {{
298 font-size: 13px;
299 color: #666;
300 margin-left: 32px;
301 line-height: 1.5;
302 }}
303 .buttons {{
304 display: flex;
305 gap: 12px;
306 margin-top: 32px;
307 }}
308 button {{
309 flex: 1;
310 padding: 14px 24px;
311 border: none;
312 border-radius: 8px;
313 font-size: 16px;
314 font-weight: 500;
315 cursor: pointer;
316 transition: all 0.2s;
317 }}
318 .btn-approve {{
319 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
320 color: white;
321 }}
322 .btn-approve:hover {{
323 transform: translateY(-2px);
324 box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
325 }}
326 .btn-deny {{
327 background: #f5f5f5;
328 color: #666;
329 }}
330 .btn-deny:hover {{
331 background: #e5e5e5;
332 }}
333 .privacy-link {{
334 text-align: center;
335 margin-top: 24px;
336 font-size: 13px;
337 color: #666;
338 }}
339 .privacy-link a {{
340 color: #667eea;
341 text-decoration: none;
342 }}
343 .privacy-link a:hover {{
344 text-decoration: underline;
345 }}
346 </style>
347</head>
348<body>
349 <div class="consent-container">
350 <div class="app-icon">🔐</div>
351 <h1>Authorize Application</h1>
352 <p class="client-id">{}</p>
353
354 <p class="permissions-title">This application is requesting the following permissions:</p>
355
356 <form id="consent-form" method="POST" action="/consent/decision">
357 <input type="hidden" name="client_id" value="{}">
358 {}
359 {}
360 <div class="scopes">
361 {}
362 </div>
363
364 <div class="buttons">
365 <button type="submit" class="btn-approve" name="approved" value="true">
366 Approve
367 </button>
368 <button type="button" class="btn-deny" onclick="denyConsent()">
369 Deny
370 </button>
371 </div>
372 </form>
373
374 <div class="privacy-link">
375 By approving, you agree to our <a href="/privacy">Privacy Policy</a> and <a href="/terms">Terms of Service</a>.
376 </div>
377 </div>
378
379 <script>
380 function denyConsent() {{
381 document.getElementById('consent-form').innerHTML += '<input type="hidden" name="approved" value="false">';
382 document.getElementById('consent-form').submit();
383 }}
384 </script>
385</body>
386</html>
387 "#,
388 client_id, client_id, state_param, redirect_uri_param, scope_items
389 )
390}
391
392fn get_scope_description(scope: &str) -> &str {
394 match scope {
395 "openid" => "Access your basic profile information",
396 "profile" => "Access your profile information including name and picture",
397 "email" => "Access your email address",
398 "address" => "Access your address information",
399 "phone" => "Access your phone number",
400 "offline_access" => "Access your information while you're offline",
401 _ => "Access to this permission",
402 }
403}
404
405fn blocked_login_html() -> String {
407 r#"
408<!DOCTYPE html>
409<html lang="en">
410<head>
411 <meta charset="UTF-8">
412 <meta name="viewport" content="width=device-width, initial-scale=1.0">
413 <title>Login Blocked - MockForge</title>
414 <style>
415 * {
416 margin: 0;
417 padding: 0;
418 box-sizing: border-box;
419 }
420 body {
421 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
422 background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
423 min-height: 100vh;
424 display: flex;
425 align-items: center;
426 justify-content: center;
427 padding: 20px;
428 }
429 .blocked-container {
430 background: white;
431 border-radius: 16px;
432 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
433 max-width: 400px;
434 width: 100%;
435 padding: 40px;
436 text-align: center;
437 }
438 .icon {
439 font-size: 64px;
440 margin-bottom: 24px;
441 }
442 h1 {
443 font-size: 24px;
444 font-weight: 600;
445 margin-bottom: 16px;
446 color: #1a1a1a;
447 }
448 p {
449 color: #666;
450 line-height: 1.6;
451 margin-bottom: 24px;
452 }
453 </style>
454</head>
455<body>
456 <div class="blocked-container">
457 <div class="icon">🚫</div>
458 <h1>Login Blocked</h1>
459 <p>Your login attempt has been blocked due to security concerns. Please contact support if you believe this is an error.</p>
460 </div>
461</body>
462</html>
463 "#.to_string()
464}
465
466pub fn consent_router(state: ConsentState) -> axum::Router {
468 use axum::routing::{get, post};
469
470 axum::Router::new()
471 .route("/consent", get(get_consent_screen))
472 .route("/consent/decision", post(submit_consent))
473 .with_state(state)
474}