safire 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/release-safire/SKILL.md +153 -0
  3. data/.rubocop.yml +11 -1
  4. data/.tool-versions +1 -1
  5. data/CHANGELOG.md +107 -1
  6. data/CONTRIBUTION.md +6 -1
  7. data/Gemfile +1 -2
  8. data/Gemfile.lock +47 -37
  9. data/README.md +60 -5
  10. data/ROADMAP.md +61 -12
  11. data/docs/Gemfile.lock +49 -45
  12. data/docs/_config.yml +2 -2
  13. data/docs/adr/ADR-001-activesupport-dependency.md +2 -2
  14. data/docs/adr/ADR-002-facade-and-forwardable.md +21 -6
  15. data/docs/adr/ADR-003-protocol-vs-client-type.md +13 -6
  16. data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +34 -8
  17. data/docs/adr/ADR-006-lazy-discovery.md +64 -5
  18. data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +40 -10
  19. data/docs/adr/ADR-009-oauth-error-hierarchy.md +131 -0
  20. data/docs/adr/ADR-010-optional-client-id-dcr-temp-client.md +90 -0
  21. data/docs/adr/ADR-011-udap-stu2-discovery-conformance.md +113 -0
  22. data/docs/adr/ADR-012-udap-signed-metadata-validation.md +104 -0
  23. data/docs/adr/ADR-013-udap-registration-request-model.md +106 -0
  24. data/docs/adr/ADR-014-udap-software-statement-signing.md +122 -0
  25. data/docs/adr/index.md +9 -3
  26. data/docs/advanced.md +22 -25
  27. data/docs/configuration/client-setup.md +126 -10
  28. data/docs/configuration/index.md +11 -7
  29. data/docs/index.md +13 -6
  30. data/docs/installation.md +3 -2
  31. data/docs/security.md +44 -5
  32. data/docs/smart-on-fhir/backend-services/index.md +2 -2
  33. data/docs/smart-on-fhir/backend-services/token-request.md +1 -1
  34. data/docs/smart-on-fhir/confidential-asymmetric/index.md +2 -2
  35. data/docs/smart-on-fhir/confidential-symmetric/index.md +1 -1
  36. data/docs/smart-on-fhir/discovery/capability-checks.md +7 -0
  37. data/docs/smart-on-fhir/dynamic-client-registration/index.md +103 -0
  38. data/docs/smart-on-fhir/dynamic-client-registration/registration.md +160 -0
  39. data/docs/smart-on-fhir/dynamic-client-registration/response.md +161 -0
  40. data/docs/smart-on-fhir/index.md +2 -1
  41. data/docs/smart-on-fhir/post-based-authorization.md +1 -1
  42. data/docs/smart-on-fhir/public-client/index.md +1 -1
  43. data/docs/troubleshooting/auth-errors.md +20 -0
  44. data/docs/troubleshooting/client-errors.md +143 -0
  45. data/docs/troubleshooting/index.md +61 -4
  46. data/docs/udap/dynamic-client-registration/index.md +198 -0
  47. data/docs/udap/dynamic-client-registration/lifecycle.md +115 -0
  48. data/docs/udap/dynamic-client-registration/registration-metadata.md +133 -0
  49. data/docs/udap/dynamic-client-registration/software-statement.md +115 -0
  50. data/docs/udap.md +187 -64
  51. data/gemfiles/activesupport_71.gemfile +30 -0
  52. data/gemfiles/activesupport_71.gemfile.lock +291 -0
  53. data/lib/safire/client.rb +141 -44
  54. data/lib/safire/client_config.rb +114 -35
  55. data/lib/safire/client_config_builder.rb +18 -0
  56. data/lib/safire/errors.rb +100 -44
  57. data/lib/safire/http_client.rb +7 -2
  58. data/lib/safire/middleware/https_only_redirects.rb +10 -4
  59. data/lib/safire/protocols/behaviours.rb +12 -1
  60. data/lib/safire/protocols/oauth_response_handling.rb +48 -0
  61. data/lib/safire/protocols/smart.rb +108 -36
  62. data/lib/safire/protocols/smart_metadata.rb +6 -0
  63. data/lib/safire/protocols/udap.rb +534 -0
  64. data/lib/safire/protocols/udap_metadata.rb +429 -0
  65. data/lib/safire/protocols/udap_registration_metadata.rb +368 -0
  66. data/lib/safire/protocols/udap_signed_metadata_validator.rb +349 -0
  67. data/lib/safire/protocols/udap_software_statement.rb +348 -0
  68. data/lib/safire/protocols.rb +6 -0
  69. data/lib/safire/uri_validation.rb +102 -0
  70. data/lib/safire/version.rb +1 -1
  71. data/lib/safire.rb +2 -0
  72. data/safire.gemspec +7 -8
  73. metadata +42 -13
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  layout: default
3
- title: "ADR-007: HTTPS-only redirect enforcement and localhost exception"
3
+ title: "ADR-007: HTTPS-only enforcement and explicit localhost opt-in"
4
4
  parent: Architecture Decision Records
