Compare commits

...

5 commits

Author SHA1 Message Date
James 4063afd00f
Merge 543d3cb088 into 9bf17c4846 2025-02-17 12:36:11 +00:00
Alex Martens 9bf17c4846
book: add OAuth2 Proxy example () 2025-02-16 05:14:47 +00:00
Firstyear ed88b72080
Exempt idm_admin and admin from denied names. ()
idm_admin and admin should be exempted from the denied names process,
as these values will already be denied due to attribute uniqueness.
Additionally improved the denied names check to only validate the
name during a change, not during a modifification. This way entries
that become denied can get themself out of the pickle.
2025-02-15 22:45:25 +00:00
Firstyear d0b0b163fd
Book fixes () 2025-02-15 16:01:44 +10:00
James Roberts 543d3cb088 Replace lazy_static with LazyLock 2025-02-08 06:16:06 -05:00
7 changed files with 279 additions and 94 deletions
book/src
server/lib/src

View file

@ -215,7 +215,7 @@ Token signing public key
### Create the Kanidm Configuration ### Create the Kanidm Configuration
By default, members of the `system_admins` or `idm_hp_oauth2_manage_priv` groups are able to create By default, members of the `idm_admins` or `idm_oauth2_admins` groups are able to create
or manage OAuth2 client integrations. or manage OAuth2 client integrations.
You can create a new client by specifying its client name, application display name and the landing You can create a new client by specifying its client name, application display name and the landing

View file

