added oidc adapter
Some checks failed
CI / Rustfmt (push) Failing after 56s
CI / Clippy (push) Successful in 2m4s
CI / Web Blocking Checks (push) Successful in 50s
CI / Cargo Audit & Deny (push) Successful in 2m2s
CI / Security Blocking Checks (push) Successful in 10s
CI / Security Advisory Checks (push) Successful in 41s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 3s
Publish Images And Chart / Publish init-packs (push) Failing after 13s
Publish Images And Chart / Publish init-user (push) Failing after 11s
CI / Web Advisory Checks (push) Successful in 1m38s
Publish Images And Chart / Publish migrations (push) Failing after 11s
Publish Images And Chart / Publish web (push) Failing after 10s
Publish Images And Chart / Publish worker (push) Failing after 10s
Publish Images And Chart / Publish sensor (push) Failing after 31s
Publish Images And Chart / Publish api (push) Failing after 10s
Publish Images And Chart / Publish notifier (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
CI / Tests (push) Successful in 1h34m2s

This commit is contained in:
2026-03-18 16:35:21 -05:00
parent 1d59ff5de4
commit 57fa3bf7cf
27 changed files with 2019 additions and 224 deletions

View File

@@ -27,6 +27,8 @@ futures = { workspace = true }
# Web framework
axum = { workspace = true, features = ["multipart"] }
axum-extra = { version = "0.10", features = ["cookie"] }
cookie = "0.18"
tower = { workspace = true }
tower-http = { workspace = true }
@@ -67,6 +69,8 @@ jsonschema = { workspace = true }
# HTTP client
reqwest = { workspace = true }
openidconnect = "4.0"
url = { workspace = true }
# Archive/compression
tar = { workspace = true }
@@ -88,6 +92,7 @@ hex = "0.4"
# OpenAPI/Swagger
utoipa = { workspace = true, features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0", features = ["axum"] }
jsonwebtoken = { workspace = true, features = ["rust_crypto"] }
[dev-dependencies]
mockall = { workspace = true }

View File

@@ -2,7 +2,7 @@
use axum::{
extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode},
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
@@ -14,6 +14,8 @@ use attune_common::auth::jwt::{
extract_token_from_header, validate_token, Claims, JwtConfig, TokenType,
};
use super::oidc::{cookie_authenticated_user, ACCESS_COOKIE_NAME};
/// Authentication middleware state
#[derive(Clone)]
pub struct AuthMiddleware {
@@ -50,21 +52,7 @@ pub async fn require_auth(
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
// Extract Authorization header
let auth_header = request
.headers()
.get(AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or(AuthError::MissingToken)?;
// Extract token from Bearer scheme
let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?;
// Validate token
let claims = validate_token(token, &auth.jwt_config).map_err(|e| match e {
super::jwt::JwtError::Expired => AuthError::ExpiredToken,
_ => AuthError::InvalidToken,
})?;
let claims = extract_claims(request.headers(), &auth.jwt_config)?;
// Add claims to request extensions
request
@@ -90,22 +78,13 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
return Ok(RequireAuth(user.clone()));
}
// Otherwise, extract and validate token directly from header
// Extract Authorization header
let auth_header = parts
.headers
.get(AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.ok_or(AuthError::MissingToken)?;
// Extract token from Bearer scheme
let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?;
// Validate token using jwt_config from app state
let claims = validate_token(token, &state.jwt_config).map_err(|e| match e {
super::jwt::JwtError::Expired => AuthError::ExpiredToken,
_ => AuthError::InvalidToken,
})?;
let claims = if let Some(user) =
cookie_authenticated_user(&parts.headers, state).map_err(map_cookie_auth_error)?
{
user.claims
} else {
extract_claims(&parts.headers, &state.jwt_config)?
};
// Allow access, sensor, and execution-scoped tokens
if claims.token_type != TokenType::Access
@@ -119,6 +98,33 @@ impl axum::extract::FromRequestParts<crate::state::SharedState> for RequireAuth
}
}
fn extract_claims(headers: &HeaderMap, jwt_config: &JwtConfig) -> Result<Claims, AuthError> {
if let Some(auth_header) = headers.get(AUTHORIZATION).and_then(|h| h.to_str().ok()) {
let token = extract_token_from_header(auth_header).ok_or(AuthError::InvalidToken)?;
return validate_token(token, jwt_config).map_err(|e| match e {
super::jwt::JwtError::Expired => AuthError::ExpiredToken,
_ => AuthError::InvalidToken,
});
}
if headers
.get(axum::http::header::COOKIE)
.and_then(|value| value.to_str().ok())
.is_some_and(|cookies| cookies.contains(ACCESS_COOKIE_NAME))
{
return Err(AuthError::InvalidToken);
}
Err(AuthError::MissingToken)
}
fn map_cookie_auth_error(error: crate::middleware::error::ApiError) -> AuthError {
match error {
crate::middleware::error::ApiError::Unauthorized(_) => AuthError::InvalidToken,
_ => AuthError::InvalidToken,
}
}
/// Authentication errors
#[derive(Debug)]
pub enum AuthError {

View File

@@ -2,6 +2,7 @@
pub mod jwt;
pub mod middleware;
pub mod oidc;
pub mod password;
pub use jwt::{generate_token, validate_token, Claims};

767
crates/api/src/auth/oidc.rs Normal file
View File

@@ -0,0 +1,767 @@
//! OpenID Connect helpers for browser login.
use attune_common::{
config::OidcConfig,
repositories::{
identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput},
Create, Update,
},
};
use axum::{
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use cookie::time::Duration as CookieDuration;
use jsonwebtoken::{
decode, decode_header,
jwk::{AlgorithmParameters, JwkSet},
Algorithm, DecodingKey, Validation,
};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims},
reqwest::Client as OidcHttpClient,
AuthorizationCode, ClientId, ClientSecret, CsrfToken, LocalizedClaim, Nonce,
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
TokenResponse as OidcTokenResponse,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
use sha2::{Digest, Sha256};
use url::{form_urlencoded::byte_serialize, Url};
use crate::{
auth::jwt::{generate_access_token, generate_refresh_token, validate_token},
dto::{CurrentUserResponse, TokenResponse},
middleware::error::ApiError,
state::SharedState,
};
pub const ACCESS_COOKIE_NAME: &str = "attune_access_token";
pub const REFRESH_COOKIE_NAME: &str = "attune_refresh_token";
pub const OIDC_ID_TOKEN_COOKIE_NAME: &str = "attune_oidc_id_token";
pub const OIDC_STATE_COOKIE_NAME: &str = "attune_oidc_state";
pub const OIDC_NONCE_COOKIE_NAME: &str = "attune_oidc_nonce";
pub const OIDC_PKCE_COOKIE_NAME: &str = "attune_oidc_pkce_verifier";
pub const OIDC_REDIRECT_COOKIE_NAME: &str = "attune_oidc_redirect_to";
const LOGIN_CALLBACK_PATH: &str = "/login/callback";
#[derive(Debug, Clone, Deserialize)]
pub struct OidcDiscoveryDocument {
#[serde(flatten)]
pub metadata: CoreProviderMetadata,
#[serde(default)]
pub end_session_endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcIdentityClaims {
pub issuer: String,
pub sub: String,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub name: Option<String>,
pub preferred_username: Option<String>,
pub groups: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct VerifiedIdTokenClaims {
iss: String,
sub: String,
#[serde(default)]
nonce: Option<String>,
#[serde(default)]
email: Option<String>,
#[serde(default)]
email_verified: Option<bool>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
preferred_username: Option<String>,
#[serde(default)]
groups: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct OidcAuthenticatedIdentity {
pub current_user: CurrentUserResponse,
pub token_response: TokenResponse,
pub id_token: String,
}
#[derive(Debug, Clone)]
pub struct OidcLoginRedirect {
pub authorization_url: String,
pub cookies: Vec<Cookie<'static>>,
}
#[derive(Debug, Clone)]
pub struct OidcLogoutRedirect {
pub redirect_url: String,
pub cookies: Vec<Cookie<'static>>,
}
#[derive(Debug, Deserialize)]
pub struct OidcCallbackQuery {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
pub error_description: Option<String>,
}
pub async fn build_login_redirect(
state: &SharedState,
redirect_to: Option<&str>,
) -> Result<OidcLoginRedirect, ApiError> {
let oidc = oidc_config(state)?;
let discovery = fetch_discovery_document(&oidc).await?;
let _http_client = OidcHttpClient::builder()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to build OIDC HTTP client: {err}"))
})?;
let redirect_uri = RedirectUrl::new(oidc.redirect_uri.clone()).map_err(|err| {
ApiError::InternalServerError(format!("Invalid OIDC redirect URI: {err}"))
})?;
let client_secret = oidc.client_secret.clone().ok_or_else(|| {
ApiError::InternalServerError("OIDC client secret is missing".to_string())
})?;
let client = CoreClient::from_provider_metadata(
discovery.metadata.clone(),
ClientId::new(oidc.client_id.clone()),
Some(ClientSecret::new(client_secret)),
)
.set_redirect_uri(redirect_uri);
let redirect_target = sanitize_redirect_target(redirect_to);
let pkce = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scopes(
oidc.scopes
.iter()
.filter(|scope| !matches!(scope.as_str(), "openid" | "email" | "profile"))
.cloned()
.map(Scope::new),
)
.set_pkce_challenge(pkce.0)
.url();
Ok(OidcLoginRedirect {
authorization_url: auth_url.to_string(),
cookies: vec![
build_cookie(
state,
OIDC_STATE_COOKIE_NAME,
csrf_state.secret().to_string(),
600,
true,
),
build_cookie(
state,
OIDC_NONCE_COOKIE_NAME,
nonce.secret().to_string(),
600,
true,
),
build_cookie(
state,
OIDC_PKCE_COOKIE_NAME,
pkce.1.secret().to_string(),
600,
true,
),
build_cookie(
state,
OIDC_REDIRECT_COOKIE_NAME,
redirect_target,
600,
false,
),
],
})
}
pub async fn handle_callback(
state: &SharedState,
headers: &HeaderMap,
query: &OidcCallbackQuery,
) -> Result<OidcAuthenticatedIdentity, ApiError> {
if let Some(error) = &query.error {
let description = query
.error_description
.as_deref()
.unwrap_or("OpenID Connect login failed");
return Err(ApiError::Unauthorized(format!("{error}: {description}")));
}
let code = query
.code
.as_ref()
.ok_or_else(|| ApiError::BadRequest("Missing authorization code".to_string()))?;
let returned_state = query
.state
.as_ref()
.ok_or_else(|| ApiError::BadRequest("Missing OIDC state".to_string()))?;
let expected_state = get_cookie_value(headers, OIDC_STATE_COOKIE_NAME)
.ok_or_else(|| ApiError::Unauthorized("Missing OIDC state cookie".to_string()))?;
let expected_nonce = get_cookie_value(headers, OIDC_NONCE_COOKIE_NAME)
.ok_or_else(|| ApiError::Unauthorized("Missing OIDC nonce cookie".to_string()))?;
let pkce_verifier = get_cookie_value(headers, OIDC_PKCE_COOKIE_NAME)
.ok_or_else(|| ApiError::Unauthorized("Missing OIDC PKCE verifier cookie".to_string()))?;
if returned_state != &expected_state {
return Err(ApiError::Unauthorized(
"OIDC state validation failed".to_string(),
));
}
let oidc = oidc_config(state)?;
let discovery = fetch_discovery_document(&oidc).await?;
let http_client = OidcHttpClient::builder()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to build OIDC HTTP client: {err}"))
})?;
let redirect_uri = RedirectUrl::new(oidc.redirect_uri.clone()).map_err(|err| {
ApiError::InternalServerError(format!("Invalid OIDC redirect URI: {err}"))
})?;
let client_secret = oidc.client_secret.clone().ok_or_else(|| {
ApiError::InternalServerError("OIDC client secret is missing".to_string())
})?;
let client = CoreClient::from_provider_metadata(
discovery.metadata.clone(),
ClientId::new(oidc.client_id.clone()),
Some(ClientSecret::new(client_secret)),
)
.set_redirect_uri(redirect_uri);
let token_response = client
.exchange_code(AuthorizationCode::new(code.clone()))
.map_err(|err| {
ApiError::InternalServerError(format!("OIDC token request is misconfigured: {err}"))
})?
.set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier))
.request_async(&http_client)
.await
.map_err(|err| ApiError::Unauthorized(format!("OIDC token exchange failed: {err}")))?;
let id_token = token_response.id_token().ok_or_else(|| {
ApiError::Unauthorized("OIDC provider did not return an ID token".to_string())
})?;
let raw_id_token = id_token.to_string();
let claims = verify_id_token(&raw_id_token, &discovery, &oidc, &expected_nonce).await?;
let mut oidc_claims = OidcIdentityClaims {
issuer: claims.iss,
sub: claims.sub,
email: claims.email,
email_verified: claims.email_verified,
name: claims.name,
preferred_username: claims.preferred_username,
groups: claims.groups,
};
if let Ok(userinfo_request) = client.user_info(token_response.access_token().to_owned(), None) {
if let Ok(userinfo) = userinfo_request.request_async(&http_client).await {
merge_userinfo_claims(&mut oidc_claims, &userinfo);
}
}
let identity = upsert_identity(state, &oidc_claims).await?;
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
let token_response = TokenResponse::new(
access_token,
refresh_token,
state.jwt_config.access_token_expiration,
)
.with_user(
identity.id,
identity.login.clone(),
identity.display_name.clone(),
);
Ok(OidcAuthenticatedIdentity {
current_user: CurrentUserResponse {
id: identity.id,
login: identity.login.clone(),
display_name: identity.display_name.clone(),
},
id_token: raw_id_token,
token_response,
})
}
pub async fn build_logout_redirect(
state: &SharedState,
headers: &HeaderMap,
) -> Result<OidcLogoutRedirect, ApiError> {
let oidc = oidc_config(state)?;
let discovery = fetch_discovery_document(&oidc).await?;
let post_logout_redirect_uri = oidc
.post_logout_redirect_uri
.clone()
.unwrap_or_else(|| "/login".to_string());
let redirect_url = if let Some(end_session_endpoint) = discovery.end_session_endpoint {
let mut url = Url::parse(&end_session_endpoint).map_err(|err| {
ApiError::InternalServerError(format!("Invalid end_session_endpoint: {err}"))
})?;
{
let mut pairs = url.query_pairs_mut();
if let Some(id_token_hint) = get_cookie_value(headers, OIDC_ID_TOKEN_COOKIE_NAME) {
pairs.append_pair("id_token_hint", &id_token_hint);
}
pairs.append_pair("post_logout_redirect_uri", &post_logout_redirect_uri);
pairs.append_pair("client_id", &oidc.client_id);
}
String::from(url)
} else {
post_logout_redirect_uri
};
Ok(OidcLogoutRedirect {
redirect_url,
cookies: clear_auth_cookies(state),
})
}
pub fn clear_auth_cookies(state: &SharedState) -> Vec<Cookie<'static>> {
[
ACCESS_COOKIE_NAME,
REFRESH_COOKIE_NAME,
OIDC_ID_TOKEN_COOKIE_NAME,
OIDC_STATE_COOKIE_NAME,
OIDC_NONCE_COOKIE_NAME,
OIDC_PKCE_COOKIE_NAME,
OIDC_REDIRECT_COOKIE_NAME,
]
.into_iter()
.map(|name| remove_cookie(state, name))
.collect()
}
pub fn build_auth_cookies(
state: &SharedState,
token_response: &TokenResponse,
id_token: &str,
) -> Vec<Cookie<'static>> {
let mut cookies = vec![
build_cookie(
state,
ACCESS_COOKIE_NAME,
token_response.access_token.clone(),
state.jwt_config.access_token_expiration,
true,
),
build_cookie(
state,
REFRESH_COOKIE_NAME,
token_response.refresh_token.clone(),
state.jwt_config.refresh_token_expiration,
true,
),
];
if !id_token.is_empty() {
cookies.push(build_cookie(
state,
OIDC_ID_TOKEN_COOKIE_NAME,
id_token.to_string(),
state.jwt_config.refresh_token_expiration,
true,
));
}
cookies
}
pub fn apply_cookies_to_headers(
headers: &mut HeaderMap,
cookies: &[Cookie<'static>],
) -> Result<(), ApiError> {
for cookie in cookies {
let value = HeaderValue::from_str(&cookie.to_string()).map_err(|err| {
ApiError::InternalServerError(format!("Failed to serialize cookie header: {err}"))
})?;
headers.append(header::SET_COOKIE, value);
}
Ok(())
}
pub fn oidc_callback_redirect_response(
state: &SharedState,
token_response: &TokenResponse,
redirect_to: Option<String>,
id_token: &str,
) -> Result<Response, ApiError> {
let redirect_target = sanitize_redirect_target(redirect_to.as_deref());
let redirect_url = format!(
"{LOGIN_CALLBACK_PATH}#access_token={}&refresh_token={}&expires_in={}&redirect_to={}",
encode_fragment_value(&token_response.access_token),
encode_fragment_value(&token_response.refresh_token),
token_response.expires_in,
encode_fragment_value(&redirect_target),
);
let mut response = Redirect::temporary(&redirect_url).into_response();
let mut cookies = build_auth_cookies(state, token_response, id_token);
cookies.push(remove_cookie(state, OIDC_STATE_COOKIE_NAME));
cookies.push(remove_cookie(state, OIDC_NONCE_COOKIE_NAME));
cookies.push(remove_cookie(state, OIDC_PKCE_COOKIE_NAME));
cookies.push(remove_cookie(state, OIDC_REDIRECT_COOKIE_NAME));
apply_cookies_to_headers(response.headers_mut(), &cookies)?;
Ok(response)
}
pub fn cookie_authenticated_user(
headers: &HeaderMap,
state: &SharedState,
) -> Result<Option<crate::auth::middleware::AuthenticatedUser>, ApiError> {
let Some(token) = get_cookie_value(headers, ACCESS_COOKIE_NAME) else {
return Ok(None);
};
let claims = validate_token(&token, &state.jwt_config).map_err(ApiError::from)?;
Ok(Some(crate::auth::middleware::AuthenticatedUser { claims }))
}
pub fn get_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(';'))
.filter_map(|part| {
let mut pieces = part.trim().splitn(2, '=');
let key = pieces.next()?.trim();
let value = pieces.next()?.trim();
if key == name {
Some(value.to_string())
} else {
None
}
})
.next()
}
fn oidc_config(state: &SharedState) -> Result<OidcConfig, ApiError> {
state
.config
.security
.oidc
.clone()
.filter(|oidc| oidc.enabled)
.ok_or_else(|| {
ApiError::NotImplemented("OIDC authentication is not configured".to_string())
})
}
async fn fetch_discovery_document(oidc: &OidcConfig) -> Result<OidcDiscoveryDocument, ApiError> {
let discovery = reqwest::get(&oidc.discovery_url).await.map_err(|err| {
ApiError::InternalServerError(format!("Failed to fetch OIDC discovery document: {err}"))
})?;
if !discovery.status().is_success() {
return Err(ApiError::InternalServerError(format!(
"OIDC discovery request failed with status {}",
discovery.status()
)));
}
discovery
.json::<OidcDiscoveryDocument>()
.await
.map_err(|err| {
ApiError::InternalServerError(format!("Failed to parse OIDC discovery document: {err}"))
})
}
async fn upsert_identity(
state: &SharedState,
oidc_claims: &OidcIdentityClaims,
) -> Result<attune_common::models::identity::Identity, ApiError> {
let existing_by_subject =
IdentityRepository::find_by_oidc_subject(&state.db, &oidc_claims.issuer, &oidc_claims.sub)
.await?;
let desired_login = derive_login(oidc_claims);
let display_name = derive_display_name(oidc_claims);
let attributes = json!({
"oidc": oidc_claims,
});
match existing_by_subject {
Some(identity) => {
let updated = UpdateIdentityInput {
display_name,
password_hash: None,
attributes: Some(attributes.clone()),
};
IdentityRepository::update(&state.db, identity.id, updated)
.await
.map_err(Into::into)
}
None => {
let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? {
Some(_) => fallback_subject_login(oidc_claims),
None => desired_login,
};
IdentityRepository::create(
&state.db,
CreateIdentityInput {
login,
display_name,
password_hash: None,
attributes,
},
)
.await
.map_err(Into::into)
}
}
}
fn derive_login(oidc_claims: &OidcIdentityClaims) -> String {
oidc_claims
.email
.clone()
.or_else(|| oidc_claims.preferred_username.clone())
.unwrap_or_else(|| fallback_subject_login(oidc_claims))
}
async fn verify_id_token(
raw_id_token: &str,
discovery: &OidcDiscoveryDocument,
oidc: &OidcConfig,
expected_nonce: &str,
) -> Result<VerifiedIdTokenClaims, ApiError> {
let header = decode_header(raw_id_token)
.map_err(|err| ApiError::Unauthorized(format!("OIDC ID token header decode failed: {err}")))?;
let algorithm = match header.alg {
Algorithm::RS256 => Algorithm::RS256,
Algorithm::RS384 => Algorithm::RS384,
Algorithm::RS512 => Algorithm::RS512,
other => {
return Err(ApiError::Unauthorized(format!(
"OIDC ID token uses unsupported signing algorithm: {other:?}"
)))
}
};
let jwks = reqwest::get(discovery.metadata.jwks_uri().url().as_str())
.await
.map_err(|err| ApiError::InternalServerError(format!("Failed to fetch OIDC JWKS: {err}")))?
.json::<JwkSet>()
.await
.map_err(|err| ApiError::InternalServerError(format!("Failed to parse OIDC JWKS: {err}")))?;
let jwk = jwks
.keys
.iter()
.find(|jwk| {
jwk.common.key_id == header.kid
&& matches!(jwk.common.public_key_use, Some(jsonwebtoken::jwk::PublicKeyUse::Signature))
&& matches!(
jwk.algorithm,
AlgorithmParameters::RSA(_) | AlgorithmParameters::EllipticCurve(_)
)
})
.ok_or_else(|| ApiError::Unauthorized("OIDC signing key not found in JWKS".to_string()))?;
let decoding_key = DecodingKey::from_jwk(jwk)
.map_err(|err| ApiError::Unauthorized(format!("OIDC JWK decode failed: {err}")))?;
let issuer = discovery.metadata.issuer().to_string();
let mut validation = Validation::new(algorithm);
validation.set_issuer(&[issuer.as_str()]);
validation.set_audience(&[oidc.client_id.as_str()]);
validation.set_required_spec_claims(&["exp", "iat", "iss", "sub", "aud"]);
validation.validate_nbf = false;
let token = decode::<VerifiedIdTokenClaims>(raw_id_token, &decoding_key, &validation)
.map_err(|err| ApiError::Unauthorized(format!("OIDC ID token validation failed: {err}")))?;
if token.claims.nonce.as_deref() != Some(expected_nonce) {
return Err(ApiError::Unauthorized(
"OIDC nonce validation failed".to_string(),
));
}
Ok(token.claims)
}
fn derive_display_name(oidc_claims: &OidcIdentityClaims) -> Option<String> {
oidc_claims
.name
.clone()
.or_else(|| oidc_claims.preferred_username.clone())
.or_else(|| oidc_claims.email.clone())
}
fn fallback_subject_login(oidc_claims: &OidcIdentityClaims) -> String {
let mut hasher = Sha256::new();
hasher.update(oidc_claims.issuer.as_bytes());
hasher.update(b":");
hasher.update(oidc_claims.sub.as_bytes());
let digest = hex::encode(hasher.finalize());
format!("oidc:{}", &digest[..24])
}
fn extract_groups_from_claims<T>(claims: &T) -> Vec<String>
where
T: Serialize,
{
let Ok(json) = serde_json::to_value(claims) else {
return Vec::new();
};
match json.get("groups") {
Some(JsonValue::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToString::to_string))
.collect(),
Some(JsonValue::String(value)) => vec![value.to_string()],
_ => Vec::new(),
}
}
fn merge_userinfo_claims(oidc_claims: &mut OidcIdentityClaims, userinfo: &CoreUserInfoClaims) {
if oidc_claims.email.is_none() {
oidc_claims.email = userinfo.email().map(|email| email.as_str().to_string());
}
if oidc_claims.name.is_none() {
oidc_claims.name = userinfo.name().and_then(first_localized_claim);
}
if oidc_claims.preferred_username.is_none() {
oidc_claims.preferred_username = userinfo
.preferred_username()
.map(|username| username.as_str().to_string());
}
if oidc_claims.groups.is_empty() {
oidc_claims.groups = extract_groups_from_claims(userinfo.additional_claims());
}
}
fn first_localized_claim<T>(claim: &LocalizedClaim<T>) -> Option<String>
where
T: std::ops::Deref<Target = String>,
{
claim
.iter()
.next()
.map(|(_, value)| value.as_str().to_string())
}
fn build_cookie(
state: &SharedState,
name: &'static str,
value: String,
max_age_seconds: i64,
http_only: bool,
) -> Cookie<'static> {
let mut cookie = Cookie::build((name, value))
.path("/")
.same_site(SameSite::Lax)
.http_only(http_only)
.max_age(CookieDuration::seconds(max_age_seconds))
.build();
if should_use_secure_cookies(state) {
cookie.set_secure(true);
}
cookie
}
fn remove_cookie(state: &SharedState, name: &'static str) -> Cookie<'static> {
let mut cookie = Cookie::build((name, String::new()))
.path("/")
.same_site(SameSite::Lax)
.http_only(true)
.max_age(CookieDuration::seconds(0))
.build();
cookie.make_removal();
if should_use_secure_cookies(state) {
cookie.set_secure(true);
}
cookie
}
fn should_use_secure_cookies(state: &SharedState) -> bool {
state.config.is_production()
|| state
.config
.security
.oidc
.as_ref()
.map(|oidc| oidc.redirect_uri.starts_with("https://"))
.unwrap_or(false)
}
fn sanitize_redirect_target(redirect_to: Option<&str>) -> String {
let fallback = "/".to_string();
let Some(redirect_to) = redirect_to else {
return fallback;
};
if redirect_to.starts_with('/') && !redirect_to.starts_with("//") {
redirect_to.to_string()
} else {
fallback
}
}
pub fn unauthorized_redirect(location: &str) -> Response {
let mut response = Redirect::to(location).into_response();
*response.status_mut() = StatusCode::FOUND;
response
}
fn encode_fragment_value(value: &str) -> String {
byte_serialize(value.as_bytes()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_redirect_target_rejects_external_urls() {
assert_eq!(sanitize_redirect_target(Some("https://example.com")), "/");
assert_eq!(sanitize_redirect_target(Some("//example.com")), "/");
assert_eq!(
sanitize_redirect_target(Some("/executions/42")),
"/executions/42"
);
}
#[test]
fn extract_groups_from_claims_accepts_array_and_string() {
let array_claims = serde_json::json!({ "groups": ["admins", "operators"] });
let string_claims = serde_json::json!({ "groups": "admins" });
assert_eq!(
extract_groups_from_claims(&array_claims),
vec!["admins".to_string(), "operators".to_string()]
);
assert_eq!(
extract_groups_from_claims(&string_claims),
vec!["admins".to_string()]
);
}
}

View File

@@ -136,3 +136,43 @@ pub struct CurrentUserResponse {
#[schema(example = "Administrator")]
pub display_name: Option<String>,
}
/// Public authentication settings for the login page.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AuthSettingsResponse {
/// Whether authentication is enabled for the server.
#[schema(example = true)]
pub authentication_enabled: bool,
/// Whether local username/password login is configured.
#[schema(example = true)]
pub local_password_enabled: bool,
/// Whether local username/password login should be shown by default.
#[schema(example = true)]
pub local_password_visible_by_default: bool,
/// Whether OIDC login is configured and enabled.
#[schema(example = false)]
pub oidc_enabled: bool,
/// Whether OIDC login should be shown by default.
#[schema(example = false)]
pub oidc_visible_by_default: bool,
/// Provider name for `?auth=<provider>`.
#[schema(example = "sso")]
pub oidc_provider_name: Option<String>,
/// User-facing provider label for the login button.
#[schema(example = "Example SSO")]
pub oidc_provider_label: Option<String>,
/// Optional icon URL shown beside the provider label.
#[schema(example = "https://auth.example.com/assets/logo.svg")]
pub oidc_provider_icon_url: Option<String>,
/// Whether unauthenticated self-service registration is allowed.
#[schema(example = false)]
pub self_registration_enabled: bool,
}

View File

@@ -30,8 +30,8 @@ pub use artifact::{
CreateVersionJsonRequest, SetDataRequest, UpdateArtifactRequest,
};
pub use auth::{
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest, RegisterRequest,
TokenResponse,
AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
RefreshTokenRequest, RegisterRequest, TokenResponse,
};
pub use common::{
ApiResponse, PaginatedResponse, PaginationMeta, PaginationParams, SuccessResponse,

View File

@@ -115,8 +115,9 @@ async fn mq_reconnect_loop(state: Arc<AppState>, mq_url: String) {
#[tokio::main]
async fn main() -> Result<()> {
// Install HMAC-only JWT crypto provider (must be before any token operations)
attune_common::auth::install_crypto_provider();
// Install a JWT crypto provider that supports both Attune's HS tokens
// and external RS256 OIDC identity tokens.
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
// Initialize tracing subscriber
tracing_subscriber::fmt()

View File

@@ -10,8 +10,8 @@ use crate::dto::{
ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, UpdateActionRequest,
},
auth::{
ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest,
RegisterRequest, TokenResponse,
AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest,
RefreshTokenRequest, RegisterRequest, TokenResponse,
},
common::{ApiResponse, PaginatedResponse, PaginationMeta, SuccessResponse},
event::{EnforcementResponse, EnforcementSummary, EventResponse, EventSummary},
@@ -68,6 +68,7 @@ use crate::dto::{
crate::routes::health::liveness,
// Authentication
crate::routes::auth::auth_settings,
crate::routes::auth::login,
crate::routes::auth::register,
crate::routes::auth::refresh_token,
@@ -202,6 +203,7 @@ use crate::dto::{
schemas(
// Common types
ApiResponse<TokenResponse>,
ApiResponse<AuthSettingsResponse>,
ApiResponse<CurrentUserResponse>,
ApiResponse<PackResponse>,
ApiResponse<PackInstallResponse>,

View File

@@ -1,7 +1,9 @@
//! Authentication routes
use axum::{
extract::State,
extract::{Query, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
Json, Router,
};
@@ -21,11 +23,16 @@ use crate::{
TokenType,
},
middleware::RequireAuth,
oidc::{
apply_cookies_to_headers, build_login_redirect, build_logout_redirect,
cookie_authenticated_user, get_cookie_value, oidc_callback_redirect_response,
OidcCallbackQuery, REFRESH_COOKIE_NAME,
},
verify_password,
},
dto::{
ApiResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest,
RegisterRequest, SuccessResponse, TokenResponse,
ApiResponse, AuthSettingsResponse, ChangePasswordRequest, CurrentUserResponse,
LoginRequest, RefreshTokenRequest, RegisterRequest, SuccessResponse, TokenResponse,
},
middleware::error::ApiError,
state::SharedState,
@@ -63,7 +70,11 @@ pub struct SensorTokenResponse {
/// Create authentication routes
pub fn routes() -> Router<SharedState> {
Router::new()
.route("/settings", get(auth_settings))
.route("/login", post(login))
.route("/oidc/login", get(oidc_login))
.route("/callback", get(oidc_callback))
.route("/logout", get(logout))
.route("/register", post(register))
.route("/refresh", post(refresh_token))
.route("/me", get(get_current_user))
@@ -72,6 +83,44 @@ pub fn routes() -> Router<SharedState> {
.route("/internal/sensor-token", post(create_sensor_token_internal))
}
/// Authentication settings endpoint
///
/// GET /auth/settings
#[utoipa::path(
get,
path = "/auth/settings",
tag = "auth",
responses(
(status = 200, description = "Authentication settings", body = inline(ApiResponse<AuthSettingsResponse>))
)
)]
pub async fn auth_settings(
State(state): State<SharedState>,
) -> Result<Json<ApiResponse<AuthSettingsResponse>>, ApiError> {
let oidc = state
.config
.security
.oidc
.as_ref()
.filter(|oidc| oidc.enabled);
let response = AuthSettingsResponse {
authentication_enabled: state.config.security.enable_auth,
local_password_enabled: state.config.security.enable_auth,
local_password_visible_by_default: state.config.security.enable_auth
&& state.config.security.login_page.show_local_login,
oidc_enabled: oidc.is_some(),
oidc_visible_by_default: oidc.is_some() && state.config.security.login_page.show_oidc_login,
oidc_provider_name: oidc.map(|oidc| oidc.provider_name.clone()),
oidc_provider_label: oidc
.map(|oidc| oidc.provider_label.clone().unwrap_or_else(|| oidc.provider_name.clone())),
oidc_provider_icon_url: oidc.and_then(|oidc| oidc.provider_icon_url.clone()),
self_registration_enabled: state.config.security.allow_self_registration,
};
Ok(Json(ApiResponse::new(response)))
}
/// Login endpoint
///
/// POST /auth/login
@@ -221,15 +270,22 @@ pub async fn register(
)]
pub async fn refresh_token(
State(state): State<SharedState>,
Json(payload): Json<RefreshTokenRequest>,
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
// Validate request
payload
.validate()
.map_err(|e| ApiError::ValidationError(format!("Invalid refresh token request: {}", e)))?;
headers: HeaderMap,
payload: Option<Json<RefreshTokenRequest>>,
) -> Result<Response, ApiError> {
let browser_cookie_refresh = payload.is_none();
let refresh_token = if let Some(Json(payload)) = payload {
payload.validate().map_err(|e| {
ApiError::ValidationError(format!("Invalid refresh token request: {}", e))
})?;
payload.refresh_token
} else {
get_cookie_value(&headers, REFRESH_COOKIE_NAME)
.ok_or_else(|| ApiError::Unauthorized("Missing refresh token".to_string()))?
};
// Validate refresh token
let claims = validate_token(&payload.refresh_token, &state.jwt_config)
let claims = validate_token(&refresh_token, &state.jwt_config)
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".to_string()))?;
// Ensure it's a refresh token
@@ -257,8 +313,18 @@ pub async fn refresh_token(
refresh_token,
state.jwt_config.access_token_expiration,
);
let response_body = Json(ApiResponse::new(response.clone()));
Ok(Json(ApiResponse::new(response)))
if browser_cookie_refresh {
let mut http_response = response_body.into_response();
apply_cookies_to_headers(
http_response.headers_mut(),
&crate::auth::oidc::build_auth_cookies(&state, &response, ""),
)?;
return Ok(http_response);
}
Ok(response_body.into_response())
}
/// Get current user endpoint
@@ -279,9 +345,15 @@ pub async fn refresh_token(
)]
pub async fn get_current_user(
State(state): State<SharedState>,
RequireAuth(user): RequireAuth,
headers: HeaderMap,
user: Result<RequireAuth, crate::auth::middleware::AuthError>,
) -> Result<Json<ApiResponse<CurrentUserResponse>>, ApiError> {
let identity_id = user.identity_id()?;
let authenticated_user = match user {
Ok(RequireAuth(user)) => user,
Err(_) => cookie_authenticated_user(&headers, &state)?
.ok_or_else(|| ApiError::Unauthorized("Unauthorized".to_string()))?,
};
let identity_id = authenticated_user.identity_id()?;
// Fetch identity from database
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
@@ -297,6 +369,67 @@ pub async fn get_current_user(
Ok(Json(ApiResponse::new(response)))
}
#[derive(Debug, Deserialize)]
pub struct OidcLoginParams {
pub redirect_to: Option<String>,
}
/// Begin browser OIDC login by redirecting to the provider.
pub async fn oidc_login(
State(state): State<SharedState>,
Query(params): Query<OidcLoginParams>,
) -> Result<Response, ApiError> {
let login_redirect = build_login_redirect(&state, params.redirect_to.as_deref()).await?;
let mut response = Redirect::temporary(&login_redirect.authorization_url).into_response();
apply_cookies_to_headers(response.headers_mut(), &login_redirect.cookies)?;
Ok(response)
}
/// Handle the OIDC authorization code callback.
pub async fn oidc_callback(
State(state): State<SharedState>,
headers: HeaderMap,
Query(query): Query<OidcCallbackQuery>,
) -> Result<Response, ApiError> {
let redirect_to = get_cookie_value(&headers, crate::auth::oidc::OIDC_REDIRECT_COOKIE_NAME);
let authenticated = crate::auth::oidc::handle_callback(&state, &headers, &query).await?;
oidc_callback_redirect_response(
&state,
&authenticated.token_response,
redirect_to,
&authenticated.id_token,
)
}
/// Logout the current browser session and optionally redirect through the provider logout flow.
pub async fn logout(
State(state): State<SharedState>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let oidc_enabled = state
.config
.security
.oidc
.as_ref()
.is_some_and(|oidc| oidc.enabled);
let response = if oidc_enabled {
let logout_redirect = build_logout_redirect(&state, &headers).await?;
let mut response = Redirect::temporary(&logout_redirect.redirect_url).into_response();
apply_cookies_to_headers(response.headers_mut(), &logout_redirect.cookies)?;
response
} else {
let mut response = Redirect::temporary("/login").into_response();
apply_cookies_to_headers(
response.headers_mut(),
&crate::auth::oidc::clear_auth_cookies(&state),
)?;
response
};
Ok(response)
}
/// Change password endpoint
///
/// POST /auth/change-password

View File

@@ -1779,7 +1779,6 @@ async fn handle_update(
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
enum PackDescriptionPatch {
Set(String),
Clear,
}
#[derive(Serialize)]

View File

@@ -258,7 +258,6 @@ async fn handle_update(
#[serde(tag = "op", content = "value", rename_all = "snake_case")]
enum TriggerDescriptionPatch {
Set(String),
Clear,
}
#[derive(Serialize)]

View File

@@ -299,6 +299,14 @@ pub struct SecurityConfig {
/// Allow unauthenticated self-service user registration
#[serde(default)]
pub allow_self_registration: bool,
/// Login page visibility defaults for the web UI.
#[serde(default)]
pub login_page: LoginPageConfig,
/// Optional OpenID Connect configuration for browser login.
#[serde(default)]
pub oidc: Option<OidcConfig>,
}
fn default_jwt_access_expiration() -> u64 {
@@ -309,6 +317,68 @@ fn default_jwt_refresh_expiration() -> u64 {
604800 // 7 days
}
/// Web login page configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginPageConfig {
/// Show the local username/password form by default.
#[serde(default = "default_true")]
pub show_local_login: bool,
/// Show the OIDC/SSO option by default when configured.
#[serde(default = "default_true")]
pub show_oidc_login: bool,
}
impl Default for LoginPageConfig {
fn default() -> Self {
Self {
show_local_login: true,
show_oidc_login: true,
}
}
}
/// OpenID Connect configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcConfig {
/// Enable OpenID Connect login flow.
#[serde(default)]
pub enabled: bool,
/// OpenID Provider discovery document URL.
pub discovery_url: String,
/// Confidential client ID.
pub client_id: String,
/// Provider name used in login-page overrides such as `?auth=<provider_name>`.
#[serde(default = "default_oidc_provider_name")]
pub provider_name: String,
/// User-facing provider label shown on the login page.
pub provider_label: Option<String>,
/// Optional icon URL shown beside the provider label on the login page.
pub provider_icon_url: Option<String>,
/// Confidential client secret.
pub client_secret: Option<String>,
/// Redirect URI registered with the provider.
pub redirect_uri: String,
/// Optional post-logout redirect URI.
pub post_logout_redirect_uri: Option<String>,
/// Optional requested scopes in addition to `openid email profile`.
#[serde(default)]
pub scopes: Vec<String>,
}
fn default_oidc_provider_name() -> String {
"oidc".to_string()
}
/// Worker configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerConfig {
@@ -681,6 +751,8 @@ impl Default for SecurityConfig {
encryption_key: None,
enable_auth: true,
allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
}
}
}
@@ -800,6 +872,37 @@ impl Config {
));
}
if let Some(oidc) = &self.security.oidc {
if oidc.enabled {
if oidc.discovery_url.trim().is_empty() {
return Err(crate::Error::validation(
"OIDC discovery URL cannot be empty when OIDC is enabled",
));
}
if oidc.client_id.trim().is_empty() {
return Err(crate::Error::validation(
"OIDC client ID cannot be empty when OIDC is enabled",
));
}
if oidc
.client_secret
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err(crate::Error::validation(
"OIDC client secret is required when OIDC is enabled",
));
}
if oidc.redirect_uri.trim().is_empty() {
return Err(crate::Error::validation(
"OIDC redirect URI cannot be empty when OIDC is enabled",
));
}
}
}
// Validate encryption key if provided
if let Some(ref key) = self.security.encryption_key {
if key.len() < 32 {
@@ -930,6 +1033,8 @@ mod tests {
encryption_key: Some("a".repeat(32)),
enable_auth: true,
allow_self_registration: false,
login_page: LoginPageConfig::default(),
oidc: None,
},
worker: None,
sensor: None,

View File

@@ -159,6 +159,27 @@ impl IdentityRepository {
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE login = $1"
).bind(login).fetch_optional(executor).await.map_err(Into::into)
}
pub async fn find_by_oidc_subject<'e, E>(
executor: E,
issuer: &str,
subject: &str,
) -> Result<Option<Identity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated
FROM identity
WHERE attributes->'oidc'->>'issuer' = $1
AND attributes->'oidc'->>'sub' = $2",
)
.bind(issuer)
.bind(subject)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
// Permission Set Repository