5
5
  nav_order: 7
6
6
  ---
7
7
 
8
- # ADR-007: HTTPS-only redirect enforcement and localhost exception
8
+ # ADR-007: HTTPS-only enforcement and explicit localhost opt-in
9
9
 
10
10
  **Status:** Accepted
11
11
 
@@ -17,17 +17,22 @@ SMART App Launch 2.2.0 requires TLS for all exchanges involving sensitive data.
17
17
 
18
18
  This is a known attack surface: a compromised or misconfigured server can use open redirects to redirect a client to an attacker-controlled HTTP endpoint.
19
19
 
20
- There is also a practical concern: developers running local FHIR servers (e.g. HAPI FHIR, Inferno test environments) use `http://localhost` or `http://127.0.0.1`. Blocking these in a development environment would make Safire unusable without a TLS termination proxy.
20
+ There is also a practical concern: developers running local FHIR servers (e.g.
21
+ HAPI FHIR, Inferno test environments) use `http://localhost` or
22
+ `http://127.0.0.1`. Blocking these entirely would make Safire awkward to use
23
+ without a TLS termination proxy, but accepting them implicitly weakens a
24
+ security boundary and can hide production misconfiguration.
21
25
 
22
26
  ---
23
27
 
24
28
  ## Decision
25
29
 
26
- HTTPS is enforced at **two layers**, both with the same localhost exception:
30
+ For client configuration and HTTP redirects, HTTPS is enforced at **two layers**.
31
+ Both layers use the same explicit local-development opt-in:
27
32
 
28
- **Layer 1 — `ClientConfig` URI validation:** all URI attributes (`base_url`, `redirect_uri`, `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`) must use `https://`, except when the host is `localhost` or `127.0.0.1`.
33
+ **Layer 1 — `ClientConfig` URI validation:** all URI attributes (`base_url`, `redirect_uri`, `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`) must use `https://`, except when `allow_insecure_localhost: true` is configured and the host is `localhost` or `127.0.0.1`.
29
34
 
30
- **Layer 2 — `HttpsOnlyRedirects` Faraday middleware:** intercepts every 3xx response before `faraday-follow_redirects` follows it. If the redirect target is not HTTPS (and not localhost), a `Safire::Errors::NetworkError` is raised immediately rather than following the redirect.
35
+ **Layer 2 — `HttpsOnlyRedirects` Faraday middleware:** intercepts every 3xx response before `faraday-follow_redirects` follows it. If the redirect target is not HTTPS, and the local-development exception is not enabled for a loopback host, a `Safire::Errors::NetworkError` is raised immediately rather than following the redirect.
31
36
 
32
37
  ```ruby
33
38
  # middleware/https_only_redirects.rb
@@ -36,14 +41,32 @@ def on_complete(env)
36
41
 
37
42
  location = env.response_headers['location']
38
43
  uri = URI.parse(location)
39
- return if uri.scheme == 'https' || localhost?(uri.host)
44
+ return if uri.scheme == 'https'
45
+ return if allow_insecure_localhost && localhost_host?(uri.host)
40
46
 
41
47
  raise Safire::Errors::NetworkError,
42
48
  "Blocked redirect to non-HTTPS URL: #{location}"
43
49
  end
44
50
  ```
45
51
 
46
- Both layers use the same localhost exception (`localhost` and `127.0.0.1`) and must stay consistent. The middleware raises `NetworkError` (transport layer) rather than `ConfigurationError` (construction time) because redirect enforcement is a runtime concern.
52
+ Both layers use the shared loopback host set in `URIValidation` (`localhost`
53
+ and `127.0.0.1`). The middleware raises `NetworkError` (transport layer) rather
54
+ than `ConfigurationError` (construction time) because redirect enforcement is a
55
+ runtime concern.
56
+
57
+ UDAP registration metadata applies the same secure default to its registration
58
+ URI fields. The UDAP Security STU2 registration profile requires
59
+ `redirect_uris` and `logo_uri` to use HTTPS.
60
+ `URIValidation#strict_https_uri?` provides that default predicate, while
61
+ `URIValidation#localhost_http_uri?` identifies the only local HTTP shape that
62
+ can be accepted when a caller explicitly opts into development mode.
63
+
64
+ `UdapRegistrationMetadata` uses the same explicit option name for non-TLS local
65
+ redirect and logo URIs. It accepts only HTTP on `localhost` or `127.0.0.1`,
66
+ logs a development-only warning when used, and never permits remote HTTP.
67
+ Safire does not infer Rails, Rack, or another framework's environment; the host
68
+ application must opt in deliberately. Metadata produced through this exception
69
+ is non-conformant and must not be used in production.
47
70
 
48
71
  ---
49
72
 
@@ -51,9 +74,16 @@ Both layers use the same localhost exception (`localhost` and `127.0.0.1`) and m
51
74
 
52
75
  **Benefits:**
