use super::AppState;
use super::api::require_auth;
use super::client_key_from_request;
use crate::auth::profiles::{AuthProfile, AuthProfileKind, profile_id};
use axum::{
extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Json},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
const SERVICE_TOKEN_HEADER: &str = "X-Construct-Service-Token";
#[derive(Serialize, Clone)]
pub struct AuthProfileSummary {
pub id: String,
pub provider: String,
pub profile_name: String,
pub kind: String,
pub account_id: Option<String>,
pub workspace_id: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct ResolvedAuth {
pub token: String,
pub kind: String,
pub provider: String,
pub profile_name: String,
pub expires_at: Option<DateTime<Utc>>,
}
pub async fn handle_list_auth_profiles(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let Some(store) = state.auth_profiles.as_ref() else {
return Json(serde_json::json!({ "profiles": [] })).into_response();
};
let data = match store.load().await {
Ok(d) => d,
Err(err) => {
tracing::warn!(error = %err, "auth-profiles list failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to load auth profiles" })),
)
.into_response();
}
};
let mut profiles: Vec<AuthProfileSummary> = data
.profiles
.into_values()
.map(|p| AuthProfileSummary {
id: p.id,
provider: p.provider,
profile_name: p.profile_name,
kind: match p.kind {
AuthProfileKind::OAuth => "oauth".to_string(),
AuthProfileKind::Token => "token".to_string(),
},
account_id: p.account_id,
workspace_id: p.workspace_id,
expires_at: p.token_set.as_ref().and_then(|t| t.expires_at),
created_at: p.created_at,
updated_at: p.updated_at,
})
.collect();
profiles.sort_by(|a, b| {
a.provider
.cmp(&b.provider)
.then_with(|| a.profile_name.cmp(&b.profile_name))
});
Json(serde_json::json!({ "profiles": profiles })).into_response()
}
#[derive(Deserialize)]
pub struct CreateAuthProfileBody {
pub provider: String,
pub profile_name: String,
pub token: String,
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub kind: Option<String>,
}
pub async fn handle_create_auth_profile(
State(state): State<AppState>,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<CreateAuthProfileBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let rate_key =
client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
let peer_is_loopback = peer_addr.ip().is_loopback();
if let Err(e) = state
.auth_limiter
.check_rate_limit(&rate_key, peer_is_loopback)
{
tracing::warn!("auth-profiles create: rate limit exceeded for {rate_key}");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": format!("Too many create attempts. Try again in {}s.", e.retry_after_secs),
"retry_after": e.retry_after_secs,
"code": "auth_profile_rate_limited"
})),
)
.into_response();
}
state
.auth_limiter
.record_attempt(&rate_key, peer_is_loopback);
let provider = body.provider.trim().to_string();
let profile_name = body.profile_name.trim().to_string();
let token = body.token.clone();
let account_id = body.account_id.and_then(|s| {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
if provider.is_empty() || profile_name.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "provider and profile_name are required",
"code": "auth_profile_missing_fields"
})),
)
.into_response();
}
if token.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "token is required",
"code": "auth_profile_missing_token"
})),
)
.into_response();
}
let kind = body.kind.as_deref().unwrap_or("token").to_ascii_lowercase();
match kind.as_str() {
"token" | "api_key" => {}
"oauth" => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "OAuth profiles must be created via the /config flow",
"code": "auth_profile_oauth_unsupported"
})),
)
.into_response();
}
other => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": format!("unsupported auth profile kind: {other}"),
"code": "auth_profile_invalid_kind"
})),
)
.into_response();
}
}
let Some(store) = state.auth_profiles.as_ref() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "auth profile store not configured on this gateway",
"code": "auth_store_unavailable"
})),
)
.into_response();
};
let id = profile_id(&provider, &profile_name);
match store.load().await {
Ok(data) => {
if data.profiles.contains_key(&id) {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": format!("auth profile already exists: {id}"),
"code": "auth_profile_already_exists"
})),
)
.into_response();
}
}
Err(err) => {
tracing::warn!(error = %err, "auth-profiles create: load failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to load auth profiles",
"code": "auth_store_load_failed"
})),
)
.into_response();
}
}
let mut profile = AuthProfile::new_token(&provider, &profile_name, token);
profile.account_id = account_id;
if let Err(err) = store.upsert_profile(profile.clone(), false).await {
tracing::warn!(error = %err, "auth-profiles create: persist failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to save auth profile",
"code": "auth_store_save_failed"
})),
)
.into_response();
}
let summary = AuthProfileSummary {
id: profile.id.clone(),
provider: profile.provider.clone(),
profile_name: profile.profile_name.clone(),
kind: match profile.kind {
AuthProfileKind::OAuth => "oauth".to_string(),
AuthProfileKind::Token => "token".to_string(),
},
account_id: profile.account_id.clone(),
workspace_id: profile.workspace_id.clone(),
expires_at: None,
created_at: profile.created_at,
updated_at: profile.updated_at,
};
(StatusCode::CREATED, Json(summary)).into_response()
}
pub async fn handle_delete_auth_profile(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let Some(store) = state.auth_profiles.as_ref() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "auth profile store not configured",
"code": "auth_store_unavailable"
})),
)
.into_response();
};
match store.remove_profile(&id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("auth profile not found: {id}"),
"code": "auth_profile_not_found"
})),
)
.into_response(),
Err(err) => {
tracing::warn!(error = %err, "auth-profile delete failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to delete auth profile",
"code": "auth_store_delete_failed"
})),
)
.into_response()
}
}
}
fn require_service_token(
state: &AppState,
headers: &HeaderMap,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let provided = headers
.get(SERVICE_TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let expected = state.service_token.as_ref();
if expected.is_empty() {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "service token not configured on this gateway"
})),
));
}
if crate::security::pairing::constant_time_eq(provided, expected) {
Ok(())
} else {
Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "missing or invalid X-Construct-Service-Token header"
})),
))
}
}
pub async fn handle_resolve_auth_profile(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_service_token(&state, &headers) {
return e.into_response();
}
let Some(store) = state.auth_profiles.as_ref() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "auth profile store not configured",
"code": "auth_store_unavailable"
})),
)
.into_response();
};
let data = match store.load().await {
Ok(d) => d,
Err(err) => {
tracing::warn!(error = %err, "auth-profile resolve: load failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to load auth profiles",
"code": "auth_store_load_failed"
})),
)
.into_response();
}
};
let Some(profile) = data.profiles.get(&id) else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("auth profile not found: {id}"),
"code": "auth_profile_not_found"
})),
)
.into_response();
};
match profile.kind {
AuthProfileKind::Token => {
let token = profile
.token
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_default();
if token.is_empty() {
return (
StatusCode::GONE,
Json(serde_json::json!({
"error": "auth profile is empty",
"code": "auth_profile_empty"
})),
)
.into_response();
}
let mut resp = Json(ResolvedAuth {
token,
kind: "token".into(),
provider: profile.provider.clone(),
profile_name: profile.profile_name.clone(),
expires_at: None,
})
.into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
resp
}
AuthProfileKind::OAuth => {
let Some(token_set) = profile.token_set.as_ref() else {
return (
StatusCode::GONE,
Json(serde_json::json!({
"error": "OAuth profile missing token_set",
"code": "auth_profile_missing_tokens"
})),
)
.into_response();
};
if let Some(expires_at) = token_set.expires_at {
if expires_at <= Utc::now() {
return (
StatusCode::GONE,
Json(serde_json::json!({
"error": "OAuth profile expired",
"code": "auth_profile_expired",
"expired_at": expires_at,
})),
)
.into_response();
}
}
let mut resp = Json(ResolvedAuth {
token: token_set.access_token.clone(),
kind: "oauth".into(),
provider: profile.provider.clone(),
profile_name: profile.profile_name.clone(),
expires_at: token_set.expires_at,
})
.into_response();
resp.headers_mut()
.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
resp
}
}
}