Skip to content

Commit

Permalink
RFC7591 OAuth dynamic client registration + OpenID Connect Dynamic Cl…
Browse files Browse the repository at this point in the history
…ient Registration (closes #136 closes #4)
  • Loading branch information
mdecimus committed Oct 1, 2024
1 parent 6a5f963 commit 200d8d7
Show file tree
Hide file tree
Showing 22 changed files with 619 additions and 108 deletions.
11 changes: 11 additions & 0 deletions crates/common/src/auth/oauth/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ pub struct OAuthConfig {
pub oauth_expiry_refresh_token_renew: u64,
pub oauth_max_auth_attempts: u32,

pub allow_anonymous_client_registration: bool,
pub require_client_authentication: bool,

pub oidc_expiry_id_token: u64,
pub oidc_signing_secret: Secret,
pub oidc_signature_algorithm: SignatureAlgorithm,
Expand Down Expand Up @@ -179,6 +182,12 @@ impl OAuthConfig {
.property_or_default::<Duration>("oauth.oidc.expiry.id-token", "15m")
.unwrap_or_else(|| Duration::from_secs(15 * 60))
.as_secs(),
allow_anonymous_client_registration: config
.property_or_default("oauth.client-registration.anonymous", "false")
.unwrap_or(false),
require_client_authentication: config
.property_or_default("oauth.client-registration.required", "false")
.unwrap_or(true),
oidc_signing_secret,
oidc_signature_algorithm,
oidc_jwks,
Expand All @@ -197,6 +206,8 @@ impl Default for OAuthConfig {
oauth_expiry_refresh_token_renew: Default::default(),
oauth_max_auth_attempts: Default::default(),
oidc_expiry_id_token: Default::default(),
allow_anonymous_client_registration: Default::default(),
require_client_authentication: Default::default(),
oidc_signing_secret: Secret::Bytes("secret".to_string().into_bytes()),
oidc_signature_algorithm: SignatureAlgorithm::HS256,
oidc_jwks: Resource {
Expand Down
1 change: 1 addition & 0 deletions crates/common/src/auth/oauth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod config;
pub mod crypto;
pub mod introspect;
pub mod oidc;
pub mod registration;
pub mod token;

pub const DEVICE_CODE_LEN: usize = 40;
Expand Down
181 changes: 181 additions & 0 deletions crates/common/src/auth/oauth/registration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(rename_all = "snake_case")]
pub struct ClientRegistrationRequest {
pub redirect_uris: Vec<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub response_types: Vec<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub grant_types: Vec<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub application_type: Option<ApplicationType>,

#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub contacts: Vec<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_uri: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub tos_uri: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks: Option<serde_json::Value>, // Using serde_json::Value for flexibility

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub sector_identifier_uri: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_type: Option<SubjectType>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_signed_response_alg: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_encrypted_response_alg: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_encrypted_response_enc: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_signed_response_alg: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_encrypted_response_alg: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub userinfo_encrypted_response_enc: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_signing_alg: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_encryption_alg: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub request_object_encryption_enc: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<TokenEndpointAuthMethod>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_signing_alg: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub default_max_age: Option<u64>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub require_auth_time: Option<bool>,

#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub default_acr_values: Vec<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub initiate_login_uri: Option<String>,

#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub request_uris: Vec<String>,

#[serde(flatten)]
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub additional_fields: HashMap<String, serde_json::Value>,
}

#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(rename_all = "snake_case")]
pub struct ClientRegistrationResponse {
// Required fields
pub client_id: String,

// Optional fields specific to the response
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_access_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_client_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id_issued_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret_expires_at: Option<u64>,

// Echo back the request
#[serde(flatten)]
pub request: ClientRegistrationRequest,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum ApplicationType {
Web,
Native,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum SubjectType {
Pairwise,
Public,
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum TokenEndpointAuthMethod {
ClientSecretPost,
ClientSecretBasic,
ClientSecretJwt,
PrivateKeyJwt,
None,
}
36 changes: 13 additions & 23 deletions crates/directory/src/backend/internal/manage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ use store::{
};
use trc::AddContext;

use crate::{Permission, Principal, QueryBy, Type, ROLE_ADMIN, ROLE_TENANT_ADMIN, ROLE_USER};
use crate::{
Permission, Principal, QueryBy, Type, MAX_TYPE_ID, ROLE_ADMIN, ROLE_TENANT_ADMIN, ROLE_USER,
};

use super::{
lookup::DirectoryStore, PrincipalAction, PrincipalField, PrincipalInfo, PrincipalUpdate,
Expand Down Expand Up @@ -271,16 +273,7 @@ impl ManageDirectory for Store {

principal.set(PrincipalField::Tenant, tenant_id);

if matches!(
principal.typ,
Type::Individual
| Type::Group
| Type::List
| Type::Role
| Type::Location
| Type::Resource
| Type::Other
) {
if !matches!(principal.typ, Type::Tenant | Type::Domain) {
if let Some(domain) = name.split('@').nth(1) {
if self
.get_principal_info(domain)
Expand Down Expand Up @@ -513,6 +506,7 @@ impl ManageDirectory for Store {
Type::Other,
Type::Location,
Type::Domain,
Type::ApiKey,
],
&[PrincipalField::Name],
0,
Expand Down Expand Up @@ -771,7 +765,12 @@ impl ManageDirectory for Store {
Type::Other,
][..],
Type::List => &[Type::Individual, Type::Group][..],
Type::Other | Type::Domain | Type::Tenant | Type::Individual => &[][..],
Type::Other
| Type::Domain
| Type::Tenant
| Type::Individual
| Type::ApiKey
| Type::OauthClient => &[][..],
Type::Role => &[Type::Role][..],
};
let mut valid_domains = AHashSet::new();
Expand All @@ -784,16 +783,7 @@ impl ManageDirectory for Store {
let new_name = new_name.to_lowercase();
if principal.inner.name() != new_name {
if tenant_id.is_some()
&& matches!(
principal.inner.typ,
Type::Individual
| Type::Group
| Type::List
| Type::Role
| Type::Location
| Type::Resource
| Type::Other
)
&& !matches!(principal.inner.typ, Type::Tenant | Type::Domain)
{
if let Some(domain) = new_name.split('@').nth(1) {
if self
Expand Down Expand Up @@ -978,7 +968,7 @@ impl ManageDirectory for Store {
PrincipalField::Quota,
PrincipalValue::IntegerList(quotas),
) if matches!(principal.inner.typ, Type::Tenant)
&& quotas.len() <= (Type::Role as usize + 2) =>
&& quotas.len() <= (MAX_TYPE_ID + 2) =>
{
principal.inner.set(PrincipalField::Quota, quotas);
}
Expand Down
5 changes: 5 additions & 0 deletions crates/directory/src/backend/internal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ pub enum PrincipalField {
EnabledPermissions,
DisabledPermissions,
Picture,
Urls,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
Expand Down Expand Up @@ -486,6 +487,7 @@ impl PrincipalField {
PrincipalField::DisabledPermissions => 12,
PrincipalField::UsedQuota => 13,
PrincipalField::Picture => 14,
PrincipalField::Urls => 15,
}
}

Expand All @@ -506,6 +508,7 @@ impl PrincipalField {
12 => Some(PrincipalField::DisabledPermissions),
13 => Some(PrincipalField::UsedQuota),
14 => Some(PrincipalField::Picture),
15 => Some(PrincipalField::Urls),
_ => None,
}
}
Expand All @@ -527,6 +530,7 @@ impl PrincipalField {
PrincipalField::EnabledPermissions => "enabledPermissions",
PrincipalField::DisabledPermissions => "disabledPermissions",
PrincipalField::Picture => "picture",
PrincipalField::Urls => "urls",
}
}

Expand All @@ -547,6 +551,7 @@ impl PrincipalField {
"enabledPermissions" => Some(PrincipalField::EnabledPermissions),
"disabledPermissions" => Some(PrincipalField::DisabledPermissions),
"picture" => Some(PrincipalField::Picture),
"urls" => Some(PrincipalField::Urls),
_ => None,
}
}
Expand Down
12 changes: 12 additions & 0 deletions crates/directory/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ impl Permission {
Permission::SieveRenameScript => "Rename Sieve scripts",
Permission::SieveCheckScript => "Validate Sieve scripts",
Permission::SieveHaveSpace => "Check available space for Sieve scripts",
Permission::OauthClientRegistration => "Register OAuth clients",
Permission::OauthClientOverride => "Override OAuth client settings",
Permission::ApiKeyList => "View API keys",
Permission::ApiKeyGet => "Retrieve specific API keys",
Permission::ApiKeyCreate => "Create new API keys",
Permission::ApiKeyUpdate => "Modify API keys",
Permission::ApiKeyDelete => "Remove API keys",
Permission::OauthClientList => "View OAuth clients",
Permission::OauthClientGet => "Retrieve specific OAuth clients",
Permission::OauthClientCreate => "Create new OAuth clients",
Permission::OauthClientUpdate => "Modify OAuth clients",
Permission::OauthClientDelete => "Remove OAuth clients",
}
}
}
Expand Down
Loading

0 comments on commit 200d8d7

Please sign in to comment.