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