diff --git a/CHANGELOG.md b/CHANGELOG.md
index a843c7a2..337461e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,15 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).
-## 6.0.1 - TBD
+## 6.1.0 - TBD
+Added support for [RFC 9944](https://datatracker.ietf.org/doc/html/rfc9944), an update to the SCIM
+standard describing device management with SCIM. For an overview on leveraging device management
+with the SCIM SDK, see the `DeviceResource` class-level Javadoc. This update includes:
+* A new `com.unboundid.scim2.common.types.devices` package that contains model classes for resources
+ such as `EndpointAppResource`, as well as extension objects such as `BleDeviceExtension`.
+* Updated `AttributeDefinition` with new fields such as `pattern`, as well as the addition of
+ `MANUFACTURER` and `ENTERPRISE` uniqueness constants.
+
Fixed an issue with deserializing a GenericScimResource object when it was embedded within a list
response.
@@ -37,6 +45,9 @@ all other SCIM objects in the library.
Updated `DateTimeUtils.parse()` to support date strings without timestamps (e.g., "1970-01-01").
The time values will always be set to the start of the day.
+Updated the `@Attribute` annotation of all `$ref` fields to label them as not required for
+consistency across groups, devices, and other model classes.
+
## 6.0.0 - 2026-May-11
The UnboundID SCIM SDK has been updated to use version 3 of the Jackson library (this release ships
with v3.1.3). This change aligns the SCIM SDK with HTTP libraries such as Spring Framework 7/Spring
@@ -598,4 +609,3 @@ Make sure the Response is always closed after a SCIM operation.
## v1.2.9 - 2016-07-08
Initial Public Release
-
diff --git a/README.md b/README.md
index 7dcab50e..8c2af81f 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ The UnboundID SCIM 2 SDK provides many strong benefits for applications that nee
with SCIM 2.0 clients or servers:
* Full support for the SCIM 2.0 protocol as defined by the latest specification. This includes
- support for PATCH operations, filter processing, bulk operations, and cursor-based pagination.
+ support for bulk operations, cursor-based pagination (RFC 9865), and device management (RFC 9944).
* A simple and intuitive Jackson-based API that facilitates SCIM workflows, minimizing the amount
of code you need to write for tasks such as performing CRUD operations on resources.
* `@NotNull` and `@Nullable` annotations are documented for all library input parameters, member
diff --git a/pom.xml b/pom.xml
index f3e3fdc4..d2a8f860 100644
--- a/pom.xml
+++ b/pom.xml
@@ -33,7 +33,7 @@
4.0.0com.unboundid.product.scim2scim2-parent
- 6.0.1-SNAPSHOT
+ 6.1.0-SNAPSHOTpomUnboundID SCIM2 SDK Parent
diff --git a/scim2-assembly/pom.xml b/scim2-assembly/pom.xml
index 8b6fb9ca..4f748348 100644
--- a/scim2-assembly/pom.xml
+++ b/scim2-assembly/pom.xml
@@ -34,7 +34,7 @@
com.unboundid.product.scim2scim2-parent
- 6.0.1-SNAPSHOT
+ 6.1.0-SNAPSHOT../pom.xmlscim2-assembly
diff --git a/scim2-sdk-client/pom.xml b/scim2-sdk-client/pom.xml
index 635dcaf5..ae7d9c22 100644
--- a/scim2-sdk-client/pom.xml
+++ b/scim2-sdk-client/pom.xml
@@ -34,7 +34,7 @@
com.unboundid.product.scim2scim2-parent
- 6.0.1-SNAPSHOT
+ 6.1.0-SNAPSHOT../pom.xmlscim2-sdk-client
diff --git a/scim2-sdk-common/pom.xml b/scim2-sdk-common/pom.xml
index 3970218e..03679406 100644
--- a/scim2-sdk-common/pom.xml
+++ b/scim2-sdk-common/pom.xml
@@ -34,7 +34,7 @@
com.unboundid.product.scim2scim2-parent
- 6.0.1-SNAPSHOT
+ 6.1.0-SNAPSHOT../pom.xmlscim2-sdk-common
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/BaseScimResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/BaseScimResource.java
index 5205f3a4..0e4a2e5d 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/BaseScimResource.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/BaseScimResource.java
@@ -49,6 +49,7 @@
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
@@ -57,8 +58,6 @@
import java.util.Objects;
import java.util.Set;
-import static com.unboundid.scim2.common.utils.StaticUtils.toList;
-
/**
*
The base SCIM object. This object contains all of the
* attributes required of SCIM objects.
@@ -223,15 +222,6 @@ public void setSchemaUrns(@NotNull final Collection schemaUrns)
this.schemaUrns = new LinkedHashSet<>(schemaUrns);
}
- /**
- * {@inheritDoc}
- */
- public void setSchemaUrns(@NotNull final String schemaUrn,
- @Nullable final String... schemaUrns)
- {
- setSchemaUrns(toList(schemaUrn, schemaUrns));
- }
-
/**
* This method is used by Jackson when deserializing JSON data into a Java
* object. This will be called for schema extensions and any unknown fields
@@ -388,6 +378,34 @@ public T getExtension(@NotNull final Class clazz)
return (ext == null) ? null : JsonUtils.nodeToValue(ext, clazz);
}
+ /**
+ * This utility method extracts the requested classes from the extension
+ * object node, and returns each existing reference as a POJO.
+ *
+ * @param The data type of the elements in the return list.
+ * @param clazzList The list of classes to try obtaining from the ObjectNode.
+ * All classes should have the {@link Schema} annotation.
+ *
+ * @return The requested list of Java objects stored on the extension node.
+ */
+ @NotNull
+ protected List getClassesFromExtension(
+ @NotNull final List> clazzList)
+ {
+ List extensions = new ArrayList<>();
+ for (var clazz : clazzList)
+ {
+ // Calling get() on the extension object node is a map lookup.
+ T extension = getExtension(clazz);
+ if (extension != null)
+ {
+ extensions.add(extension);
+ }
+ }
+
+ return extensions;
+ }
+
/**
* Sets a SCIM extension to the given value based on the annotations
* of the class provided. The value will be set for an extension named
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java
index 6be225d2..02ac45e6 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/GenericScimResource.java
@@ -294,15 +294,6 @@ public void setSchemaUrns(@NotNull final Collection schemaUrns)
}
}
- /**
- * {@inheritDoc}
- */
- public void setSchemaUrns(@NotNull final String schemaUrn,
- @Nullable final String... schemaUrns)
- {
- setSchemaUrns(toList(schemaUrn, schemaUrns));
- }
-
/**
* {@inheritDoc}
*/
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/ScimResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/ScimResource.java
index ef6c02c9..3b5fd516 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/ScimResource.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/ScimResource.java
@@ -35,6 +35,7 @@
import com.unboundid.scim2.common.annotations.NotNull;
import com.unboundid.scim2.common.annotations.Nullable;
import com.unboundid.scim2.common.types.Meta;
+import com.unboundid.scim2.common.utils.StaticUtils;
import java.util.Collection;
@@ -140,7 +141,11 @@ public interface ScimResource
* @param schemaUrns An optional parameter for additional schema URNs. Any
* {@code null} values will be ignored.
*/
- void setSchemaUrns(@NotNull String schemaUrn, @Nullable String... schemaUrns);
+ default void setSchemaUrns(@NotNull String schemaUrn,
+ @Nullable String... schemaUrns)
+ {
+ setSchemaUrns(StaticUtils.toList(schemaUrn, schemaUrns));
+ }
/**
* Returns the GenericScimResource representation of this ScimResource. If
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/annotations/Attribute.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/annotations/Attribute.java
index 8b72cde9..98d42cad 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/annotations/Attribute.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/annotations/Attribute.java
@@ -124,4 +124,12 @@ AttributeDefinition.Mutability mutability()
* @return For a multi-valued attribute, the type of the child object.
*/
@NotNull Class> multiValueClass() default NullType.class;
+
+ /**
+ * An optional regular expression that string-typed values must match.
+ *
+ * @return The regular expression, or an empty string for no constraint.
+ * @since 6.1.0
+ */
+ @NotNull String pattern() default "";
}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/AttributeDefinition.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/AttributeDefinition.java
index 4836bde0..dd6f66a2 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/AttributeDefinition.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/AttributeDefinition.java
@@ -40,6 +40,8 @@
import com.unboundid.scim2.common.annotations.Nullable;
import com.unboundid.scim2.common.exceptions.BadRequestException;
import com.unboundid.scim2.common.messages.SearchRequest;
+import com.unboundid.scim2.common.types.devices.DeviceResource;
+import com.unboundid.scim2.common.types.devices.EndpointAppDeviceExtension;
import com.unboundid.scim2.common.utils.JsonUtils;
import java.util.Arrays;
@@ -161,7 +163,7 @@ public enum Type
DECIMAL("decimal"),
/**
- * Integer datatype.
+ * A numeric integer value. This can be more than 32 bits.
*/
INTEGER("integer"),
@@ -432,7 +434,31 @@ public enum Uniqueness
/**
* Indicates that this attribute's value must be globally unique.
*/
- GLOBAL("global");
+ GLOBAL("global"),
+
+ /**
+ * Indicates that this attribute's value is enforced as unique by a device
+ * manufacturer. This is used in the context of a {@link DeviceResource}.
+ *
+ * @since 6.1.0
+ */
+ MANUFACTURER("manufacturer"),
+
+ /**
+ * Indicates that this attribute's value is enforced as unique by a separate
+ * system. This is generally an identity gateway or provisioning service
+ * that is linked to a SCIM service. For example, an identity platform may
+ * wish to manage their users in a SCIM cloud service, but store passwords
+ * and other sensitive data in separate on-premise infrastructure. This
+ * data's uniqueness can be managed outside the SCIM service.
+ *
+ *
+ * This value is generally used with an {@link EndpointAppDeviceExtension}.
+ *
+ * @since 6.1.0
+ */
+ ENTERPRISE("enterprise"),
+ ;
@NotNull
private final String name;
@@ -502,6 +528,22 @@ public static Uniqueness fromName(@Nullable final String name)
uniqueness = AttributeDefinition.Uniqueness.NONE)
private final Type type;
+ /**
+ * An optional regular expression that all values of this attribute must
+ * match. This is only used for string-typed attributes. For example, for an
+ * expression of {@code ^[1-9]}, the value of the string attribute must begin
+ * with a digit 1 through 9.
+ */
+ @Nullable
+ @Attribute(description =
+ "An optional regex that describes the form of the attribute value.",
+ isRequired = false,
+ isCaseExact = true,
+ mutability = AttributeDefinition.Mutability.READ_ONLY,
+ returned = AttributeDefinition.Returned.DEFAULT,
+ uniqueness = AttributeDefinition.Uniqueness.NONE)
+ private final String pattern;
+
@Nullable
@Attribute(description = "When an attribute is of type \"complex\", " +
"\"subAttributes\" defines set of sub-attributes.",
@@ -686,6 +728,12 @@ public static class Builder
@Nullable
private Collection referenceTypes;
+ /**
+ * An optional regular expression constraint for string-typed attributes.
+ */
+ @Nullable
+ private String pattern;
+
/**
* Create a new builder.
*/
@@ -879,6 +927,21 @@ public Builder addReferenceTypes(@Nullable final String... referenceTypes)
return this;
}
+ /**
+ * Sets the regular expression constraint for string-typed attributes.
+ *
+ * @param pattern The regular expression, or {@code null} for no constraint.
+ * @return This builder instance.
+ *
+ * @since 6.1.0
+ */
+ @NotNull
+ public Builder setPattern(@Nullable final String pattern)
+ {
+ this.pattern = (pattern == null || pattern.isEmpty()) ? null : pattern;
+ return this;
+ }
+
/**
* Clears all values in this builder back to the default.
*
@@ -900,6 +963,7 @@ public Builder clear()
description = null;
canonicalValues = null;
referenceTypes = null;
+ this.pattern = null;
return this;
}
@@ -924,7 +988,8 @@ public AttributeDefinition build()
mutability,
returned,
uniqueness,
- referenceTypes);
+ referenceTypes,
+ pattern);
}
}
@@ -947,6 +1012,7 @@ public AttributeDefinition build()
* @param uniqueness This field represents the uniqueness constraints of this
* attribute. {@link Uniqueness#NONE} is the default value.
* @param refTypes The reference type of this attribute.
+ * @param pattern An optional regex pattern that string values must match.
*/
@JsonCreator
AttributeDefinition(
@@ -973,8 +1039,23 @@ public AttributeDefinition build()
@Nullable @JsonProperty(value = "uniqueness")
final Uniqueness uniqueness,
@Nullable @JsonProperty(value = "referenceTypes")
- final Collection refTypes)
+ final Collection refTypes,
+ @Nullable @JsonProperty(value = "pattern")
+ final String pattern)
{
+ // Default values as described by RFC 7643 Section 2.2.
+ this.type = type == null ? Type.STRING : type;
+ this.mutability = mutability == null ? Mutability.READ_WRITE : mutability;
+ this.returned = returned == null ? Returned.DEFAULT : returned;
+ this.uniqueness = uniqueness == null ? Uniqueness.NONE : uniqueness;
+
+ if (pattern != null && this.type != Type.STRING)
+ {
+ throw new IllegalStateException(
+ "Cannot set the 'pattern' of an attribute for non-string types.");
+ }
+ this.pattern = pattern;
+
this.name = name;
this.subAttributes = subAttrs == null ? null : List.copyOf(subAttrs);
this.multiValued = multiValued;
@@ -983,12 +1064,6 @@ public AttributeDefinition build()
this.canonicalValues = canonicals == null ? null : List.copyOf(canonicals);
this.caseExact = caseExact;
this.referenceTypes = refTypes == null ? null : List.copyOf(refTypes);
-
- // Default values as described by RFC 7643 Section 2.2.
- this.type = type == null ? Type.STRING : type;
- this.mutability = mutability == null ? Mutability.READ_WRITE : mutability;
- this.returned = returned == null ? Returned.DEFAULT : returned;
- this.uniqueness = uniqueness == null ? Uniqueness.NONE : uniqueness;
}
/**
@@ -1136,6 +1211,18 @@ public Collection getReferenceTypes()
return referenceTypes;
}
+ /**
+ * Fetches the regular expression constraint for this attribute. If defined,
+ * string-typed values must match this pattern.
+ *
+ * @return The regular expression, or {@code null} if no constraint is set.
+ */
+ @Nullable
+ public String getPattern()
+ {
+ return pattern;
+ }
+
/**
* Retrieves a string representation of this attribute definition.
*
@@ -1177,7 +1264,8 @@ public boolean equals(@Nullable final Object o)
&& Objects.equals(returned, that.returned)
&& Objects.equals(subAttributes, that.subAttributes)
&& Objects.equals(type, that.type)
- && Objects.equals(uniqueness, that.uniqueness);
+ && Objects.equals(uniqueness, that.uniqueness)
+ && Objects.equals(pattern, that.pattern);
}
/**
@@ -1190,6 +1278,6 @@ public int hashCode()
{
return Objects.hash(caseExact, multiValued, required, canonicalValues,
description, mutability, name, referenceTypes, returned, subAttributes,
- type, uniqueness);
+ type, uniqueness, pattern);
}
}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Group.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Group.java
index 845ad157..184d522b 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Group.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Group.java
@@ -36,6 +36,7 @@
import com.unboundid.scim2.common.annotations.Attribute;
import com.unboundid.scim2.common.annotations.NotNull;
import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.utils.JsonUtils;
import java.net.URI;
import java.util.Objects;
@@ -126,7 +127,7 @@ public String getValue()
* Specifies the identifier of the User's group.
*
* @param value The identifier of the User's group.
- * @return This object.
+ * @return This group membership identifier.
*/
@NotNull
public Group setValue(@Nullable final String value)
@@ -152,9 +153,8 @@ public URI getRef()
* Specifies the URI of the corresponding Group resource to which the user
* belongs.
*
- * @param ref The URI of the corresponding Group resource to which the user
- * belongs.
- * @return This object.
+ * @param ref The URI of the {@link GroupResource} being referenced.
+ * @return This group membership identifier.
*/
@NotNull
public Group setRef(@Nullable final URI ref)
@@ -163,6 +163,22 @@ public Group setRef(@Nullable final URI ref)
return this;
}
+ /**
+ * Alternate version of {@link #setRef(URI)} that accepts a string.
+ *
+ * @param ref The URI of the {@link GroupResource} being referenced.
+ * @return This group membership identifier.
+ * @throws IllegalArgumentException If the value was not a valid URI.
+ *
+ * @since 6.1.0
+ */
+ @NotNull
+ public Group setRef(@Nullable final String ref)
+ throws IllegalArgumentException
+ {
+ return setRef((ref == null) ? null : URI.create(ref));
+ }
+
/**
* Specifies the display name, primarily used for display purposes.
*
@@ -178,7 +194,7 @@ public String getDisplay()
* Specifies the display name, primarily used for display purposes.
*
* @param display The display name.
- * @return This object.
+ * @return This group membership identifier.
*/
@NotNull
public Group setDisplay(@Nullable final String display)
@@ -202,7 +218,7 @@ public String getType()
* Specifies the label indicating the attribute's function.
*
* @param type The label indicating the attribute's function.
- * @return This object.
+ * @return This group membership identifier.
*/
@NotNull
public Group setType(@Nullable final String type)
@@ -244,4 +260,17 @@ public int hashCode()
{
return Objects.hash(value, ref, display, type);
}
+
+ /**
+ * Retrieves a string representation of this group membership identifier.
+ *
+ * @return A string representation of this group membership identifier.
+ */
+ @Override
+ @NotNull
+ public String toString()
+ {
+ return JsonUtils.getObjectWriter().withDefaultPrettyPrinter()
+ .writeValueAsString(this);
+ }
}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java
index bdc2c19e..0afe5080 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java
@@ -36,6 +36,7 @@
import com.unboundid.scim2.common.annotations.NotNull;
import com.unboundid.scim2.common.annotations.Nullable;
import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.types.devices.DeviceResource;
import java.util.List;
import java.util.Objects;
@@ -48,8 +49,8 @@
* RFC 7643 section 4.2. A group resource, or "group", is a collection of
* other resources, which helps organize user accounts. Groups often contain
* {@link UserResource} objects, but can contain other resource types, including
- * other group resources. Groups contained within other groups are referred to
- * as "nested" groups.
+ * {@link DeviceResource} or even other group resources. Groups contained within
+ * other groups are referred to as "nested" groups.
*
*
* Groups help enable role-based access control, such as allowing a group of
@@ -76,7 +77,7 @@
* "displayName": "Example Group With One Member",
* "members": [{
* "value": "cab1e",
- * "type": "DIRECT"
+ * "type": "User"
* }]
* }
*
@@ -85,7 +86,7 @@
*
* GroupResource group = new GroupResource()
* .setDisplayName("Example Group With One Member")
- * .setMembers(new Member().setValue("cab1e").setType("DIRECT"));
+ * .setMembers(new Member().setValue("cab1e").setType("User"));
* group.setId("8e4d749e-6dde-420a-8d71-00faf8d57510");
*
*/
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Manager.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Manager.java
index c456367f..dee605c2 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Manager.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Manager.java
@@ -59,7 +59,7 @@ public class Manager
@Nullable
@Attribute(description = "The URI of the SCIM resource representing " +
"the User's manager.",
- isRequired = true,
+ isRequired = false,
isCaseExact = false,
mutability = AttributeDefinition.Mutability.READ_WRITE,
returned = AttributeDefinition.Returned.DEFAULT,
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Member.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Member.java
index ea8d88ec..24db2aeb 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Member.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/Member.java
@@ -56,7 +56,7 @@ public class Member
@Nullable
@Attribute(description = "A label indicating the type of resource, e.g.,"
+ " 'User' or 'Group'",
- canonicalValues = { "User", "Group" },
+ canonicalValues = { "User", "Group", "Device", "EndpointApp" },
isCaseExact = false,
mutability = AttributeDefinition.Mutability.IMMUTABLE,
returned = AttributeDefinition.Returned.DEFAULT,
@@ -67,7 +67,7 @@ public class Member
@Nullable
@Attribute(description = "The URI of the member resource.",
isRequired = false,
- referenceTypes = { "User", "Group" },
+ referenceTypes = { "User", "Group", "Device", "EndpointApp" },
mutability = AttributeDefinition.Mutability.IMMUTABLE,
returned = AttributeDefinition.Returned.DEFAULT,
uniqueness = AttributeDefinition.Uniqueness.NONE)
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java
new file mode 100644
index 00000000..8b92e657
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BleDeviceExtension.java
@@ -0,0 +1,542 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.types.AttributeDefinition;
+import com.unboundid.scim2.common.utils.JsonUtils;
+import com.unboundid.scim2.common.utils.SchemaUtils;
+import com.unboundid.scim2.common.utils.StaticUtils;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.TreeSet;
+
+
+/**
+ * This class represents the Bluetooth Low Energy (BLE) device extension defined
+ * by
+ * RFC 9944 Section 7.1. For more background on devices, see the
+ * {@link DeviceResource} class.
+ *
+ *
+ * The following fields are defined on this extension:
+ *
+ *
{@code deviceMacAddress}: A string value that represents a public MAC
+ * address assigned by the manufacturer.
+ *
{@code irk}: The Identity Resolving Key unique to each
+ * device and used to resolve a random address
+ * received from a peer device.
+ *
{@code isRandom}: Indicates whether the device's MAC address
+ * is public (false) or random (true). A random
+ * address is either a private address (if an
+ * IRK is present) or a static random address.
+ *
{@code mobility}: Indicates BLE device mobility. If set to
+ * {@code true}, the device can be expected to
+ * move within a network of Access Points.
+ *
{@code versionSupport}: A set of strings that specifies the BLE
+ * versions supported by the device.
+ *
{@code pairingMethods}: A set of strings that specifies pairing
+ * methods associated with the BLE device.
+ *
{@code separateBroadcastAddress}: Represents an address used for
+ * broadcasts. This value MUST NOT be set when an IRK is provided.
+ *
+ *
+ *
+ * The following JSON represents a device with a BLE extension:
+ *
+ *
+ * Bluetooth Low Energy SCIM data is noteworthy in that it contains a nested
+ * extension schema value. These nested values represent the type of "pairing
+ * method" that is compatible with the device. For example, some Bluetooth
+ * devices display a numeric code during the connection process. Pairing methods
+ * are defined as subclasses of {@link BlePairingMethod}, and include:
+ *
+ *
{@link BlePairingJustWorks}
+ *
{@link BlePairingNull}
+ *
{@link BlePairingOutOfBand}
+ *
{@link BlePairingPassKey}
+ *
+ *
+ * When these classes are passed to {@link #setPairingExtension}, the value of
+ * the {@code pairingMethods} property will automatically be updated, so that
+ * array value does not need to be handled manually.
+ */
+@Schema(id = "urn:ietf:params:scim:schemas:extension:ble:2.0:Device",
+ name = "BLE Extension",
+ description = "BLE extension for a Device resource")
+@JsonPropertyOrder({"versionSupport", "deviceMacAddress"})
+public class BleDeviceExtension extends DeviceExtension
+{
+ @NotNull
+ @Attribute(description = "The BLE versions supported by this device.",
+ isRequired = true,
+ isCaseExact = false,
+ multiValueClass = String.class)
+ private final List versionSupport;
+
+ @NotNull
+ @Attribute(description = "A string value that represents a public MAC"
+ + " address assigned by the manufacturer.",
+ isRequired = true,
+ isCaseExact = false,
+ uniqueness = AttributeDefinition.Uniqueness.MANUFACTURER,
+ pattern = MAC_PATTERN)
+ private final String deviceMacAddress;
+
+ @Attribute(description =
+ "Indicates whether the device MAC address is a random address.",
+ isRequired = false)
+ private boolean isRandom = false;
+
+ @Nullable
+ @Attribute(description = """
+ The Identity Resolving Key (IRK) for this BLE device. This is a \
+ cryptographic secret that is never returned in responses.""",
+ isRequired = false,
+ isCaseExact = false,
+ mutability = AttributeDefinition.Mutability.WRITE_ONLY,
+ returned = AttributeDefinition.Returned.NEVER,
+ uniqueness = AttributeDefinition.Uniqueness.MANUFACTURER)
+ private String irk;
+
+ @Nullable
+ @Attribute(description = "Indicates whether a device supports BLE mobility.",
+ isRequired = false)
+ private Boolean mobility;
+
+ @NotNull
+ @Attribute(description =
+ "One or more separate broadcast addresses used by this BLE device.",
+ isRequired = false,
+ isCaseExact = false,
+ multiValueClass = String.class,
+ pattern = MAC_PATTERN)
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private List separateBroadcastAddress = List.of();
+
+ @NotNull
+ @Attribute(description =
+ "BLE pairing methods supported by this device, expressed as schema URNs.",
+ isRequired = true,
+ isCaseExact = true,
+ multiValueClass = String.class)
+ private final TreeSet pairingMethods = new TreeSet<>();
+
+ /**
+ * Creates a new Bluetooth Low Energy device extension.
+ *
+ * @param deviceMacAddress The device's MAC address.
+ * @param versionSupport The BLE versions supported by this device.
+ */
+ @JsonCreator
+ public BleDeviceExtension(
+ @NotNull @JsonProperty(value = "deviceMacAddress", required = true)
+ final String deviceMacAddress,
+ @NotNull @JsonProperty(value = "versionSupport", required = true)
+ final List versionSupport)
+ {
+ this.deviceMacAddress = Objects.requireNonNull(deviceMacAddress);
+ this.versionSupport = List.copyOf(versionSupport);
+ }
+
+ /**
+ * Creates a new Bluetooth Low Energy device extension.
+ *
+ * @param deviceMacAddress The device's MAC address.
+ * @param versionSupport A non-null BLE version supported by this device.
+ * @param versions An optional argument for additional versions.
+ */
+ public BleDeviceExtension(@NotNull final String deviceMacAddress,
+ @NotNull final String versionSupport,
+ @Nullable final String... versions)
+ {
+ this(deviceMacAddress, StaticUtils.toList(versionSupport, versions));
+ }
+
+ /**
+ * Fetches the Bluetooth device MAC address.
+ *
+ * @return The MAC address.
+ */
+ @NotNull
+ public String getDeviceMacAddress()
+ {
+ return deviceMacAddress;
+ }
+
+ /**
+ * Fetches whether the device MAC address is a random address.
+ *
+ * @return {@code true} if random, or {@code false} if not.
+ */
+ public boolean getIsRandom()
+ {
+ return isRandom;
+ }
+
+ /**
+ * Specifies whether the device MAC address is a random address.
+ *
+ * @param isRandom The boolean value indicating if the MAC address is random.
+ * @return This Bluetooth Low Energy extension.
+ */
+ @NotNull
+ public BleDeviceExtension setIsRandom(final boolean isRandom)
+ {
+ this.isRandom = isRandom;
+ return this;
+ }
+
+ /**
+ * Fetches the list of separate broadcast addresses for this BLE device.
+ *
+ * @return The list of separate broadcast addresses.
+ */
+ @NotNull
+ public List getSeparateBroadcastAddress()
+ {
+ return separateBroadcastAddress;
+ }
+
+ /**
+ * Specifies the list of separate broadcast addresses for this BLE device.
+ *
+ * @param separateBroadcastAddress The list of separate broadcast addresses.
+ * @return This Bluetooth Low Energy extension.
+ * @throws IllegalStateException If the IRK is also set.
+ */
+ @NotNull
+ public BleDeviceExtension setSeparateBroadcastAddress(
+ @Nullable final List separateBroadcastAddress)
+ throws IllegalStateException
+ {
+ validate(this.irk, separateBroadcastAddress);
+ this.separateBroadcastAddress = (separateBroadcastAddress == null)
+ ? List.of() : List.copyOf(separateBroadcastAddress);
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setSeparateBroadcastAddress(List)}.
+ *
+ * @param address A non-null address.
+ * @param addresses An optional set of additional arguments. Any
+ * {@code null} values will be ignored.
+ * @return This Bluetooth Low Energy extension.
+ * @throws IllegalStateException If the IRK is also set.
+ */
+ @NotNull
+ public BleDeviceExtension setSeparateBroadcastAddress(
+ @NotNull final String address,
+ @Nullable final String... addresses)
+ throws IllegalStateException
+ {
+ return setSeparateBroadcastAddress(StaticUtils.toList(address, addresses));
+ }
+
+ /**
+ * Fetches the Identity Resolving Key for this BLE device.
+ *
+ * @return The IRK.
+ */
+ @Nullable
+ public String getIrk()
+ {
+ return irk;
+ }
+
+ /**
+ * Specifies the Identity Resolving Key for this BLE device.
+ *
+ * @param irk The IRK.
+ * @return This Bluetooth Low Energy extension.
+ * @throws IllegalStateException If the separateBroadcastAddress is also set.
+ */
+ @NotNull
+ public BleDeviceExtension setIrk(@Nullable final String irk)
+ throws IllegalStateException
+ {
+ validate(irk, this.separateBroadcastAddress);
+ this.irk = irk;
+ return this;
+ }
+
+ /**
+ * Fetches whether this device supports BLE mobility.
+ *
+ * @return A boolean indicating whether mobility is supported, or {@code null}
+ * if the value is not set.
+ */
+ @Nullable
+ public Boolean getMobility()
+ {
+ return mobility;
+ }
+
+ /**
+ * Specifies whether this device supports BLE mobility.
+ *
+ * @param mobility {@code true} if mobility is supported.
+ * @return This Bluetooth Low Energy extension.
+ */
+ @NotNull
+ public BleDeviceExtension setMobility(@Nullable final Boolean mobility)
+ {
+ this.mobility = mobility;
+ return this;
+ }
+
+ /**
+ * Fetches the BLE versions supported by this device.
+ *
+ * @return The list of supported BLE versions.
+ */
+ @NotNull
+ public List getVersionSupport()
+ {
+ return versionSupport;
+ }
+
+ /**
+ * Fetches the BLE pairing methods supported by this device.
+ *
+ * @return The list of pairing method schema URNs.
+ */
+ @NotNull
+ public List getPairingMethods()
+ {
+ return List.copyOf(pairingMethods);
+ }
+
+ /**
+ * Manually sets the BLE {@code pairingMethods} field.
+ *
+ *
+ * In general, methods like {@link #setPairingExtension(BlePairingMethod)}
+ * should be used instead, as this will automatically set the appropriate
+ * fields.
+ *
+ * @param pairingMethods The list of pairing methods.
+ */
+ public void setPairingMethods(@NotNull final List pairingMethods)
+ {
+ this.pairingMethods.clear();
+ this.pairingMethods.addAll(pairingMethods);
+ }
+
+ /**
+ * Sets a BLE pairing method extension on this device extension, and adds
+ * its schema URN to the {@code pairingMethods} field.
+ *
+ * @param pairingMethod The pairing method extension to set.
+ * @return This Bluetooth Low Energy extension.
+ */
+ @NotNull
+ public BleDeviceExtension setPairingExtension(
+ @NotNull final BlePairingMethod pairingMethod)
+ {
+ setExtension(pairingMethod);
+
+ // setExtension() adds to the schema URNs, which should always be empty for
+ // this class.
+ getSchemaUrns().clear();
+
+ String schema = SchemaUtils.getSchemaUrn(pairingMethod.getClass());
+ pairingMethods.add(schema);
+ return this;
+ }
+
+ /**
+ * Removes a BLE pairing method extension from this device extension, and
+ * removes its schema URN from the {@code pairingMethods} field. For example:
+ *
+ *
+ * @param The data type of the pairing method extension.
+ * @param pairingClass The class of the pairing method extension to remove.
+ * @return This Bluetooth Low Energy extension.
+ */
+ @NotNull
+ public BleDeviceExtension removePairingExtension(
+ @NotNull final Class pairingClass)
+ {
+ removeExtension(pairingClass);
+ String schema = SchemaUtils.getSchemaUrn(pairingClass);
+ pairingMethods.remove(schema);
+ return this;
+ }
+
+ /**
+ * Obtains all compatible pairing method objects from this device extension.
+ *
+ * @return The list of pairing method objects.
+ */
+ @NotNull
+ @JsonIgnore
+ public List getPairingMethodExtensions()
+ {
+ List> pairingClasses = List.of(
+ BlePairingJustWorks.class,
+ BlePairingNull.class,
+ BlePairingOutOfBand.class,
+ BlePairingPassKey.class);
+
+ return getClassesFromExtension(pairingClasses);
+ }
+
+ /**
+ * Ensures that the {@code irk} and {@code separateBroadcastAddress} fields
+ * are not both set.
+ *
+ * @throws IllegalStateException If both fields are set.
+ */
+ private void validate(@Nullable final String irkValue,
+ @Nullable final List addresses)
+ {
+ if (irkValue != null && addresses != null && !addresses.isEmpty())
+ {
+ throw new IllegalStateException("The 'separateBroadcastAddress' and"
+ + " 'irk' fields cannot both be set on a BleDeviceExtension.");
+ }
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this BLE device
+ * extension.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this extension,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof BleDeviceExtension that
+ && super.equals(o)
+ && versionSupport.equals(that.versionSupport)
+ && Objects.equals(deviceMacAddress, that.deviceMacAddress)
+ && isRandom == that.isRandom
+ && Objects.equals(irk, that.irk)
+ && Objects.equals(mobility, that.mobility)
+ && separateBroadcastAddress.equals(that.separateBroadcastAddress)
+ && pairingMethods.equals(that.pairingMethods);
+ }
+
+ /**
+ * Retrieves a hash code for this BLE device extension.
+ *
+ * @return A hash code for this BLE device extension.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(super.hashCode(), versionSupport, deviceMacAddress,
+ isRandom, irk, mobility, separateBroadcastAddress, pairingMethods);
+ }
+
+ /**
+ * Retrieves a string representation of this BLE device extension, with the
+ * {@code irk} value redacted.
+ *
+ * @return A string representation of this BLE device extension.
+ */
+ @Override
+ @NotNull
+ public String toString()
+ {
+ return JsonUtils.toRedactedString(this, "irk");
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingJustWorks.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingJustWorks.java
new file mode 100644
index 00000000..cca1d6bf
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingJustWorks.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.types.AttributeDefinition;
+
+
+/**
+ * This class represents a {@link BlePairingMethod} subtype corresponding to the
+ * Bluetooth Low Energy "Just Works" method. This pairing method does not
+ * require a key to pair devices, and its pairing key is always set to
+ * {@code null}. For more background on how this object interacts with a device
+ * extension, see {@link BleDeviceExtension}.
+ *
+ *
+ * The following JSON represents the form for this pairing method as it would
+ * appear inside a {@link BleDeviceExtension}. This pairing method contains only
+ * the {@code key} field, which is always {@code null}.
+ *
+ */
+@Schema(
+ id = "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device",
+ name = "Just Works Auth BLE",
+ description = "BLE Just Works pairing method")
+public class BlePairingJustWorks extends BlePairingMethod
+{
+ @Nullable
+ @Attribute(description = """
+ The pairing key for the "Just Works" method. The value is always null.""",
+ isRequired = true,
+ mutability = AttributeDefinition.Mutability.IMMUTABLE)
+ @JsonInclude(JsonInclude.Include.ALWAYS)
+ private final Integer key = null;
+
+ /**
+ * Obtains the pairing key of a BLE Just Works extension. This method will
+ * always return {@code null} and primarily exists for Jackson serialization.
+ *
+ * @return The pairing key's value, which is always {@code null}.
+ */
+ @Nullable
+ public Integer getKey()
+ {
+ return key;
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this BLE Just Works
+ * pairing method.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this object,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ return o instanceof BlePairingJustWorks;
+ }
+
+ /**
+ * Retrieves a hash code for this BLE Just Works pairing method.
+ *
+ * @return A hash code for this object.
+ */
+ @Override
+ public int hashCode()
+ {
+ return getClass().hashCode();
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java
new file mode 100644
index 00000000..e57d246c
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingMethod.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.utils.JsonUtils;
+
+
+/**
+ * This class represents a supertype for "pairing methods" that define protocols
+ * for establishing a Bluetooth connection. This class allows BLE pairing method
+ * objects to be nested within a {@link BleDeviceExtension} with a common parent
+ * class type.
+ *
+ *
+ * For more background on devices, see the {@link DeviceResource} class.
+ */
+public abstract class BlePairingMethod
+{
+ @Override
+ @NotNull
+ public String toString()
+ {
+ return JsonUtils.getObjectWriter().withDefaultPrettyPrinter()
+ .writeValueAsString(this);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingNull.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingNull.java
new file mode 100644
index 00000000..e1de7160
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingNull.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+
+
+/**
+ * This class represents a {@link BlePairingMethod} subtype corresponding to the
+ * Bluetooth Low Energy "Null" method. For more background on how this object
+ * interacts with a device extension, see {@link BleDeviceExtension}.
+ *
+ *
+ * This pairing method JSON object is unique in that it is empty:
+ *
+ *
+ * Instead, the presence of this value in the {@code pairingMethods} list of a
+ * {@link BleDeviceExtension} indicates that no pairing is required. This value
+ * can be set on a device extension with the following Java code:
+ *
+ */
+@Schema(
+ id = "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device",
+ name = "Pairing Null",
+ description = "BLE Null pairing method — no pairing required")
+public class BlePairingNull extends BlePairingMethod
+{
+ /**
+ * Indicates whether the provided object is equal to this BLE pairing null
+ * bean.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this bean,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ return o instanceof BlePairingNull;
+ }
+
+ /**
+ * Retrieves a hash code for this BLE pairing null bean.
+ *
+ * @return A hash code for this bean.
+ */
+ @Override
+ public int hashCode()
+ {
+ return getClass().hashCode();
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java
new file mode 100644
index 00000000..35571d59
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingOutOfBand.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.utils.JsonUtils;
+import tools.jackson.databind.node.NumericNode;
+
+import java.util.Objects;
+
+
+/**
+ * This class represents a {@link BlePairingMethod} subtype corresponding to the
+ * Bluetooth Low Energy "Out-of-Band" method. For more background on how this
+ * object interacts with a device extension, see {@link BleDeviceExtension}.
+ *
+ *
+ * The following fields are defined:
+ *
+ *
{@code key}: A value received from out-of-band sources,
+ * such as Near Field Communication (NFC).
+ *
{@code randomNumber}: A number that represents a cryptographic
+ * "number-used-once" added to the key.
+ *
{@code confirmationNumber}: An optional number that some solutions
+ * require in a RESTful message exchange.
+ *
+ *
+ * The following JSON represents the form for this pairing method as it would
+ * appear inside a {@link BleDeviceExtension}:
+ *
+ *
+ * This value can be set on a device extension with the following Java code:
+ *
+ * bleDeviceExtension.setPairingExtension(
+ * new BlePairingOutOfBand("retrievedKey", 238796813516896L));
+ *
+ */
+@Schema(id = "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device",
+ name = "Out-of-Band Pairing for BLE",
+ description = "BLE Out-of-Band pairing method")
+public class BlePairingOutOfBand extends BlePairingMethod
+{
+ @NotNull
+ @Attribute(description = "The OOB pairing key.",
+ isRequired = true,
+ isCaseExact = true)
+ private final String key;
+
+ // Stored as a JsonNode for flexible equivalency evaluation.
+ @NotNull
+ @Attribute(description = "The random number used in OOB pairing.",
+ isRequired = true)
+ @JsonProperty("randomNumber")
+ private final NumericNode randomNumber;
+
+ @Nullable
+ @Attribute(description = "The confirmation number used in OOB pairing.")
+ private Integer confirmationNumber;
+
+ /**
+ * Creates a Bluetooth Low Energy out-of-band pairing method.
+ *
+ * @param key The key.
+ * @param randomNumber The cryptographic number-used-once value.
+ * @param confirmationNumber The optional confirmation number.
+ */
+ @JsonCreator
+ public BlePairingOutOfBand(
+ @NotNull @JsonProperty(value = "key", required = true)
+ final String key,
+ @JsonProperty(value = "randomNumber", required = true)
+ final long randomNumber,
+ @Nullable @JsonProperty(value = "confirmationNumber")
+ final Integer confirmationNumber)
+ {
+ this.key = Objects.requireNonNull(key);
+ this.randomNumber = JsonUtils.asNumericNode(randomNumber);
+ this.confirmationNumber = confirmationNumber;
+ }
+
+ /**
+ * Creates a Bluetooth Low Energy out-of-band pairing method.
+ *
+ * @param key The key.
+ * @param randomNumber The cryptographic number-used-once value.
+ */
+ public BlePairingOutOfBand(@NotNull final String key, final long randomNumber)
+ {
+ this(key, randomNumber, null);
+ }
+
+ /**
+ * Fetches the OOB pairing key.
+ *
+ * @return The OOB pairing key.
+ */
+ @NotNull
+ public String getKey()
+ {
+ return key;
+ }
+
+ /**
+ * Fetches the random number used in OOB pairing.
+ *
+ * @return The random number.
+ */
+ @JsonIgnore
+ public long getRandomNumber()
+ {
+ return randomNumber.longValue();
+ }
+
+ /**
+ * Fetches the confirmation number used in OOB pairing.
+ *
+ * @return The confirmation number.
+ */
+ @Nullable
+ public Integer getConfirmationNumber()
+ {
+ return confirmationNumber;
+ }
+
+ /**
+ * Specifies the confirmation number used in OOB pairing.
+ *
+ * @param confirmationNumber The confirmation number.
+ * @return This object.
+ */
+ @NotNull
+ public BlePairingOutOfBand setConfirmationNumber(
+ @Nullable final Integer confirmationNumber)
+ {
+ this.confirmationNumber = confirmationNumber;
+ return this;
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this BLE pairing object.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this object,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof BlePairingOutOfBand that
+ && randomNumber.equals(that.randomNumber)
+ && Objects.equals(key, that.key)
+ && Objects.equals(confirmationNumber, that.confirmationNumber);
+ }
+
+ /**
+ * Retrieves a hash code for this BLE OOB pairing object.
+ *
+ * @return A hash code for this object.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(key, randomNumber, confirmationNumber);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingPassKey.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingPassKey.java
new file mode 100644
index 00000000..9c5d47ab
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/BlePairingPassKey.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+
+import java.util.Objects;
+
+
+/**
+ * This class represents a {@link BlePairingMethod} subtype corresponding to the
+ * Bluetooth Low Energy "Pass Key" method. The passkey pairing method requires a
+ * six-digit key to pair devices. For more background on how this object
+ * interacts with a device extension, see {@link BleDeviceExtension}.
+ *
+ *
+ * The following JSON represents the form for this pairing method as it would
+ * appear inside a {@link BleDeviceExtension}. This contains a mandatory key
+ * that takes a numeric value up to six digits.
+ *
+ *
+ * In JSON form, this integer value will not have six digits if the integer
+ * value is less than {@code 100,000}, as leading zeroes are not allowed in JSON
+ * integer values. If a six-digit string value is required, use
+ * {@link #getKeyAsString()}.
+ */
+@Schema(id = "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device",
+ name = "Passkey Pairing for BLE",
+ description = "BLE Pass Key pairing method")
+public class BlePairingPassKey extends BlePairingMethod
+{
+ @Attribute(description =
+ "The six-digit passkey (000000-999999) used for BLE pairing.",
+ isRequired = true)
+ private final int key;
+
+ /**
+ * Creates a new BLE Passkey pairing method.
+ *
+ * @param key The six-digit passkey (000000–999999).
+ *
+ * @throws IllegalArgumentException If the key is not a valid 6 digit value.
+ */
+ @JsonCreator
+ public BlePairingPassKey(
+ @JsonProperty(value = "key", required = true) final int key)
+ throws IllegalArgumentException
+ {
+ if (key < 0 || key > 999_999)
+ {
+ throw new IllegalArgumentException(
+ "The provided key was not a six digit value: " + key);
+ }
+
+ this.key = key;
+ }
+
+ /**
+ * Alternate constructor that accepts a string input.
+ *
+ * @param key The six-digit passkey as a string.
+ *
+ * @throws IllegalArgumentException If the key is not a valid 6 digit value.
+ */
+ public BlePairingPassKey(@NotNull final String key)
+ throws IllegalArgumentException
+ {
+ this(Integer.parseInt(Objects.requireNonNull(key)));
+ }
+
+ /**
+ * Retrieves the passkey as an integer.
+ *
+ * @return The six-digit passkey.
+ */
+ public int getKey()
+ {
+ return key;
+ }
+
+ /**
+ * Retrieves the passkey as a six-digit string value.
+ *
+ * @return The six-digit passkey.
+ */
+ @NotNull
+ @JsonIgnore
+ public String getKeyAsString()
+ {
+ return "%06d".formatted(key);
+ }
+
+
+ /**
+ * Indicates whether the provided object is equal to this BLE Passkey object.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this object,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof BlePairingPassKey that && key == that.key;
+ }
+
+ /**
+ * Retrieves a hash code for this BLE Passkey object.
+ *
+ * @return A hash code for this object.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(key);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java
new file mode 100644
index 00000000..9887a206
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/CertificateInfo.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.utils.JsonUtils;
+
+import java.util.Objects;
+
+/**
+ * This class represents a complex type for public key data as defined by
+ *
+ * RFC 9944 Section 6.3.1.
+ *
+ *
+ * In SCIM, a {@code CertificateInfo} is associated with an
+ * {@link EndpointAppResource}. It contains values from an X.509 certificate:
+ *
+ *
{@code rootCA}: The root Certificate Authority (the top-level
+ * trust anchor). This will be base64-encoded.
+ *
{@code subjectName}: This string represents either the subjectName or
+ * the subjectAlternateName of the certificate.
+ * This field is required.
+ *
+ *
+ * For example JSON structure and Java code, see {@link EndpointAppResource}.
+ *
+ *
+ * As stated above, {@code rootCA} will be a {@link java.util.Base64} value. For
+ * {@code subjectName}, if the field represents a subjectAlternateName, it will
+ * be a DNS name such as {@code www.example.com}. Otherwise, it will represent a
+ * DN (distinguished name) such as {@code CN=EX1,O=Example,C=US}.
+ *
+ *
+ * SCIM applications should consider the following mandates with regard to a
+ * certificateInfo object and client tokens on an EndpointAppResource:
+ *
+ *
If the SCIM service accepts both a provided certificateInfo and a
+ * client token, then control/telemetry services MUST validate both.
+ *
If the SCIM service accepts a certificateInfo object, it MUST return
+ * that object in the response to the client.
+ *
If the SCIM service does not accept a certificateInfo and does not
+ * generate a clientToken, then an external authentication method, such
+ * as OAuth 2, MUST be pre-arranged.
+ *
+ */
+@JsonPropertyOrder({"rootCA", "subjectName"})
+public class CertificateInfo
+{
+ @Nullable
+ @Attribute(description = "The certificate authority for the endpoint app.",
+ isRequired = false)
+ private String rootCA;
+
+ @NotNull
+ @Attribute(description =
+ "The subject name for the endpoint application certificate.",
+ isRequired = true)
+ @JsonProperty
+ private final String subjectName;
+
+ /**
+ * Creates a new CertificateInfo.
+ *
+ * @param subjectName The Distinguished Name or DNS name.
+ */
+ public CertificateInfo(
+ @NotNull @JsonProperty(value = "subjectName", required = true)
+ final String subjectName)
+ {
+ this.subjectName = Objects.requireNonNull(subjectName);
+ }
+
+ /**
+ * Retrieves the root certificate authority for the endpoint application.
+ *
+ * @return The root CA.
+ */
+ @Nullable
+ public String getRootCA()
+ {
+ return rootCA;
+ }
+
+ /**
+ * Specifies the root certificate authority for the endpoint application.
+ *
+ * @param rootCA The root CA.
+ * @return This CertificateInfo.
+ */
+ @NotNull
+ public CertificateInfo setRootCA(@Nullable final String rootCA)
+ {
+ this.rootCA = rootCA;
+ return this;
+ }
+
+ /**
+ * Retrieves the subject name for the endpoint application certificate.
+ *
+ * @return The subject name.
+ */
+ @NotNull
+ public String getSubjectName()
+ {
+ return subjectName;
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this certificate info.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this
+ * certificate info, or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof CertificateInfo that
+ && Objects.equals(rootCA, that.rootCA)
+ && Objects.equals(subjectName, that.subjectName);
+ }
+
+ /**
+ * Retrieves a string representation of this certificate info.
+ *
+ * @return A string representation of this certificate info.
+ */
+ @Override
+ @NotNull
+ public String toString()
+ {
+ return JsonUtils.getObjectWriter().withDefaultPrettyPrinter()
+ .writeValueAsString(this);
+ }
+
+ /**
+ * Retrieves a hash code for this certificate info.
+ *
+ * @return A hash code for this certificate info.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(rootCA, subjectName);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java
new file mode 100644
index 00000000..a165decc
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceExtension.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.unboundid.scim2.common.BaseScimResource;
+import com.unboundid.scim2.common.annotations.NotNull;
+
+import java.util.Collection;
+import java.util.List;
+
+
+/**
+ * This class represents a supertype for all device extension objects described
+ * in {@link DeviceResource}. This structure allows device extension objects to
+ * be nested within a {@link DeviceResource} without printing a {@code schemas}
+ * value of their own.
+ */
+@JsonIgnoreProperties("schemas")
+public abstract class DeviceExtension extends BaseScimResource
+{
+ /**
+ * The regex matching MAC addresses as defined by RFC 9944.
+ */
+ @NotNull
+ public static final String MAC_PATTERN =
+ "^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$";
+
+ /**
+ * Creates a new device extension.
+ */
+ protected DeviceExtension()
+ {
+ super.setSchemaUrns(List.of());
+ }
+
+ @Override
+ public void setSchemaUrns(@NotNull final Collection schemaUrns)
+ throws UnsupportedOperationException
+ {
+ throw new UnsupportedOperationException(
+ "Cannot set the 'schemas' value of a device extension.");
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceResource.java
new file mode 100644
index 00000000..8d759129
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DeviceResource.java
@@ -0,0 +1,424 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.unboundid.scim2.common.BaseScimResource;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.types.AttributeDefinition;
+import com.unboundid.scim2.common.types.Group;
+import com.unboundid.scim2.common.utils.StaticUtils;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Objects;
+
+
+/**
+ * This class represents the {@code device} resource type as described by
+ * RFC 9944.
+ * Management of IoT (Internet of Things) devices was added to SCIM to handle
+ * device provisioning for authentication use cases. These use cases include
+ * both user-based authentication and OAuth/certificate authentication.
+ *
+ *
+ * In SCIM, a device resource can represent many types of devices that are
+ * managed by users. These include Bluetooth, Wi-Fi Easy Connect (DPP), Zigbee
+ * low-power devices, and more. The following fields are defined on a device:
+ *
+ *
{@code displayName}: The human-readable name of the device.
+ *
{@code active}: Indicates whether the device is in use.
+ *
{@code mudUrl}: An optional Manufacturer Usage Description URL
+ * for this device as defined by RFC 8520.
+ *
{@code groups}: An optional list of groups that this device
+ * belongs to. Membership may be given directly,
+ * through a nested group, or dynamically computed.
+ *
+ *
+ *
+ * In addition to the above fields, a device resource generally contains more
+ * standard-specific data that is stored in a schema extension. For example,
+ * consider the following Zigbee device resource in JSON form:
+ *
+ *
+ * This device resource may be created with the following Java code. Values for
+ * metadata use constants below, but will generally come from a database.
+ *
+ *
+ * Data specific to the Zigbee device is stored under the relevant extension
+ * schema. This structure organizes standard-specific data, since Zigbee
+ * information (e.g., the unique EUI 64 address) is not applicable to other
+ * device types. It also allows consumers to differentiate between device types,
+ * which each have their own URN identifier listed below. Note that all
+ * extension classes descend from the {@link DeviceExtension} class.
+ *
+ *
+ * To fetch the device extension(s) stored on a device resource, use the
+ * {@link #getDeviceExtensions()} method and the {@code instanceof} keyword:
+ *
+ * By their nature, devices sometimes rely on cryptography to establish trusted
+ * connections and verification. As a result, device extension JSON objects from
+ * a client may contain secret values that should not be exposed, particularly:
+ *
+ *
The Identity Resolving Key (IRK) of a {@link BleDeviceExtension}.
+ *
The bootstrap key of a {@link DppDeviceExtension}.
+ *
The voucher of an {@link FdoDeviceExtension}.
+ *
+ *
+ * When these extension objects are converted to JSON, the SCIM SDK prints such
+ * secret values if they are present. This behavior is necessary when a client
+ * application sends this information to a SCIM service. However, SCIM services
+ * should ensure that they are not returned to a client under any circumstances.
+ * The scim2-sdk-server's {@code ResourcePreparer} class may be used to handle
+ * these values.
+ *
+ *
+ * Although some device extensions use strong encryption, the use of a device
+ * extension does not guarantee this. For example, MAC Authenticated Bypass used
+ * by {@link EthernetMabDeviceExtension} is specifically considered a legacy
+ * form of weak authentication, so it is important to be aware of different
+ * authentication types.
+ *
+ * @since 6.1.0
+ */
+@Schema(id = "urn:ietf:params:scim:schemas:core:2.0:Device",
+ name = "Core Device Schema",
+ description = "Device")
+public class DeviceResource extends BaseScimResource
+{
+ @Nullable
+ @Attribute(description = "A human-readable name for the device, suitable"
+ + " for display to end-users.",
+ isCaseExact = false)
+ private String displayName;
+
+ @Attribute(description = "A Boolean value indicating whether the device"
+ + " is currently active.",
+ isRequired = true)
+ private boolean active;
+
+ @Nullable
+ @Attribute(description = "A URI pointing to the Manufacturer Usage"
+ + " Description (MUD) file for this device, as defined by RFC 8520.",
+ isCaseExact = true,
+ referenceTypes = "external")
+ private URI mudUrl;
+
+ @NotNull
+ @Attribute(description = "A list of groups to which the device belongs.",
+ isCaseExact = true,
+ mutability = AttributeDefinition.Mutability.READ_ONLY,
+ multiValueClass = Group.class)
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private List groups = List.of();
+
+ /**
+ * Retrieves the human-readable display name of this device.
+ *
+ * @return The display name of this device.
+ */
+ @Nullable
+ public String getDisplayName()
+ {
+ return displayName;
+ }
+
+ /**
+ * Specifies the human-readable display name of this device.
+ *
+ * @param displayName The display name of this device.
+ * @return This device resource.
+ */
+ @NotNull
+ public DeviceResource setDisplayName(@Nullable final String displayName)
+ {
+ this.displayName = displayName;
+ return this;
+ }
+
+ /**
+ * Retrieves whether this device is currently active.
+ *
+ * @return The device's active status.
+ */
+ public boolean getActive()
+ {
+ return active;
+ }
+
+ /**
+ * Specifies whether this device is currently active.
+ *
+ * @param active The device's active status.
+ * @return This device resource.
+ */
+ @NotNull
+ public DeviceResource setActive(final boolean active)
+ {
+ this.active = active;
+ return this;
+ }
+
+ /**
+ * Retrieves the Manufacturer Usage Description URL for this device.
+ *
+ * @return The MUD URL for this device.
+ */
+ @Nullable
+ public URI getMudUrl()
+ {
+ return mudUrl;
+ }
+
+ /**
+ * Retrieves the Manufacturer Usage Description URL as a string.
+ *
+ * @return The MUD URL for this device.
+ */
+ @Nullable
+ @JsonIgnore
+ public String getMudUrlString()
+ {
+ return (mudUrl == null) ? null : mudUrl.toString();
+ }
+
+ /**
+ * Specifies the Manufacturer Usage Description URL for this device.
+ *
+ * @param mudUrl The MUD URL for this device.
+ * @return This device resource.
+ */
+ @NotNull
+ public DeviceResource setMudUrl(@Nullable final URI mudUrl)
+ {
+ this.mudUrl = mudUrl;
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setMudUrl(URI)} that accepts a string.
+ *
+ * @param mudUrl The MUD URL for this device.
+ * @return This device resource.
+ *
+ * @throws IllegalArgumentException If the string was not a valid URI.
+ */
+ @NotNull
+ public DeviceResource setMudUrl(@Nullable final String mudUrl)
+ throws IllegalArgumentException
+ {
+ return setMudUrl((mudUrl == null) ? null : URI.create(mudUrl));
+ }
+
+ /**
+ * Retrieves the list of groups to which this device belongs.
+ *
+ * @return The list of groups to which this device belongs.
+ */
+ @NotNull
+ public List getGroups()
+ {
+ return groups;
+ }
+
+ /**
+ * Specifies the list of groups to which this device belongs.
+ *
+ * @param groups The list of groups to which this device belongs.
+ * @return This device resource.
+ */
+ @NotNull
+ public DeviceResource setGroups(@Nullable final List groups)
+ {
+ this.groups = (groups == null) ? List.of() : groups;
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setGroups(List)}.
+ *
+ * @param group A non-null group.
+ * @param groups An optional set of additional arguments. Any
+ * {@code null} values will be ignored.
+ * @return This device resource.
+ */
+ @NotNull
+ public DeviceResource setGroups(@NotNull final Group group,
+ @Nullable final Group... groups)
+ {
+ return setGroups(StaticUtils.toList(group, groups));
+ }
+
+ /**
+ * Fetches the device extensions attached to this device resource. This can
+ * contain multiple values, particularly if the device contains an
+ * {@link EndpointAppDeviceExtension}.
+ *
+ * @return The device extensions.
+ */
+ @NotNull
+ @JsonIgnore
+ public List getDeviceExtensions()
+ {
+ List> extensionClasses = List.of(
+ BleDeviceExtension.class,
+ ZigbeeDeviceExtension.class,
+ DppDeviceExtension.class,
+ FdoDeviceExtension.class,
+ EthernetMabDeviceExtension.class,
+ EndpointAppDeviceExtension.class);
+
+ return getClassesFromExtension(extensionClasses);
+ }
+
+ /**
+ * Attaches a device extension (e.g., {@link ZigbeeDeviceExtension}) to this
+ * resource. This is equivalent to calling {@link #setExtension(Object)},
+ * though it returns the DeviceResource for builder pattern calls.
+ *
+ * @param ext The device extension to attach.
+ * @return This device resource.
+ */
+ @NotNull
+ public DeviceResource setDeviceExtension(@NotNull final DeviceExtension ext)
+ {
+ setExtension(ext);
+ return this;
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this device resource.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this device
+ * resource, or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof DeviceResource that
+ && active == that.active
+ && Objects.equals(displayName, that.displayName)
+ && Objects.equals(mudUrl, that.mudUrl)
+ && Objects.equals(groups, that.groups)
+ && super.equals(o);
+ }
+
+ /**
+ * Retrieves a hash code for this device resource.
+ *
+ * @return A hash code for this device resource.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(super.hashCode(), active, displayName, mudUrl, groups);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DppDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DppDeviceExtension.java
new file mode 100644
index 00000000..e3578ebb
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/DppDeviceExtension.java
@@ -0,0 +1,392 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.types.AttributeDefinition;
+import com.unboundid.scim2.common.utils.JsonUtils;
+import com.unboundid.scim2.common.utils.StaticUtils;
+
+import java.util.List;
+import java.util.Objects;
+
+
+/**
+ * This class represents a Device Provisioning Protocol extension for a device,
+ * which allows enabling Wi-Fi Easy Connect in SCIM as defined by
+ * RFC 9944. For
+ * more background on devices, see the {@link DeviceResource} class.
+ *
+ *
+ * The following fields are defined on this extension:
+ *
+ *
{@code dppVersion}: An integer that represents the version of
+ * DPP the device supports.
+ *
{@code bootstrapKey}: An Elliptic Curve Diffie-Hellman (ECDH)
+ * public key used for bootstrapping.
+ *
{@code deviceMacAddress}: A MAC address stored as a string.
+ *
{@code serialNumber}: An alphanumeric serial number that may
+ * also be passed as bootstrap information.
+ *
{@code classChannel}: Values representing the global operating
+ * class and channel for bootstrap info.
+ *
{@code bootstrappingMethod}: Values representing the bootstrap methods
+ * available on the enrollee device.
+ *
+ *
+ *
+ * The following JSON represents a device with a DPP extension:
+ *
+ */
+@Schema(id = "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device",
+ name = "Wi-Fi Easy Connect",
+ description = "DPP extension for a Device resource")
+@JsonPropertyOrder({"dppVersion", "bootstrappingMethod", "bootstrapKey"})
+public class DppDeviceExtension extends DeviceExtension
+{
+ @Attribute(description = "The DPP protocol version supported by this"
+ + " device.",
+ isRequired = true)
+ private final int dppVersion;
+
+ @NotNull
+ @Attribute(description =
+ "The bootstrapping methods supported by this DPP device.",
+ isCaseExact = false,
+ multiValueClass = String.class)
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private List bootstrappingMethod = List.of();
+
+ @NotNull
+ @Attribute(description = "The DPP bootstrapping public key for this"
+ + " device. This is a cryptographic secret that is never returned.",
+ isRequired = true,
+ isCaseExact = true,
+ mutability = AttributeDefinition.Mutability.WRITE_ONLY,
+ returned = AttributeDefinition.Returned.NEVER,
+ uniqueness = AttributeDefinition.Uniqueness.NONE)
+ private final String bootstrapKey;
+
+ /**
+ * Creates a new Device Provisioning Protocol (DPP) extension.
+ *
+ * @param dppVersion The DPP version supported by this device.
+ * @param bootstrapKey The DPP bootstrapping public key for this device.
+ */
+ @JsonCreator
+ public DppDeviceExtension(
+ @NotNull @JsonProperty(value = "dppVersion", required = true)
+ final Integer dppVersion,
+ @NotNull @JsonProperty(value = "bootstrapKey", required = true)
+ final String bootstrapKey)
+ {
+ this.dppVersion = Objects.requireNonNull(dppVersion);
+ this.bootstrapKey = Objects.requireNonNull(bootstrapKey);
+ }
+
+ @Nullable
+ @Attribute(description = "The MAC address of this DPP device.",
+ isCaseExact = false,
+ uniqueness = AttributeDefinition.Uniqueness.MANUFACTURER,
+ pattern = MAC_PATTERN)
+ private String deviceMacAddress;
+
+ @NotNull
+ @Attribute(description =
+ "The class/channel pairs supported by this DPP device.",
+ isCaseExact = false,
+ multiValueClass = String.class)
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private List classChannel = List.of();
+
+ @Nullable
+ @Attribute(description = "The serial number of this DPP device.",
+ isCaseExact = false)
+ private String serialNumber;
+
+ /**
+ * Fetches the Device Provisioning Protocol version of this device.
+ *
+ * @return The DPP version.
+ */
+ public int getDppVersion()
+ {
+ return dppVersion;
+ }
+
+ /**
+ * Fetches the DPP bootstrapping public key.
+ *
+ * @return The bootstrap key.
+ */
+ @NotNull
+ public String getBootstrapKey()
+ {
+ return bootstrapKey;
+ }
+
+ /**
+ * Fetches the MAC address of this DPP device.
+ *
+ * @return The MAC address.
+ */
+ @Nullable
+ public String getDeviceMacAddress()
+ {
+ return deviceMacAddress;
+ }
+
+ /**
+ * Specifies the MAC address of this DPP device.
+ *
+ * @param deviceMacAddress The MAC address.
+ * @return This DPP extension.
+ */
+ @NotNull
+ public DppDeviceExtension setDeviceMacAddress(
+ @Nullable final String deviceMacAddress)
+ {
+ this.deviceMacAddress = deviceMacAddress;
+ return this;
+ }
+
+ /**
+ * Fetches the serial number of this DPP device.
+ *
+ * @return The serial number.
+ */
+ @Nullable
+ public String getSerialNumber()
+ {
+ return serialNumber;
+ }
+
+ /**
+ * Specifies the serial number of this DPP device.
+ *
+ * @param serialNumber The serial number.
+ * @return This DPP extension.
+ */
+ @NotNull
+ public DppDeviceExtension setSerialNumber(@Nullable final String serialNumber)
+ {
+ this.serialNumber = serialNumber;
+ return this;
+ }
+
+ /**
+ * Fetches the bootstrapping methods supported by this DPP device. The name of
+ * this attribute is singular, but a DPP device may define multiple methods.
+ *
+ * @return The list of bootstrapping methods.
+ */
+ @NotNull
+ public List getBootstrappingMethod()
+ {
+ return bootstrappingMethod;
+ }
+
+ /**
+ * Specifies the bootstrapping methods supported by this DPP device.
+ *
+ * @param bootstrappingMethod The list of bootstrapping methods.
+ * @return This DPP extension.
+ */
+ @NotNull
+ public DppDeviceExtension setBootstrappingMethod(
+ @Nullable final List bootstrappingMethod)
+ {
+ this.bootstrappingMethod =
+ (bootstrappingMethod == null) ? List.of() : bootstrappingMethod;
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setBootstrappingMethod(List)}.
+ *
+ * @param method A non-null bootstrapping method.
+ * @param methods An optional set of additional arguments. Any
+ * {@code null} values will be ignored.
+ * @return This DPP extension.
+ */
+ @NotNull
+ public DppDeviceExtension setBootstrappingMethod(
+ @NotNull final String method,
+ @Nullable final String... methods)
+ {
+ return setBootstrappingMethod(StaticUtils.toList(method, methods));
+ }
+
+ /**
+ * Fetches the class/channel pairs supported by this DPP device.
+ *
+ * @return The list of class/channel pairs.
+ */
+ @NotNull
+ public List getClassChannel()
+ {
+ return classChannel;
+ }
+
+ /**
+ * Specifies the class/channel pairs supported by this DPP device.
+ *
+ * @param classChannel The list of class/channel pairs.
+ * @return This DPP extension.
+ */
+ @NotNull
+ public DppDeviceExtension setClassChannel(
+ @Nullable final List classChannel)
+ {
+ this.classChannel = (classChannel == null) ? List.of() : classChannel;
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setClassChannel(List)}.
+ *
+ * @param channel A non-null class/channel string.
+ * @param channels An optional set of additional arguments. Any
+ * {@code null} values will be ignored.
+ * @return This DPP extension.
+ */
+ @NotNull
+ public DppDeviceExtension setClassChannel(@NotNull final String channel,
+ @Nullable final String... channels)
+ {
+ return setClassChannel(StaticUtils.toList(channel, channels));
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this DPP device
+ * extension.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this extension,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof DppDeviceExtension that
+ && Objects.equals(dppVersion, that.dppVersion)
+ && Objects.equals(bootstrapKey, that.bootstrapKey)
+ && Objects.equals(deviceMacAddress, that.deviceMacAddress)
+ && Objects.equals(serialNumber, that.serialNumber)
+ && Objects.equals(bootstrappingMethod, that.bootstrappingMethod)
+ && Objects.equals(classChannel, that.classChannel);
+ }
+
+ /**
+ * Retrieves a hash code for this DPP device extension.
+ *
+ * @return A hash code for this DPP device extension.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(dppVersion, bootstrapKey, deviceMacAddress,
+ serialNumber, bootstrappingMethod, classChannel);
+ }
+
+ /**
+ * Retrieves a string representation of this DPP device extension, with the
+ * {@code bootstrapKey} value redacted.
+ *
+ * @return A string representation of this DPP device extension.
+ */
+ @Override
+ @NotNull
+ public String toString()
+ {
+ return JsonUtils.toRedactedString(this, "bootstrapKey");
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppDeviceExtension.java
new file mode 100644
index 00000000..07cb4a2a
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppDeviceExtension.java
@@ -0,0 +1,358 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.types.AttributeDefinition;
+import com.unboundid.scim2.common.utils.StaticUtils;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Objects;
+
+
+/**
+ * This class represents an endpoint application extension for a device per
+ *
+ * RFC 9944 Section 7.6.
+ *
+ *
+ * Some device types (e.g., Bluetooth and Zigbee) require an application gateway
+ * interface to manage them. This class represents the list of applications that
+ * connect to an external system. Ultimately, this extension links a device to
+ * the {@link EndpointAppResource} instances that manage it. For more background
+ * on devices, see the {@link DeviceResource} class.
+ *
+ *
+ * The following fields are defined on this extension:
+ *
+ *
{@code deviceControlEnterpriseEndpoint}: The URL of the enterprise
+ * endpoint used to reach the enterprise gateway for device control.
+ *
{@code telemetryEnterpriseEndpoint}: The URL of the enterprise
+ * endpoint used to reach the enterprise gateway for telemetry.
+ *
{@code applications}: A list of {@link EndpointAppReference}
+ * entries, each identifying an associated {@link EndpointAppResource}
+ * via a resource ID value and a {@code $ref} URL.
+ *
+ *
+ *
+ * The following JSON represents a device with a BLE extension and an
+ * endpoint applications extension:
+ *
+ */
+@Schema(
+ id = "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device",
+ name = "Application Endpoint Extension",
+ description = "Endpoint applications extension for a Device resource")
+public class EndpointAppDeviceExtension extends DeviceExtension
+{
+ // Like $ref fields, this is marked as not required since clients will not set
+ // this value in requests they send to SCIM services.
+ @Nullable
+ @Attribute(description =
+ "The URI of the enterprise device-control endpoint application.",
+ isRequired = false,
+ isCaseExact = true,
+ mutability = AttributeDefinition.Mutability.READ_ONLY,
+ uniqueness = AttributeDefinition.Uniqueness.ENTERPRISE,
+ referenceTypes = "external")
+ private URI deviceControlEnterpriseEndpoint;
+
+ @Nullable
+ @Attribute(description =
+ "The URI of the enterprise telemetry endpoint application.",
+ isRequired = false,
+ isCaseExact = true,
+ mutability = AttributeDefinition.Mutability.READ_ONLY,
+ uniqueness = AttributeDefinition.Uniqueness.ENTERPRISE,
+ referenceTypes = "external")
+ private URI telemetryEnterpriseEndpoint;
+
+ @NotNull
+ @Attribute(description =
+ "The list of EndpointApp resources associated with this device.",
+ isRequired = true,
+ multiValueClass = EndpointAppReference.class)
+ private List applications = List.of();
+
+ /**
+ * Retrieves the URI of the enterprise device-control endpoint.
+ *
+ * @return The device-control endpoint URI.
+ */
+ @Nullable
+ public URI getDeviceControlEnterpriseEndpoint()
+ {
+ return deviceControlEnterpriseEndpoint;
+ }
+
+ /**
+ * Retrieves the URI of the enterprise device-control endpoint as a string.
+ *
+ * @return The device-control endpoint URI.
+ */
+ @Nullable
+ @JsonIgnore
+ public String getDeviceControlEnterpriseEndpointString()
+ {
+ URI controlEndpoint = getDeviceControlEnterpriseEndpoint();
+ return (controlEndpoint == null) ? null : controlEndpoint.toString();
+ }
+
+ /**
+ * Specifies the URI of the enterprise device-control endpoint.
+ *
+ * @param deviceControlEnterpriseEndpoint The device-control endpoint URI.
+ * @return This endpoint application extension.
+ */
+ @NotNull
+ public EndpointAppDeviceExtension setDeviceControlEnterpriseEndpoint(
+ @Nullable final URI deviceControlEnterpriseEndpoint)
+ {
+ this.deviceControlEnterpriseEndpoint = deviceControlEnterpriseEndpoint;
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setDeviceControlEnterpriseEndpoint(URI)}
+ * that accepts a string.
+ *
+ * @param endpoint The device-control endpoint URI.
+ * @return This endpoint application extension.
+ *
+ * @throws IllegalArgumentException If the string was not a valid URI.
+ */
+ @NotNull
+ public EndpointAppDeviceExtension setDeviceControlEnterpriseEndpoint(
+ @Nullable final String endpoint)
+ throws IllegalArgumentException
+ {
+ return setDeviceControlEnterpriseEndpoint(
+ (endpoint == null) ? null : URI.create(endpoint));
+ }
+
+ /**
+ * Retrieves the URI of the enterprise telemetry endpoint.
+ *
+ * @return The telemetry endpoint URI.
+ */
+ @Nullable
+ public URI getTelemetryEnterpriseEndpoint()
+ {
+ return telemetryEnterpriseEndpoint;
+ }
+
+ /**
+ * Retrieves the URI of the enterprise telemetry endpoint as a string.
+ *
+ * @return The telemetry endpoint URI.
+ */
+ @Nullable
+ @JsonIgnore
+ public String getTelemetryEnterpriseEndpointString()
+ {
+ URI telemetryEndpoint = getTelemetryEnterpriseEndpoint();
+ return (telemetryEndpoint == null) ? null : telemetryEndpoint.toString();
+ }
+
+ /**
+ * Specifies the URI of the enterprise telemetry endpoint.
+ *
+ * @param telemetryEnterpriseEndpoint The telemetry endpoint URI.
+ * @return This endpoint application extension.
+ */
+ @NotNull
+ public EndpointAppDeviceExtension setTelemetryEnterpriseEndpoint(
+ @Nullable final URI telemetryEnterpriseEndpoint)
+ {
+ this.telemetryEnterpriseEndpoint = telemetryEnterpriseEndpoint;
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setTelemetryEnterpriseEndpoint(URI)} that
+ * accepts a string.
+ *
+ * @param endpoint The telemetry endpoint URI.
+ * @return This endpoint application extension.
+ *
+ * @throws IllegalArgumentException If the string was not a valid URI.
+ */
+ @NotNull
+ public EndpointAppDeviceExtension setTelemetryEnterpriseEndpoint(
+ @Nullable final String endpoint)
+ throws IllegalArgumentException
+ {
+ return setTelemetryEnterpriseEndpoint(
+ (endpoint == null) ? null : URI.create(endpoint));
+ }
+
+ /**
+ * Retrieves the list of EndpointApp resources associated with this device.
+ *
+ * @return The list of endpoint application references.
+ */
+ @NotNull
+ public List getApplications()
+ {
+ return applications;
+ }
+
+ /**
+ * Specifies the list of EndpointApp resources associated with this device.
+ *
+ * @param applications The list of endpoint application references.
+ * @return This endpoint application extension.
+ */
+ @NotNull
+ public EndpointAppDeviceExtension setApplications(
+ @Nullable final List applications)
+ {
+ this.applications = (applications == null) ? List.of() : applications;
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setApplications(List)}.
+ *
+ * @param app A non-null application reference.
+ * @param apps An optional set of additional arguments.
+ *
+ * @return This endpoint application extension.
+ */
+ @NotNull
+ public EndpointAppDeviceExtension setApplications(
+ @NotNull final EndpointAppReference app,
+ @Nullable final EndpointAppReference... apps)
+ {
+ return setApplications(StaticUtils.toList(app, apps));
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this endpoint application
+ * extension.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this extension,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof EndpointAppDeviceExtension that
+ && Objects.equals(deviceControlEnterpriseEndpoint,
+ that.deviceControlEnterpriseEndpoint)
+ && Objects.equals(telemetryEnterpriseEndpoint,
+ that.telemetryEnterpriseEndpoint)
+ && Objects.equals(applications, that.applications);
+ }
+
+ /**
+ * Retrieves a hash code for this endpoint application extension.
+ *
+ * @return A hash code for this endpoint extension.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(deviceControlEnterpriseEndpoint,
+ telemetryEnterpriseEndpoint, applications);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppReference.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppReference.java
new file mode 100644
index 00000000..4732e241
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppReference.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.types.AttributeDefinition;
+import com.unboundid.scim2.common.utils.JsonUtils;
+
+import java.net.URI;
+import java.util.Objects;
+import java.util.UUID;
+
+
+/**
+ * A reference to an {@link EndpointAppResource} within an
+ * {@link EndpointAppDeviceExtension}, as defined by RFC 9944. For more
+ * background on devices, see the {@link DeviceResource} class.
+ */
+public class EndpointAppReference
+{
+ @Nullable
+ @Attribute(description = "The identifier of the EndpointApp resource.",
+ isRequired = true,
+ isCaseExact = false)
+ private String value;
+
+ @Nullable
+ @Attribute(description = "The URI of the EndpointApp resource.",
+ isRequired = false,
+ isCaseExact = true,
+ referenceTypes = { "EndpointApp" },
+ mutability = AttributeDefinition.Mutability.READ_ONLY)
+ @JsonProperty("$ref")
+ private URI ref;
+
+ /**
+ * Retrieves the identifier of the EndpointApp resource.
+ *
+ * @return The EndpointApp identifier.
+ */
+ @Nullable
+ public String getValue()
+ {
+ return value;
+ }
+
+ /**
+ * Specifies the identifier of the EndpointApp resource. This should generally
+ * be a {@link UUID}.
+ *
+ * @param value The EndpointApp identifier.
+ * @return This object.
+ */
+ @NotNull
+ public EndpointAppReference setValue(@Nullable final String value)
+ {
+ this.value = value;
+ return this;
+ }
+
+ /**
+ * Alternate method that accepts a UUID object as a value.
+ *
+ * @param value The EndpointApp identifier.
+ * @return This object.
+ */
+ @NotNull
+ public EndpointAppReference setValue(@Nullable final UUID value)
+ {
+ return setValue(value == null ? null : value.toString());
+ }
+
+ /**
+ * Retrieves the URI of the EndpointApp resource.
+ *
+ * @return The URI.
+ */
+ @Nullable
+ public URI getRef()
+ {
+ return ref;
+ }
+
+ /**
+ * Retrieves the URI of the EndpointApp resource.
+ *
+ * @return The URI.
+ */
+ @Nullable
+ @JsonIgnore
+ public String getRefString()
+ {
+ return (getRef() == null) ? null : getRef().toString();
+ }
+
+ /**
+ * Specifies the URI of the EndpointApp resource.
+ *
+ * @param ref The URI.
+ * @return This object.
+ */
+ @NotNull
+ public EndpointAppReference setRef(@Nullable final URI ref)
+ {
+ this.ref = ref;
+ return this;
+ }
+
+ /**
+ * Specifies a string URI of the EndpointApp resource.
+ *
+ * @param ref The URI.
+ * @return This object.
+ *
+ * @throws IllegalArgumentException If the provided string was not a URI.
+ */
+ @NotNull
+ public EndpointAppReference setRef(@Nullable final String ref)
+ throws IllegalArgumentException
+ {
+ return setRef((ref == null) ? null : URI.create(ref));
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this application entry.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this entry,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof EndpointAppReference that
+ && Objects.equals(value, that.value)
+ && Objects.equals(ref, that.ref);
+ }
+
+ /**
+ * Retrieves a string representation of this application entry.
+ *
+ * @return A string representation of this application entry.
+ */
+ @Override
+ @NotNull
+ public String toString()
+ {
+ return JsonUtils.getObjectWriter().withDefaultPrettyPrinter()
+ .writeValueAsString(this);
+ }
+
+ /**
+ * Retrieves a hash code for this application entry.
+ *
+ * @return A hash code for this application entry.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(value, ref);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java
new file mode 100644
index 00000000..f6aca330
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EndpointAppResource.java
@@ -0,0 +1,342 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.unboundid.scim2.common.BaseScimResource;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.types.AttributeDefinition;
+import com.unboundid.scim2.common.types.Group;
+import com.unboundid.scim2.common.utils.StaticUtils;
+
+import java.util.List;
+import java.util.Objects;
+
+
+/**
+ * This class represents an "endpoint application" resource type as defined by
+ * RFC 9944
+ * Section 6. An EndpointApp resource represents an external application
+ * that interacts with a device. The following types of applications are
+ * defined in SCIM:
+ *
+ *
deviceControl: Sends commands to control a device.
+ *
telemetry: Receives data from a device, generally for observability.
+ *
+ *
+ *
+ * An endpoint application resource contains the following fields:
+ *
+ *
{@code applicationType}: The type of application. Must be either
+ * {@code deviceControl} or {@code telemetry}.
+ *
{@code applicationName}: A human-readable name for the application.
+ *
{@code clientToken}: A read-only token used by the endpoint
+ * application to authenticate to the service.
+ *
{@link CertificateInfo}: Certificate info for the endpoint app.
+ *
{@code groups}: A list of groups to which the endpoint
+ * application belongs.
+ *
+ *
+ *
+ * The following example JSON object represents an EndpointApp resource:
+ *
+ *
+ * See {@link CertificateInfo} for more details on how SCIM services should
+ * handle client certificate data.
+ *
+ * @since 6.1.0
+ */
+@Schema(id = "urn:ietf:params:scim:schemas:core:2.0:EndpointApp",
+ name = "Endpoint Application",
+ description = "Endpoint Application")
+public class EndpointAppResource extends BaseScimResource
+{
+ /**
+ * The maximum length of a client token as defined by RFC 9944 Section 6.2.
+ */
+ public static final int MAX_TOKEN_LENGTH = 500;
+
+ @Nullable
+ @Attribute(description = "The type of the endpoint application.",
+ isRequired = true,
+ isCaseExact = false,
+ canonicalValues = {"deviceControl", "telemetry"},
+ mutability = AttributeDefinition.Mutability.IMMUTABLE)
+ private String applicationType;
+
+ @Nullable
+ @Attribute(description = "The human-readable name of the endpoint "
+ + "application.",
+ isRequired = true,
+ isCaseExact = false)
+ private String applicationName;
+
+ @Nullable
+ @Attribute(description = """
+ A read-only token used by the endpoint application to authenticate to the
+ service. The token is generated by the server and MUST NOT exceed 500
+ characters in length (RFC 9944 Section 6.2).""",
+ isCaseExact = true,
+ mutability = AttributeDefinition.Mutability.READ_ONLY)
+ private String clientToken;
+
+ @Nullable
+ @Attribute(description = "Certificate info for the endpoint application.")
+ private CertificateInfo certificateInfo;
+
+ @NotNull
+ @Attribute(description =
+ "A list of groups to which the endpoint application belongs.",
+ mutability = AttributeDefinition.Mutability.READ_ONLY,
+ multiValueClass = Group.class)
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private List groups = List.of();
+
+ /**
+ * Retrieves the application type of this endpoint application.
+ *
+ * @return The application type.
+ */
+ @Nullable
+ public String getApplicationType()
+ {
+ return applicationType;
+ }
+
+ /**
+ * Specifies the application type of this endpoint application. This
+ * attribute is immutable after creation.
+ *
+ * @param type The application type.
+ * @return This endpoint application resource.
+ */
+ @NotNull
+ public EndpointAppResource setApplicationType(@Nullable final String type)
+ {
+ applicationType = type;
+ return this;
+ }
+
+ /**
+ * Retrieves the human-readable name of this endpoint application.
+ *
+ * @return The application name.
+ */
+ @Nullable
+ public String getApplicationName()
+ {
+ return applicationName;
+ }
+
+ /**
+ * Specifies the human-readable name of this endpoint application.
+ *
+ * @param name The application name.
+ * @return This endpoint application resource.
+ */
+ @NotNull
+ public EndpointAppResource setApplicationName(@Nullable final String name)
+ {
+ applicationName = name;
+ return this;
+ }
+
+ /**
+ * Retrieves the client token for this endpoint application.
+ *
+ * @return The client token.
+ */
+ @Nullable
+ public String getClientToken()
+ {
+ return clientToken;
+ }
+
+
+ /**
+ * Specifies the client token for this endpoint application.
+ *
+ * @param clientToken The client token.
+ * @return This endpoint application resource.
+ *
+ * @throws IllegalArgumentException If the provided token is longer than the
+ * {@link #MAX_TOKEN_LENGTH}.
+ */
+ @NotNull
+ public EndpointAppResource setClientToken(@Nullable final String clientToken)
+ throws IllegalArgumentException
+ {
+ if (clientToken != null && clientToken.length() > MAX_TOKEN_LENGTH)
+ {
+ throw new IllegalArgumentException(
+ "Client token lengths cannot be greater than " + MAX_TOKEN_LENGTH);
+ }
+
+ this.clientToken = clientToken;
+ return this;
+ }
+
+ /**
+ * Retrieves the certificate information for this endpoint application.
+ *
+ * @return The certificate information.
+ */
+ @Nullable
+ public CertificateInfo getCertificateInfo()
+ {
+ return certificateInfo;
+ }
+
+ /**
+ * Specifies the certificate information for this endpoint application.
+ *
+ * @param certificateInfo The certificate information.
+ * @return This endpoint application resource.
+ */
+ @NotNull
+ public EndpointAppResource setCertificateInfo(
+ @Nullable final CertificateInfo certificateInfo)
+ {
+ this.certificateInfo = certificateInfo;
+ return this;
+ }
+
+ /**
+ * Retrieves the list of groups to which this endpoint application belongs.
+ *
+ * @return The list of groups.
+ */
+ @NotNull
+ public List getGroups()
+ {
+ return groups;
+ }
+
+ /**
+ * Specifies the list of groups to which this endpoint application belongs.
+ *
+ * @param groups The list of groups.
+ * @return This endpoint application resource.
+ */
+ @NotNull
+ public EndpointAppResource setGroups(@Nullable final List groups)
+ {
+ this.groups = (groups == null) ? List.of() : groups;
+ return this;
+ }
+
+ /**
+ * Alternate version of {@link #setGroups(List)}.
+ *
+ * @param group The first group object.
+ * @param groups An optional set of additional arguments.
+ *
+ * @return This endpoint application resource.
+ */
+ @NotNull
+ public EndpointAppResource setGroups(@NotNull final Group group,
+ @Nullable final Group... groups)
+ {
+ return setGroups(StaticUtils.toList(group, groups));
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this endpoint app
+ * resource.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this endpoint
+ * app resource, or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof EndpointAppResource that
+ && super.equals(o)
+ && Objects.equals(applicationType, that.applicationType)
+ && Objects.equals(applicationName, that.applicationName)
+ && Objects.equals(clientToken, that.clientToken)
+ && Objects.equals(certificateInfo, that.certificateInfo)
+ && Objects.equals(groups, that.groups);
+ }
+
+ /**
+ * Retrieves a hash code for this endpoint app resource.
+ *
+ * @return A hash code for this endpoint app resource.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(super.hashCode(), applicationType, applicationName,
+ clientToken, certificateInfo, groups);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java
new file mode 100644
index 00000000..4354bf0e
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/EthernetMabDeviceExtension.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+
+import java.util.Objects;
+
+
+/**
+ * This class represents the Ethernet MAC Authentication Bypass (MAB) extension
+ * per
+ * RFC 9944 Section 7.3. This extension enables a legacy means of weak
+ * authentication that is supported in many wired Ethernet solutions. For more
+ * background on devices, see the {@link DeviceResource} class.
+ *
+ *
+ * The following JSON represents this extension as it appears on a device
+ * resource. This extension contains a single value, {@code deviceMacAddress},
+ * which represents the Ethernet address to be provisioned onto the network.
+ *
+ */
+@Schema(
+ id = "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device",
+ name = "Ethernet MAB",
+ description = "Ethernet MAB extension for a Device resource")
+public class EthernetMabDeviceExtension extends DeviceExtension
+{
+ @NotNull
+ @Attribute(description = "The Ethernet MAC address of this device.",
+ isRequired = true,
+ isCaseExact = false,
+ pattern = MAC_PATTERN)
+ private final String deviceMacAddress;
+
+ /**
+ * Creates a new Ethernet MAC Authentication Bypass (MAB) extension.
+ *
+ * @param deviceMacAddress The Ethernet MAC address of this device.
+ */
+ @JsonCreator
+ public EthernetMabDeviceExtension(
+ @NotNull @JsonProperty(value = "deviceMacAddress", required = true)
+ final String deviceMacAddress)
+ {
+ this.deviceMacAddress = Objects.requireNonNull(deviceMacAddress);
+ }
+
+ /**
+ * Retrieves the Ethernet MAC address of this device.
+ *
+ * @return The MAC address.
+ */
+ @NotNull
+ public String getDeviceMacAddress()
+ {
+ return deviceMacAddress;
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this Ethernet MAB
+ * extension.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this extension,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof EthernetMabDeviceExtension that
+ && Objects.equals(deviceMacAddress, that.deviceMacAddress);
+ }
+
+ /**
+ * Retrieves a hash code for this Ethernet MAB extension.
+ *
+ * @return A hash code for this Ethernet MAB extension.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(deviceMacAddress);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/FdoDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/FdoDeviceExtension.java
new file mode 100644
index 00000000..240cd7e7
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/FdoDeviceExtension.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.types.AttributeDefinition;
+import com.unboundid.scim2.common.utils.JsonUtils;
+
+import java.util.Objects;
+
+
+/**
+ * This class represents a FIDO Device Onboard (FDO) device extension defined by
+ * RFC 9944.
+ * FIDO Device Onboard is an automated service that detects devices connected to
+ * a network, and a device management service establishes secure connections to
+ * these devices by leveraging public key credentials. For more background on
+ * devices, see the {@link DeviceResource} class.
+ *
+ *
+ * This object contains a single field, {@code fdoVoucher}. This is a
+ * cryptographic ownership voucher that is stored as a PEM-encoded object. It is
+ * declared as write-only and must never appear in SCIM service responses.
+ * Furthermore, the SCIM service MUST know how to process the voucher, either
+ * directly or by forwarding it along to an owner process.
+ *
+ *
+ * The following JSON represents a device with an FDO extension:
+ *
+ */
+@Schema(
+ id="urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device",
+ name = "FIDO Device Onboard",
+ description = "FIDO Device Onboard extension for a Device resource")
+public class FdoDeviceExtension extends DeviceExtension
+{
+ @NotNull
+ @Attribute(description = "The FDO ownership voucher for this device. This is"
+ + " a cryptographic secret that should never be returned in responses.",
+ isRequired = true,
+ isCaseExact = false,
+ mutability = AttributeDefinition.Mutability.WRITE_ONLY,
+ returned = AttributeDefinition.Returned.NEVER,
+ uniqueness = AttributeDefinition.Uniqueness.NONE)
+ private String fdoVoucher = "";
+
+
+ /**
+ * Creates a FIDO Device Onboard (FDO) extension object.
+ *
+ * @param fdoVoucher The PEM-encoded voucher.
+ */
+ @JsonCreator
+ public FdoDeviceExtension(
+ @NotNull @JsonProperty(value = "fdoVoucher", required = true)
+ final String fdoVoucher)
+ {
+ setFdoVoucher(fdoVoucher);
+ }
+
+ /**
+ * Retrieves the FDO ownership voucher.
+ *
+ * @return The FDO voucher.
+ */
+ @NotNull
+ public String getFdoVoucher()
+ {
+ return fdoVoucher;
+ }
+
+ /**
+ * Specifies the PEM-encoded FDO ownership voucher.
+ *
+ * @param voucher The FDO voucher.
+ * @return This FDO extension.
+ */
+ @NotNull
+ public FdoDeviceExtension setFdoVoucher(@NotNull final String voucher)
+ {
+ this.fdoVoucher = Objects.requireNonNull(voucher);
+ return this;
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this FDO extension.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this extension,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof FdoDeviceExtension that
+ && Objects.equals(fdoVoucher, that.fdoVoucher);
+ }
+
+ /**
+ * Retrieves a hash code for this FDO extension.
+ *
+ * @return A hash code for this FDO extension.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(fdoVoucher);
+ }
+
+ /**
+ * Retrieves a string representation of this FDO extension, with the
+ * {@code fdoVoucher} value redacted.
+ *
+ * @return A string representation of this FDO extension.
+ */
+ @Override
+ @NotNull
+ public String toString()
+ {
+ return JsonUtils.toRedactedString(this, "fdoVoucher");
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java
new file mode 100644
index 00000000..04f34e60
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/ZigbeeDeviceExtension.java
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.unboundid.scim2.common.annotations.Attribute;
+import com.unboundid.scim2.common.annotations.NotNull;
+import com.unboundid.scim2.common.annotations.Nullable;
+import com.unboundid.scim2.common.annotations.Schema;
+import com.unboundid.scim2.common.utils.StaticUtils;
+
+import java.util.List;
+import java.util.Objects;
+
+
+/**
+ * This class represents a Zigbee device extension for a device as defined by
+ * RFC 9944. Zigbee
+ * is a suite of protocols that are generally used for low-power hardware
+ * devices. For more background on devices, see {@link DeviceResource}.
+ *
+ */
+@Schema(id = "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device",
+ name = "Zigbee",
+ description = "Zigbee extension for a Device resource")
+public class ZigbeeDeviceExtension extends DeviceExtension
+{
+ /**
+ * The regex matching Extended Unique Identifiers as defined by RFC 9944.
+ */
+ @NotNull
+ public static final String EUI64_PATTERN =
+ "^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){7}$";
+
+ @NotNull
+ @Attribute(description = "The Zigbee versions supported by this device.",
+ isRequired = true,
+ isCaseExact = false,
+ multiValueClass = String.class)
+ private final List versionSupport;
+
+ @NotNull
+ @Attribute(description = "The IEEE EUI-64 address of this Zigbee device."
+ + " Must match the pattern: " + EUI64_PATTERN,
+ isRequired = true,
+ isCaseExact = false,
+ pattern = EUI64_PATTERN)
+ private final String deviceEui64Address;
+
+ /**
+ * Creates a new Zigbee device extension.
+ *
+ * @param versionSupport The Zigbee versions supported by this device.
+ * @param deviceEui64Address The 64-bit Extended Unique Identifier of this
+ * Zigbee device.
+ */
+ @JsonCreator
+ public ZigbeeDeviceExtension(
+ @NotNull @JsonProperty(value = "versionSupport", required = true)
+ final List versionSupport,
+ @NotNull @JsonProperty(value = "deviceEui64Address", required = true)
+ final String deviceEui64Address)
+ {
+ this.versionSupport = List.copyOf(versionSupport);
+ this.deviceEui64Address = Objects.requireNonNull(deviceEui64Address);
+ }
+
+ /**
+ * Creates a new Zigbee device extension.
+ *
+ * @param deviceEui64Address The 64-bit Extended Unique Identifier.
+ * @param version A Zigbee version supported by this device.
+ * @param versions Additional versions supported by this device.
+ */
+ public ZigbeeDeviceExtension(@NotNull final String deviceEui64Address,
+ @NotNull final String version,
+ @Nullable final String... versions)
+ {
+ this(StaticUtils.toList(version, versions), deviceEui64Address);
+ }
+
+ /**
+ * Fetches the 64-bit Extended Unique Identifier of this Zigbee device.
+ *
+ * @return The EUI-64 address.
+ */
+ @NotNull
+ public String getDeviceEui64Address()
+ {
+ return deviceEui64Address;
+ }
+
+ /**
+ * Fetches the Zigbee versions supported by this device.
+ *
+ * @return The list of supported Zigbee versions.
+ */
+ @NotNull
+ public List getVersionSupport()
+ {
+ return versionSupport;
+ }
+
+ /**
+ * Indicates whether the provided object is equal to this Zigbee device
+ * extension.
+ *
+ * @param o The object to compare.
+ * @return {@code true} if the provided object is equal to this extension,
+ * or {@code false} if not.
+ */
+ @Override
+ public boolean equals(@Nullable final Object o)
+ {
+ if (this == o)
+ {
+ return true;
+ }
+
+ return o instanceof ZigbeeDeviceExtension that
+ && Objects.equals(deviceEui64Address, that.deviceEui64Address)
+ && Objects.equals(versionSupport, that.versionSupport);
+ }
+
+ /**
+ * Retrieves a hash code for this Zigbee device extension.
+ *
+ * @return A hash code for this Zigbee device extension.
+ */
+ @Override
+ public int hashCode()
+ {
+ return Objects.hash(deviceEui64Address, versionSupport);
+ }
+}
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java
new file mode 100644
index 00000000..09b98560
--- /dev/null
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/devices/package-info.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+/**
+ * This package contains resource and extension model classes for RFC 9944,
+ * which defines device resource types. For more information, see
+ * {@link com.unboundid.scim2.common.types.devices.DeviceResource}.
+ *
+ * @since 6.1.0
+ */
+package com.unboundid.scim2.common.types.devices;
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java
index f43f7da9..69f31d29 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java
@@ -33,6 +33,7 @@
package com.unboundid.scim2.common.utils;
import com.unboundid.scim2.common.Path;
+import com.unboundid.scim2.common.ScimResource;
import com.unboundid.scim2.common.annotations.NotNull;
import com.unboundid.scim2.common.annotations.Nullable;
import com.unboundid.scim2.common.exceptions.BadRequestException;
@@ -46,8 +47,11 @@
import tools.jackson.databind.ObjectWriter;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ArrayNode;
+import tools.jackson.databind.node.IntNode;
import tools.jackson.databind.node.JsonNodeFactory;
+import tools.jackson.databind.node.LongNode;
import tools.jackson.databind.node.NullNode;
+import tools.jackson.databind.node.NumericNode;
import tools.jackson.databind.node.ObjectNode;
import tools.jackson.databind.node.StringNode;
import tools.jackson.databind.type.CollectionType;
@@ -1099,6 +1103,59 @@ else if (element.isObject() || element.isArray())
}
}
+ /**
+ * Converts a primitive value to a numeric JsonNode.
+ *
+ *
+ * This method is needed for consistent behavior for Java classes that store
+ * {@code long} values in ObjectNodes, generally within an extension schema.
+ * During deserialization, integer-like values are only stored as a long if
+ * the value requires more than 32 bits. This can cause problems when another
+ * object is instantiated in code using a long, and the objects are compared
+ * with {@code equals()}. Since the 64-bit data type is different from the
+ * 32-bit one, this will always return false even if the actual numbers
+ * represented are identical.
+ *
+ *
+ * To avoid these problems, this utility method mimics this behavior, and
+ * returns long values as a 32-bit numeric node unless 64 bits are necessary.
+ *
+ * @param value The number to convert to a numeric node.
+ * @return The numeric node.
+ */
+ @NotNull
+ public static NumericNode asNumericNode(final long value)
+ {
+ return (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE)
+ ? LongNode.valueOf(value) : IntNode.valueOf((int) value);
+ }
+
+ /**
+ * Prints an object as a pretty-printed JSON string, overriding values
+ * of top-level string fields that match the provided list.
+ *
+ * @param resource The resource to serialize.
+ * @param redactedFields The names of the fields to redact.
+ *
+ * @return A pretty-printed JSON string with the specified fields redacted.
+ */
+ @NotNull
+ public static String toRedactedString(@NotNull final ScimResource resource,
+ @NotNull final String... redactedFields)
+ {
+ ObjectNode node = valueToNode(resource);
+ for (String field : redactedFields)
+ {
+ if (node.has(field))
+ {
+ node.put(field, "--REDACTED--");
+ }
+ }
+
+ return getObjectWriter().withDefaultPrettyPrinter()
+ .writeValueAsString(node);
+ }
+
/**
* Factory method for constructing a SCIM compatible Jackson
* {@link ObjectReader} with default settings. Note that the resulting
diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java
index a5bb4455..7a3e3cd6 100644
--- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java
+++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/SchemaUtils.java
@@ -254,6 +254,7 @@ private static Collection getAttributes(
addMutability(attributeBuilder, schemaProperty);
addMultiValued(attributeBuilder, propertyDescriptor, schemaProperty);
addCanonicalValues(attributeBuilder, schemaProperty);
+ addPattern(attributeBuilder, schemaProperty);
Class> propertyCls = propertyDescriptor.getPropertyType();
@@ -292,10 +293,8 @@ private static Collection getAttributes(
* @param propertyDescriptor property descriptor for the field to build
* the attribute for.
* @param jsonProperty the Jackson JsonProperty annotation for the field.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addName(
+ private static void addName(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@NotNull final PropertyDescriptor propertyDescriptor,
@Nullable final JsonProperty jsonProperty)
@@ -309,8 +308,6 @@ private static AttributeDefinition.Builder addName(
{
attributeBuilder.setName(propertyDescriptor.getName());
}
-
- return attributeBuilder;
}
/**
@@ -322,10 +319,8 @@ private static AttributeDefinition.Builder addName(
* the attribute for.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addMultiValued(
+ private static void addMultiValued(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@NotNull final PropertyDescriptor propertyDescriptor,
@NotNull final Attribute schemaProperty)
@@ -354,8 +349,6 @@ private static AttributeDefinition.Builder addMultiValued(
}
attributeBuilder.setMultiValued(multiValued);
-
- return attributeBuilder;
}
/**
@@ -365,10 +358,8 @@ private static AttributeDefinition.Builder addMultiValued(
* @param attributeBuilder builder for a scim attribute.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addDescription(
+ private static void addDescription(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@Nullable final Attribute schemaProperty)
{
@@ -376,8 +367,6 @@ private static AttributeDefinition.Builder addDescription(
{
attributeBuilder.setDescription(schemaProperty.description());
}
-
- return attributeBuilder;
}
/**
@@ -387,10 +376,8 @@ private static AttributeDefinition.Builder addDescription(
* @param attributeBuilder builder for a scim attribute.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addCaseExact(
+ private static void addCaseExact(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@Nullable final Attribute schemaProperty)
{
@@ -398,8 +385,6 @@ private static AttributeDefinition.Builder addCaseExact(
{
attributeBuilder.setCaseExact(schemaProperty.isCaseExact());
}
-
- return attributeBuilder;
}
/**
@@ -409,10 +394,8 @@ private static AttributeDefinition.Builder addCaseExact(
* @param attributeBuilder builder for a scim attribute.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addRequired(
+ private static void addRequired(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@Nullable final Attribute schemaProperty)
{
@@ -420,8 +403,6 @@ private static AttributeDefinition.Builder addRequired(
{
attributeBuilder.setRequired(schemaProperty.isRequired());
}
-
- return attributeBuilder;
}
/**
@@ -431,10 +412,8 @@ private static AttributeDefinition.Builder addRequired(
* @param attributeBuilder builder for a scim attribute.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addCanonicalValues(
+ private static void addCanonicalValues(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@Nullable final Attribute schemaProperty)
{
@@ -442,8 +421,6 @@ private static AttributeDefinition.Builder addCanonicalValues(
{
attributeBuilder.addCanonicalValues(schemaProperty.canonicalValues());
}
-
- return attributeBuilder;
}
/**
@@ -453,10 +430,8 @@ private static AttributeDefinition.Builder addCanonicalValues(
* @param attributeBuilder builder for a scim attribute.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addReturned(
+ private static void addReturned(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@Nullable final Attribute schemaProperty)
{
@@ -464,8 +439,6 @@ private static AttributeDefinition.Builder addReturned(
{
attributeBuilder.setReturned(schemaProperty.returned());
}
-
- return attributeBuilder;
}
/**
@@ -475,10 +448,8 @@ private static AttributeDefinition.Builder addReturned(
* @param attributeBuilder builder for a scim attribute.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addUniqueness(
+ private static void addUniqueness(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@Nullable final Attribute schemaProperty)
{
@@ -486,8 +457,6 @@ private static AttributeDefinition.Builder addUniqueness(
{
attributeBuilder.setUniqueness(schemaProperty.uniqueness());
}
-
- return attributeBuilder;
}
/**
@@ -497,10 +466,8 @@ private static AttributeDefinition.Builder addUniqueness(
* @param attributeBuilder builder for a scim attribute.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addReferenceTypes(
+ private static void addReferenceTypes(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@Nullable final Attribute schemaProperty)
{
@@ -508,8 +475,24 @@ private static AttributeDefinition.Builder addReferenceTypes(
{
attributeBuilder.addReferenceTypes(schemaProperty.referenceTypes());
}
+ }
- return attributeBuilder;
+ /**
+ * This method will find the regular expression constraint for the attribute,
+ * and add it to the builder if the annotation specifies a non-empty pattern.
+ *
+ * @param attributeBuilder builder for a scim attribute.
+ * @param schemaProperty the schema property annotation for the field
+ * to build an attribute for.
+ */
+ private static void addPattern(
+ @NotNull final AttributeDefinition.Builder attributeBuilder,
+ @Nullable final Attribute schemaProperty)
+ {
+ if (schemaProperty != null && !schemaProperty.pattern().isEmpty())
+ {
+ attributeBuilder.setPattern(schemaProperty.pattern());
+ }
}
/**
@@ -520,10 +503,8 @@ private static AttributeDefinition.Builder addReferenceTypes(
* @param attributeBuilder builder for a scim attribute.
* @param schemaProperty the schema property annotation for the field
* to build an attribute for.
- * @return this.
*/
- @NotNull
- private static AttributeDefinition.Builder addMutability(
+ private static void addMutability(
@NotNull final AttributeDefinition.Builder attributeBuilder,
@Nullable final Attribute schemaProperty)
{
@@ -535,8 +516,6 @@ private static AttributeDefinition.Builder addMutability(
{
attributeBuilder.setMutability(AttributeDefinition.Mutability.READ_WRITE);
}
-
- return attributeBuilder;
}
/**
@@ -551,8 +530,8 @@ private static AttributeDefinition.Builder addMutability(
private static AttributeDefinition.Type getAttributeType(
@NotNull final Class> cls)
{
- if ((cls == Integer.class) ||
- (cls == int.class))
+ if (cls == Integer.class || cls == int.class
+ || cls == Long.class || cls == long.class)
{
return AttributeDefinition.Type.INTEGER;
}
diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/AttributeDefinitionTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/AttributeDefinitionTest.java
index df6ff459..253ddee1 100644
--- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/AttributeDefinitionTest.java
+++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/AttributeDefinitionTest.java
@@ -116,7 +116,8 @@ public void testAttributeSerialization()
String minimalJson = """
{
"name": "attrName",
- "multiValued": false
+ "multiValued": false,
+ "pattern": "[a-z]+"
}""";
AttributeDefinition deserializedMinimal = reader.readValue(minimalJson);
@@ -128,6 +129,7 @@ public void testAttributeSerialization()
.setReturned(AttributeDefinition.Returned.DEFAULT)
.setUniqueness(AttributeDefinition.Uniqueness.NONE)
.setCaseExact(false)
+ .setPattern("[a-z]+")
.build();
assertThat(deserializedMinimal).isEqualTo(expectedMinimal);
@@ -145,6 +147,20 @@ public void testAttributeSerialization()
}""";
assertThatThrownBy(() -> reader.readValue(noMultiValued))
.isInstanceOf(JacksonException.class);
+
+ // Attributes cannot use patterns on non-string types.
+ String invalidPattern = """
+ {
+ "name": "attrName",
+ "type": "boolean",
+ "multiValued": false,
+ "pattern": "*"
+ }""";
+ assertThatThrownBy(() -> reader.readValue(invalidPattern))
+ .isInstanceOf(JacksonException.class)
+ .hasMessageContaining("Cannot set the 'pattern' of an attribute for")
+ .hasMessageContaining("non-string types");
+
}
/**
diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java
new file mode 100644
index 00000000..79beea21
--- /dev/null
+++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/devices/DevicesTest.java
@@ -0,0 +1,893 @@
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Copyright 2026 Ping Identity Corporation
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License (GPLv2 only)
+ * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+package com.unboundid.scim2.common.types.devices;
+
+import com.unboundid.scim2.common.types.Group;
+import com.unboundid.scim2.common.types.Meta;
+import com.unboundid.scim2.common.utils.JsonUtils;
+import org.testng.annotations.Test;
+
+import java.net.URI;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+/**
+ * This class contains tests for {@link DeviceResource}, device extensions such
+ * as {@link BleDeviceExtension}, and more of the {@code devices} package.
+ */
+@Test
+public class DevicesTest
+{
+ /**
+ * Tests the base {@link DeviceResource} class.
+ */
+ @Test
+ public void testDeviceResource()
+ {
+ String json = """
+ {
+ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:Device" ],
+ "id": "e9e30dba-f08f-4109-8486-d5c6a3316111",
+ "displayName": "BLE Example Monitor",
+ "active": true
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ DeviceResource device = new DeviceResource()
+ .setDisplayName("BLE Example Monitor")
+ .setActive(true);
+ device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111");
+
+ assertThat(device.getId())
+ .isEqualTo("e9e30dba-f08f-4109-8486-d5c6a3316111");
+ assertThat(device.getDisplayName()).isEqualTo("BLE Example Monitor");
+ assertThat(device.getActive()).isTrue();
+ assertThat(device.getMudUrlString()).isNull();
+ assertThat(device.getGroups()).isNotNull().isEmpty();
+ assertThat(device.getActive()).isTrue();
+ assertThat(device.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(device);
+ DeviceResource deserialized = JsonUtils.getObjectReader()
+ .forType(DeviceResource.class).readValue(serialized);
+ assertThat(device).isEqualTo(deserialized);
+
+ Group group = new Group()
+ .setValue("beef")
+ .setRef("https://example.com/Groups/beef")
+ .setDisplay("Admins")
+ .setType("direct");
+
+ DeviceResource device2 = new DeviceResource()
+ .setDisplayName("BLE Example Monitor")
+ .setActive(false)
+ .setMudUrl("https://example.com/mud/device.json")
+ .setGroups(group);
+ device2.setId("e9e30dba-f08f-4109-8486-d5c6a3316111");
+
+ assertThat(device2.getDisplayName()).isEqualTo("BLE Example Monitor");
+ assertThat(device2.getActive()).isFalse();
+ assertThat(device2.getMudUrl())
+ .isEqualTo(URI.create("https://example.com/mud/device.json"));
+ assertThat(device2.getMudUrlString())
+ .isEqualTo("https://example.com/mud/device.json");
+ assertThat(device2.getGroups()).containsExactly(group);
+ assertThat(device2.toString()).contains("https://example.com/Groups/beef");
+ assertThat(group.toString()).contains("https://example.com/Groups/beef");
+ }
+
+ /**
+ * Tests for {@link EndpointAppResource}.
+ */
+ @Test
+ public void testEndpointAppResource()
+ {
+ String json = """
+ {
+ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:EndpointApp" ],
+ "id": "e9e30dba-f08f-4109-8486-d5c6a3316212",
+ "applicationType": "deviceControl",
+ "applicationName": "Device Control App 1",
+ "certificateInfo": {
+ "rootCA": "SGV5U3RvcERlY29kaW5nTWU=",
+ "subjectName": "www.example.com"
+ }
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ CertificateInfo cert = new CertificateInfo("www.example.com")
+ .setRootCA("SGV5U3RvcERlY29kaW5nTWU=");
+
+ EndpointAppResource app = new EndpointAppResource()
+ .setApplicationType("deviceControl")
+ .setApplicationName("Device Control App 1")
+ .setCertificateInfo(cert);
+ app.setId("e9e30dba-f08f-4109-8486-d5c6a3316212");
+
+ assertThat(app.getId()).isEqualTo("e9e30dba-f08f-4109-8486-d5c6a3316212");
+ assertThat(app.getApplicationType()).isEqualTo("deviceControl");
+ assertThat(app.getApplicationName()).isEqualTo("Device Control App 1");
+ assertThat(app.getCertificateInfo()).isEqualTo(cert);
+ assertThat(app.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(app);
+ EndpointAppResource deserialized = JsonUtils.getObjectReader()
+ .forType(EndpointAppResource.class).readValue(serialized);
+ assertThat(app).isEqualTo(deserialized);
+
+ Group group = new Group().setValue("group1").setType("direct");
+ EndpointAppResource rich = new EndpointAppResource()
+ .setApplicationType("telemetry")
+ .setApplicationName("Telemetry App")
+ .setClientToken("tok-secret")
+ .setCertificateInfo(cert)
+ .setGroups(group);
+ rich.setId("e9e30dba-f08f-4109-8486-d5c6a3316212");
+
+ assertThat(rich.getClientToken()).isEqualTo("tok-secret");
+ assertThat(rich.getGroups()).containsExactly(group);
+
+ String richSerialized = JsonUtils.getObjectWriter().writeValueAsString(rich);
+ EndpointAppResource richDeserialized = JsonUtils.getObjectReader()
+ .forType(EndpointAppResource.class).readValue(richSerialized);
+ assertThat(rich).isEqualTo(richDeserialized);
+
+ // Ensure max length restrictions on client tokens.
+ assertThatThrownBy(() -> new EndpointAppResource()
+ .setClientToken("a".repeat(501)))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ /**
+ * Tests for {@link CertificateInfo}.
+ */
+ @Test
+ public void testCertificateInfo()
+ {
+ // The subjectName DN is printed in the form typically seen in certs.
+ String json = """
+ {
+ "rootCA": "SGV5U3RvcERlY29kaW5nTWU=",
+ "subjectName": "CN=EX1, O=Example, C=US"
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ CertificateInfo cert = new CertificateInfo("CN=EX1, O=Example, C=US")
+ .setRootCA("SGV5U3RvcERlY29kaW5nTWU=");
+
+ assertThat(cert.getRootCA()).isEqualTo("SGV5U3RvcERlY29kaW5nTWU=");
+ assertThat(cert.getSubjectName()).isEqualTo("CN=EX1, O=Example, C=US");
+ assertThat(cert.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(cert);
+ CertificateInfo deserialized = JsonUtils.getObjectReader()
+ .forType(CertificateInfo.class).readValue(serialized);
+ assertThat(cert).isEqualTo(deserialized);
+ }
+
+ /**
+ * Tests for {@link BleDeviceExtension}.
+ */
+ @Test
+ public void testBleDeviceExtension()
+ {
+ String json = """
+ {
+ "schemas": [
+ "urn:ietf:params:scim:schemas:core:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:ble:2.0:Device"
+ ],
+ "id": "e9e30dba-f08f-4109-8486-d5c6a3316111",
+ "displayName": "Bluetooth Low Energy Sample Device",
+ "active": true,
+ "urn:ietf:params:scim:schemas:extension:ble:2.0:Device": {
+ "versionSupport": [ "5.4" ],
+ "deviceMacAddress": "2C:54:91:88:C9:E2",
+ "isRandom": false,
+ "mobility": true,
+ "separateBroadcastAddress": [
+ "AA:BB:88:77:22:11",
+ "AA:BB:88:77:22:12"
+ ],
+ "pairingMethods": [
+ "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device"
+ ],
+ "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device": {
+ "key": null
+ },
+ "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device": { },
+ "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device": {
+ "key": "OOB_Key",
+ "randomNumber": 1828,
+ "confirmationNumber": 3
+ },
+ "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device": {
+ "key": 12
+ }
+ }
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ // Obtain the device and its nested extension in object form.
+ DeviceResource deserialized = JsonUtils.getObjectReader()
+ .forType(DeviceResource.class).readValue(expectedJson);
+ BleDeviceExtension extension =
+ deserialized.getExtension(BleDeviceExtension.class);
+ assertThat(extension).isNotNull();
+
+ // Evaluate the individual fields of the extension first.
+ assertThat(extension.getDeviceMacAddress()).isEqualTo("2C:54:91:88:C9:E2");
+ assertThat(extension.getIrk()).isNull();
+ assertThat(extension.getIsRandom()).isFalse();
+ assertThat(extension.getSeparateBroadcastAddress())
+ .containsExactly("AA:BB:88:77:22:11", "AA:BB:88:77:22:12");
+ assertThat(extension.getMobility()).isTrue();
+ assertThat(extension.getVersionSupport()).containsExactly("5.4");
+ assertThat(extension.getPairingMethods()).containsExactly(
+ "urn:ietf:params:scim:schemas:extension:pairingJustWorks:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:pairingNull:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:pairingOOB:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:pairingPassKey:2.0:Device"
+ );
+
+ // Fetching the elements in object form should result in a list in the same
+ // order.
+ assertThat(extension.getPairingMethodExtensions()).containsExactly(
+ new BlePairingJustWorks(),
+ new BlePairingNull(),
+ new BlePairingOutOfBand("OOB_Key", 1828).setConfirmationNumber(3),
+ new BlePairingPassKey("000012"));
+
+ // Instantiate an object equivalent to the JSON.
+ BleDeviceExtension ble =
+ new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
+ .setIsRandom(false)
+ .setSeparateBroadcastAddress(
+ "AA:BB:88:77:22:11", "AA:BB:88:77:22:12")
+ .setMobility(true)
+ .setPairingExtension(new BlePairingPassKey(12))
+ .setPairingExtension(new BlePairingOutOfBand("OOB_Key", 1828, 3))
+ .setPairingExtension(new BlePairingNull())
+ .setPairingExtension(new BlePairingJustWorks());
+
+ DeviceResource device = new DeviceResource()
+ .setDisplayName("Bluetooth Low Energy Sample Device")
+ .setActive(true)
+ .setDeviceExtension(ble);
+ device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111");
+ assertThat(device).isEqualTo(deserialized);
+
+ // Check the overall JSON form of the object when it is serialized.
+ assertThat(device.toString()).isEqualTo(expectedJson);
+
+ // Check the order for pairing method URNs and objects. These were added in
+ // non-alphabetical order, but should still return a consistent order.
+ assertThat(ble.getPairingMethods())
+ .containsExactlyElementsOf(extension.getPairingMethods());
+ assertThat(ble.getPairingMethodExtensions())
+ .containsExactlyElementsOf(extension.getPairingMethodExtensions());
+
+ // Try obtaining a field stored on a nested BlePairingMethod object.
+ assertThat(ble.getPairingMethodExtensions())
+ .filteredOn(p -> p instanceof BlePairingOutOfBand)
+ .hasSize(1)
+ .first()
+ .isInstanceOfSatisfying(BlePairingOutOfBand.class,
+ oob -> assertThat(oob.getRandomNumber()).isEqualTo(1828));
+
+ // Test removing the pairing method from the device.
+ int originalSize = ble.getPairingMethodExtensions().size();
+ assertThat(ble.getPairingMethodExtensions()).hasSize(originalSize);
+ assertThat(ble.getPairingMethods()).hasSize(originalSize);
+ assertThat(ble).isEqualTo(deserialized.getExtension(BleDeviceExtension.class));
+
+ ble = ble.removePairingExtension(BlePairingOutOfBand.class);
+ assertThat(ble.getPairingMethodExtensions())
+ .doesNotHaveAnyElementsOfTypes(BlePairingOutOfBand.class);
+ assertThat(ble.getPairingMethodExtensions()).hasSize(originalSize - 1);
+ assertThat(ble.getPairingMethods()).hasSize(originalSize - 1);
+ assertThat(ble)
+ .isNotEqualTo(deserialized.getExtension(BleDeviceExtension.class));
+
+ // Try the same call again. This tests removal of a pairing method object
+ // that is not present on a BLE extension. This should be a no-op.
+ var list = List.copyOf(ble.getPairingMethodExtensions());
+ ble.removePairingExtension(BlePairingOutOfBand.class);
+ assertThat(ble.getPairingMethodExtensions()).isEqualTo(list);
+ assertThat(ble.getPairingMethods()).hasSameSizeAs(list);
+
+ // Test manually setting the pairing methods.
+ ble.setPairingMethods(List.of("urn:customValue"));
+ assertThat(ble.getPairingMethods()).containsOnly("urn:customValue");
+
+ // Ensure the irk field can be set.
+ BleDeviceExtension withIrk =
+ new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
+ .setIsRandom(false)
+ .setIrk("resolvingKey")
+ .setMobility(true);
+
+ assertThat(withIrk.getIrk()).isEqualTo("resolvingKey");
+
+ // Calling toString() should not print the IRK to avoid leaking the
+ // value in log messages.
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(withIrk);
+ assertThat(serialized).contains(withIrk.getIrk());
+ assertThat(withIrk.toString()).contains("--REDACTED--")
+ .doesNotContain(withIrk.getIrk());
+ }
+
+ /**
+ * Tests for {@link BlePairingJustWorks}.
+ */
+ @Test
+ public void testBlePairingJustWorks()
+ {
+ // The SCIM SDK generally does not print null values. However, printing null
+ // is explicitly mandated for the Just Works pairing method.
+ String json = """
+ {
+ "key": null
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ String serialized = new BlePairingJustWorks().toString();
+ assertThat(serialized).isEqualTo(expectedJson);
+
+ BlePairingJustWorks deserialized = JsonUtils.getObjectReader()
+ .forType(BlePairingJustWorks.class).readValue(json);
+ assertThat(deserialized).isEqualTo(new BlePairingJustWorks());
+ }
+
+ /**
+ * Tests for {@link BlePairingNull}.
+ */
+ @Test
+ public void testBlePairingNull()
+ {
+ String json = "{}";
+ BlePairingNull object = new BlePairingNull();
+
+ String serialized = JsonUtils.getObjectWriter().withDefaultPrettyPrinter()
+ .writeValueAsString(object);
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+ assertThat(serialized).isEqualTo(expectedJson);
+
+ BlePairingNull deserialized = JsonUtils.getObjectReader()
+ .forType(BlePairingNull.class).readValue(serialized);
+ assertThat(object).isEqualTo(deserialized);
+ }
+
+ /**
+ * Tests for {@link BlePairingOutOfBand}.
+ */
+ @Test
+ public void testBlePairingOOB()
+ {
+ String json = """
+ {
+ "key": "retrievedKey",
+ "randomNumber": 987654
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ BlePairingOutOfBand figure6 = new BlePairingOutOfBand(
+ "retrievedKey", 987654);
+
+ assertThat(figure6.getKey()).isEqualTo("retrievedKey");
+ assertThat(figure6.getRandomNumber()).isEqualTo(987654);
+ assertThat(figure6.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(figure6);
+ BlePairingOutOfBand deserialized = JsonUtils.getObjectReader()
+ .forType(BlePairingOutOfBand.class).readValue(serialized);
+ assertThat(figure6).isEqualTo(deserialized);
+
+ BlePairingOutOfBand withConfirmation = new BlePairingOutOfBand(
+ "retrievedKey", 987654)
+ .setConfirmationNumber(111222);
+
+ assertThat(withConfirmation.getConfirmationNumber()).isEqualTo(111222);
+
+ String fullSerialized =
+ JsonUtils.getObjectWriter().writeValueAsString(withConfirmation);
+ BlePairingOutOfBand fullDeserialized = JsonUtils.getObjectReader()
+ .forType(BlePairingOutOfBand.class).readValue(fullSerialized);
+ assertThat(withConfirmation).isEqualTo(fullDeserialized);
+ }
+
+ /**
+ * Tests for {@link BlePairingPassKey}.
+ */
+ @Test
+ public void testBlePairingPassKey()
+ {
+ // Leading zeroes are not permitted in JSON integers. This passkey's value
+ // is actually "003456".
+ String json = """
+ {
+ "key": 3456
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ BlePairingPassKey passKey = new BlePairingPassKey(3456);
+
+ assertThat(passKey.getKey()).isEqualTo(3456);
+ assertThat(passKey.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(passKey);
+ BlePairingPassKey deserialized = JsonUtils.getObjectReader()
+ .forType(BlePairingPassKey.class).readValue(serialized);
+ assertThat(passKey).isEqualTo(deserialized);
+
+ assertThat(passKey.getKeyAsString()).isEqualTo("003456");
+
+ // Attempt using values that are out of bounds.
+ assertThatThrownBy(() -> new BlePairingPassKey(-1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("The provided key was not a six digit value");
+ assertThatThrownBy(() -> new BlePairingPassKey(1_000_000))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("The provided key was not a six digit value");
+ }
+
+ /**
+ * Tests for {@link DppDeviceExtension}.
+ */
+ @Test
+ public void testDppDeviceExtension()
+ {
+ String json = """
+ {
+ "schemas": [
+ "urn:ietf:params:scim:schemas:core:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device"
+ ],
+ "id": "e9e30dba-f08f-4109-8486-d5c6a3316111",
+ "displayName": "WiFi Example Monitor",
+ "active": true,
+ "urn:ietf:params:scim:schemas:extension:dpp:2.0:Device": {
+ "dppVersion": 2,
+ "bootstrappingMethod": [ "QR" ],
+ "bootstrapKey": "V2F5IGJleW9uZCBjcmF6eSwgU2hhZHkgZ29uZQ==",
+ "deviceMacAddress": "2C:54:91:88:C9:F2",
+ "classChannel": [ "81/1", "115/36" ],
+ "serialNumber": "4774LH2b4044"
+ }
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ DppDeviceExtension dpp =
+ new DppDeviceExtension(2, "V2F5IGJleW9uZCBjcmF6eSwgU2hhZHkgZ29uZQ==")
+ .setDeviceMacAddress("2C:54:91:88:C9:F2")
+ .setSerialNumber("4774LH2b4044")
+ .setBootstrappingMethod("QR")
+ .setClassChannel("81/1", "115/36");
+
+ DeviceResource device = new DeviceResource()
+ .setDisplayName("WiFi Example Monitor")
+ .setActive(true)
+ .setDeviceExtension(dpp);
+ device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111");
+
+ assertThat(device.getDeviceExtensions()).containsOnly(dpp);
+ assertThat(dpp.getDppVersion()).isEqualTo(2);
+ assertThat(dpp.getBootstrapKey())
+ .isEqualTo("V2F5IGJleW9uZCBjcmF6eSwgU2hhZHkgZ29uZQ==");
+ assertThat(dpp.getDeviceMacAddress()).isEqualTo("2C:54:91:88:C9:F2");
+ assertThat(dpp.getSerialNumber()).isEqualTo("4774LH2b4044");
+ assertThat(dpp.getBootstrappingMethod()).containsExactly("QR");
+ assertThat(dpp.getClassChannel()).containsExactly("81/1", "115/36");
+ assertThat(device.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(device);
+ DeviceResource deserialized = JsonUtils.getObjectReader()
+ .forType(DeviceResource.class).readValue(serialized);
+ assertThat(device).isEqualTo(deserialized);
+ assertThat(deserialized.getDeviceExtensions()).containsOnly(dpp);
+
+ // Calling toString() should not print the secret key to avoid leaking the
+ // value in log messages.
+ assertThat(serialized).contains(dpp.getBootstrapKey());
+ assertThat(dpp.toString()).contains("--REDACTED--")
+ .doesNotContain(dpp.getBootstrapKey());
+ }
+
+ /**
+ * Tests for {@link EthernetMabDeviceExtension}.
+ */
+ @Test
+ public void testEthernetMabDeviceExtension()
+ {
+ String json = """
+ {
+ "schemas": [
+ "urn:ietf:params:scim:schemas:core:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device"
+ ],
+ "id": "e9e30dba-f08f-4109-8486-d5c6a3316111",
+ "displayName": "Example Ethernet Device",
+ "active": true,
+ "urn:ietf:params:scim:schemas:extension:ethernet-mab:2.0:Device": {
+ "deviceMacAddress": "2C:54:91:88:C9:E2"
+ }
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ EthernetMabDeviceExtension mab =
+ new EthernetMabDeviceExtension("2C:54:91:88:C9:E2");
+
+ DeviceResource device = new DeviceResource()
+ .setDisplayName("Example Ethernet Device")
+ .setActive(true)
+ .setDeviceExtension(mab);
+ device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111");
+
+ assertThat(device.getDeviceExtensions()).containsOnly(mab);
+ assertThat(mab.getDeviceMacAddress()).isEqualTo("2C:54:91:88:C9:E2");
+ assertThat(device.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(device);
+ DeviceResource deserialized = JsonUtils.getObjectReader()
+ .forType(DeviceResource.class).readValue(serialized);
+ assertThat(device).isEqualTo(deserialized);
+ assertThat(deserialized.getDeviceExtensions()).containsOnly(mab);
+ }
+
+ /**
+ * Tests for {@link FdoDeviceExtension}.
+ */
+ @Test
+ public void testFidoDeviceOnboardExtension()
+ {
+ // fdoVoucher, like other secret fields, can be present in client requests,
+ // but will be removed in server responses by scim2-sdk-server's
+ // ResourcePreparer, or by other alternative means.
+ String json = """
+ {
+ "schemas": [
+ "urn:ietf:params:scim:schemas:core:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device"
+ ],
+ "id": "e9e30dba-f08f-4109-8486-d5c6a3316111",
+ "displayName": "Example Ethernet Device",
+ "active": true,
+ "urn:ietf:params:scim:schemas:extension:fido-device-onboard:2.0:Device": {
+ "fdoVoucher": "voucher"
+ }
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ FdoDeviceExtension fdo = new FdoDeviceExtension("voucher");
+
+ DeviceResource device = new DeviceResource()
+ .setDisplayName("Example Ethernet Device")
+ .setActive(true)
+ .setDeviceExtension(fdo);
+ device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111");
+
+ assertThat(device.getDeviceExtensions()).containsOnly(fdo);
+ assertThat(fdo.getFdoVoucher()).isEqualTo("voucher");
+ assertThat(device.toString()).isEqualTo(expectedJson);
+
+ fdo = fdo.setFdoVoucher("newVoucher");
+ assertThat(fdo.getFdoVoucher()).isEqualTo("newVoucher");
+
+ // Reset the voucher to the original value for the next phase of the test.
+ fdo = fdo.setFdoVoucher("voucher");
+ assertThat(fdo.getFdoVoucher()).isEqualTo("voucher");
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(device);
+ DeviceResource deserialized = JsonUtils.getObjectReader()
+ .forType(DeviceResource.class).readValue(serialized);
+ assertThat(device).isEqualTo(deserialized);
+ assertThat(deserialized.getDeviceExtensions()).containsOnly(fdo);
+
+ // Calling toString() should not print the secret key to avoid leaking the
+ // value in log messages.
+ assertThat(serialized).contains(fdo.getFdoVoucher());
+ assertThat(fdo.toString()).contains("--REDACTED--")
+ .doesNotContain(fdo.getFdoVoucher());
+ }
+
+ /**
+ * Tests for {@link ZigbeeDeviceExtension}.
+ */
+ @Test
+ public void testZigbeeDeviceExtension()
+ {
+ String json = """
+ {
+ "schemas": [
+ "urn:ietf:params:scim:schemas:core:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device"
+ ],
+ "id": "e9e30dba",
+ "meta": {
+ "resourceType": "Device",
+ "created": "1970-01-23T04:56:22Z",
+ "lastModified": "1970-05-13T04:42:34Z",
+ "location": "https://example.com/v2/Devices/e9e30dba"
+ },
+ "displayName": "Zigbee Example Monitor",
+ "active": true,
+ "mudUrl": "https://example.com/lightbulbs/colour/v1",
+ "urn:ietf:params:scim:schemas:extension:zigbee:2.0:Device": {
+ "versionSupport": [ "3.0" ],
+ "deviceEui64Address": "50:32:5F:FF:FE:E7:67:28"
+ }
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ ZigbeeDeviceExtension zigbee =
+ new ZigbeeDeviceExtension("50:32:5F:FF:FE:E7:67:28", "3.0");
+
+ DeviceResource device = new DeviceResource()
+ .setDisplayName("Zigbee Example Monitor")
+ .setActive(true)
+ .setMudUrl("https://example.com/lightbulbs/colour/v1")
+ .setDeviceExtension(zigbee);
+ device.setId("e9e30dba");
+ device.setMeta(new Meta()
+ .setResourceType("Device")
+ .setCreatedMillis(1918582000L)
+ .setLastModifiedMillis(11421754000L)
+ .setLocationString("https://example.com/v2/Devices/e9e30dba"));
+
+ assertThat(device.getDeviceExtensions()).containsOnly(zigbee);
+ assertThat(zigbee.getDeviceEui64Address()).isEqualTo("50:32:5F:FF:FE:E7:67:28");
+ assertThat(zigbee.getVersionSupport()).containsExactly("3.0");
+ assertThat(device.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(device);
+ DeviceResource deserialized = JsonUtils.getObjectReader()
+ .forType(DeviceResource.class).readValue(serialized);
+ assertThat(device).isEqualTo(deserialized);
+ assertThat(deserialized.getDeviceExtensions()).containsOnly(zigbee);
+ }
+
+ /**
+ * Ensure that device extensions are explicitly forbidden from having a value
+ * for {@code schemas}.
+ */
+ @Test
+ public void testNoSchemasForDeviceExtensions()
+ {
+ EthernetMabDeviceExtension mab = new EthernetMabDeviceExtension("address");
+ assertThatThrownBy(() -> mab.setSchemaUrns("urn:invalidUrn"))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessage("Cannot set the 'schemas' value of a device extension.");
+
+ // Any 'schemas' JSON value should be dropped, as it is invalid.
+ String wifiJson = """
+ {
+ "schemas": [ "urn:this:should:be:unused" ],
+ "dppVersion": 3,
+ "bootstrappingMethod": [ "QR" ],
+ "bootstrapKey": "secretKey",
+ "deviceMacAddress": "00:11:22:33:44:55"
+ }""";
+
+ DppDeviceExtension wifiExtension = JsonUtils.getObjectReader()
+ .forType(DppDeviceExtension.class).readValue(wifiJson);
+ assertThat(wifiExtension.getSchemaUrns()).isEmpty();
+ }
+
+ /**
+ * Tests for {@link EndpointAppReference}.
+ */
+ @Test
+ public void testEndpointAppReference()
+ {
+ String json = """
+ {
+ "value": "e9e30dba",
+ "$ref": "https://example.com/v2/EndpointApps/e9e30dba"
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ EndpointAppReference app = new EndpointAppReference()
+ .setValue("e9e30dba")
+ .setRef("https://example.com/v2/EndpointApps/e9e30dba");
+
+ assertThat(app.getValue())
+ .isEqualTo("e9e30dba");
+ assertThat(app.getRefString())
+ .isEqualTo("https://example.com/v2/EndpointApps/e9e30dba");
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(app);
+ assertThat(app.toString()).isEqualTo(expectedJson);
+
+ EndpointAppReference deserialized = JsonUtils.getObjectReader()
+ .forType(EndpointAppReference.class).readValue(serialized);
+ assertThat(app).isEqualTo(deserialized);
+ }
+
+ /**
+ * Tests for {@link EndpointAppDeviceExtension}.
+ */
+ @Test
+ public void testEndpointAppsDeviceExtension()
+ {
+ String json = """
+ {
+ "schemas": [
+ "urn:ietf:params:scim:schemas:core:2.0:Device",
+ "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device"
+ ],
+ "id": "e9e30dba-f08f-4109-8486-d5c6a3316111",
+ "displayName": "BLE Example Monitor",
+ "active": true,
+ "urn:ietf:params:scim:schemas:extension:endpointAppsExt:2.0:Device": {
+ "deviceControlEnterpriseEndpoint":
+ "https://example.com/device_control_app_endpoint/",
+ "telemetryEnterpriseEndpoint":
+ "mqtts://example.com/telemetry_app_endpoint/",
+ "applications": [
+ {
+ "value": "e9e30dba-f08f-4109-8486-d5c6a3316212",
+ "$ref": "https://example.com/v2/EndpointApps/e9e30dba-f08f-4109-8486-d5c6a3316212"
+ },
+ {
+ "value": "e9e30dba-f08f-4109-8486-d5c6a3316333",
+ "$ref": "https://example.com/v2/EndpointApps/e9e30dba-f08f-4109-8486-d5c6a3316333"
+ }
+ ]
+ }
+ }""";
+ String expectedJson = JsonUtils.getObjectReader().readTree(json)
+ .toPrettyString();
+
+ EndpointAppReference app1 = new EndpointAppReference()
+ .setValue(UUID.fromString("e9e30dba-f08f-4109-8486-d5c6a3316212"))
+ .setRef("https://example.com/v2/EndpointApps/"
+ + "e9e30dba-f08f-4109-8486-d5c6a3316212");
+ EndpointAppReference app2 = new EndpointAppReference()
+ .setValue("e9e30dba-f08f-4109-8486-d5c6a3316333")
+ .setRef("https://example.com/v2/EndpointApps/"
+ + "e9e30dba-f08f-4109-8486-d5c6a3316333");
+
+ EndpointAppDeviceExtension ext = new EndpointAppDeviceExtension()
+ .setDeviceControlEnterpriseEndpoint(
+ "https://example.com/device_control_app_endpoint/")
+ .setTelemetryEnterpriseEndpoint(
+ "mqtts://example.com/telemetry_app_endpoint/")
+ .setApplications(app1, app2);
+
+ DeviceResource device = new DeviceResource()
+ .setDisplayName("BLE Example Monitor")
+ .setActive(true)
+ .setDeviceExtension(ext);
+ device.setId("e9e30dba-f08f-4109-8486-d5c6a3316111");
+
+ assertThat(device.getDeviceExtensions()).containsOnly(ext);
+ assertThat(ext.getDeviceControlEnterpriseEndpointString())
+ .isEqualTo("https://example.com/device_control_app_endpoint/");
+ assertThat(ext.getTelemetryEnterpriseEndpointString())
+ .isEqualTo("mqtts://example.com/telemetry_app_endpoint/");
+ assertThat(ext.getApplications()).containsExactly(app1, app2);
+ assertThat(device.toString()).isEqualTo(expectedJson);
+
+ String serialized = JsonUtils.getObjectWriter().writeValueAsString(device);
+ DeviceResource deserialized = JsonUtils.getObjectReader()
+ .forType(DeviceResource.class).readValue(serialized);
+ assertThat(device).isEqualTo(deserialized);
+ assertThat(deserialized.getDeviceExtensions()).containsOnly(ext);
+ }
+
+ /**
+ * Tests RFC 9944 Section 7.1.1 mutual-exclusion constraint between
+ * {@code irk} and {@code separateBroadcastAddress}.
+ */
+ @Test
+ public void testBleIrkAndSeparateBroadcastAddressMutualExclusion()
+ {
+ // Setting only irk is accepted.
+ BleDeviceExtension onlyIrk =
+ new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
+ .setIrk("secretIRK");
+ assertThat(onlyIrk.getIrk()).isEqualTo("secretIRK");
+
+ // Setting only separateBroadcastAddress is accepted.
+ BleDeviceExtension onlySba =
+ new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
+ .setSeparateBroadcastAddress("AA:BB:88:77:22:11");
+ assertThat(onlySba.getSeparateBroadcastAddress())
+ .containsExactly("AA:BB:88:77:22:11");
+
+ // Setting irk when separateBroadcastAddress is already set must throw.
+ assertThatThrownBy(() ->
+ new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
+ .setSeparateBroadcastAddress("AA:BB:88:77:22:11")
+ .setIrk("secretIRK"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("fields cannot both be set");
+
+ // Setting separateBroadcastAddress when irk is already set must throw.
+ assertThatThrownBy(() ->
+ new BleDeviceExtension("2C:54:91:88:C9:E2", "5.4")
+ .setIrk("secretIRK")
+ .setSeparateBroadcastAddress("AA:BB:88:77:22:11"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("fields cannot both be set");
+ }
+
+ /**
+ * Test valid {@code hashCode()} implementations in the devices package by
+ * ensuring the classes do not have conflicting values.
+ */
+ @Test
+ public void testHashCode()
+ {
+ List