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