Add initial OIDC support #33

This commit is contained in:
Lennart
2025-04-13 15:31:58 +02:00
parent f132f9ccc8
commit cf3d9bb16b
10 changed files with 672 additions and 14 deletions

View File

@@ -7,6 +7,7 @@ repository.workspace = true
publish = false
[dependencies]
openidconnect.workspace = true
askama.workspace = true
askama_web.workspace = true
actix-session = { workspace = true }
@@ -19,3 +20,4 @@ rust-embed.workspace = true
futures-core.workspace = true
hex.workspace = true
mime_guess.workspace = true
reqwest.workspace = true

View File

@@ -9,4 +9,9 @@
<input type="password" name="password" placeholder="password">
<button type="submit">Login</button>
</form>
{% if let Some(OidcProviderData {name, redirect_url}) = oidc_data %}
<a href="{{ redirect_url }}">Login with {{ name }}</a>
{% endif %}
{% endblock %}

View File

@@ -1,9 +1,20 @@
use openidconnect::{ClientId, ClientSecret, IssuerUrl, Scope};
use serde::{Deserialize, Serialize};
fn default_enabled() -> bool {
true
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct OidcConfig {
pub name: String,
pub issuer: IssuerUrl,
pub client_id: ClientId,
pub client_secret: Option<ClientSecret>,
pub scopes: Vec<Scope>,
pub allow_sign_up: bool,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct FrontendConfig {
@@ -12,4 +23,6 @@ pub struct FrontendConfig {
pub secret_key: [u8; 64],
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub oidc: Option<OidcConfig>,
}

View File

@@ -12,6 +12,7 @@ use actix_web::{
use askama::Template;
use askama_web::WebTemplate;
use assets::{Assets, EmbedService};
use oidc::{route_get_oidc, route_get_oidc_callback};
use routes::{
addressbook::{route_addressbook, route_addressbook_restore},
calendar::{route_calendar, route_calendar_restore},
@@ -25,6 +26,7 @@ use std::sync::Arc;
mod assets;
mod config;
mod oidc;
mod routes;
pub use config::FrontendConfig;
@@ -130,6 +132,7 @@ pub fn configure_frontend<AP: AuthenticationProvider, CS: CalendarStore, AS: Add
.app_data(Data::from(auth_provider))
.app_data(Data::from(cal_store.clone()))
.app_data(Data::from(addr_store.clone()))
.app_data(Data::new(frontend_config.clone()))
.service(EmbedService::<Assets>::new("/assets".to_owned()))
.service(web::resource("").route(web::method(Method::GET).to(route_root)))
.service(
@@ -158,6 +161,16 @@ pub fn configure_frontend<AP: AuthenticationProvider, CS: CalendarStore, AS: Add
.name("frontend_login")
.route(web::method(Method::GET).to(route_get_login))
.route(web::method(Method::POST).to(route_post_login::<AP>)),
)
.service(
web::resource("/login/oidc")
.name("frontend_login_oidc")
.route(web::method(Method::GET).to(route_get_oidc)),
)
.service(
web::resource("/login/oidc/callback")
.name("frontend_oidc_callback")
.route(web::method(Method::GET).to(route_get_oidc_callback::<AP>)),
),
);
}

View File

@@ -0,0 +1,242 @@
use crate::{FrontendConfig, config::OidcConfig};
use actix_session::{Session, SessionInsertError};
use actix_web::{
HttpRequest, HttpResponse, Responder, ResponseError,
body::BoxBody,
error::UrlGenerationError,
http::StatusCode,
web::{Data, Query, Redirect},
};
use openidconnect::{
AuthenticationFlow, AuthorizationCode, ClaimsVerificationError, ConfigurationError, CsrfToken,
EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce,
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse,
UserInfoClaims,
core::{CoreClient, CoreGenderClaim, CoreProviderMetadata, CoreResponseType},
url::ParseError,
};
use rustical_store::auth::{AuthenticationProvider, User, user::PrincipalType::Individual};
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum OidcError {
#[error("Cannot generate redirect url, something's not configured correctly")]
OidcParseError(#[from] ParseError),
#[error("Cannot generate redirect url, something's not configured correctly")]
ActixUrlGenerationError(#[from] UrlGenerationError),
#[error("RustiCal is not configured correctly for OIDC")]
IncorrectConfiguration,
#[error(transparent)]
OidcConfigurationError(#[from] ConfigurationError),
#[error(transparent)]
OidcClaimsVerificationError(#[from] ClaimsVerificationError),
#[error(transparent)]
SessionInsertError(#[from] SessionInsertError),
#[error(transparent)]
StoreError(#[from] rustical_store::Error),
#[error("{0}")]
Other(&'static str),
}
impl ResponseError for OidcError {
fn status_code(&self) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}
fn error_response(&self) -> HttpResponse<BoxBody> {
HttpResponse::build(self.status_code()).body(self.to_string())
}
}
pub(crate) struct OidcProviderData<'a> {
pub name: &'a str,
pub redirect_url: String,
}
const SESSION_KEY_OIDC_STATE: &str = "oidc_state";
#[derive(Debug, Deserialize, Serialize)]
struct OidcState {
state: CsrfToken,
nonce: Nonce,
pkce_verifier: PkceCodeVerifier,
}
fn get_http_client() -> reqwest::Client {
reqwest::ClientBuilder::new()
// Following redirects opens the client up to SSRF vulnerabilities.
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Something went wrong :(")
}
async fn get_oidc_client(
OidcConfig {
issuer,
client_id,
client_secret,
..
}: OidcConfig,
http_client: &reqwest::Client,
redirect_uri: RedirectUrl,
) -> Result<
CoreClient<
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>,
OidcError,
> {
let provider_metadata = CoreProviderMetadata::discover_async(issuer, http_client)
.await
.map_err(|_| OidcError::Other("Failed to discover OpenID provider"))?;
Ok(CoreClient::from_provider_metadata(
provider_metadata.clone(),
client_id.clone(),
client_secret.clone(),
)
.set_redirect_uri(redirect_uri))
}
/// Endpoint that redirects to the authorize endpoint of the OIDC service
pub async fn route_get_oidc(
req: HttpRequest,
config: Data<FrontendConfig>,
session: Session,
) -> Result<impl Responder, OidcError> {
let oidc_config = config
.oidc
.clone()
.ok_or(OidcError::IncorrectConfiguration)?;
let http_client = get_http_client();
let oidc_client = get_oidc_client(
oidc_config.clone(),
&http_client,
RedirectUrl::new(req.url_for_static("frontend_oidc_callback")?.to_string())?,
)
.await?;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token, nonce) = oidc_client
.authorize_url(
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scopes(oidc_config.scopes.clone())
.set_pkce_challenge(pkce_challenge)
.url();
session.insert(
SESSION_KEY_OIDC_STATE,
OidcState {
state: csrf_token,
nonce,
pkce_verifier,
},
)?;
Ok(Redirect::to(auth_url.to_string()).see_other())
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthCallbackQuery {
code: AuthorizationCode,
iss: IssuerUrl,
// scope: String,
// state: String,
}
pub async fn route_get_oidc_callback<AP: AuthenticationProvider>(
req: HttpRequest,
config: Data<FrontendConfig>,
session: Session,
auth_provider: Data<AP>,
Query(AuthCallbackQuery { code, iss }): Query<AuthCallbackQuery>,
) -> Result<impl Responder, OidcError> {
let oidc_config = config
.oidc
.clone()
.ok_or(OidcError::IncorrectConfiguration)?;
assert_eq!(iss, oidc_config.issuer);
let oidc_state = session
.remove_as::<OidcState>(SESSION_KEY_OIDC_STATE)
.ok_or(OidcError::Other("No local OIDC state"))?
.map_err(|_| OidcError::Other("Error parsing OIDC state"))?;
let http_client = get_http_client();
let oidc_client = get_oidc_client(
oidc_config.clone(),
&http_client,
RedirectUrl::new(req.url_for_static("frontend_oidc_callback")?.to_string())?,
)
.await?;
let token_response = oidc_client
.exchange_code(code)?
.set_pkce_verifier(oidc_state.pkce_verifier)
.request_async(&http_client)
.await
.map_err(|_| OidcError::Other("Error requesting token"))?;
let id_claims = token_response
.id_token()
.ok_or(OidcError::Other("OIDC provider did not return an ID token"))?
.claims(&oidc_client.id_token_verifier(), &oidc_state.nonce)?;
let user_info_claims: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = oidc_client
.user_info(
token_response.access_token().clone(),
Some(id_claims.subject().clone()),
)?
.request_async(&http_client)
.await
.map_err(|_| OidcError::Other("Error fetching user info"))?;
let user_id = user_info_claims
.preferred_username()
.ok_or(OidcError::Other("Missing preferred_username claim"))?
.to_string();
let mut user = auth_provider.get_principal(&user_id).await?;
if user.is_none() {
let new_user = User {
id: user_id,
displayname: None,
app_tokens: vec![],
password: None,
principal_type: Individual,
memberships: vec![],
};
auth_provider.insert_principal(new_user.clone()).await?;
user = Some(new_user);
}
// Complete login flow
if let Some(user) = user {
session.insert("user", user.id.clone())?;
Ok(
Redirect::to(req.url_for("frontend_user", &[user.id])?.to_string())
.temporary()
.respond_to(&req)
.map_into_boxed_body(),
)
} else {
// Add user provisioning
Ok(HttpResponse::build(StatusCode::UNAUTHORIZED).body("User does not exist"))
}
}

View File

@@ -1,3 +1,4 @@
use crate::{FrontendConfig, oidc::OidcProviderData};
use actix_session::Session;
use actix_web::{
HttpRequest, HttpResponse, Responder,
@@ -11,10 +12,21 @@ use serde::Deserialize;
#[derive(Template, WebTemplate)]
#[template(path = "pages/login.html")]
struct LoginPage;
struct LoginPage<'a> {
oidc_data: Option<OidcProviderData<'a>>,
}
pub async fn route_get_login() -> impl Responder {
LoginPage
pub async fn route_get_login(req: HttpRequest, config: Data<FrontendConfig>) -> impl Responder {
LoginPage {
oidc_data: config.oidc.as_ref().map(|oidc| OidcProviderData {
name: &oidc.name,
redirect_url: req
.url_for_static("frontend_login_oidc")
.unwrap()
.to_string(),
}),
}
.respond_to(&req)
}
#[derive(Deserialize)]