1use 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#[derive(Debug, Deserialize)]
21pub struct ConsentRequest {
22 pub client_id: String,
24 pub scope: Option<String>,
26 pub state: Option<String>,
28 pub code: Option<String>,
30}
31
32#[derive(Debug, Deserialize)]
34pub struct ConsentDecisionRequest {
35 pub client_id: String,
37 pub state: Option<String>,
39 pub approved: bool,
41 pub scopes: Vec<String>,
43}
44
45#[derive(Clone)]
47pub struct ConsentState {
48 pub oauth2_state: OAuth2ServerState,
50 pub risk_engine: Arc<RiskEngine>,
52}
53
54pub async fn get_consent_screen(
56 State(state): State<ConsentState>,
57 Query(params): Query<ConsentRequest>,
58) -> Result<Html<String>, StatusCode> {
59 let risk_factors = HashMap::new();
63 let risk_assessment = state.risk_engine.assess_risk("user-default", &risk_factors).await;
64
65 if risk_assessment.recommended_action == RiskAction::Block {
67 return Ok(Html(blocked_login_html()));
68 }
69
70 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 let html = generate_consent_screen_html(¶ms.client_id, &scopes, params.state.as_deref());
79 Ok(Html(html))
80}
81
82pub 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 Ok(Json(serde_json::json!({
97 "approved": true,
98 "scopes": request.scopes,
99 "message": "Consent approved"
100 })))
101}
102
103fn 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
323fn 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
336fn 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
397pub 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}