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.
- checksums.yaml +4 -4
- data/.claude/skills/release-safire/SKILL.md +153 -0
- data/.rubocop.yml +11 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +107 -1
- data/CONTRIBUTION.md +6 -1
- data/Gemfile +1 -2
- data/Gemfile.lock +47 -37
- data/README.md +60 -5
- data/ROADMAP.md +61 -12
- data/docs/Gemfile.lock +49 -45
- data/docs/_config.yml +2 -2
- data/docs/adr/ADR-001-activesupport-dependency.md +2 -2
- data/docs/adr/ADR-002-facade-and-forwardable.md +21 -6
- data/docs/adr/ADR-003-protocol-vs-client-type.md +13 -6
- data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +34 -8
- data/docs/adr/ADR-006-lazy-discovery.md +64 -5
- data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +40 -10
- data/docs/adr/ADR-009-oauth-error-hierarchy.md +131 -0
- data/docs/adr/ADR-010-optional-client-id-dcr-temp-client.md +90 -0
- data/docs/adr/ADR-011-udap-stu2-discovery-conformance.md +113 -0
- data/docs/adr/ADR-012-udap-signed-metadata-validation.md +104 -0
- data/docs/adr/ADR-013-udap-registration-request-model.md +106 -0
- data/docs/adr/ADR-014-udap-software-statement-signing.md +122 -0
- data/docs/adr/index.md +9 -3
- data/docs/advanced.md +22 -25
- data/docs/configuration/client-setup.md +126 -10
- data/docs/configuration/index.md +11 -7
- data/docs/index.md +13 -6
- data/docs/installation.md +3 -2
- data/docs/security.md +44 -5
- data/docs/smart-on-fhir/backend-services/index.md +2 -2
- data/docs/smart-on-fhir/backend-services/token-request.md +1 -1
- data/docs/smart-on-fhir/confidential-asymmetric/index.md +2 -2
- data/docs/smart-on-fhir/confidential-symmetric/index.md +1 -1
- data/docs/smart-on-fhir/discovery/capability-checks.md +7 -0
- data/docs/smart-on-fhir/dynamic-client-registration/index.md +103 -0
- data/docs/smart-on-fhir/dynamic-client-registration/registration.md +160 -0
- data/docs/smart-on-fhir/dynamic-client-registration/response.md +161 -0
- data/docs/smart-on-fhir/index.md +2 -1
- data/docs/smart-on-fhir/post-based-authorization.md +1 -1
- data/docs/smart-on-fhir/public-client/index.md +1 -1
- data/docs/troubleshooting/auth-errors.md +20 -0
- data/docs/troubleshooting/client-errors.md +143 -0
- data/docs/troubleshooting/index.md +61 -4
- data/docs/udap/dynamic-client-registration/index.md +198 -0
- data/docs/udap/dynamic-client-registration/lifecycle.md +115 -0
- data/docs/udap/dynamic-client-registration/registration-metadata.md +133 -0
- data/docs/udap/dynamic-client-registration/software-statement.md +115 -0
- data/docs/udap.md +187 -64
- data/gemfiles/activesupport_71.gemfile +30 -0
- data/gemfiles/activesupport_71.gemfile.lock +291 -0
- data/lib/safire/client.rb +141 -44
- data/lib/safire/client_config.rb +114 -35
- data/lib/safire/client_config_builder.rb +18 -0
- data/lib/safire/errors.rb +100 -44
- data/lib/safire/http_client.rb +7 -2
- data/lib/safire/middleware/https_only_redirects.rb +10 -4
- data/lib/safire/protocols/behaviours.rb +12 -1
- data/lib/safire/protocols/oauth_response_handling.rb +48 -0
- data/lib/safire/protocols/smart.rb +108 -36
- data/lib/safire/protocols/smart_metadata.rb +6 -0
- data/lib/safire/protocols/udap.rb +534 -0
- data/lib/safire/protocols/udap_metadata.rb +429 -0
- data/lib/safire/protocols/udap_registration_metadata.rb +368 -0
- data/lib/safire/protocols/udap_signed_metadata_validator.rb +349 -0
- data/lib/safire/protocols/udap_software_statement.rb +348 -0
- data/lib/safire/protocols.rb +6 -0
- data/lib/safire/uri_validation.rb +102 -0
- data/lib/safire/version.rb +1 -1
- data/lib/safire.rb +2 -0
- data/safire.gemspec +7 -8
- metadata +42 -13
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
layout: default
|
|
3
|
-
title: "ADR-007: HTTPS-only
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
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
|
|
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.
|