use actix_web::{HttpRequest, HttpResponse};
use athena_auth_core::{
ExtractedSessionToken, ValidateSessionInput, ValidatedSessionContext,
extract_session_token_from_headers, validate_session,
};
use athena_chat::ChatContext;
use chrono::Utc;
use crate::AppState;
use crate::api::chat::auth_logging::{
ChatSessionAuthOutcome, build_missing_token_details, build_session_source_details,
spawn_chat_session_auth_log,
};
use crate::api::client_context::request_auth_pool;
use crate::api::client_context::required_client_name;
use crate::api::headers::request_context::athena_request_id;
use crate::api::response::{service_unavailable, unauthorized};
const SESSION_COOKIE_NAME: &str = "athena-auth.session-token";
#[derive(Debug, Default)]
pub struct AuthResolver;
impl AuthResolver {
pub async fn resolve(
&self,
req: &HttpRequest,
state: &AppState,
) -> Result<ChatContext, HttpResponse> {
let client_name = required_client_name(req)?;
let Some(session_token) = extract_chat_session_token(req) else {
spawn_chat_session_auth_log(
state,
req,
Some(client_name.as_str()),
None,
None,
ChatSessionAuthOutcome::MissingSessionToken,
Some("Missing or invalid Athena Auth session".to_string()),
build_missing_token_details(
req.headers().contains_key("authorization"),
req.headers().contains_key("cookie"),
SESSION_COOKIE_NAME,
),
);
return Err(unauthorized(
"Chat authentication required",
"Missing or invalid Athena Auth session",
));
};
let pool = request_auth_pool(req, state).await?;
let validated_session = validate_session(
&pool,
ValidateSessionInput {
token: &session_token.token,
now: Utc::now(),
include_user: true,
include_organization: true,
},
)
.await
.map_err(|err| {
let error_message = format!("failed to verify Athena Auth session: {err}");
spawn_chat_session_auth_log(
state,
req,
Some(client_name.as_str()),
Some(session_token.source),
None,
ChatSessionAuthOutcome::AuthStoreUnavailable,
Some(error_message.clone()),
build_session_source_details(session_token.source),
);
service_unavailable("Chat auth store unavailable", error_message)
})?;
let Some(validated_session) = validated_session else {
spawn_chat_session_auth_log(
state,
req,
Some(client_name.as_str()),
Some(session_token.source),
None,
ChatSessionAuthOutcome::InvalidSession,
Some("Missing or invalid Athena Auth session".to_string()),
build_session_source_details(session_token.source),
);
return Err(unauthorized(
"Chat authentication required",
"Missing or invalid Athena Auth session",
));
};
let client_name_for_logging = client_name.clone();
let chat_context = build_chat_context(
validated_session.clone(),
client_name,
athena_request_id(req),
)
.map_err(|response| {
spawn_chat_session_auth_log(
state,
req,
Some(client_name_for_logging.as_str()),
Some(session_token.source),
Some(&validated_session),
ChatSessionAuthOutcome::MissingOrganization,
Some("No active organization resolved for actor".to_string()),
build_session_source_details(session_token.source),
);
response
})?;
spawn_chat_session_auth_log(
state,
req,
Some(chat_context.client_name.as_str()),
Some(session_token.source),
Some(&validated_session),
ChatSessionAuthOutcome::Authenticated,
None,
build_session_source_details(session_token.source),
);
Ok(chat_context)
}
}
fn extract_chat_session_token(req: &HttpRequest) -> Option<ExtractedSessionToken> {
extract_session_token_from_headers(
req.headers()
.get("authorization")
.and_then(|value| value.to_str().ok()),
req.headers()
.get("cookie")
.and_then(|value| value.to_str().ok()),
Some(SESSION_COOKIE_NAME),
)
}
fn build_chat_context(
validated_session: ValidatedSessionContext,
client_name: String,
request_id: Option<String>,
) -> Result<ChatContext, HttpResponse> {
let organization_id = validated_session
.active_organization_id
.clone()
.or_else(|| validated_session.organization_ids.first().cloned())
.ok_or_else(|| {
unauthorized(
"Chat organization required",
"No active organization resolved for actor",
)
})?;
let role_ids = validated_session.merged_roles();
Ok(ChatContext {
user_id: validated_session.user_id,
organization_id,
client_name,
is_admin: actor_is_admin(&role_ids, validated_session.has_admin_scope),
role_ids,
request_id,
})
}
fn actor_is_admin(role_ids: &[String], has_admin_scope: bool) -> bool {
has_admin_scope
|| role_ids.iter().any(|role| {
matches!(
role.as_str(),
"admin" | "super_admin" | "owner" | "storage_admin" | "storage-admin"
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::test::TestRequest;
use athena_auth_core::SessionTokenSource;
fn sample_session() -> ValidatedSessionContext {
ValidatedSessionContext {
session_id: "session-1".to_string(),
user_id: "user-1".to_string(),
email: Some("user@example.com".to_string()),
name: Some("User".to_string()),
role: Some("member".to_string()),
active_organization_id: Some("org-2".to_string()),
organization_ids: vec!["org-1".to_string()],
member_roles: vec!["owner".to_string()],
permissions: Vec::new(),
has_admin_scope: false,
expires_at: Utc::now(),
}
}
#[test]
fn build_chat_context_prefers_active_organization() {
let ctx = build_chat_context(
sample_session(),
"acme".to_string(),
Some("req-1".to_string()),
)
.expect("chat context should resolve");
assert_eq!(ctx.organization_id, "org-2");
assert_eq!(
ctx.role_ids,
vec!["member".to_string(), "owner".to_string()]
);
assert!(ctx.is_admin);
}
#[test]
fn build_chat_context_falls_back_to_first_member_organization() {
let mut session = sample_session();
session.active_organization_id = None;
let ctx = build_chat_context(session, "acme".to_string(), None)
.expect("chat context should resolve");
assert_eq!(ctx.organization_id, "org-1");
}
#[test]
fn build_chat_context_rejects_missing_organization_membership() {
let mut session = sample_session();
session.active_organization_id = None;
session.organization_ids.clear();
let response = build_chat_context(session, "acme".to_string(), None)
.expect_err("missing org should fail");
assert_eq!(response.status(), actix_web::http::StatusCode::UNAUTHORIZED);
}
#[test]
fn extract_chat_session_token_accepts_cookie() {
let req = TestRequest::default()
.insert_header(("cookie", "athena-auth.session-token=session_cookie"))
.to_http_request();
let extracted = extract_chat_session_token(&req).expect("cookie token should resolve");
assert_eq!(extracted.token, "session_cookie");
assert_eq!(extracted.source, SessionTokenSource::Cookie);
}
}