53
76
  - Defence-in-depth: HTTPS is enforced at both config time and at every HTTP redirect, closing the redirect-based attack vector
54
- - Consistent localhost exception across both enforcement points — `http://localhost` works in both URI validation and redirect following
77
+ - Consistent explicit localhost opt-in across config validation, redirect following,
78
+ and UDAP registration metadata
79
+ - UDAP registration fields remain strict by default while non-TLS local testing
80
+ requires an explicit, narrowly scoped opt-in
55
81
  - Clear error message when a non-HTTPS redirect is blocked, pointing directly at the offending URL
56
82
 
57
83
  **Trade-offs:**
58
- - The localhost exception must be maintained in two places — `ClientConfig#localhost_host?` and `HttpsOnlyRedirects` — any change to the exception policy must be applied to both; this duplication is intentional (the two layers are independent defences) but must be kept in sync
84
+ - The config and redirect layers are independent defences, but they share the
85
+ loopback host policy through `URIValidation`; changes to that helper affect
86
+ both layers and must be reviewed as a security policy change
87
+ - Callers enabling the local-development exception are responsible for tying it
88
+ to their application's environment and preventing production use
59
89
  - Blocking non-HTTPS redirects can cause unexpected failures if a FHIR server uses HTTP-to-HTTPS redirect chains (e.g. `http://fhir.example.com` → `https://fhir.example.com`); callers should configure `base_url` with the final HTTPS URL directly
