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