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