@ -556,6 +556,65 @@ php occ config:app:set --value=0 user_oidc allow_multiple_user_backends
You can login directly by appending `?direct=1` to your login page. You can re-enable other backends You can login directly by appending `?direct=1` to your login page. You can re-enable other backends
by setting the value to `1` by setting the value to `1`
## OAuth2 Proxy
OAuth2 Proxy is a reverse proxy that provides authentication with OpenID Connect identity providers.
It is typically used to secure web applications without native OpenID Connect support.
Prepare the environment.
Due to a [lack of public client support](https://github.com/oauth2-proxy/oauth2-proxy/issues/1714) we have to set it up as a basic client.
```bash
kanidm system oauth2 create webapp 'webapp.example.com' 'https://webapp.example.com'
kanidm system add-redirect-url webapp 'https://webapp.example.com/oauth2/callback'
kanidm system oauth2 update-scope-map webapp email openid
kanidm system oauth2 get webapp
kanidm system oauth2 show-basic-secret webapp
<SECRET>
```
Create a user group.
```bash
kanidm group create 'webapp_admin'
```
Setup the claim-map to add `webapp_group` to the userinfo claim.
```bash
kanidm system oauth2 update-claim-map-join 'webapp' 'webapp_group' array
kanidm system oauth2 update-claim-map 'webapp' 'webapp_group' 'webapp_admin' 'webapp_admin'
```
Authorize users for the application.
Additionally OAuth2 Proxy requires all users have an email, reference this issue for more details:
- <https://github.com/oauth2-proxy/oauth2-proxy/issues/2667>
```bash
kanidm person update '<user>' --legalname 'Personal Name' --mail 'user@example.com'
kanidm group add-members 'webapp_admin' '<user>'
```
And add the following to your OAuth2 Proxy config.
```toml
provider = "oidc"
scope = "openid email"
# change to match your kanidm domain and client id
oidc_issuer_url = "https://idm.example.com/oauth2/openid/webapp"
# client ID from `kanidm system oauth2 create`
client_id = "webapp"
# redirect URL from `kanidm system add-redirect-url webapp`
redirect_url = "https://webapp.example.com/oauth2/callback"
# claim name from `kanidm system oauth2 update-claim-map-join`
oidc_groups_claim = "webapp_group"
# user group from `kanidm group create`
allowed_groups = ["webapp_admin"]
# secret from `kanidm system oauth2 show-basic-secret webapp`
client_secret = "<SECRET>"
```
## Outline ## Outline
> These instructions were tested with self-hosted Outline 0.80.2. > These instructions were tested with self-hosted Outline 0.80.2.

View file

@ -22,6 +22,7 @@ This is a list of supported features and standards within Kanidm.
- [RFC4519 LDAP Schema](https://www.rfc-editor.org/rfc/rfc4519) - [RFC4519 LDAP Schema](https://www.rfc-editor.org/rfc/rfc4519)
- FreeIPA User Schema - FreeIPA User Schema
- [RFC7644 SCIM Bulk Data Import](https://www.rfc-editor.org/rfc/rfc7644) - [RFC7644 SCIM Bulk Data Import](https://www.rfc-editor.org/rfc/rfc7644)
- NOTE: SCIM is only supported for synchronisation from another IDP at this time.
# Database # Database

View file

@ -208,6 +208,16 @@ pub static ref SCHEMA_ATTR_DENIED_NAME: SchemaAttribute = SchemaAttribute {
..Default::default() ..Default::default()
}; };
pub static ref SCHEMA_ATTR_DENIED_NAME_DL10: SchemaAttribute = SchemaAttribute {
uuid: UUID_SCHEMA_ATTR_DENIED_NAME,
name: Attribute::DeniedName,
description: "Iname values that are not allowed to be used in 'name'.".to_string(),
syntax: SyntaxType::Utf8StringIname,
multivalue: true,
..Default::default()
};
pub static ref SCHEMA_ATTR_DOMAIN_TOKEN_KEY: SchemaAttribute = SchemaAttribute { pub static ref SCHEMA_ATTR_DOMAIN_TOKEN_KEY: SchemaAttribute = SchemaAttribute {
uuid: UUID_SCHEMA_ATTR_DOMAIN_TOKEN_KEY, uuid: UUID_SCHEMA_ATTR_DOMAIN_TOKEN_KEY,
name: Attribute::DomainTokenKey, name: Attribute::DomainTokenKey,

View file

@ -10,7 +10,7 @@ impl Plugin for ValueDeny {
"plugin_value_deny" "plugin_value_deny"
} }
#[instrument(level = "debug", name = "base_pre_create_transform", skip_all)] #[instrument(level = "debug", name = "denied_names_pre_create_transform", skip_all)]
#[allow(clippy::cognitive_complexity)] #[allow(clippy::cognitive_complexity)]
fn pre_create_transform( fn pre_create_transform(
qs: &mut QueryServerWriteTransaction, qs: &mut QueryServerWriteTransaction,
@ -19,9 +19,25 @@ impl Plugin for ValueDeny {
) -> Result<(), OperationError> { ) -> Result<(), OperationError> {
let denied_names = qs.denied_names(); let denied_names = qs.denied_names();
if denied_names.is_empty() {
// Nothing to check.
return Ok(());
}
let mut pass = true; let mut pass = true;
for entry in cand { for entry in cand {
// If the entry doesn't have a uuid, it's invalid anyway and will fail schema.
if let Some(e_uuid) = entry.get_uuid() {
// SAFETY - Thanks to JpWarren blowing his nipper clean off, we need to
// assert that the break glass and system accounts are NOT subject to
// this process.
if e_uuid < DYNAMIC_RANGE_MINIMUM_UUID {
// These entries are exempt
continue;
}
}
if let Some(name) = entry.get_ava_single_iname(Attribute::Name) { if let Some(name) = entry.get_ava_single_iname(Attribute::Name) {
if denied_names.contains(name) { if denied_names.contains(name) {
pass = false; pass = false;
@ -37,27 +53,24 @@ impl Plugin for ValueDeny {
} }
} }
#[instrument(level = "debug", name = "base_pre_modify", skip_all)]
fn pre_modify( fn pre_modify(
qs: &mut QueryServerWriteTransaction, qs: &mut QueryServerWriteTransaction,
_pre_cand: &[Arc<EntrySealedCommitted>], pre_cand: &[Arc<EntrySealedCommitted>],
cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>, cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>,
_me: &ModifyEvent, _me: &ModifyEvent,
) -> Result<(), OperationError> { ) -> Result<(), OperationError> {
Self::modify(qs, cand) Self::modify(qs, pre_cand, cand)
} }
#[instrument(level = "debug", name = "base_pre_modify", skip_all)]
fn pre_batch_modify( fn pre_batch_modify(
qs: &mut QueryServerWriteTransaction, qs: &mut QueryServerWriteTransaction,
_pre_cand: &[Arc<EntrySealedCommitted>], pre_cand: &[Arc<EntrySealedCommitted>],
cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>, cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>,
_me: &BatchModifyEvent, _me: &BatchModifyEvent,
) -> Result<(), OperationError> { ) -> Result<(), OperationError> {
Self::modify(qs, cand) Self::modify(qs, pre_cand, cand)
} }
#[instrument(level = "debug", name = "base::verify", skip_all)]
fn verify(qs: &mut QueryServerReadTransaction) -> Vec<Result<(), ConsistencyError>> { fn verify(qs: &mut QueryServerReadTransaction) -> Vec<Result<(), ConsistencyError>> {
let denied_names = qs.denied_names().clone(); let denied_names = qs.denied_names().clone();
@ -68,7 +81,15 @@ impl Plugin for ValueDeny {
match qs.internal_search(filt) { match qs.internal_search(filt) {
Ok(entries) => { Ok(entries) => {
for entry in entries { for entry in entries {
results.push(Err(ConsistencyError::DeniedName(entry.get_uuid()))); let e_uuid = entry.get_uuid();
// SAFETY - Thanks to JpWarren blowing his nipper clean off, we need to
// assert that the break glass accounts are NOT subject to this process.
if e_uuid < DYNAMIC_RANGE_MINIMUM_UUID {
// These entries are exempt
continue;
}
results.push(Err(ConsistencyError::DeniedName(e_uuid)));
} }
} }
Err(err) => { Err(err) => {
@ -83,17 +104,37 @@ impl Plugin for ValueDeny {
} }
impl ValueDeny { impl ValueDeny {
#[instrument(level = "debug", name = "denied_names_modify", skip_all)]
fn modify( fn modify(
qs: &mut QueryServerWriteTransaction, qs: &mut QueryServerWriteTransaction,
cand: &mut Vec<Entry<EntryInvalid, EntryCommitted>>, pre_cand: &[Arc<EntrySealedCommitted>],
cand: &mut [EntryInvalidCommitted],
) -> Result<(), OperationError> { ) -> Result<(), OperationError> {
let denied_names = qs.denied_names(); let denied_names = qs.denied_names();
if denied_names.is_empty() {
// Nothing to check.
return Ok(());
}
let mut pass = true; let mut pass = true;
for entry in cand { for (pre_entry, post_entry) in pre_cand.iter().zip(cand.iter()) {
if let Some(name) = entry.get_ava_single_iname(Attribute::Name) { // If the entry doesn't have a uuid, it's invalid anyway and will fail schema.
if denied_names.contains(name) { let e_uuid = pre_entry.get_uuid();
// SAFETY - Thanks to JpWarren blowing his nipper clean off, we need to
// assert that the break glass accounts are NOT subject to this process.
if e_uuid < DYNAMIC_RANGE_MINIMUM_UUID {
// These entries are exempt
continue;
}
let pre_name = pre_entry.get_ava_single_iname(Attribute::Name);
let post_name = post_entry.get_ava_single_iname(Attribute::Name);
if let Some(name) = post_name {
// Only if the name is changing, and is denied.
if pre_name != post_name && denied_names.contains(name) {
pass = false; pass = false;
error!(?name, "name denied by system configuration"); error!(?name, "name denied by system configuration");
} }
@ -117,10 +158,10 @@ mod tests {
let me_inv_m = ModifyEvent::new_internal_invalid( let me_inv_m = ModifyEvent::new_internal_invalid(
filter!(f_eq(Attribute::Uuid, PVUUID_SYSTEM_CONFIG.clone())), filter!(f_eq(Attribute::Uuid, PVUUID_SYSTEM_CONFIG.clone())),
ModifyList::new_list(vec![Modify::Present( ModifyList::new_list(vec![
Attribute::DeniedName, Modify::Present(Attribute::DeniedName, Value::new_iname("tobias")),
Value::new_iname("tobias"), Modify::Present(Attribute::DeniedName, Value::new_iname("ellie")),
)]), ]),
); );
assert!(server_txn.modify(&me_inv_m).is_ok()); assert!(server_txn.modify(&me_inv_m).is_ok());
@ -148,30 +189,103 @@ mod tests {
#[qs_test] #[qs_test]
async fn test_valuedeny_modify(server: &QueryServer) { async fn test_valuedeny_modify(server: &QueryServer) {
setup_name_deny(server).await; // Create an entry that has a name which will become denied to test how it
// interacts.
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap(); let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
let t_uuid = Uuid::new_v4(); let e_uuid = Uuid::new_v4();
assert!(server_txn assert!(server_txn
.internal_create(vec![entry_init!( .internal_create(vec![entry_init!(
(Attribute::Class, EntryClass::Object.to_value()), (Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::Account.to_value()), (Attribute::Class, EntryClass::Account.to_value()),
(Attribute::Class, EntryClass::Person.to_value()), (Attribute::Class, EntryClass::Person.to_value()),
(Attribute::Name, Value::new_iname("newname")), (Attribute::Name, Value::new_iname("ellie")),
(Attribute::Uuid, Value::Uuid(t_uuid)), (Attribute::Uuid, Value::Uuid(e_uuid)),
(Attribute::Description, Value::new_utf8s("Tobias")), (Attribute::Description, Value::new_utf8s("Ellie Meow")),
(Attribute::DisplayName, Value::new_utf8s("Tobias")) (Attribute::DisplayName, Value::new_utf8s("Ellie Meow"))
),]) ),])
.is_ok()); .is_ok());
// Now mod it assert!(server_txn.commit().is_ok());
setup_name_deny(server).await;
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
// Attempt to mod ellie.
// Can mod a different attribute
assert!(server_txn assert!(server_txn
.internal_modify_uuid( .internal_modify_uuid(
t_uuid, e_uuid,
&ModifyList::new_purge_and_set(Attribute::DisplayName, Value::new_utf8s("tobias"))
)
.is_ok());
// Can't mod to another invalid name.
assert!(server_txn
.internal_modify_uuid(
e_uuid,
&ModifyList::new_purge_and_set(Attribute::Name, Value::new_iname("tobias")) &ModifyList::new_purge_and_set(Attribute::Name, Value::new_iname("tobias"))
) )
.is_err()); .is_err());
// Can mod to a valid name.
assert!(server_txn
.internal_modify_uuid(
e_uuid,
&ModifyList::new_purge_and_set(
Attribute::Name,
Value::new_iname("miss_meowington")
)
)
.is_ok());
// Now mod from the valid name to an invalid one.
assert!(server_txn
.internal_modify_uuid(
e_uuid,
&ModifyList::new_purge_and_set(Attribute::Name, Value::new_iname("tobias"))
)
.is_err());
assert!(server_txn.commit().is_ok());
}
#[qs_test]
async fn test_valuedeny_jpwarren_special(server: &QueryServer) {
// Assert that our break glass accounts are exempt from this processing.
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
let me_inv_m = ModifyEvent::new_internal_invalid(
filter!(f_eq(Attribute::Uuid, PVUUID_SYSTEM_CONFIG.clone())),
ModifyList::new_list(vec![
Modify::Present(Attribute::DeniedName, Value::new_iname("admin")),
Modify::Present(Attribute::DeniedName, Value::new_iname("idm_admin")),
]),
);
assert!(server_txn.modify(&me_inv_m).is_ok());
assert!(server_txn.commit().is_ok());
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
assert!(server_txn
.internal_modify_uuid(
UUID_IDM_ADMIN,
&ModifyList::new_purge_and_set(
Attribute::DisplayName,
Value::new_utf8s("Idm Admin")
)
)
.is_ok());
assert!(server_txn
.internal_modify_uuid(
UUID_ADMIN,
&ModifyList::new_purge_and_set(Attribute::DisplayName, Value::new_utf8s("Admin"))
)
.is_ok());
assert!(server_txn.commit().is_ok());
} }
#[qs_test] #[qs_test]

View file

@ -427,7 +427,10 @@ impl QueryServerWriteTransaction<'_> {
// =========== Apply changes ============== // =========== Apply changes ==============
// Now update schema // Now update schema
let idm_schema_changes = [SCHEMA_CLASS_DOMAIN_INFO_DL10.clone().into()]; let idm_schema_changes = [
SCHEMA_ATTR_DENIED_NAME_DL10.clone().into(),
SCHEMA_CLASS_DOMAIN_INFO_DL10.clone().into(),
];
idm_schema_changes idm_schema_changes
.into_iter() .into_iter()

View file

@ -11,6 +11,7 @@ use std::convert::TryFrom;
use std::fmt; use std::fmt;
use std::fmt::Formatter; use std::fmt::Formatter;
use std::str::FromStr; use std::str::FromStr;
use std::sync::LazyLock;
use std::time::Duration; use std::time::Duration;
#[cfg(test)] #[cfg(test)]
@ -47,83 +48,80 @@ use kanidm_proto::scim_v1::ScimOauth2ClaimMapJoinChar;
use kanidm_proto::v1::UatPurposeStatus; use kanidm_proto::v1::UatPurposeStatus;
use std::hash::Hash; use std::hash::Hash;
lazy_static! { pub static SPN_RE: LazyLock<Regex> = LazyLock::new(|| {
pub static ref SPN_RE: Regex = { #[allow(clippy::expect_used)]
#[allow(clippy::expect_used)] Regex::new("(?P<name>[^@]+)@(?P<realm>[^@]+)").expect("Invalid SPN regex found")
Regex::new("(?P<name>[^@]+)@(?P<realm>[^@]+)").expect("Invalid SPN regex found") });
};
pub static ref DISALLOWED_NAMES: HashSet<&'static str> = { pub static DISALLOWED_NAMES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
// Most of these were removed in favour of the unixd daemon filtering out // Most of these were removed in favour of the unixd daemon filtering out
// local users instead. // local users instead.
let mut m = HashSet::with_capacity(2); let mut m = HashSet::with_capacity(2);
m.insert("root"); m.insert("root");
m.insert("dn=token"); m.insert("dn=token");
m m
}; });
/// Only lowercase+numbers, with limited chars. /// Only lowercase+numbers, with limited chars.
pub static ref INAME_RE: Regex = { pub static INAME_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new("^[a-z][a-z0-9-_\\.]{0,63}$").expect("Invalid Iname regex found") Regex::new("^[a-z][a-z0-9-_\\.]{0,63}$").expect("Invalid Iname regex found")
}; });
/// Only alpha-numeric with limited special chars and space /// Only alpha-numeric with limited special chars and space
pub static ref LABEL_RE: Regex = { pub static LABEL_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new("^[a-zA-Z0-9][ a-zA-Z0-9-_\\.@]{0,63}$").expect("Invalid Iname regex found") Regex::new("^[a-zA-Z0-9][ a-zA-Z0-9-_\\.@]{0,63}$").expect("Invalid Iname regex found")
}; });
/// Only lowercase+numbers, with limited chars. /// Only lowercase+numbers, with limited chars.
pub static ref HEXSTR_RE: Regex = { pub static HEXSTR_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new("^[a-f0-9]+$").expect("Invalid hexstring regex found") Regex::new("^[a-f0-9]+$").expect("Invalid hexstring regex found")
}; });
pub static ref EXTRACT_VAL_DN: Regex = { pub static EXTRACT_VAL_DN: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new("^(([^=,]+)=)?(?P<val>[^=,]+)").expect("extract val from dn regex") Regex::new("^(([^=,]+)=)?(?P<val>[^=,]+)").expect("extract val from dn regex")
// Regex::new("^(([^=,]+)=)?(?P<val>[^=,]+)(,.*)?$").expect("Invalid Iname regex found") // Regex::new("^(([^=,]+)=)?(?P<val>[^=,]+)(,.*)?$").expect("Invalid Iname regex found")
}; });
pub static ref NSUNIQUEID_RE: Regex = { pub static NSUNIQUEID_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new("^[0-9a-fA-F]{8}-[0-9a-fA-F]{8}-[0-9a-fA-F]{8}-[0-9a-fA-F]{8}$").expect("Invalid Nsunique regex found") Regex::new("^[0-9a-fA-F]{8}-[0-9a-fA-F]{8}-[0-9a-fA-F]{8}-[0-9a-fA-F]{8}$").expect("Invalid Nsunique regex found")
}; });
/// Must not contain whitespace. /// Must not contain whitespace.
pub static ref OAUTHSCOPE_RE: Regex = { pub static OAUTHSCOPE_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new("^[0-9a-zA-Z_]+$").expect("Invalid oauthscope regex found") Regex::new("^[0-9a-zA-Z_]+$").expect("Invalid oauthscope regex found")
}; });
pub static ref SINGLELINE_RE: Regex = { pub static SINGLELINE_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new("[\n\r\t]").expect("Invalid singleline regex found") Regex::new("[\n\r\t]").expect("Invalid singleline regex found")
}; });
/// Per https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address /// Per https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
/// this regex validates for valid emails. /// this regex validates for valid emails.
pub static ref VALIDATE_EMAIL_RE: Regex = { pub static VALIDATE_EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new(r"^[a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$").expect("Invalid singleline regex found") Regex::new(r"^[a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$").expect("Invalid singleline regex found")
}; });
// Formerly checked with // Formerly checked with
/* /*
pub static ref ESCAPES_RE: Regex = { pub static ref ESCAPES_RE: Regex = {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new(r"\x1b\[([\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])") Regex::new(r"\x1b\[([\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])")
.expect("Invalid escapes regex found") .expect("Invalid escapes regex found")
}; };
*/ */
pub static ref UNICODE_CONTROL_RE: Regex = { pub static UNICODE_CONTROL_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
Regex::new(r"[[:cntrl:]]") Regex::new(r"[[:cntrl:]]").expect("Invalid unicode control regex found")
.expect("Invalid unicode control regex found") });
};
}
#[derive(Debug, Clone, PartialOrd, Ord, Eq, PartialEq, Hash)] #[derive(Debug, Clone, PartialOrd, Ord, Eq, PartialEq, Hash)]
// https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim // https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim