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},
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}
30
31/// Consent decision request
32#[derive(Debug, Deserialize)]
33pub struct ConsentDecisionRequest {
34    /// Client ID
35    pub client_id: String,
36    /// State parameter
37    pub state: Option<String>,
38    /// Whether consent was approved
39    pub approved: bool,
40    /// Approved scopes
41    pub scopes: Vec<String>,
42}
43
44/// Consent screen state
45#[derive(Clone)]
46pub struct ConsentState {
47    /// OAuth2 server state
48    pub oauth2_state: OAuth2ServerState,
49    /// Risk engine
50    pub risk_engine: Arc<RiskEngine>,
51}
52
53/// Get consent screen
54pub async fn get_consent_screen(
55    State(state): State<ConsentState>,
56    Query(params): Query<ConsentRequest>,
57) -> Result<Html<String>, StatusCode> {
58    // Check risk assessment
59    // For mock server, use empty risk factors (can be overridden via risk simulation API)
60    // In production, extract risk factors from request context (IP, device fingerprint, etc.)
61    let risk_factors = HashMap::new();
62    let risk_assessment = state.risk_engine.assess_risk("user-default", &risk_factors).await;
63
64    // If risk is too high, block or require additional verification
65    if risk_assessment.recommended_action == RiskAction::Block {
66        return Ok(Html(blocked_login_html()));
67    }
68
69    // Parse scopes
70    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    // Generate consent screen HTML
77    let html = generate_consent_screen_html(&params.client_id, &scopes, params.state.as_deref());
78    Ok(Html(html))
79}
80
81/// Submit consent decision
82pub 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    // Store consent decision and redirect back to OAuth2 flow
94    // In a full implementation, this would store consent and redirect to authorization endpoint
95    Ok(Json(serde_json::json!({
96        "approved": true,
97        "scopes": request.scopes,
98        "message": "Consent approved"
99    })))
100}
101
102/// Generate consent screen HTML
103fn 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
322/// Get scope description
323fn 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
335/// Generate blocked login HTML
336fn 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
396/// Create consent router
397pub 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}