oidc authentication

This commit is contained in:
2025-08-09 00:31:35 +02:00
parent 2c9b44d215
commit 5f4201428a
34 changed files with 1766 additions and 84 deletions

View File

@@ -1,4 +1,7 @@
use crate::domain::warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics};
use crate::domain::{
oidc::ports::OidcMetrics,
warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics},
};
#[derive(Debug, Clone, Copy)]
pub struct MetricsDebugLogger;
@@ -175,17 +178,17 @@ impl AuthMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] Warren creation by admin failed");
}
async fn record_auth_warren_edit_success(&self) -> () {
async fn record_auth_warren_edit_success(&self) {
tracing::debug!("[Metrics] Warren edit by admin succeeded");
}
async fn record_auth_warren_edit_failure(&self) -> () {
async fn record_auth_warren_edit_failure(&self) {
tracing::debug!("[Metrics] Warren edit by admin failed");
}
async fn record_auth_warren_deletion_success(&self) -> () {
async fn record_auth_warren_deletion_success(&self) {
tracing::debug!("[Metrics] Warren deletion by admin succeeded");
}
async fn record_auth_warren_deletion_failure(&self) -> () {
async fn record_auth_warren_deletion_failure(&self) {
tracing::debug!("[Metrics] Warren deletion by admin failed");
}
@@ -203,6 +206,13 @@ impl AuthMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] User login failed");
}
async fn record_user_login_oidc_success(&self) {
tracing::debug!("[Metrics] User login succeeded");
}
async fn record_user_login_oidc_failure(&self) {
tracing::debug!("[Metrics] User login failed");
}
async fn record_user_creation_success(&self) {
tracing::debug!("[Metrics] User creation succeeded");
}
@@ -350,3 +360,19 @@ impl AuthMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] Auth warren cp failed");
}
}
impl OidcMetrics for MetricsDebugLogger {
async fn record_get_redirect_success(&self) {
tracing::debug!("[Metrics] OIDC get redirect succeeded");
}
async fn record_get_redirect_failure(&self) {
tracing::debug!("[Metrics] OIDC get redirect failed");
}
async fn record_get_user_info_success(&self) {
tracing::debug!("[Metrics] OIDC get user info succeeded");
}
async fn record_get_user_info_failure(&self) {
tracing::debug!("[Metrics] OIDC get user info failed");
}
}

View File

@@ -1,4 +1,5 @@
pub mod file_system;
pub mod metrics_debug_logger;
pub mod notifier_debug_logger;
pub mod oidc;
pub mod postgres;

View File

@@ -1,17 +1,25 @@
use uuid::Uuid;
use crate::domain::warren::{
models::{
auth_session::requests::FetchAuthSessionResponse,
file::{AbsoluteFilePath, LsResponse},
user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User},
user_warren::UserWarren,
warren::{
Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse,
WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse,
},
use crate::domain::{
oidc::{
ports::OidcNotifier,
requests::{GetRedirectResponse, GetUserInfoResponse},
},
warren::{
models::{
auth_session::requests::FetchAuthSessionResponse,
file::{AbsoluteFilePath, LsResponse},
user::{
ListAllUsersAndWarrensResponse, LoginUserOidcResponse, LoginUserResponse, User,
},
user_warren::UserWarren,
warren::{
Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse,
WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse,
},
},
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
},
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
};
#[derive(Debug, Clone, Copy)]
@@ -220,6 +228,13 @@ impl AuthNotifier for NotifierDebugLogger {
tracing::debug!("[Notifier] Logged in user {}", response.user().name());
}
async fn user_logged_in_oidc(&self, response: &LoginUserOidcResponse) {
tracing::debug!(
"[Notifier] Logged in user {} with OIDC",
response.user().name()
);
}
async fn auth_session_created(&self, user_id: &Uuid) {
tracing::debug!("[Notifier] Created auth session for user {}", user_id);
}
@@ -354,3 +369,19 @@ impl AuthNotifier for NotifierDebugLogger {
)
}
}
impl OidcNotifier for NotifierDebugLogger {
async fn get_redirect(&self, response: &GetRedirectResponse) {
tracing::debug!("[Notifier] Got OIDC redirect: {}", response.url());
}
async fn get_user_info(&self, response: &GetUserInfoResponse) {
tracing::debug!(
"[Notifier] Got OIDC user info: {} ({})",
response
.info()
.preferred_username()
.unwrap_or(response.info().name()),
response.info().sub(),
);
}
}

