Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
server_url = "https://inv.kurosaki.cx"

[sso.gitlab]
issuer = "https://gitlab.computer.surgery"
scopes = ["openid", "email"]
code_challenge = false

[sso.gitlab.endpoints]
type = "discoverable"

[sso.gitlab.credentials]
type = "post"
client_id = "xxx"
client_secret = "xxx"

[sso.gitlab.claims]
9 changes: 9 additions & 0 deletions crates/authifier/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,12 @@ rust-argon2 = "1.0.0"
# Email
lettre = "0.10.0-alpha.4"
handlebars = "4.3.0"

# SSO
sha2 = "0.10.8"
base64 = "0.22.1"
serde_urlencoded = "0.7.1"
jsonwebtoken = "9.3.0"
form_urlencoded = "1.2.1"
oauth2-types = "0.11.0"
mime = "0.3.17"
18 changes: 18 additions & 0 deletions crates/authifier/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,56 @@ mod email_verification;
mod ip_resolve;
mod passwords;
mod shield;
mod sso;

pub use blocklists::*;
pub use captcha::*;
pub use email_verification::*;
pub use ip_resolve::*;
pub use passwords::*;
use reqwest::Url;
pub use shield::*;
pub use sso::*;

/// Authifier configuration
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Config {
/// Check if passwords are compromised
#[serde(default)]
pub password_scanning: PasswordScanning,

/// Email block list
///
/// Use to block common disposable mail providers.
/// Enabled by default.
#[serde(default)]
pub email_block_list: EmailBlockList,

/// Captcha options
#[serde(default)]
pub captcha: Captcha,

/// Authifier Shield settings
#[serde(default)]
pub shield: Shield,

/// Email verification
#[serde(default)]
pub email_verification: EmailVerificationConfig,

/// Whether to only allow registrations if the user has an invite code
#[serde(default)]
pub invite_only: bool,

/// Whether this application is running behind Cloudflare
#[serde(default)]
pub resolve_ip: ResolveIp,

/// Single sign-on
#[serde(default)]
pub sso: SSO,

/// Public server URL
#[serde(default)]
pub server_url: Option<Url>,
}
274 changes: 274 additions & 0 deletions crates/authifier/src/config/sso.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
use std::{
borrow::Borrow,
collections::{HashMap, HashSet},
hash::{Hash, Hasher},
ops::Deref,
str::FromStr,
};

use reqwest::Url;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase", tag = "type")]
pub enum Endpoints {
Discoverable,
Manual {
authorization: Box<Url>,
token: Box<Url>,
userinfo: Box<Url>,
},
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase", tag = "type")]
pub enum Credentials {
None {
client_id: String,
},
Basic {
client_id: String,
client_secret: String,
},
Post {
client_id: String,
client_secret: String,
},
}

impl Credentials {
pub fn client_id(&self) -> &str {
match self {
Credentials::None { client_id }
| Credentials::Basic { client_id, .. }
| Credentials::Post { client_id, .. } => client_id,
}
}
}

#[derive(Debug, Serialize, Clone, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Claim {
Id,
Username,
Picture,
Email,
Custom(String),
}

