ADR-011: UdapMetadata entity — structural validation separate from cryptographic validation
Status: Accepted
Context
UDAP Security STU2 discovery returns a JSON object from /.well-known/udap. That object must be parsed into a typed entity and validated before any downstream flow (Dynamic Client Registration, JWT client authentication, etc.) can proceed. Two distinct validation concerns arise:
- Structural validation — are all required fields present? Do they satisfy the fixed-value and conditional constraints specified in STU2?
- Cryptographic validation — is the
signed_metadataJWT signature valid, and does the embedded X.509 chain chain to a trusted anchor?
These concerns operate at different layers: structural checks need only the parsed JSON object, while cryptographic checks require a trusted certificate store and key material. Mixing them inside the same class would make the entity difficult to test (crypto requires real certs) and would couple two unrelated failure modes.
Decision
UdapMetadata handles structural parsing and validation only. It inherits from Safire::Entity (the same base used by SmartMetadata) and follows the same warn-and-return-false convention for valid?.
Conformance target: HL7 UDAP Security STU2 / v2.0.0 (discovery section).
Signed metadata: STU2 uses signed_metadata (not the deprecated signed_endpoints from earlier drafts). signed_metadata is treated as a required field by UdapMetadata#valid? and as an opaque string. Cryptographic validation of the JWT is intentionally deferred to a dedicated cryptographic validator to be introduced in a future PR.
Presence check uses nil?, not blank?: Several required array fields — for example, udap_authorization_extensions_supported — may legitimately be empty arrays in a conformant response. Using blank? would flag [] as absent; using nil? preserves the distinction between “field not present in the JSON response” and “field present but empty”.
Array type and element validation is explicit: Discovery metadata is untrusted JSON. UdapMetadata#valid? verifies every array-valued field is an Array whose elements are all Strings before performing profile, grant, non-empty, or subset checks. Public helper methods also treat malformed scalar metadata as unsupported instead of using Ruby string include? semantics.
Value-level constraints in valid?:
udap_versions_supportedmust equal["1"]exactly (STU2 fixed value)udap_profiles_supportedmust include"udap_dcr"and"udap_authn"(both required by STU2)token_endpoint_auth_methods_supportedmust equal["private_key_jwt"]exactly (STU2 fixed value)scopes_supported,grant_types_supported, and both JWT signing algorithm arrays must each have at least one elementsigned_metadatamust be a compact-JWS string: exactly three dot-separated segments where every segment contains only base64url characters ([A-Za-z0-9\-_], no padding); JWT header algorithm (alg), required claim presence, and signature are not validated here — these are deferred to the cryptographic validator (future PR)- endpoint URL fields (
token_endpoint,registration_endpoint, conditionallyauthorization_endpoint) must be absolute HTTPS URLs; plain HTTP is accepted only forlocalhostand127.0.0.1to support development without TLS — any other scheme on those hosts (e.g.ftp://localhost) is rejected; this exception does not apply in production authorization_endpointis conditionally required whengrant_types_supportedincludes"authorization_code""udap_authz"is conditionally required inudap_profiles_supportedwhengrant_types_supportedincludes"client_credentials""authorization_code"is conditionally required ingrant_types_supportedwhen"refresh_token"is also presentudap_authorization_extensions_requiredis conditionally required whenudap_authorization_extensions_supportedis non-empty; its values must be a subset ofudap_authorization_extensions_supportedudap_certifications_requiredis conditionally required whenudap_certifications_supportedis non-empty; its values must be a subset ofudap_certifications_supported
Public helpers follow a two-tier naming convention:
- Profile checks (
dynamic_registration_profile?,jwt_client_auth_profile?, etc.) test only whether the server advertises the profile string inudap_profiles_supported; they do not check whether all required supporting fields are present. - Capability checks (
supports_dynamic_registration?,supports_jwt_client_auth?, etc.) combine profile advertisement with the minimum preconditions needed to start that flow:supports_dynamic_registration?requiresudap_dcrprofile and a validregistration_endpointsupports_jwt_client_auth?requiresudap_authnprofile and a validtoken_endpointsupports_client_authorization?requiresudap_authzprofile,client_credentialsingrant_types_supported, and a validtoken_endpoint
Consequences
Benefits:
- Structural conformance is independently testable without any certificate infrastructure
valid?follows the same warn-and-return-false contract asSmartMetadata#valid?, giving callers a consistent API across protocolssigned_metadatacryptographic validation can be added (or skipped in dev/test) without touching the entity
Trade-offs:
- A structurally valid
UdapMetadataobject is not automatically cryptographically validated; callers that require full STU2 conformance must also perform cryptographic validation of thesigned_metadataJWT after structural validation passes; a dedicated validator will be introduced in a future PR