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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: "ADR-014: UDAP software-statement signing"
|
|
4
|
+
parent: Architecture Decision Records
|
|
5
|
+
nav_order: 14
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ADR-014: UDAP software-statement signing
|
|
9
|
+
|
|
10
|
+
**Status:** Accepted
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Context
|
|
15
|
+
|
|
16
|
+
UDAP Dynamic Client Registration submits caller-controlled registration
|
|
17
|
+
metadata inside a signed software statement. HL7 UDAP Security STU2 requires
|
|
18
|
+
that statement to be a compact JWS with an `alg` header, an inline leaf-first
|
|
19
|
+
`x5c` certificate chain, issuer and subject set to the client URI, audience set
|
|
20
|
+
to the authorization server's registration endpoint, a short validity window,
|
|
21
|
+
and a fresh `jti`.
|
|
22
|
+
|
|
23
|
+
Safire already has `JWTAssertion` for SMART private-key JWT authentication, but
|
|
24
|
+
that class has SMART-specific audiences, claims, headers, algorithms, and key
|
|
25
|
+
distribution assumptions. Reusing it for UDAP would require protocol branches
|
|
26
|
+
in the signing path and make both flows harder to reason about.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
### Use a dedicated builder
|
|
33
|
+
|
|
34
|
+
`UdapSoftwareStatement` builds only UDAP registration software statements. It
|
|
35
|
+
accepts a validated `UdapRegistrationMetadata` object, a client URI, the
|
|
36
|
+
discovered registration endpoint, a private key, a leaf-first certificate
|
|
37
|
+
chain, and the server-advertised registration signing algorithms.
|
|
38
|
+
|
|
39
|
+
The builder has no HTTP behavior and does not perform discovery. UDAP protocol
|
|
40
|
+
orchestration is responsible for fetching metadata, checking DCR capability,
|
|
41
|
+
POSTing the request envelope, and parsing the response.
|
|
42
|
+
|
|
43
|
+
### Keep URI comparison exact
|
|
44
|
+
|
|
45
|
+
The client URI is used exactly as `iss` and `sub` and must exactly match a
|
|
46
|
+
`uniformResourceIdentifier` Subject Alternative Name entry in the leaf
|
|
47
|
+
certificate. Safire does not derive it from `ClientConfig#issuer` and does not
|
|
48
|
+
canonicalize case, ports, or trailing slashes.
|
|
49
|
+
|
|
50
|
+
STU2 does not constrain the URI scheme used for client identifiers, so Safire
|
|
51
|
+
does not require `client_uri` to use HTTPS. HTTP and HTTPS client URIs must have
|
|
52
|
+
a host, while trust-community schemes such as `did:` can be valid when they are
|
|
53
|
+
absolute and exactly match the certificate URI SAN.
|
|
54
|
+
|
|
55
|
+
The registration endpoint is different: it is a server endpoint used as `aud`
|
|
56
|
+
and must be an absolute HTTPS URI by default. `allow_insecure_localhost: true`
|
|
57
|
+
permits HTTP only on `localhost` or `127.0.0.1` for local development. The value
|
|
58
|
+
is still signed exactly as supplied.
|
|
59
|
+
|
|
60
|
+
### Generate a minimal JOSE header
|
|
61
|
+
|
|
62
|
+
Safire includes only the required `alg` and `x5c` header fields. It does not
|
|
63
|
+
emit `typ`, `kid`, `jku`, or `x5u`. STU2's experimental `jku` alternative does
|
|
64
|
+
not apply to registration requests.
|
|
65
|
+
|
|
66
|
+
`x5c` is generated from the supplied certificate chain in leaf-first issuer
|
|
67
|
+
order. Each entry is Base64 DER with no PEM wrapper.
|
|
68
|
+
|
|
69
|
+
### Constrain algorithm selection
|
|
70
|
+
|
|
71
|
+
Safire supports the STU2 algorithm set it can sign safely:
|
|
72
|
+
|
|
73
|
+
| Key | Algorithms |
|
|
74
|
+
|-----|------------|
|
|
75
|
+
| RSA | `RS256`, `RS384` |
|
|
76
|
+
| EC P-256 | `ES256` |
|
|
77
|
+
| EC P-384 | `ES384` |
|
|
78
|
+
|
|
79
|
+
An explicit algorithm must be supported by Safire, compatible with the private
|
|
80
|
+
key, and advertised by the server. Without an explicit algorithm, Safire chooses
|
|
81
|
+
the first key-compatible advertised algorithm, preferring `RS256` over `RS384`
|
|
82
|
+
for RSA keys because `RS256` is the STU2 baseline.
|
|
83
|
+
|
|
84
|
+
Protocol orchestration separately requires every DCR-capable server to advertise
|
|
85
|
+
mandatory `RS256` support before Safire signs with any compatible advertised
|
|
86
|
+
algorithm. This enforces the server baseline independently from the configured
|
|
87
|
+
client key type.
|
|
88
|
+
|
|
89
|
+
### Validate local signing identity, not server trust
|
|
90
|
+
|
|
91
|
+
Before signing, Safire parses and snapshots the certificate chain, checks that
|
|
92
|
+
the supplied chain is leaf-first and issuer-ordered, verifies that the private
|
|
93
|
+
key matches the leaf certificate, and checks that the client URI appears in the
|
|
94
|
+
leaf certificate URI SAN. Certificate validity dates are checked when the
|
|
95
|
+
builder is constructed and rechecked each time a software statement is emitted.
|
|
96
|
+
|
|
97
|
+
Safire does not decide whether the authorization server will trust the client
|
|
98
|
+
certificate chain. Chain-building to trust anchors, revocation policy, and
|
|
99
|
+
community trust for the client certificate remain authorization-server
|
|
100
|
+
decisions during registration processing.
|
|
101
|
+
|
|
102
|
+
### Keep test seams explicit
|
|
103
|
+
|
|
104
|
+
The builder accepts a clock and a JTI generator. Production defaults are
|
|
105
|
+
`Time.now` and `SecureRandom.uuid`; specs inject deterministic values without
|
|
106
|
+
stubbing global randomness.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Consequences
|
|
111
|
+
|
|
112
|
+
- SMART JWT assertions and UDAP software statements remain separate, readable
|
|
113
|
+
signing paths.
|
|
114
|
+
- Invalid local signing configuration fails before a malformed JWT can be
|
|
115
|
+
produced.
|
|
116
|
+
- The generated JWT is short-lived: `exp` is always `iat + 300`.
|
|
117
|
+
- Private keys, compact JWTs, and certificate contents are not included in
|
|
118
|
+
validation error messages.
|
|
119
|
+
- End-to-end UDAP registration lifecycle operations use this builder through
|
|
120
|
+
`Protocols::Udap#register_client` and `Protocols::Udap#cancel_registration`;
|
|
121
|
+
applications should prefer the facade methods over direct software-statement
|
|
122
|
+
construction.
|
data/docs/adr/index.md
CHANGED
|
@@ -17,6 +17,12 @@ Architecture Decision Records (ADRs) document significant design decisions made
|
|
|
17
17
|
| [ADR-003]({% link adr/ADR-003-protocol-vs-client-type.md %}) | `protocol:` and `client_type:` as orthogonal dimensions | Accepted |
|
|
18
18
|
| [ADR-004]({% link adr/ADR-004-clientconfig-immutability-and-entity-masking.md %}) | `ClientConfig` immutability and `Entity` sensitive attribute masking | Accepted |
|
|
19
19
|
| [ADR-005]({% link adr/ADR-005-per-client-http-ownership.md %}) | Per-client `HTTPClient` ownership — no shared connection pool | Accepted |
|
|
20
|
-
| [ADR-006]({% link adr/ADR-006-lazy-discovery.md %}) | Lazy
|
|
21
|
-
| [ADR-007]({% link adr/ADR-007-https-only-redirects-and-localhost-exception.md %}) | HTTPS-only
|
|
22
|
-
| [ADR-008]({% link adr/ADR-008-warn-return-false-for-compliance-validation.md %}) | Warn and return false for compliance validation — raise only for configuration errors | Accepted |
|
|
20
|
+
| [ADR-006]({% link adr/ADR-006-lazy-discovery.md %}) | Lazy discovery — no HTTP in constructors | Accepted |
|
|
21
|
+
| [ADR-007]({% link adr/ADR-007-https-only-redirects-and-localhost-exception.md %}) | HTTPS-only enforcement and explicit localhost opt-in | Accepted |
|
|
22
|
+
| [ADR-008]({% link adr/ADR-008-warn-return-false-for-compliance-validation.md %}) | Warn and return false for compliance validation — raise only for configuration errors | Accepted |
|
|
23
|
+
| [ADR-009]({% link adr/ADR-009-oauth-error-hierarchy.md %}) | `OAuthError` base class and `ReceivesFields` mixin for protocol error hierarchy | Accepted |
|
|
24
|
+
| [ADR-010]({% link adr/ADR-010-optional-client-id-dcr-temp-client.md %}) | `client_id` optional at initialization — deferred validation for the DCR temp-client pattern | Accepted |
|
|
25
|
+
| [ADR-011]({% link adr/ADR-011-udap-stu2-discovery-conformance.md %}) | `UdapMetadata` entity — structural validation separate from cryptographic validation | Accepted |
|
|
26
|
+
| [ADR-012]({% link adr/ADR-012-udap-signed-metadata-validation.md %}) | `signed_metadata` JWT validation — design and chain verification defaults | Accepted |
|
|
27
|
+
| [ADR-013]({% link adr/ADR-013-udap-registration-request-model.md %}) | UDAP registration metadata as an immutable value object | Accepted |
|
|
28
|
+
| [ADR-014]({% link adr/ADR-014-udap-software-statement-signing.md %}) | UDAP software-statement signing | Accepted |
|
data/docs/advanced.md
CHANGED
|
@@ -32,14 +32,7 @@ class SmartMetadataService
|
|
|
32
32
|
|
|
33
33
|
def self.fetch(base_url)
|
|
34
34
|
Rails.cache.fetch("smart_metadata:#{base_url}", expires_in: CACHE_TTL) do
|
|
35
|
-
|
|
36
|
-
base_url: base_url,
|
|
37
|
-
client_id: 'discovery_only',
|
|
38
|
-
redirect_uri: 'https://example.com',
|
|
39
|
-
scopes: []
|
|
40
|
-
)
|
|
41
|
-
client = Safire::Client.new(config)
|
|
42
|
-
client.server_metadata.to_hash
|
|
35
|
+
Safire::Client.new({ base_url: base_url }).server_metadata.to_hash
|
|
43
36
|
end
|
|
44
37
|
end
|
|
45
38
|
|
|
@@ -142,11 +135,11 @@ class TokenManager
|
|
|
142
135
|
token_params = { refresh_token: session[:refresh_token] }
|
|
143
136
|
response = client.refresh_token(token_params)
|
|
144
137
|
|
|
145
|
-
session[:access_token] = response[
|
|
146
|
-
session[:refresh_token] = response[
|
|
147
|
-
session[:token_expires_at] = Time.current + response[
|
|
138
|
+
session[:access_token] = response['access_token']
|
|
139
|
+
session[:refresh_token] = response['refresh_token'] || session[:refresh_token]
|
|
140
|
+
session[:token_expires_at] = Time.current + response['expires_in'].to_i.seconds
|
|
148
141
|
|
|
149
|
-
response[
|
|
142
|
+
response['access_token']
|
|
150
143
|
end
|
|
151
144
|
end
|
|
152
145
|
```
|
|
@@ -178,9 +171,9 @@ Override the default scopes for specific actions without reconfiguring the clien
|
|
|
178
171
|
|
|
179
172
|
```ruby
|
|
180
173
|
def launch_with_scopes(client, extra_scopes: [])
|
|
181
|
-
base_scopes
|
|
182
|
-
merged
|
|
183
|
-
client.authorization_url(
|
|
174
|
+
base_scopes = ['openid', 'profile', 'patient/*.read']
|
|
175
|
+
merged = (base_scopes + extra_scopes).uniq
|
|
176
|
+
client.authorization_url(custom_scopes: merged)
|
|
184
177
|
end
|
|
185
178
|
|
|
186
179
|
# Requesting additional write access for a specific workflow
|
|
@@ -207,11 +200,11 @@ class SmartAuthController < ApplicationController
|
|
|
207
200
|
|
|
208
201
|
# Step 1 — Redirect user to the authorization server
|
|
209
202
|
def launch
|
|
210
|
-
|
|
211
|
-
session[:
|
|
212
|
-
session[:state] =
|
|
203
|
+
auth_data = @client.authorization_url
|
|
204
|
+
session[:code_verifier] = auth_data[:code_verifier]
|
|
205
|
+
session[:state] = auth_data[:state]
|
|
213
206
|
|
|
214
|
-
redirect_to auth_url, allow_other_host: true
|
|
207
|
+
redirect_to auth_data[:auth_url], allow_other_host: true
|
|
215
208
|
end
|
|
216
209
|
|
|
217
210
|
# Step 2 — Handle the authorization server callback
|
|
@@ -221,15 +214,19 @@ class SmartAuthController < ApplicationController
|
|
|
221
214
|
return
|
|
222
215
|
end
|
|
223
216
|
|
|
224
|
-
|
|
217
|
+
unless params[:state] == session.delete(:state)
|
|
218
|
+
redirect_to root_path, alert: 'Invalid state parameter'
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
token_response = @client.request_access_token(
|
|
225
223
|
code: params[:code],
|
|
226
|
-
|
|
227
|
-
pkce_verifier: session.delete(:pkce_verifier)
|
|
224
|
+
code_verifier: session.delete(:code_verifier)
|
|
228
225
|
)
|
|
229
226
|
|
|
230
|
-
session[:access_token] = token_response[
|
|
231
|
-
session[:refresh_token] = token_response[
|
|
232
|
-
session[:token_expires_at] = Time.current + token_response[
|
|
227
|
+
session[:access_token] = token_response['access_token']
|
|
228
|
+
session[:refresh_token] = token_response['refresh_token']
|
|
229
|
+
session[:token_expires_at] = Time.current + token_response['expires_in'].to_i.seconds
|
|
233
230
|
|
|
234
231
|
redirect_to dashboard_path
|
|
235
232
|
end
|
|
@@ -45,11 +45,20 @@ config = Safire::ClientConfig.new(
|
|
|
45
45
|
client = Safire::Client.new(config)
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
{: .note }
|
|
49
|
+
> `client_id` is intentionally optional at construction. `authorization_url`,
|
|
50
|
+
> `request_access_token`, `refresh_token`, and `request_backend_token` validate
|
|
51
|
+
> it when called. This permits a temporary client to call `register_client` and
|
|
52
|
+
> obtain an identifier first. Other flow-specific credentials and signing
|
|
53
|
+
> requirements may also be validated when the operation that needs them runs.
|
|
54
|
+
> See [SMART Dynamic Client Registration]({{ site.baseurl }}/smart-on-fhir/dynamic-client-registration/)
|
|
55
|
+
> or [UDAP Dynamic Client Registration]({{ site.baseurl }}/udap/dynamic-client-registration/).
|
|
56
|
+
|
|
48
57
|
---
|
|
49
58
|
|
|
50
59
|
## Protocol and Client Type
|
|
51
60
|
|
|
52
|
-
`protocol:` and `client_type:` are keyword arguments to `Safire::Client.new`.
|
|
61
|
+
`protocol:` and `client_type:` are keyword arguments to `Safire::Client.new`. `protocol:` selects the protocol implementation; `client_type:` selects the SMART authentication style and is not used for UDAP.
|
|
53
62
|
|
|
54
63
|
```ruby
|
|
55
64
|
client = Safire::Client.new(config, protocol: :smart, client_type: :confidential_symmetric)
|
|
@@ -62,9 +71,91 @@ Selects the authorization protocol. Defaults to `:smart`.
|
|
|
62
71
|
| Value | Status | Description |
|
|
63
72
|
|-------|--------|-------------|
|
|
64
73
|
| `:smart` | Implemented | SMART App Launch 2.2.0 |
|
|
65
|
-
| `:udap` |
|
|
74
|
+
| `:udap` | Partial | UDAP Security STU2 discovery and Dynamic Client Registration — `server_metadata` validates `signed_metadata`, supports optional `community:`, and accepts trust policy keywords (`trusted_anchors:`, `crls:`, `revocation_checker:`, `verify_chain:`); `register_client` and `cancel_registration` submit certificate-backed UDAP registration lifecycle requests; auth/token flows raise `NotImplementedError` |
|
|
75
|
+
|
|
76
|
+
For UDAP, `client_type:` is not applicable. Passing any explicit value, either at initialization or through `client.client_type=`, raises `Safire::Errors::ConfigurationError`. Future UDAP authentication flows will use signed JWT assertions rather than SMART client types.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
client = Safire::Client.new(
|
|
80
|
+
{ base_url: 'https://fhir.example.com' },
|
|
81
|
+
protocol: :udap
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
metadata = client.server_metadata(verify_chain: false) # development/test only
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Production UDAP discovery must validate `signed_metadata` with trust anchors and an explicit revocation policy:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
ca_cert = OpenSSL::X509::Certificate.new(File.read('udap_ca.pem'))
|
|
91
|
+
ca_crl = OpenSSL::X509::CRL.new(File.read('udap_ca.crl'))
|
|
92
|
+
|
|
93
|
+
metadata = client.server_metadata(
|
|
94
|
+
trusted_anchors: [ca_cert],
|
|
95
|
+
crls: [ca_crl]
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Pass `community:` when the server participates in a specific UDAP trust community:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
metadata = client.server_metadata(
|
|
103
|
+
community: 'https://udap.example.org/community1',
|
|
104
|
+
trusted_anchors: [ca_cert],
|
|
105
|
+
crls: [ca_crl]
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
See [UDAP]({{ site.baseurl }}/udap/) for validation helpers, 404/204 discovery behavior, and the `verify_chain: false` development caveat.
|
|
110
|
+
|
|
111
|
+
#### UDAP client signing credentials
|
|
66
112
|
|
|
67
|
-
|
|
113
|
+
UDAP software statements use a client private key and a leaf-first,
|
|
114
|
+
issuer-ordered X.509 certificate chain. Configure these reusable credentials on
|
|
115
|
+
the client:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
config = Safire::ClientConfig.new(
|
|
119
|
+
base_url: 'https://fhir.example.com',
|
|
120
|
+
private_key: File.read(ENV.fetch('UDAP_CLIENT_PRIVATE_KEY_PATH')),
|
|
121
|
+
certificate_chain: [
|
|
122
|
+
File.read(ENV.fetch('UDAP_CLIENT_CERTIFICATE_PATH')),
|
|
123
|
+
File.read(ENV.fetch('UDAP_CLIENT_ISSUING_CA_PATH'))
|
|
124
|
+
],
|
|
125
|
+
jwt_algorithm: 'RS256'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
client = Safire::Client.new(config, protocol: :udap)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`certificate_chain` accepts PEM strings or
|
|
132
|
+
`OpenSSL::X509::Certificate` instances. The leaf certificate must be first, as
|
|
133
|
+
required by the
|
|
134
|
+
[UDAP Security STU2 JWT header profile](https://hl7.org/fhir/us/udap-security/STU2/general.html#jwt-headers).
|
|
135
|
+
`ClientConfig` requires a non-empty collection, copies and freezes PEM strings,
|
|
136
|
+
and snapshots certificate objects as DER. Accessing the chain returns fresh
|
|
137
|
+
certificate objects, so subsequent caller mutations cannot alter the configured
|
|
138
|
+
identity. Safire performs PEM parsing, private-key matching, validity checks,
|
|
139
|
+
and URI SAN checks when a software statement is built.
|
|
140
|
+
|
|
141
|
+
Configured credentials are intended to serve as defaults for UDAP Dynamic
|
|
142
|
+
Client Registration, with per-call overrides for applications that select
|
|
143
|
+
signing identities dynamically. UDAP discovery does not access these signing
|
|
144
|
+
credentials.
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
registration = client.register_client(
|
|
148
|
+
{
|
|
149
|
+
client_name: 'Example Backend Service',
|
|
150
|
+
contacts: ['mailto:security@example.com'],
|
|
151
|
+
grant_types: ['client_credentials'],
|
|
152
|
+
scope: 'system/Patient.rs'
|
|
153
|
+
},
|
|
154
|
+
client_uri: 'https://client.example.com',
|
|
155
|
+
trusted_anchors: [udap_ca],
|
|
156
|
+
crls: [udap_crl]
|
|
157
|
+
)
|
|
158
|
+
```
|
|
68
159
|
|
|
69
160
|
### Client Type
|
|
70
161
|
|
|
@@ -113,11 +204,15 @@ For a decision guide on which workflow to use, see [SMART App Launch — Choosin
|
|
|
113
204
|
|
|
114
205
|
## URI Validation
|
|
115
206
|
|
|
116
|
-
All URI
|
|
207
|
+
All URI attributes supplied through `ClientConfig` are validated at
|
|
208
|
+
initialization. Per-call values, such as UDAP registration metadata, are
|
|
209
|
+
validated by the operation-specific value object or protocol flow. Safire
|
|
210
|
+
raises `Safire::Errors::ConfigurationError` for invalid configuration URIs:
|
|
117
211
|
|
|
118
212
|
- URIs must be well-formed (scheme + host required)
|
|
119
|
-
- URIs must use `https` — required
|
|
120
|
-
- **
|
|
213
|
+
- URIs must use `https` — required for SMART App Launch and UDAP discovery
|
|
214
|
+
- **Development exception:** `http` loopback URIs (`localhost` and `127.0.0.1`)
|
|
215
|
+
are permitted only when `allow_insecure_localhost: true` is configured
|
|
121
216
|
|
|
122
217
|
The following attributes are validated:
|
|
123
218
|
|
|
@@ -132,17 +227,36 @@ The following attributes are validated:
|
|
|
132
227
|
|
|
133
228
|
If you need to bypass discovery and provide endpoints directly, set `authorization_endpoint` and `token_endpoint` in your config. Safire will use them as-is instead of fetching `/.well-known/smart-configuration`.
|
|
134
229
|
|
|
230
|
+
For a local development server without TLS, opt in deliberately:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
config = Safire::ClientConfig.new(
|
|
234
|
+
base_url: 'http://localhost:3000/fhir',
|
|
235
|
+
redirect_uri: 'http://localhost:3000/callback',
|
|
236
|
+
allow_insecure_localhost: ENV['APP_ENV'] == 'development'
|
|
237
|
+
)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Safire does not infer your framework environment. Leave the default `false` in
|
|
241
|
+
production.
|
|
242
|
+
|
|
243
|
+
UDAP discovery always uses the FHIR `base_url` and the `/.well-known/udap` endpoint. UDAP endpoint values are taken from discovered, signed metadata rather than from SMART endpoint overrides.
|
|
244
|
+
|
|
135
245
|
---
|
|
136
246
|
|
|
137
247
|
## Credential Protection
|
|
138
248
|
|
|
139
|
-
`ClientConfig` prevents `client_secret
|
|
249
|
+
`ClientConfig` prevents `client_secret`, `private_key`, and
|
|
250
|
+
`certificate_chain` from leaking in logs or REPL output. The certificate chain
|
|
251
|
+
is masked even though certificates are public because the full chain can be
|
|
252
|
+
large and identifies the client's operational signing identity.
|
|
140
253
|
|
|
141
254
|
`#to_hash` replaces sensitive fields with `'[FILTERED]'`:
|
|
142
255
|
|
|
143
256
|
```ruby
|
|
144
|
-
config.to_hash[:client_secret]
|
|
145
|
-
config.to_hash[:
|
|
257
|
+
config.to_hash[:client_secret] # => "[FILTERED]"
|
|
258
|
+
config.to_hash[:certificate_chain] # => "[FILTERED]"
|
|
259
|
+
config.to_hash[:base_url] # => "https://fhir.example.com"
|
|
146
260
|
```
|
|
147
261
|
|
|
148
262
|
`#inspect` is overridden to mask sensitive fields and omit `nil` attributes, so REPL sessions and error messages never expose credentials:
|
|
@@ -157,4 +271,6 @@ config.inspect
|
|
|
157
271
|
## Next Steps
|
|
158
272
|
|
|
159
273
|
- [Logging]({{ site.baseurl }}/configuration/logging/) — configure Safire's logger and HTTP request logging
|
|
160
|
-
- [
|
|
274
|
+
- [UDAP]({{ site.baseurl }}/udap/) — discover UDAP metadata and register certificate-backed clients
|
|
275
|
+
- [SMART Dynamic Client Registration]({{ site.baseurl }}/smart-on-fhir/dynamic-client-registration/) — obtain a `client_id` at runtime using RFC 7591
|
|
276
|
+
- [SMART App Launch Workflows]({{ site.baseurl }}/smart-on-fhir/) — step-by-step authorization flow guides
|
data/docs/configuration/index.md
CHANGED
|
@@ -29,7 +29,7 @@ flowchart TD
|
|
|
29
29
|
A -->|"resolves config"| B
|
|
30
30
|
A -->|"validates protocol + client_type"| C
|
|
31
31
|
C -->|":smart (default)"| D
|
|
32
|
-
C -->|":udap
|
|
32
|
+
C -->|":udap"| G["Protocols::Udap\n— discovery implemented\n— DCR implemented\n— auth flows planned"]
|
|
33
33
|
D -->|"lazily fetches"| E
|
|
34
34
|
E -->|"HTTP"| F
|
|
35
35
|
```
|
|
@@ -41,19 +41,23 @@ flowchart TD
|
|
|
41
41
|
| Parameter | Type | Required | Default | Description |
|
|
42
42
|
|-----------|------|----------|---------|-------------|
|
|
43
43
|
| `base_url` | String | Yes | — | FHIR server base URL |
|
|
44
|
-
| `client_id` | String |
|
|
45
|
-
| `redirect_uri` | String |
|
|
44
|
+
| `client_id` | String | No | — | OAuth2 client identifier — required by all authorization flows; validated at call time, not at construction |
|
|
45
|
+
| `redirect_uri` | String | No | — | Registered callback URL — required for App Launch flows; not used in Backend Services |
|
|
46
46
|
| `protocol:` | Symbol | No | `:smart` | Authorization protocol — `:smart` or `:udap` |
|
|
47
|
-
| `client_type:` | Symbol | No | `:public` | SMART client type — `:public`, `:confidential_symmetric`, or `:confidential_asymmetric` |
|
|
47
|
+
| `client_type:` | Symbol | No | `nil` (→ `:public` for SMART) | SMART client type — `:public`, `:confidential_symmetric`, or `:confidential_asymmetric`; not applicable for `:udap` (any explicit value raises `ConfigurationError`) |
|
|
48
48
|
| `client_secret` | String | No | — | Required for `:confidential_symmetric` |
|
|
49
|
-
| `private_key` | OpenSSL::PKey / String | No | — | RSA/EC private key;
|
|
49
|
+
| `private_key` | OpenSSL::PKey / String | No | — | RSA/EC private key; used by SMART asymmetric clients and as the UDAP client signing key |
|
|
50
|
+
| `certificate_chain` | Array of PEM strings / OpenSSL::X509::Certificate | No | — | Non-empty, leaf-first, issuer-ordered client certificate chain for UDAP software-statement signing |
|
|
50
51
|
| `kid` | String | No | — | Key ID matching the public key registered with the server |
|
|
51
|
-
| `jwt_algorithm` | String | No | auto | `RS384` or `ES384`;
|
|
52
|
-
| `jwks_uri` | String | No | — | URL to client's public JWKS, included as `jku` in JWT
|
|
52
|
+
| `jwt_algorithm` | String | No | auto | SMART: `RS384` or `ES384`; UDAP registration: `RS256`, `RS384`, `ES256`, or `ES384`, constrained by the key and server metadata |
|
|
53
|
+
| `jwks_uri` | String | No | — | SMART-only URL to the client's public JWKS, included as `jku` in SMART JWT assertions |
|
|
53
54
|
| `scopes` | Array | No | — | Default scopes for authorization requests |
|
|
54
55
|
| `authorization_endpoint` | String | No | — | Override the discovered authorization endpoint |
|
|
55
56
|
| `token_endpoint` | String | No | — | Override the discovered token endpoint |
|
|
56
57
|
|
|
58
|
+
`certificate_chain` and the UDAP algorithm values are used by
|
|
59
|
+
`Safire::Client#register_client` when `protocol: :udap`.
|
|
60
|
+
|
|
57
61
|
## In This Section
|
|
58
62
|
|
|
59
63
|
- [Client Setup]({{ site.baseurl }}/configuration/client-setup/) — creating a client, protocol and client type selection, URI rules, and credential protection
|
data/docs/index.md
CHANGED
|
@@ -3,7 +3,7 @@ layout: default
|
|
|
3
3
|
title: Home
|
|
4
4
|
nav_order: 1
|
|
5
5
|
permalink: /
|
|
6
|
-
description: "Safire
|
|
6
|
+
description: "Safire implements SMART App Launch 2.2.0 plus UDAP Security STU2 discovery and Dynamic Client Registration for Ruby clients."
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Safire Documentation
|
|
@@ -11,7 +11,7 @@ description: "Safire is a Ruby gem implementing SMART App Launch 2.2.0 and UDAP
|
|
|
11
11
|
[](https://badge.fury.io/rb/safire)
|
|
12
12
|
[](https://github.com/vanessuniq/safire/actions)
|
|
13
13
|
|
|
14
|
-
A lean Ruby gem implementing **[SMART App Launch](https://hl7.org/fhir/smart-app-launch/)** and **[UDAP](https://hl7.org/fhir/us/udap-security/)**
|
|
14
|
+
A lean Ruby gem implementing **[SMART App Launch](https://hl7.org/fhir/smart-app-launch/)** flows and **[UDAP Security STU2 / v2.0.0](https://hl7.org/fhir/us/udap-security/STU2/index.html)** discovery and Dynamic Client Registration for clients.
|
|
15
15
|
|
|
16
16
|
## Quick Navigation
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@ A lean Ruby gem implementing **[SMART App Launch](https://hl7.org/fhir/smart-app
|
|
|
20
20
|
| [Getting Started]({{ site.baseurl }}/installation/) | Install Safire and quick start guide |
|
|
21
21
|
| [Configuration]({{ site.baseurl }}/configuration/) | All configuration options and parameters |
|
|
22
22
|
| [SMART]({{ site.baseurl }}/smart-on-fhir/) | App Launch (Public, Confidential Symmetric, Confidential Asymmetric) and Backend Services |
|
|
23
|
-
| [UDAP]({{ site.baseurl }}/udap/) | UDAP
|
|
23
|
+
| [UDAP]({{ site.baseurl }}/udap/) | UDAP Security discovery plus certificate-backed Dynamic Client Registration |
|
|
24
24
|
| [Security Guide]({{ site.baseurl }}/security/) | HTTPS, credential protection, token storage, key rotation |
|
|
25
25
|
| [Advanced Examples]({{ site.baseurl }}/advanced/) | Caching, multi-server, token management, complete Rails integration |
|
|
26
26
|
| [Troubleshooting]({{ site.baseurl }}/troubleshooting/) | Common issues and solutions |
|
|
@@ -37,9 +37,15 @@ A lean Ruby gem implementing **[SMART App Launch](https://hl7.org/fhir/smart-app
|
|
|
37
37
|
- POST-Based Authorization
|
|
38
38
|
- Backend Services (client_credentials grant, JWT assertion, no user interaction or PKCE)
|
|
39
39
|
|
|
40
|
-
### UDAP
|
|
40
|
+
### UDAP Security (STU2)
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
- Discovery (`/.well-known/udap`) with optional community scoping
|
|
43
|
+
- Dynamic Client Registration metadata validation and normalization
|
|
44
|
+
- X.509-backed software-statement signing for registration, modification, and cancellation
|
|
45
|
+
|
|
46
|
+
JWT assertion authentication and Tiered OAuth remain planned. See
|
|
47
|
+
[ROADMAP.md](https://github.com/vanessuniq/safire/blob/main/ROADMAP.md) for
|
|
48
|
+
details.
|
|
43
49
|
|
|
44
50
|
## Demo Application
|
|
45
51
|
|
|
@@ -49,7 +55,8 @@ A Sinatra-based demo app is included to help you explore Safire's features:
|
|
|
49
55
|
bin/demo
|
|
50
56
|
```
|
|
51
57
|
|
|
52
|
-
Visit http://localhost:4567 to test SMART
|
|
58
|
+
Visit http://localhost:4567 to test SMART registration and authorization flows,
|
|
59
|
+
UDAP signed metadata discovery, and the UDAP registration lifecycle.
|
|
53
60
|
|
|
54
61
|
See [`examples/sinatra_app/README.md`](https://github.com/vanessuniq/safire/tree/main/examples/sinatra_app) for details.
|
|
55
62
|
|
data/docs/installation.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
layout: default
|
|
3
3
|
title: Installation
|
|
4
4
|
nav_order: 2
|
|
5
|
-
|
|
5
|
+
permalink: /installation/
|
|
6
|
+
description: "How to install the Safire Ruby gem and get started with SMART App Launch and UDAP in your healthcare application."
|
|
6
7
|
---
|
|
7
8
|
|
|
8
9
|
# Installation
|
|
@@ -19,7 +20,7 @@ description: "How to install the Safire Ruby gem and get started with SMART App
|
|
|
19
20
|
|
|
20
21
|
## Install
|
|
21
22
|
|
|
22
|
-
**Requirements:** Ruby ≥
|
|
23
|
+
**Requirements:** Ruby ≥ 3.2. OpenSSL is bundled with Ruby — no separate install needed.
|
|
23
24
|
|
|
24
25
|
Add to your Gemfile:
|
|
25
26
|
|
data/docs/security.md
CHANGED
|
@@ -21,7 +21,9 @@ This guide covers security requirements and best practices for every Safire inte
|
|
|
21
21
|
|
|
22
22
|
## HTTPS and Redirect URI Rules
|
|
23
23
|
|
|
24
|
-
All production FHIR integrations must use HTTPS. Safire enforces this at
|
|
24
|
+
All production FHIR integrations must use HTTPS. Safire enforces this at
|
|
25
|
+
configuration time — HTTP URIs are rejected unless the caller explicitly opts
|
|
26
|
+
into the local-development loopback exception.
|
|
25
27
|
|
|
26
28
|
```ruby
|
|
27
29
|
# config/environments/production.rb
|
|
@@ -42,11 +44,15 @@ config = Safire::ClientConfig.new(
|
|
|
42
44
|
)
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
Localhost is permitted
|
|
47
|
+
Localhost HTTP is permitted only when the application enables it deliberately:
|
|
46
48
|
|
|
47
49
|
```ruby
|
|
48
50
|
# ✅ Allowed for local development only
|
|
49
|
-
|
|
51
|
+
config = Safire::ClientConfig.new(
|
|
52
|
+
base_url: 'http://localhost:3000/fhir',
|
|
53
|
+
redirect_uri: 'http://localhost:3000/auth/callback',
|
|
54
|
+
allow_insecure_localhost: ENV['APP_ENV'] == 'development'
|
|
55
|
+
)
|
|
50
56
|
```
|
|
51
57
|
|
|
52
58
|
---
|
|
@@ -93,7 +99,7 @@ def fetch_client_secret
|
|
|
93
99
|
end
|
|
94
100
|
```
|
|
95
101
|
|
|
96
|
-
### Private Keys
|
|
102
|
+
### Private Keys and Client Certificates
|
|
97
103
|
|
|
98
104
|
```ruby
|
|
99
105
|
# ❌ NEVER: render or log the key
|
|
@@ -124,7 +130,8 @@ def fetch_private_key
|
|
|
124
130
|
end
|
|
125
131
|
```
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
For SMART confidential-asymmetric flows, use strong keys compatible with the
|
|
134
|
+
SMART signing algorithms:
|
|
128
135
|
|
|
129
136
|
```ruby
|
|
130
137
|
# RSA: minimum 2048-bit, 4096-bit recommended
|
|
@@ -137,6 +144,38 @@ key = OpenSSL::PKey::EC.generate('secp384r1')
|
|
|
137
144
|
{: .note }
|
|
138
145
|
> Safire automatically masks `client_secret` and `private_key` in `inspect` output and error messages, so they will not appear in Rails logs even if a `ClientConfig` object is accidentally logged.
|
|
139
146
|
|
|
147
|
+
UDAP Dynamic Client Registration additionally requires a leaf-first client
|
|
148
|
+
certificate chain. The certificates contain public material, but they identify
|
|
149
|
+
the client's operational signing identity and can be large, so Safire masks
|
|
150
|
+
`certificate_chain` in `ClientConfig#inspect` and `#to_hash` as well.
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
udap_client = Safire::Client.new(
|
|
154
|
+
{
|
|
155
|
+
base_url: 'https://fhir.example.com',
|
|
156
|
+
private_key: Rails.application.credentials.udap[:private_key_pem],
|
|
157
|
+
certificate_chain: [
|
|
158
|
+
File.read(ENV.fetch('UDAP_CLIENT_CERTIFICATE_PATH')),
|
|
159
|
+
File.read(ENV.fetch('UDAP_CLIENT_ISSUING_CA_PATH'))
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
protocol: :udap
|
|
163
|
+
)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Keep the two UDAP trust directions separate:
|
|
167
|
+
|
|
168
|
+
- `private_key` and `certificate_chain` identify the client and sign
|
|
169
|
+
registration software statements.
|
|
170
|
+
- `trusted_anchors`, `crls`, and `revocation_checker` validate the server's
|
|
171
|
+
discovery `signed_metadata`.
|
|
172
|
+
|
|
173
|
+
Never use `verify_chain: false` or `allow_insecure_localhost: true` in
|
|
174
|
+
production. The generated credentials and self-declaration JWTs in the
|
|
175
|
+
unbundled Sinatra demo are development fixtures, not production trust material.
|
|
176
|
+
See [UDAP Dynamic Client Registration]({% link udap/dynamic-client-registration/index.md %})
|
|
177
|
+
for the complete trust model.
|
|
178
|
+
|
|
140
179
|
---
|
|
141
180
|
|
|
142
181
|
## Token and Session Security
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
layout: default
|
|
3
3
|
title: Backend Services Workflow
|
|
4
4
|
parent: SMART
|
|
5
|
-
nav_order:
|
|
5
|
+
nav_order: 7
|
|
6
6
|
has_children: true
|
|
7
7
|
permalink: /smart-on-fhir/backend-services/
|
|
8
8
|
---
|
|
@@ -56,7 +56,7 @@ Suitable for:
|
|
|
56
56
|
|
|
57
57
|
### Client Registration
|
|
58
58
|
|
|
59
|
-
Before making any token requests, the client **SHALL** register with the authorization server following the [confidential asymmetric client registration](https://hl7.org/fhir/smart-app-launch/client-confidential-asymmetric.html#registering-a-client-communicating-public-keys) steps defined in the SMART App Launch specification. Registration communicates the client's public key(s) to the server, either via a JWKS URI or by uploading the JWKS directly.
|
|
59
|
+
Before making any token requests, the client **SHALL** register with the authorization server following the [confidential asymmetric client registration](https://hl7.org/fhir/smart-app-launch/client-confidential-asymmetric.html#registering-a-client-communicating-public-keys) steps defined in the SMART App Launch specification. Registration communicates the client's public key(s) to the server, either via a JWKS URI or by uploading the JWKS directly. If the server supports RFC 7591, you can automate this step with Safire's `register_client` — see the [Dynamic Client Registration]({% link smart-on-fhir/dynamic-client-registration/index.md %}) guide.
|
|
60
60
|
|
|
61
61
|
### Key Pair and JWKS
|
|
62
62
|
|
|
@@ -117,7 +117,7 @@ end
|
|
|
117
117
|
begin
|
|
118
118
|
token_data = client.request_backend_token
|
|
119
119
|
rescue Safire::Errors::ConfigurationError => e
|
|
120
|
-
# private_key or kid missing
|
|
120
|
+
# client_id, private_key, or kid missing — only private_key and kid can be overridden at call time
|
|
121
121
|
Rails.logger.error("Backend services misconfigured: #{e.message}")
|
|
122
122
|
raise
|
|
123
123
|
rescue Safire::Errors::TokenError => e
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
layout: default
|
|
3
3
|
title: Confidential Asymmetric Client Workflow
|
|
4
4
|
parent: SMART
|
|
5
|
-
nav_order:
|
|
5
|
+
nav_order: 5
|
|
6
6
|
has_children: true
|
|
7
7
|
permalink: /smart-on-fhir/confidential-asymmetric/
|
|
8
8
|
---
|
|
@@ -50,7 +50,7 @@ Suitable for:
|
|
|
50
50
|
|
|
51
51
|
## Prerequisites: Keys, JWKS, and Algorithm
|
|
52
52
|
|
|
53
|
-
Before writing any flow code, you need a key pair, a key ID, and a way for the authorization server to verify your public key.
|
|
53
|
+
Before writing any flow code, you need a key pair, a key ID, and a way for the authorization server to verify your public key. If the server supports RFC 7591, you can register dynamically using Safire's `register_client` — see the [Dynamic Client Registration]({% link smart-on-fhir/dynamic-client-registration/index.md %}) guide.
|
|
54
54
|
|
|
55
55
|
### Generating a Key Pair
|
|
56
56
|
|