#[derive(Clone, Debug)]
pub struct IdProvider {
pub id: String,

pub issuer: Url,
pub name: Option<String>,
pub icon: Option<Url>,

pub scopes: Vec<String>,
pub endpoints: Endpoints,
pub credentials: Credentials,
pub claims: HashMap<Claim, String>,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows flexibility for claims, claims are a hashmap of information about the user, returned in the token or userinfo response i.e. ("preferred_email" -> "[email protected]", etc.) and this will allow cases where we wanna point out that "preferred_email" corresponds to the Email claim in case of unconventional keys.


pub code_challenge: bool,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

impl Borrow<str> for IdProvider {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following impls are defined in order to prevent ID providers with duplicate IDs in the configuration.

fn borrow(&self) -> &str {
&self.id
}
}

impl PartialEq for IdProvider {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}

impl Eq for IdProvider {}

impl Hash for IdProvider {
fn hash<H>(&self, state: &mut H)
where
H: Hasher,
{
self.id.hash(state);
}
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SSO(HashSet<IdProvider>);
Copy link
Member

@Zomatree Zomatree Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason this isnt a HashMap? would remove the need for a custom Serialize, Deserialize and Hash impl

Suggested change
pub struct SSO(HashSet<IdProvider>);
pub struct SSO(pub HashSet<IdProvider>);


impl Serialize for SSO {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
#[derive(Serialize)]
struct Mock {
pub issuer: Url,
pub name: Option<String>,
pub icon: Option<Url>,
pub scopes: Vec<String>,
pub endpoints: Endpoints,
pub credentials: Credentials,
pub claims: HashMap<Claim, String>,
pub code_challenge: bool,
}

let map: HashMap<String, Mock> = self
.iter()
.map(|provider| {
(
provider.id.clone(),
Mock {
issuer: provider.issuer.clone(),
name: provider.name.clone(),
icon: provider.icon.clone(),
scopes: provider.scopes.clone(),
endpoints: provider.endpoints.clone(),
credentials: provider.credentials.clone(),
claims: provider.claims.clone(),
code_challenge: provider.code_challenge,
},
)
})
.collect();

map.serialize(serializer)
}
}

impl<'de> Deserialize<'de> for SSO {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
pub struct Mock {
pub issuer: Url,
pub name: Option<String>,
pub icon: Option<Url>,

pub scopes: Vec<String>,
pub endpoints: Endpoints,
pub credentials: Credentials,
pub claims: HashMap<Claim, String>,

pub code_challenge: bool,
}

let map: HashMap<String, Mock> =
HashMap::deserialize(deserializer).map_err(de::Error::custom)?;

Ok(SSO(map
.into_iter()
.map(|(id, mock)| IdProvider {
id,
issuer: mock.issuer,
name: mock.name,
icon: mock.icon,
scopes: mock.scopes,
endpoints: mock.endpoints,
credentials: mock.credentials,
claims: mock.claims,
code_challenge: mock.code_challenge,
})
.collect()))
}
}

impl Deref for SSO {
type Target = HashSet<IdProvider>;

fn deref(&self) -> &Self::Target {
&self.0
}
}

impl<'de> Deserialize<'de> for Claim {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Deserialize::deserialize(deserializer)
.and_then(|s| str::parse(s).map_err(de::Error::custom))
}
}

impl FromStr for Claim {
type Err = std::convert::Infallible;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s.to_lowercase().as_str() {
"sub" | "id" => Claim::Id,
"username" | "preferred_username" => Claim::Username,
"picture" => Claim::Picture,
"email" => Claim::Email,
other => Claim::Custom(other.to_string()),
})
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn deserialize_sso_config() {
let value = serde_json::json!(
{
"Gitlab": {
"issuer": "https://gitlab.com",
"scopes": ["openid"],

"endpoints": {
"type": "discoverable"
},
"credentials": {
"type": "post",
"client_id": "foobar",
"client_secret": "baz"
},
"claims": {
"id": "sub",
"email": "preferred_email"
},

"code_challenge": false,
}
}
);

let result: SSO = serde_json::from_value(value).expect("config deserializes successfully");

assert_eq!(
result,
SSO([IdProvider {
id: "Gitlab".to_owned(),

issuer: "https://gitlab.com"
.parse()
.expect("issuer should be valid"),
name: None,
icon: None,

scopes: vec!["openid".to_owned()],
endpoints: Endpoints::Discoverable,
credentials: Credentials::Post {
client_id: "foobar".to_owned(),
client_secret: "baz".to_owned(),
},
claims: [
(Claim::Id, "sub".to_owned()),
(Claim::Email, "preferred_email".to_owned())
]
.into_iter()
.collect(),

code_challenge: false,
}]
.into_iter()
.collect())
);
}
}
Loading
Loading