Menu
Public repository / main
AkurAI-CRM / crates/crm-api/src/auth/mod.rs
Latest change 3ca078e63808e39f59571f8562e9a829f261ae13 - Restore CSRF guard, admin-gated merge, IdP logout and login redirects by Ólafur Búi Ólafsson
//! Browser login against the AkurAI IdP: authorization code + PKCE (S256),
//! server-stored single-use state bound to an `akurai_oauth_state` cookie,
//! ID token checks, access-token introspection, `/userinfo` group gating,
//! and an `akurai_session` cookie. Logout ends the IdP session too.
pub mod csrf;
pub mod id_token;
pub mod idp_client;
pub mod login_state;
pub mod pkce;
pub mod session;
use crate::router_setup::{internal_error, json_response, CrmState};
use akurai_http::{Request, Response};
use akurai_json::Value;
use akurai_storage::BTree;
use idp_client::{urldecode, Config};
use login_state::LoginState;
use std::sync::{Arc, Mutex, MutexGuard};
pub const STATE_COOKIE: &str = "akurai_oauth_state";
type Handler = Box<dyn Fn(&Request) -> Response + Send + Sync>;
/// A login failure shown to the browser: HTTP status plus a safe message.
#[derive(Debug, PartialEq)]
pub struct LoginError {
pub status: u16,
pub message: String,
}
impl LoginError {
fn new(status: u16, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}
fn response(&self) -> Response {
Response::new(self.status)
.with_text(&self.message)
.with_header("Cache-Control", "no-store")
}
}
/// Where the browser goes after a successful login.
#[derive(Debug, PartialEq)]
pub struct LoggedIn {
pub session_id: String,
pub return_to: String,
}
fn now() -> i64 {
CrmState::now()
}
fn with_db<T>(
state: &Arc<Mutex<CrmState>>,
f: impl FnOnce(&mut BTree) -> T,
) -> Result<T, Response> {
let state = state.lock().map_err(|_| internal_error("lock poisoned"))?;
let mut db: MutexGuard<'_, BTree> = state
.db
.lock()
.map_err(|_| internal_error("db lock poisoned"))?;
Ok(f(&mut db))
}
fn query_param(req: &Request, name: &str) -> Option<String> {
req.query.as_deref()?.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then(|| urldecode(value))
})
}
fn redirect(location: &str) -> Response {
Response::new(302)
.with_header("Location", location)
.with_header("Cache-Control", "no-store")
}
fn with_cookie(mut response: Response, cookie: &str) -> Response {
response
.headers
.push(("Set-Cookie".to_string(), cookie.to_string()));
response
}
fn state_cookie(value: &str, max_age: i64) -> String {
format!("{STATE_COOKIE}={value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age}")
}
fn sso_not_configured(error: &str) -> Response {
Response::new(503).with_text(&format!(
"akurai-crm: single sign-on is not configured: {error}"
))
}
/// Start a login: persist state + PKCE verifier + nonce, redirect to the IdP.
pub fn begin_login(
config: &Config,
db: &mut BTree,
return_to: Option<&str>,
now: i64,
) -> Result<Response, String> {
let state = pkce::random_token()?;
let login = LoginState {
code_verifier: pkce::random_token()?,
nonce: pkce::random_token()?,
return_to: login_state::safe_return_to(return_to),
};
login_state::store(db, &state, &login, now)?;
let location = config.authorization_url(
&state,
&login.nonce,
&pkce::s256_challenge(&login.code_verifier),
);
Ok(with_cookie(
redirect(&location),
&state_cookie(&state, login_state::LOGIN_STATE_TTL_SECS),
))
}
/// Finish a login from the IdP callback request.
pub fn complete_login(
config: &Config,
db: &mut BTree,
req: &Request,
now: i64,
) -> Result<LoggedIn, LoginError> {
let bad = |message: &str| LoginError::new(400, message);
let upstream = |message: String| LoginError::new(502, message);
if let Some(error) = query_param(req, "error") {
return Err(bad(&format!("login was not completed: {error}")));
}
let state = query_param(req, "state")
.filter(|s| !s.is_empty())
.ok_or_else(|| bad("invalid_login_response"))?;
let cookie_state =
session::cookie(req, STATE_COOKIE).ok_or_else(|| bad("state cookie is missing"))?;
if cookie_state != state {
return Err(bad("state does not match the state cookie"));
}
let login = login_state::take(db, &state, now)
.ok_or_else(|| bad("state is unknown, expired, or already used"))?;
let code = query_param(req, "code")
.filter(|c| !c.is_empty())
.ok_or_else(|| bad("code is missing"))?;
let tokens = config
.exchange_code(&code, &login.code_verifier)
.map_err(upstream)?;
let identity = id_token::validate(
&tokens.id_token,
&id_token::Expected {
issuer: &config.issuer,
client_id: &config.client_id,
nonce: &login.nonce,
now,
},
)
.map_err(upstream)?;
let subject = identity.sub;
let introspection = config.introspect(&tokens.access_token).map_err(upstream)?;
let active = introspection.get("active") == Some(&Value::Bool(true));
let introspected_sub = introspection.get("sub").and_then(Value::as_str);
if !active || introspected_sub != Some(subject.as_str()) {
return Err(LoginError::new(
401,
"access token is not active for this subject",
));
}
let user = config.userinfo(&tokens.access_token).map_err(upstream)?;
if user.sub != subject {
return Err(upstream(
"userinfo subject does not match the id_token".into(),
));
}
if !idp_client::groups_allowed(&config.allowed_groups, &user.groups) {
return Err(LoginError::new(403, idp_client::GROUP_DENIED));
}
let session_id = session::create_with_role(
db,
&user.sub,
&user.email,
&user.tenant_id,
session::Role::parse(&identity.role),
)
.map_err(|e| LoginError::new(500, e))?;
Ok(LoggedIn {
session_id,
return_to: login.return_to,
})
}
/// GET /auth/login[?return_to=/path] — redirect to the IdP.
pub fn login_route(state: Arc<Mutex<CrmState>>) -> Handler {
Box::new(move |req: &Request| {
let config = match Config::from_env() {
Ok(config) => config,
Err(error) => return sso_not_configured(&error),
};
let return_to = query_param(req, "return_to");
match with_db(&state, |db| {
begin_login(&config, db, return_to.as_deref(), now())
}) {
Ok(Ok(response)) => response,
Ok(Err(error)) => internal_error(&error),
Err(response) => response,
}
})
}
/// GET /auth/callback — validate, create the session, return to the app.
pub fn callback_route(state: Arc<Mutex<CrmState>>) -> Handler {
Box::new(move |req: &Request| {
let config = match Config::from_env() {
Ok(config) => config,
Err(error) => return sso_not_configured(&error),
};
let outcome = match with_db(&state, |db| complete_login(&config, db, req, now())) {
Ok(outcome) => outcome,
Err(response) => return response,
};
let response = match outcome {
Ok(logged_in) => with_cookie(
redirect(&logged_in.return_to),
&session::set_cookie(&logged_in.session_id),
),
Err(error) => error.response(),
};
with_cookie(response, &state_cookie("", 0))
})
}
/// GET /api/me — current user from the session.
pub fn me_route(state: Arc<Mutex<CrmState>>) -> Handler {
Box::new(move |req: &Request| {
let user = match session::extract_session_id(req) {
Some(sid) => match with_db(&state, |db| session::get_user(db, &sid)) {
Ok(user) => user,
Err(response) => return response,
},
None => None,
};
match user {
Some(u) => json_response(Value::Object(vec![
("authenticated".into(), Value::Bool(true)),
("sub".into(), Value::Str(u.sub)),
("email".into(), Value::Str(u.email)),
("tenantId".into(), Value::Str(u.tenant_id)),
])),
None => json_response(Value::Object(vec![(
"authenticated".into(),
Value::Bool(false),
)])),
}
})
}
/// Drop the local session and send the browser to the IdP's `/logout`
/// (or home when SSO is not configured).
pub fn logout(config: Option<&Config>, db: &mut BTree, req: &Request) -> Response {
if let Some(sid) = session::extract_session_id(req) {
if let Err(error) = session::delete(db, &sid) {
return internal_error(&error);
}
}
let location = config.map_or_else(|| "/".to_string(), Config::logout_url);
with_cookie(redirect(&location), &session::clear_cookie())
}
/// GET /auth/logout
pub fn logout_route(state: Arc<Mutex<CrmState>>) -> Handler {
Box::new(move |req: &Request| {
let config = Config::from_env().ok();
with_db(&state, |db| logout(config.as_ref(), db, req)).unwrap_or_else(|response| response)
})
}
/// Fresh database path under `target/test-db` (never /tmp).
#[cfg(test)]
pub(crate) fn test_db_path(name: &str) -> String {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-db");
std::fs::create_dir_all(dir).unwrap();
let path = format!("{dir}/{name}-{}.db", std::process::id());
let _ = std::fs::remove_file(&path);
path
}
#[cfg(test)]
pub(crate) fn test_db(name: &str) -> BTree {
BTree::open(test_db_path(name)).unwrap()
}
#[cfg(test)]
mod tests;