@@ -0,0 +1,131 @@
1
+ ---
2
+ layout: default
3
+ title: "ADR-009: OAuthError base class and ReceivesFields mixin for protocol error hierarchy"
4
+ parent: Architecture Decision Records
5
+ nav_order: 9
6
+ ---
7
+
8
+ # ADR-009: OAuthError base class and ReceivesFields mixin for protocol error hierarchy
9
+
10
+ **Status:** Accepted
11
+
12
+ ---
13
+
14
+ ## Context
15
+
16
+ Three protocol operations can return OAuth2-style error responses: token exchange (`TokenError`), authorization failure (`AuthError`), and dynamic client registration (`RegistrationError`). All three carry the same RFC-defined fields — HTTP `status`, an OAuth2 `error` code, and an `error_description` string — and all three produce structured error messages from those fields.
17
+
18
+ `TokenError` and `RegistrationError` have a second failure path: the server returns a 2xx response but omits the field the caller requires (`access_token` or `client_id`). In that case the error must report which fields were present in the response, without logging their values, to assist debugging without leaking data.
19
+
20
+ Without a shared foundation, each error class would duplicate the constructor, the attribute readers, and the message-building logic.
21
+
22
+ **Option A — Duplicate per class:** each error class independently defines `attr_reader :status, :error_code, :error_description`, its own constructor, and its own `build_message` method.
23
+
24
+ **Option B — Extract a shared base:** introduce `OAuthError < Error` with a template-method design; each subclass overrides `operation_label` to supply the lead phrase of the error message. Extract a `ReceivesFields` mixin for the structural failure path (`received_fields` attribute + constructor forwarding), included only by the two classes that need it.
25
+
26
+ ---
27
+
28
+ ## Decision
29
+
30
+ Option B — `OAuthError` as a shared base class with `ReceivesFields` as a private mixin.
31
+
32
+ ```ruby
33
+ class OAuthError < Error
34
+ attr_reader :status, :error_code, :error_description
35
+
36
+ def initialize(status: nil, error_code: nil, error_description: nil)
37
+ @status = status; @error_code = error_code; @error_description = error_description
38
+ super(build_message)
39
+ end
40
+
41
+ private
42
+
43
+ def operation_label
44
+ raise NotImplementedError, "#{self.class} must define #operation_label"
45
+ end
46
+
47
+ def build_message
48
+ parts = [operation_label]
49
+ parts << "HTTP #{@status}" if @status
50
+ parts << @error_code if @error_code
51
+ parts << @error_description if @error_description
52
+ parts.join(' — ')
53
+ end
54
+ end
55
+
56
+ module ReceivesFields
57
+ def self.included(base) = base.attr_reader :received_fields
58
+ def initialize(received_fields: nil, **) = (@received_fields = received_fields; super(**))
59
+ end
60
+
61
+ class TokenError < OAuthError; include ReceivesFields; ... end
62
+ class AuthError < OAuthError; ... end
63
+ class RegistrationError < OAuthError; include ReceivesFields; ... end
64
+ ```
65
+
66
+ Each subclass defines only `operation_label` and, when needed, overrides `build_message` for the structural path.
67
+
68
+ ---
69
+
70
+ ## Consequences
71
+
72
+ **Benefits:**
73
+ - DRY: each concrete error class is a handful of lines; the shared attributes and message format are defined once
74
+ - Consistent error messages across all three operations; callers and log parsers see the same structure
75
+ - `ReceivesFields` is `@api private` — callers interact only with `TokenError` and `RegistrationError`; the mixin is an implementation detail
76
+ - Adding a new OAuth-style error (e.g. for a future introspection endpoint) requires only a new subclass with `operation_label`
77
+
78
+ **Trade-offs:**
79
+ - The `operation_label` template method raises `NotImplementedError` at runtime if a subclass forgets to define it; a compile-time check is not possible in Ruby
80
+ - `ReceivesFields` modifies the constructor via `super(**)` forwarding, which requires care when the inheritance chain has multiple `initialize` overrides
81
+
82
+ ---
83
+
84
+ ## Amendment: Shared protocol response handling
85
+
86
+ UDAP Dynamic Client Registration uses the same RFC 7591 success and OAuth-style
87
+ error response shapes as SMART registration. Keeping response translation as
88
+ private methods on `Protocols::Smart` would either couple UDAP to SMART or
89
+ duplicate parsing at the protocol trust boundary.
90
+
91
+ `Protocols::OAuthResponseHandling` now owns two private transformations shared
92
+ by protocol implementations:
93
+
94
+ - converting a Faraday error response into a typed `OAuthError` subclass
95
+ - validating and normalizing an RFC 7591 registration success response
96
+
97
+ The module accepts already-received response bodies. It does not send HTTP
98
+ requests, select endpoints, log response values, or apply protocol-specific
99
+ policy.
100
+
101
+ Registration success responses are normalized to string-keyed hashes and must
102
+ contain a non-blank string `client_id`, as required by RFC 7591. Missing
103
+ `client_id` responses continue to report only the received field names. A
104
+ present but blank or non-string `client_id` raises `RegistrationError` with a
105
+ structural error description. This intentionally hardens SMART registration:
106
+ valid RFC 7591 responses are unchanged, while malformed identifiers that were
107
+ previously accepted through `present?` now fail closed.
108
+
109
+ Malformed or non-object OAuth error bodies produce an error containing the HTTP
110
+ status only. Parsed JSON objects preserve their protocol error codes, including
111
+ UDAP-specific values such as `invalid_software_statement`.
112
+
113
+ ### Amendment consequences
114
+
115
+ **Benefits:**
116
+
117
+ - SMART and UDAP share one auditable response boundary without sharing request
118
+ construction or endpoint policy
119
+ - response key normalization makes direct and middleware-parsed hashes behave
120
+ consistently
121
+ - malformed successful registrations fail before an invalid identifier reaches
122
+ application state
123
+
124
+ **Trade-offs:**
125
+
126
+ - the private module contains generic OAuth error translation and
127
+ registration-specific success validation; splitting two small transformations
128
+ into separate objects would add indirection without creating an independent
129
+ responsibility
130
+ - SMART servers returning a non-string `client_id` are now rejected even if an
131
+ earlier Safire version accepted the malformed value
@@ -0,0 +1,90 @@
1
+ ---
2
+ layout: default
3
+ title: "ADR-010: client_id optional at initialization — deferred validation for the DCR temp-client pattern"
4
+ parent: Architecture Decision Records
5
+ nav_order: 10
6
+ ---
7
+
8
+ # ADR-010: client_id optional at initialization — deferred validation for the DCR temp-client pattern
9
+
10
+ **Status:** Accepted
11
+
12
+ ---
13
+
14
+ ## Context
15
+
16
+ RFC 7591 Dynamic Client Registration requires calling the authorization server's registration endpoint before a `client_id` exists. The natural usage pattern is:
17
+
18
+ 1. Create a temporary `Safire::Client` with only `base_url` (no `client_id` yet)
19
+ 2. Call `register_client` to POST metadata and receive `client_id` from the server
20
+ 3. Build a properly configured client with the returned `client_id` for subsequent authorization flows
21
+
22
+ Previously, `ClientConfig#validate!` required `client_id` to be present at construction time. A caller following the temp-client pattern could not even construct the initial client, so DCR was impossible without out-of-band `client_id` knowledge.
23
+
24
+ **Option A — Keep client_id required; provide a separate class:** introduce a `Safire::RegistrationClient` (or similar) that omits the `client_id` requirement, performs DCR, and returns a configured `Safire::Client`.
25
+
26
+ **Option B — Make client_id optional at initialization; validate at call time:** remove the construction-time presence check for `client_id`; each flow method that requires it validates and raises `ConfigurationError` at the point of the call.
27
+
28
+ ---
29
+
30
+ ## Decision
31
+
32
+ Option B — `client_id` is optional at `ClientConfig` and `Client` initialization.
33
+
34
+ Construction succeeds with only `base_url`:
35
+
36
+ ```ruby
37
+ temp_client = Safire::Client.new({ base_url: 'https://fhir.example.com' })
38
+ registration = temp_client.register_client({ client_name: 'My App', ... })
39
+
40
+ client = Safire::Client.new({
41
+ base_url: 'https://fhir.example.com',
42
+ client_id: registration['client_id'],
43
+ ...
44
+ })
45
+ ```
46
+
47
+ Flow methods that require `client_id` (`authorization_url`,
48
+ `request_access_token`, `refresh_token`, and `request_backend_token`) validate
49
+ its presence at call time and raise `ConfigurationError` if absent.
50
+
51
+ ### 2026-07-04 amendment: UDAP certificate-backed temp clients
52
+
53
+ UDAP Dynamic Client Registration uses the same temp-client pattern, but the
54
+ temporary client is identified by a private key and X.509 certificate chain
55
+ rather than by SMART client type credentials:
56
+
57
+ ```ruby
58
+ temp_client = Safire::Client.new(
59
+ {
60
+ base_url: 'https://fhir.example.com',
61
+ private_key: File.read('client-key.pem'),
62
+ certificate_chain: [File.read('client-cert.pem')]
63
+ },
64
+ protocol: :udap
65
+ )
66
+
67
+ registration = temp_client.register_client(
68
+ metadata,
69
+ client_uri: 'https://client.example.com',
70
+ trusted_anchors: [udap_ca],
71
+ crls: [udap_crl]
72
+ )
73
+ ```
74
+
75
+ `client_id` remains optional at construction. UDAP `register_client` discovers
76
+ the registration endpoint, signs a software statement using the configured or
77
+ per-call signing identity, and returns the server-assigned `client_id`.
78
+
79
+ ---
80
+
81
+ ## Consequences
82
+
83
+ **Benefits:**
84
+ - The temp-client pattern works with the existing `Safire::Client` class; no new class is needed
85
+ - No proliferation of client variants; the public API surface stays small
86
+ - Consistent with how `token_endpoint` and `authorization_endpoint` are handled: both are optional at construction and resolved via lazy discovery when needed
87
+
88
+ **Trade-offs:**
89
+ - A misconfigured client (missing `client_id`) fails at call time rather than construction time; callers may see the error later than expected
90
+ - This is an intentional trade-off: `client_id` is now in the same category as other lazily-resolved attributes, so the surprise of deferred validation is mitigated by the established pattern
@@ -0,0 +1,113 @@
1
+ ---
2
+ layout: default
3
+ title: "ADR-011: UdapMetadata entity — structural validation separate from cryptographic validation"
4
+ parent: Architecture Decision Records
5
+ nav_order: 11
6
+ ---
7
+
8
+ # ADR-011: `UdapMetadata` entity — structural validation separate from cryptographic validation
9
+
10
+ **Status:** Accepted
11
+
12
+ ---
13
+
14
+ ## Context
15
+
16
+ UDAP Security STU2 discovery returns a JSON object from `/.well-known/udap`. That object must be
17
+ parsed into a typed entity and validated before any downstream flow (Dynamic Client Registration,
18
+ JWT client authentication, etc.) can proceed. Two distinct validation concerns arise:
19
+
20
+ 1. **Structural validation** — are all required fields present? Do they satisfy the fixed-value
21
+ and conditional constraints specified in STU2?
22
+ 2. **Cryptographic validation** — is the `signed_metadata` JWT signature valid, and does the
23
+ embedded X.509 chain chain to a trusted anchor?
24
+
25
+ These concerns operate at different layers: structural checks need only the parsed JSON object,
26
+ while cryptographic checks require a trusted certificate store and key material. Mixing them
27
+ inside the same class would make the entity difficult to test (crypto requires real certs) and
28
+ would couple two unrelated failure modes.
29
+
30
+ ---
31
+
32
+ ## Decision
33
+
34
+ `UdapMetadata` handles structural parsing and validation only. It inherits from `Safire::Entity`
35
+ (the same base used by `SmartMetadata`) and follows the same warn-and-return-false convention
36
+ for `valid?`.
37
+
38
+ **Conformance target:** HL7 UDAP Security STU2 / v2.0.0
39
+ ([discovery section](https://hl7.org/fhir/us/udap-security/STU2/discovery.html)).
40
+
41
+ **Signed metadata:** STU2 uses `signed_metadata` (not the deprecated `signed_endpoints` from
42
+ earlier drafts). `signed_metadata` is treated as a required field by `UdapMetadata#valid?` and
43
+ as an opaque string. Cryptographic validation of the JWT is intentionally deferred to a
44
+ dedicated cryptographic validator; see
45
+ [ADR-012]({% link adr/ADR-012-udap-signed-metadata-validation.md %}).
46
+
47
+ **Presence check uses `nil?`, not `blank?`:** Several required array fields — for example,
48
+ `udap_authorization_extensions_supported` — may legitimately be empty arrays in a conformant
49
+ response. Using `blank?` would flag `[]` as absent; using `nil?` preserves the distinction
50
+ between "field not present in the JSON response" and "field present but empty".
51
+
52
+ **Array type and element validation is explicit:** Discovery metadata is untrusted JSON.
53
+ `UdapMetadata#valid?` verifies every array-valued field is an `Array` whose elements are all
54
+ `String`s before performing profile, grant, non-empty, or subset checks. Public helper methods
55
+ also treat malformed scalar metadata as unsupported instead of using Ruby string `include?`
56
+ semantics.
57
+
58
+ **Value-level constraints in `valid?`:**
59
+
60
+ - `udap_versions_supported` must equal `["1"]` exactly (STU2 fixed value)
61
+ - `udap_profiles_supported` must include `"udap_dcr"` and `"udap_authn"` (both required by STU2)
62
+ - `token_endpoint_auth_methods_supported` must equal `["private_key_jwt"]` exactly (STU2 fixed value)
63
+ - `scopes_supported`, `grant_types_supported`, and both JWT signing algorithm arrays must each have at least one element
64
+ - `signed_metadata` must be a compact-JWS string: exactly three dot-separated segments where
65
+ every segment contains only base64url characters (`[A-Za-z0-9\-_]`, no padding); JWT header
66
+ algorithm (`alg`), required claim presence, and signature are not validated here — these are
67
+ deferred to the cryptographic validator
68
+ - endpoint URL fields (`token_endpoint`, `registration_endpoint`, conditionally
69
+ `authorization_endpoint`) must be absolute HTTPS URLs; plain HTTP is accepted only when
70
+ `allow_insecure_localhost: true` is configured and the host is `localhost` or `127.0.0.1`
71
+ to support development without TLS — any other scheme on those hosts (e.g. `ftp://localhost`)
72
+ is rejected; this exception does not apply in production
73
+ - `authorization_endpoint` is conditionally required when `grant_types_supported` includes
74
+ `"authorization_code"`
75
+ - `"udap_authz"` is conditionally required in `udap_profiles_supported` when `grant_types_supported`
76
+ includes `"client_credentials"`
77
+ - `"authorization_code"` is conditionally required in `grant_types_supported` when `"refresh_token"`
78
+ is also present
79
+ - `udap_authorization_extensions_required` is conditionally required when
80
+ `udap_authorization_extensions_supported` is non-empty; its values must be a subset of
81
+ `udap_authorization_extensions_supported`
82
+ - `udap_certifications_required` is conditionally required when `udap_certifications_supported`
83
+ is non-empty; its values must be a subset of `udap_certifications_supported`
84
+
85
+ **Public helpers follow a two-tier naming convention:**
86
+
87
+ - *Profile checks* (`dynamic_registration_profile?`, `jwt_client_auth_profile?`, etc.) test
88
+ only whether the server advertises the profile string in `udap_profiles_supported`; they do
89
+ not check whether all required supporting fields are present.
90
+ - *Capability checks* (`supports_dynamic_registration?`, `supports_jwt_client_auth?`, etc.)
91
+ combine profile advertisement with the minimum preconditions needed to start that flow:
92
+ - `supports_dynamic_registration?` requires `udap_dcr` profile and a valid `registration_endpoint`
93
+ - `supports_jwt_client_auth?` requires `udap_authn` profile and a valid `token_endpoint`
94
+ - `supports_client_authorization?` requires `udap_authz` profile, `client_credentials` in
95
+ `grant_types_supported`, and a valid `token_endpoint`
96
+
97
+ ---
98
+
99
+ ## Consequences
100
+
101
+ **Benefits:**
102
+
103
+ - Structural conformance is independently testable without any certificate infrastructure
104
+ - `valid?` follows the same warn-and-return-false contract as `SmartMetadata#valid?`, giving
105
+ callers a consistent API across protocols
106
+ - `signed_metadata` cryptographic validation stays separate from the entity; see
107
+ [ADR-012]({% link adr/ADR-012-udap-signed-metadata-validation.md %})
108
+
109
+ **Trade-offs:**
110
+
111
+ - A structurally valid `UdapMetadata` object is not automatically cryptographically validated;
112
+ callers that require full STU2 conformance must also perform cryptographic validation of the
113
+ `signed_metadata` JWT after structural validation passes
@@ -0,0 +1,104 @@
1
+ ---
2
+ layout: default
3
+ title: "ADR-012: signed_metadata JWT validation — design and chain verification defaults"
4
+ parent: Architecture Decision Records
5
+ nav_order: 12
6
+ ---
7
+
8
+ # ADR-012: `signed_metadata` JWT validation — design and chain verification defaults
9
+
10
+ **Status:** Accepted
11
+
12
+ ---
13
+
14
+ ## Context
15
+
16
+ UDAP Security STU2 requires that servers include a `signed_metadata` JWT in their discovery
17
+ response. Per the spec, clients MUST validate this JWT before using any of the discovered endpoint
18
+ URLs. Signed endpoint claims (`token_endpoint`, `registration_endpoint`, and optionally
19
+ `authorization_endpoint`) take precedence over the unsigned values in the JSON body.
20
+
21
+ Validation involves:
22
+
23
+ 1. Decoding the JOSE header and verifying `alg == RS256` and `x5c` is present.
24
+ 2. Verifying the JWT signature against the leaf certificate in `x5c[0]`.
25
+ 3. Validating the X.509 chain using `x5c[1..]`, caller-supplied trust anchors, and explicit
26
+ revocation policy/material.
27
+ 4. Checking required claims: `iss`, `sub`, `exp`, `iat`, `jti`, `token_endpoint`,
28
+ `registration_endpoint`, and conditionally `authorization_endpoint`.
29
+
30
+ ---
31
+
32
+ ## Decisions
33
+
34
+ ### Separate validator class (`UdapSignedMetadataValidator`)
35
+
36
+ Cryptographic validation is isolated in `UdapSignedMetadataValidator`, keeping it out of
37
+ `UdapMetadata` (the structural entity) and `Udap` (the HTTP discovery orchestrator). This follows
38
+ the single-responsibility principle and keeps the entity layer free of crypto dependencies.
39
+
40
+ ### Warn-and-return-nil per failure; raise only on unrecoverable parse errors
41
+
42
+ Validation failures log a warning through `Safire.logger.warn` and return `nil`
43
+ rather than raising. Decode, header, signature, and chain failures stop checks
44
+ that depend on them; once a payload is trusted enough to inspect, claim checks
45
+ run together so one call surfaces every applicable claim warning. The only
46
+ exception is malformed certificate DER in `x5c`, which raises
47
+ `Safire::Errors::CertificateError` because the certificate cannot be parsed.
48
+
49
+ ### Chain and revocation verification on by default; `verify_chain: false` for dev/test only
50
+
51
+ `verify_chain: true` is the secure production default. Skipping chain and revocation verification
52
+ is an explicit opt-in intended for development and testing against servers whose certificates are
53
+ not rooted in a trusted CA. The parameter threads from `Udap#server_metadata` through to
54
+ `UdapSignedMetadataValidator`.
55
+
56
+ When `verify_chain: true`, validation fails closed unless the caller supplies either `crls:` or a
57
+ custom `revocation_checker:`. CRLs are applied to the OpenSSL certificate store with CRL checking
58
+ enabled. A custom checker must return literal `true`; any other value or exception is treated as a
59
+ revocation validation failure.
60
+
61
+ ### Signed endpoint claims merged before constructing `UdapMetadata`
62
+
63
+ `Udap#fetch_metadata` validates `signed_metadata` and merges the authoritative signed endpoint
64
+ claims over the unsigned JSON values before constructing the `UdapMetadata` instance. Callers
65
+ never receive a `UdapMetadata` object with unverified endpoint URLs. If validation fails,
66
+ `Safire::Errors::DiscoveryError` is raised.
67
+
68
+ ### Cached metadata is revalidated before reuse
69
+
70
+ `Protocols::Udap` caches parsed metadata per community and trust policy, but cache hits are not
71
+ blind returns. Before returning cached metadata, Safire revalidates the original `signed_metadata`
72
+ JWT against the current trust policy. If the JWT has expired, the certificate chain no longer
73
+ validates, or the configured revocation policy rejects it, the cache entry is discarded and
74
+ discovery is fetched again.
75
+
76
+ ### `UdapMetadata#signed_metadata_valid?` for re-validation
77
+
78
+ `UdapMetadata` exposes `signed_metadata_valid?(base_url:, ...)` as a convenience for callers who
79
+ hold a metadata object and want to explicitly re-validate (for example, with a different trust
80
+ anchor set). Instances returned by `Udap#server_metadata` are already pre-validated.
81
+
82
+ ### DCR consumes authoritative signed endpoint claims
83
+
84
+ UDAP Dynamic Client Registration is the first Safire flow that acts on
85
+ discovered UDAP endpoints after discovery. `Protocols::Udap#register_client`
86
+ and `Protocols::Udap#cancel_registration` use the `registration_endpoint` from
87
+ the `UdapMetadata` instance returned by `server_metadata`. Because
88
+ `server_metadata` merges signed endpoint claims over unsigned JSON before
89
+ constructing the entity, registration lifecycle requests post to the
90
+ authoritative signed endpoint when signed and unsigned values differ.
91
+
92
+ ---
93
+
94
+ ## Consequences
95
+
96
+ - Production use requires providing `trusted_anchors:` plus an explicit revocation policy
97
+ (`crls:` or `revocation_checker:`) to `server_metadata`. With `verify_chain: true`, Safire
98
+ validates against caller-supplied material; it does not fall back to the operating system trust
99
+ store or perform implicit online revocation checks.
100
+ - `CertificateError` is reserved for unrecoverable DER parse failures. All other validation
101
+ decisions use the warn-and-return-nil pattern.
102
+ - `UdapMetadata#valid?` remains a structural check only; it does not invoke the JWT validator.
103
+ - Downstream UDAP flows must consume endpoints from `UdapMetadata` returned by
104
+ `server_metadata`; they must not read unsigned discovery JSON directly.
@@ -0,0 +1,106 @@
1
+ ---
2
+ layout: default
3
+ title: "ADR-013: UDAP registration metadata as an immutable value object"
4
+ parent: Architecture Decision Records
5
+ nav_order: 13
6
+ ---
7
+
8
+ # ADR-013: UDAP registration metadata as an immutable value object
9
+
10
+ **Status:** Accepted
11
+
12
+ ---
13
+
14
+ ## Context
15
+
16
+ UDAP Dynamic Client Registration signs caller-controlled client metadata into a
17
+ software statement. Once signed, that statement is authoritative: the
18
+ registration server uses it instead of an unsigned request-body field when the
19
+ same metadata appears in both places.
20
+
21
+ This creates a trust boundary before cryptographic construction. Safire must
22
+ reject malformed or ambiguous registration metadata before signing, while still
23
+ preserving valid extension fields allowed by RFC 7591. Discovery entities are
24
+ not an appropriate model for this boundary: they represent remote input and use
25
+ warn-and-return-false compliance checks, whereas registration metadata is local
26
+ input that must either produce one canonical payload or raise.
27
+
28
+ The conformance target is
29
+ [HL7 UDAP Security STU2 / v2.0.0](https://hl7.org/fhir/us/udap-security/STU2/registration.html),
30
+ which builds on the
31
+ [UDAP Dynamic Client Registration profile](https://www.udap.org/udap-dynamic-client-registration-stu1.html).
32
+
33
+ ---
34
+
35
+ ## Decision
36
+
37
+ ### Use a dedicated strict value object
38
+
39
+ `UdapRegistrationMetadata` validates and normalizes the caller's metadata at
40
+ construction. Invalid input raises `Safire::Errors::ValidationError` with the
41
+ failing attribute and a value-free reason. The class does not inherit from
42
+ `Entity` and does not use the discovery layer's warn-and-return-false contract.
43
+
44
+ Top-level string and symbol keys are normalized to strings. Supplying both forms
45
+ of one key is rejected rather than allowing insertion order to decide which
46
+ value is signed. The value object and its canonical internal hash are frozen,
47
+ and `to_h` returns a defensive copy.
48
+
49
+ ### Enforce exact STU2 grant shapes
50
+
51
+ Registration accepts exactly one primary grant:
52
+
53
+ - `authorization_code`, optionally with `refresh_token`
54
+ - `client_credentials`
55
+
56
+ Unknown and duplicate grant values are rejected. `redirect_uris`, `logo_uri`,
57
+ and generated `response_types: ["code"]` apply only to authorization-code
58
+ registration. Redirect and logo URIs require absolute HTTPS by default.
59
+
60
+ For local development without TLS, callers may explicitly pass
61
+ `allow_insecure_localhost: true`. This permits HTTP only on `localhost` and
62
+ `127.0.0.1`; remote HTTP remains invalid. Safire warns only when the exception
63
+ is actually used. The library does not infer a framework environment because
64
+ an implicit environment default could silently weaken production validation.
65
+ Metadata created through the exception is intentionally documented as
66
+ non-conformant.
67
+
68
+ Cancellation is a separate `operation: :cancel` mode. Callers omit
69
+ `grant_types`; Safire injects an empty array and removes authorization-only
70
+ metadata from the canonical result.
71
+
72
+ ### Reserve protocol-owned fields
73
+
74
+ Safire generates `token_endpoint_auth_method: "private_key_jwt"` and, when
75
+ applicable, `response_types`. Callers cannot supply those fields, registered JWT
76
+ claims, or request-envelope fields such as `software_statement`,
77
+ `certifications`, and `udap`.
78
+
79
+ Unknown RFC 7591 extension fields are retained only when their values are exact
80
+ JSON data: strings, finite JSON numbers, booleans, null, arrays, or objects with
81
+ string keys and recursively valid values. Ruby-specific values and recursive
82
+ collections are rejected.
83
+
84
+ ### Use the standard-library mail boundary
85
+
86
+ `contacts` must be a non-empty array of absolute URI strings with at least one
87
+ valid `mailto:` address. Safire parses mail contacts as `URI::MailTo`, obtains
88
+ addresses from `URI::MailTo#to`, and applies
89
+ `URI::MailTo::EMAIL_REGEXP`. It does not maintain a project-specific email
90
+ regular expression.
91
+
92
+ ---
93
+
94
+ ## Consequences
95
+
96
+ - Signing code can consume one canonical, already validated metadata hash.
97
+ - Caller mutations cannot change metadata between validation and signing.
98
+ - Protocol-owned claims cannot be shadowed through caller input.
99
+ - Plain HTTP remains impossible for remote hosts; local HTTP requires a literal
100
+ boolean opt-in and produces a warning.
101
+ - Extension metadata remains forward-compatible without permitting arbitrary
102
+ Ruby objects into a JWT payload.
103
+ - Metadata validation remains independent of software-statement signing and the
104
+ network registration lifecycle. `Protocols::Udap#register_client` and
105
+ `Protocols::Udap#cancel_registration` consume this value object when
106
+ performing end-to-end UDAP registration operations.