1use 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#[derive(Debug, Deserialize)]
20pub struct ConsentRequest {
21 pub client_id: String,
23 pub scope: Option<String>,
25 pub state: Option<String>,
27 pub code: Option<String>,
29}
30
31#[derive(Debug, Deserialize)]
33pub struct ConsentDecisionRequest {
34 pub client_id: String,
36 pub state: Option<String>,
38 pub approved: bool,
40 pub scopes: Vec<String>,
42}
43
44#[derive(Clone)]
46pub struct ConsentState {
47 pub oauth2_state: OAuth2ServerState,
49 pub risk_engine: Arc<RiskEngine>,
51}
52
53pub async fn get_consent_screen(
55 State(state): State<ConsentState>,
56 Query(params): Query<ConsentRequest>,
57) -> Result<Html<String>, StatusCode> {
58 let risk_factors = HashMap::new();
62 let risk_assessment = state.risk_engine.assess_risk("user-default", &risk_factors).await;
63
64 if risk_assessment.recommended_action == RiskAction::Block {
66 return Ok(Html(blocked_login_html()));
67 }
68
69 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 let html = generate_consent_screen_html(¶ms.client_id, &scopes, params.state.as_deref());
78 Ok(Html(html))
79}
80
81pub 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 let code = uuid::Uuid::new_v4().to_string();
95 let expires_at = chrono::Utc::now().timestamp() + 600; let code_info = crate::handlers::oauth2_server::AuthorizationCodeInfo {
98 client_id: request.client_id.clone(),
99 redirect_uri: String::new(), 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 {
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
121fn 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
341fn 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
354fn 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
415pub 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}