View File

@@ -0,0 +1,187 @@
use std::{str::FromStr as _, sync::Arc};
use anyhow::Context as _;
use openid::{Client, CompactJson, CustomClaims, Discovered, Options, StandardClaims};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
config::Config,
domain::{
oidc::{
models::UserInfo,
ports::OidcRepository,
requests::{
GetRedirectError, GetRedirectRequest, GetRedirectResponse, GetUserInfoError,
GetUserInfoRequest, GetUserInfoResponse,
},
},
warren::models::user::{UserEmail, UserName},
},
};
type OidcClient = Client<Discovered, Claims>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OidcConfig {
issuer_url: String,
client_id: String,
client_secret: String,
redirect_url: String,
origin_url: String,
}
impl OidcConfig {
pub fn from_env() -> anyhow::Result<Self> {
let issuer_url = Config::load_env("OIDC_ISSUER_URL")?;
let client_id = Config::load_env("OIDC_CLIENT_ID")?;
let client_secret = Config::load_env("OIDC_CLIENT_SECRET")?;
let origin = Config::load_env("OIDC_ORIGIN_URL")?;
let redirect_url = Config::load_env("OIDC_REDIRECT_URL")?;
Ok(Self::new(
issuer_url,
client_id,
client_secret,
redirect_url,
origin,
))
}
pub fn new(
issuer_url: String,
client_id: String,
client_secret: String,
redirect_url: String,
origin_url: String,
) -> Self {
Self {
issuer_url,
client_id,
client_secret,
redirect_url,
origin_url,
}
}
}
#[derive(Debug, Clone)]
pub struct Oidc {
client: OidcClient,
auth_options: Arc<openid::Options>,
}
impl Oidc {
pub async fn new(config: &OidcConfig) -> anyhow::Result<Self> {
let client = OidcClient::discover(
config.client_id.clone(),
config.client_secret.clone(),
config.redirect_url.clone(),
Url::from_str(&config.issuer_url)?,
)
.await?;
let auth_options = Options {
scope: Some("openid profile email".into()),
state: Some(config.origin_url.clone()),
..Default::default()
};
Ok(Self {
client,
auth_options: Arc::new(auth_options),
})
}
}
#[derive(Debug, Deserialize, Serialize)]
struct Claims {
warren_admin: Option<bool>,
#[serde(flatten)]
standard_claims: StandardClaims,
}
impl CustomClaims for Claims {
fn standard_claims(&self) -> &StandardClaims {
&self.standard_claims
}
}
impl CompactJson for Claims {}
impl OidcRepository for Oidc {
async fn get_redirect(
&self,
_request: GetRedirectRequest,
) -> Result<GetRedirectResponse, GetRedirectError> {
let url = self.client.auth_url(&self.auth_options);
Ok(GetRedirectResponse::new(url.into()))
}
async fn get_user_info(
&self,
request: GetUserInfoRequest,
) -> Result<GetUserInfoResponse, GetUserInfoError> {
let mut token: openid::Token<Claims> = self
.client
.request_token(request.code())
.await
.context("Failed to request token")?
.into();
let id_token = token
.id_token
.as_mut()
.context("Token didn't have id_token field")?;
self.client
.decode_token(id_token)
.context("Failed to decode token")?;
id_token
.payload_mut()
.context("Failed to get id_token payload")?
.standard_claims
.azp = Some(self.client.client_id.clone());
self.client
.validate_token(id_token, None, None)
.context("Failed to validate token")?;
let user_info = try_get_user_info(
id_token
.payload()
.context("Failed to get id token payload")?,
)?;
Ok(GetUserInfoResponse::new(user_info))
}
}
fn try_get_user_info(claims: &Claims) -> anyhow::Result<UserInfo> {
let sub = claims.standard_claims.sub.clone();
let raw = &claims.standard_claims.userinfo;
let name = UserName::new(raw.name.as_ref().context("Missing name")?)?;
let email = UserEmail::new(raw.email.as_ref().context("Missing email")?)?;
let preferred_username = if let Some(preferred_username) = raw.preferred_username.as_ref() {
Some(UserName::new(preferred_username)?)
} else {
None
};
let user_info = UserInfo::new(
sub,
name,
email,
preferred_username,
raw.picture.as_ref().map(|url| url.clone().into()),
raw.locale.clone(),
raw.updated_at,
claims.warren_admin,
);
Ok(user_info)
}

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use anyhow::{Context as _, anyhow};
use anyhow::{Context as _, anyhow, bail};
use argon2::{
Argon2, PasswordHash, PasswordVerifier as _,
password_hash::{
@@ -23,8 +23,9 @@ use crate::domain::warren::{
},
},
user::{
CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError,
EditUserRequest, ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest,
CreateOrUpdateUserOidcError, CreateOrUpdateUserOidcRequest, CreateUserError,
CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, EditUserRequest,
ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest,
ListAllUsersAndWarrensResponse, ListUsersError, ListUsersRequest, User, UserEmail,
UserName, UserPassword, VerifyUserPasswordError, VerifyUserPasswordRequest,
},
@@ -66,6 +67,30 @@ impl AuthRepository for Postgres {
Ok(user)
}
async fn create_or_update_user_oidc(
&self,
request: CreateOrUpdateUserOidcRequest,
) -> Result<User, CreateOrUpdateUserOidcError> {
let mut connection = self
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
let user = self
.create_or_update_user(
&mut connection,
request.sub(),
request.name(),
request.email(),
request.admin(),
)
.await
.context(format!("Failed to create or update user"))?;
Ok(user)
}
async fn edit_user(&self, request: EditUserRequest) -> Result<User, EditUserError> {
let mut connection = self
.pool
@@ -127,7 +152,11 @@ impl AuthRepository for Postgres {
}
})?;
self.check_user_password_against_hash(request.password(), user.password_hash())?;
self.check_user_password_against_hash(
request.password(),
user.password_hash()
.ok_or(VerifyUserPasswordError::PasswordNotAllowed)?,
)?;
Ok(user)
}
@@ -423,6 +452,121 @@ impl Postgres {
Ok(user)
}
async fn create_or_update_user(
&self,
connection: &mut PgConnection,
sub: &String,
name: &UserName,
email: &UserEmail,
is_admin: bool,
) -> anyhow::Result<User> {
let mut tx = connection.begin().await?;
let existing_user: Option<User> = sqlx::query_as(
"
SELECT
*
FROM
users
WHERE
oidc_sub = $1 OR
email = $2
",
)
.bind(sub)
.bind(email)
.fetch_optional(&mut *tx)
.await?;
let user: User = match existing_user {
Some(existing_user) => {
if let Some(existing_oidc_sub) = existing_user.oidc_sub() {
if existing_oidc_sub != sub {
// TODO: Return a proper error
bail!("The user is already linked to another OIDC subject");
}
sqlx::query_as(
"
UPDATE
users
SET
name = $3,
email = $4,
admin = $5
WHERE
id = $1 AND
oidc_sub = $2
RETURNING
*
",
)
.bind(existing_user.id())
.bind(sub)
.bind(name)
.bind(email)
.bind(is_admin)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_as(
"
UPDATE
users
SET
oidc_sub = $2,
name = $3,
email = $4,
admin = $5
WHERE
id = $1 AND
oidc_sub IS NULL
RETURNING
*
",
)
.bind(existing_user.id())
.bind(sub)
.bind(name)
.bind(email)
.bind(is_admin)
.fetch_one(&mut *tx)
.await?
}
}
None => {
sqlx::query_as(
"
INSERT INTO users (
oidc_sub,
name,
email,
admin
)
VALUES (
$1,
$2,
$3,
$4
)
RETURNING
*
",
)
.bind(sub)
.bind(name)
.bind(email)
.bind(is_admin)
.fetch_one(&mut *tx)
.await?
}
};
tx.commit().await?;
Ok(user)
}
async fn edit_user(
&self,
connection: &mut PgConnection,