ADR-013: UDAP registration metadata as an immutable value object
Status: Accepted
Context
UDAP Dynamic Client Registration signs caller-controlled client metadata into a software statement. Once signed, that statement is authoritative: the registration server uses it instead of an unsigned request-body field when the same metadata appears in both places.
This creates a trust boundary before cryptographic construction. Safire must reject malformed or ambiguous registration metadata before signing, while still preserving valid extension fields allowed by RFC 7591. Discovery entities are not an appropriate model for this boundary: they represent remote input and use warn-and-return-false compliance checks, whereas registration metadata is local input that must either produce one canonical payload or raise.
The conformance target is HL7 UDAP Security STU2 / v2.0.0, which builds on the UDAP Dynamic Client Registration profile.
Decision
Use a dedicated strict value object
UdapRegistrationMetadata validates and normalizes the caller’s metadata at construction. Invalid input raises Safire::Errors::ValidationError with the failing attribute and a value-free reason. The class does not inherit from Entity and does not use the discovery layer’s warn-and-return-false contract.
Registration orchestration constructs this value object once, before discovery or other network activity. Scope diagnostics and software-statement signing consume the same immutable instance; Safire does not independently normalize the caller’s raw Hash at multiple stages.
The value object validates the scope string’s local shape only. Discovery-bound scope policy lives in Protocols::Udap: registration and modification reject a requested wildcard that is not advertised exactly in the server’s scopes_supported metadata, while that check stays warning-only for cancellation so scope-advertisement drift alone cannot block cleanup (see ADR-015).
Top-level string and symbol keys are normalized to strings. Supplying both forms of one key is rejected rather than allowing insertion order to decide which value is signed. The value object and its canonical internal hash are frozen, and to_h returns a defensive copy.
Enforce exact STU2 grant shapes
Registration accepts exactly one primary grant:
authorization_code, optionally withrefresh_tokenclient_credentials
Unknown and duplicate grant values are rejected. redirect_uris, logo_uri, and generated response_types: ["code"] apply only to authorization-code registration. Redirect and logo URIs require absolute HTTPS by default, and redirect URIs must not contain fragment components.
The STU2 logo requirement has two different validation boundaries. Safire can prove locally that logo_uri is an absolute HTTPS URI, so malformed, relative, and insecure public values fail before signing. Safire cannot prove that the remote representation is PNG, JPG/JPEG, or GIF from a URI path alone. Familiar image suffixes remain a quiet heuristic; other usable URI paths produce a value-free warning and remain the caller’s responsibility.
Safire does not dereference logo_uri during validation. Fetching an untrusted caller-supplied URI would introduce SSRF, latency, redirect, and remote availability concerns into construction of an otherwise local value object, without reliably proving what the authorization server will later retrieve.
For local development without TLS, callers may explicitly pass allow_insecure_localhost: true. This permits HTTP only on localhost and 127.0.0.1; remote HTTP remains invalid. Safire warns only when the exception is actually used. The library does not infer a framework environment because an implicit environment default could silently weaken production validation. Metadata created through the exception is intentionally documented as non-conformant.
Cancellation is a separate operation: :cancel mode. Callers omit grant_types; Safire injects an empty array and removes authorization-only metadata from the canonical result.
Reserve protocol-owned fields
Safire generates token_endpoint_auth_method: "private_key_jwt" and, when applicable, response_types. Callers cannot supply those fields, registered JWT claims, or request-envelope fields such as software_statement, certifications, and udap.
Unknown RFC 7591 extension fields are retained only when their values are exact JSON data: strings, finite JSON numbers, booleans, null, arrays, or objects with string keys and recursively valid values. Ruby-specific values and recursive collections are rejected.
Use the standard-library mail boundary
contacts must be a non-empty array of absolute URI strings with at least one valid mailto: address. Safire parses mail contacts as URI::MailTo, obtains addresses from URI::MailTo#to, and applies URI::MailTo::EMAIL_REGEXP. It does not maintain a project-specific email regular expression.
Consequences
- Signing code can consume one canonical, already validated metadata hash.
- Invalid caller metadata fails before discovery, signing, or POSTing.
- Caller mutations cannot change metadata between validation and signing.
- Protocol-owned claims cannot be shadowed through caller input.
- Plain HTTP remains impossible for remote hosts; local HTTP requires a literal boolean opt-in and produces a warning.
- Logo media type remains a caller obligation when it cannot be established from the URI path; Safire reports that uncertainty without fetching the URI.
- Extension metadata remains forward-compatible without permitting arbitrary Ruby objects into a JWT payload.
- Metadata validation remains independent of software-statement signing and the network registration lifecycle.
Protocols::Udap#register_clientandProtocols::Udap#cancel_registrationconsume this value object when performing end-to-end UDAP registration operations.