Skip to main content

mockforge_http/handlers/
consent.rs

1//! Consent screen handlers
2//!
3//! This module provides endpoints for OAuth2 consent screens with
4//! permissions/scopes toggles and risk simulation integration.
5
6use 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/// Consent request parameters
19#[derive(Debug, Deserialize)]
20pub struct ConsentRequest {
21    /// Client ID
22    pub client_id: String,
23    /// Scopes (space-separated)
24    pub scope: Option<String>,
25    /// State parameter
26    pub state: Option<String>,
27    /// Authorization code (if consent already given)
28    pub code: Option<String>,
29    /// Redirect URI for the OAuth2 flow
30    pub redirect_uri: Option<String>,
31}
32
33/// Consent decision request
34#[derive(Debug, Deserialize)]
35pub struct ConsentDecisionRequest {
36    /// Client ID
37    pub client_id: String,
38    /// State parameter
39    pub state: Option<String>,
40    /// Whether consent was approved
41    pub approved: bool,
42    /// Approved scopes
43    pub scopes: Vec<String>,
44    /// Redirect URI for the OAuth2 flow
45    pub redirect_uri: Option<String>,
46}
47
48/// Consent screen state
49#[derive(Clone)]
50pub struct ConsentState {
51    /// OAuth2 server state
52    pub oauth2_state: OAuth2ServerState,
53    /// Risk engine
54    pub risk_engine: Arc<RiskEngine>,
55}
56
57/// Get consent screen
58pub async fn get_consent_screen(
59    State(state): State<ConsentState>,
60    headers: HeaderMap,
61    Query(params): Query<ConsentRequest>,
62) -> Result<Html<String>, StatusCode> {
63    // Extract risk factors from request context
64    let mut risk_factors: HashMap<String, f64> = HashMap::new();
65
66    // IP-based risk: unknown IPs get a baseline factor
67    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        // Loopback/private IPs are lower risk
73        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    // User-agent risk: missing or unusual user agents are higher risk
87    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 is too high, block or require additional verification
101    if risk_assessment.recommended_action == RiskAction::Block {
102        return Ok(Html(blocked_login_html()));
103    }
104
105    // Parse scopes
106    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    // Generate consent screen HTML
113    let html = generate_consent_screen_html(
114        &params.client_id,
115        &scopes,
116        params.state.as_deref(),
117        params.redirect_uri.as_deref(),
118    );
119    Ok(Html(html))
120}
121
122/// Submit consent decision
123pub 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    // Generate authorization code and store it in OAuth2 state
135    let code = uuid::Uuid::new_v4().to_string();
136    let expires_at = chrono::Utc::now().timestamp() + 600; // 10 minute expiry
137
138    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    // Store the authorization code
149    {
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
162/// Generate consent screen HTML
163fn 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
392/// Get scope description
393fn 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
405/// Generate blocked login HTML
406fn 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
466/// Create consent router
467pub 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}