use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{Html, Json},
};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use crate::auth::risk_engine::{RiskAction, RiskEngine};
use crate::handlers::oauth2_server::OAuth2ServerState;
#[derive(Debug, Deserialize)]
pub struct ConsentRequest {
pub client_id: String,
pub scope: Option<String>,
pub state: Option<String>,
pub code: Option<String>,
pub redirect_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ConsentDecisionRequest {
pub client_id: String,
pub state: Option<String>,
pub approved: bool,
pub scopes: Vec<String>,
pub redirect_uri: Option<String>,
}
#[derive(Clone)]
pub struct ConsentState {
pub oauth2_state: OAuth2ServerState,
pub risk_engine: Arc<RiskEngine>,
}
pub async fn get_consent_screen(
State(state): State<ConsentState>,
headers: HeaderMap,
Query(params): Query<ConsentRequest>,
) -> Result<Html<String>, StatusCode> {
let mut risk_factors: HashMap<String, f64> = HashMap::new();
if let Some(ip) = headers
.get("x-forwarded-for")
.or_else(|| headers.get("x-real-ip"))
.and_then(|h| h.to_str().ok())
{
let ip_trimmed = ip.split(',').next().unwrap_or(ip).trim();
let ip_risk = if ip_trimmed.starts_with("127.")
|| ip_trimmed == "::1"
|| ip_trimmed.starts_with("10.")
|| ip_trimmed.starts_with("192.168.")
{
0.1
} else {
0.3
};
risk_factors.insert("ip_risk".to_string(), ip_risk);
}
if let Some(ua) = headers.get("user-agent").and_then(|h| h.to_str().ok()) {
let ua_risk = if ua.contains("bot") || ua.contains("curl") || ua.contains("wget") {
0.5
} else {
0.1
};
risk_factors.insert("user_agent_risk".to_string(), ua_risk);
} else {
risk_factors.insert("user_agent_risk".to_string(), 0.6);
}
let risk_assessment = state.risk_engine.assess_risk("user-default", &risk_factors).await;
if risk_assessment.recommended_action == RiskAction::Block {
return Ok(Html(blocked_login_html()));
}
let scopes = params
.scope
.as_ref()
.map(|s| s.split(' ').map(|s| s.to_string()).collect::<Vec<_>>())
.unwrap_or_else(Vec::new);
let html = generate_consent_screen_html(
¶ms.client_id,
&scopes,
params.state.as_deref(),
params.redirect_uri.as_deref(),
);
Ok(Html(html))
}
pub async fn submit_consent(
State(state): State<ConsentState>,
Json(request): Json<ConsentDecisionRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
if !request.approved {
return Ok(Json(serde_json::json!({
"approved": false,
"message": "Consent denied"
})));
}
let code = uuid::Uuid::new_v4().to_string();
let expires_at = chrono::Utc::now().timestamp() + 600;
let code_info = crate::handlers::oauth2_server::AuthorizationCodeInfo {
client_id: request.client_id.clone(),
redirect_uri: request.redirect_uri.clone().unwrap_or_default(),
scopes: request.scopes.clone(),
user_id: "consent-user".to_string(),
state: request.state.clone(),
expires_at,
tenant_context: None,
};
{
let mut auth_codes = state.oauth2_state.auth_codes.write().await;
auth_codes.insert(code.clone(), code_info);
}
Ok(Json(serde_json::json!({
"approved": true,
"scopes": request.scopes,
"code": code,
"message": "Consent approved"
})))
}
fn generate_consent_screen_html(
client_id: &str,
scopes: &[String],
state: Option<&str>,
redirect_uri: Option<&str>,
) -> String {
let scope_items = scopes
.iter()
.map(|scope| {
let description = get_scope_description(scope);
format!(
r#"
<div class="scope-item">
<label class="scope-toggle">
<input type="checkbox" name="scope" value="{}" checked>
<span class="scope-name">{}</span>
</label>
<p class="scope-description">{}</p>
</div>
"#,
scope, scope, description
)
})
.collect::<String>();
let state_param = state
.map(|s| format!(r#"<input type="hidden" name="state" value="{}">"#, s))
.unwrap_or_default();
let redirect_uri_param = redirect_uri
.map(|u| format!(r#"<input type="hidden" name="redirect_uri" value="{}">"#, u))
.unwrap_or_default();
format!(
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorize Application - MockForge</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}}
.consent-container {{
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 100%;
padding: 40px;
animation: slideUp 0.3s ease-out;
}}
@keyframes slideUp {{
from {{
opacity: 0;
transform: translateY(20px);
}}
to {{
opacity: 1;
transform: translateY(0);
}}
}}
.app-icon {{
width: 64px;
height: 64px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: white;
}}
h1 {{
font-size: 24px;
font-weight: 600;
text-align: center;
margin-bottom: 8px;
color: #1a1a1a;
}}
.client-id {{
text-align: center;
color: #666;
font-size: 14px;
margin-bottom: 32px;
}}
.permissions-title {{
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #1a1a1a;
}}
.scope-item {{
padding: 16px;
border: 1px solid #e5e5e5;
border-radius: 8px;
margin-bottom: 12px;
transition: all 0.2s;
}}
.scope-item:hover {{
border-color: #667eea;
background: #f8f9ff;
}}
.scope-toggle {{
display: flex;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
}}
.scope-toggle input[type="checkbox"] {{
width: 20px;
height: 20px;
margin-right: 12px;
cursor: pointer;
accent-color: #667eea;
}}
.scope-name {{
font-weight: 500;
color: #1a1a1a;
}}
.scope-description {{
font-size: 13px;
color: #666;
margin-left: 32px;
line-height: 1.5;
}}
.buttons {{
display: flex;
gap: 12px;
margin-top: 32px;
}}
button {{
flex: 1;
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}}
.btn-approve {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}}
.btn-approve:hover {{
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}}
.btn-deny {{
background: #f5f5f5;
color: #666;
}}
.btn-deny:hover {{
background: #e5e5e5;
}}
.privacy-link {{
text-align: center;
margin-top: 24px;
font-size: 13px;
color: #666;
}}
.privacy-link a {{
color: #667eea;
text-decoration: none;
}}
.privacy-link a:hover {{
text-decoration: underline;
}}
</style>
</head>
<body>
<div class="consent-container">
<div class="app-icon">🔐</div>
<h1>Authorize Application</h1>
<p class="client-id">{}</p>
<p class="permissions-title">This application is requesting the following permissions:</p>
<form id="consent-form" method="POST" action="/consent/decision">
<input type="hidden" name="client_id" value="{}">
{}
{}
<div class="scopes">
{}
</div>
<div class="buttons">
<button type="submit" class="btn-approve" name="approved" value="true">
Approve
</button>
<button type="button" class="btn-deny" onclick="denyConsent()">
Deny
</button>
</div>
</form>
<div class="privacy-link">
By approving, you agree to our <a href="/privacy">Privacy Policy</a> and <a href="/terms">Terms of Service</a>.
</div>
</div>
<script>
function denyConsent() {{
document.getElementById('consent-form').innerHTML += '<input type="hidden" name="approved" value="false">';
document.getElementById('consent-form').submit();
}}
</script>
</body>
</html>
"#,
client_id, client_id, state_param, redirect_uri_param, scope_items
)
}
fn get_scope_description(scope: &str) -> &str {
match scope {
"openid" => "Access your basic profile information",
"profile" => "Access your profile information including name and picture",
"email" => "Access your email address",
"address" => "Access your address information",
"phone" => "Access your phone number",
"offline_access" => "Access your information while you're offline",
_ => "Access to this permission",
}
}
fn blocked_login_html() -> String {
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Blocked - MockForge</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.blocked-container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 400px;
width: 100%;
padding: 40px;
text-align: center;
}
.icon {
font-size: 64px;
margin-bottom: 24px;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 16px;
color: #1a1a1a;
}
p {
color: #666;
line-height: 1.6;
margin-bottom: 24px;
}
</style>
</head>
<body>
<div class="blocked-container">
<div class="icon">🚫</div>
<h1>Login Blocked</h1>
<p>Your login attempt has been blocked due to security concerns. Please contact support if you believe this is an error.</p>
</div>
</body>
</html>
"#.to_string()
}
pub fn consent_router(state: ConsentState) -> axum::Router {
use axum::routing::{get, post};
axum::Router::new()
.route("/consent", get(get_consent_screen))
.route("/consent/decision", post(submit_consent))
.with_state(state)
}