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
@@ -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 SMART discovery — no HTTP in constructors | Accepted |
21
- | [ADR-007]({% link adr/ADR-007-https-only-redirects-and-localhost-exception.md %}) | HTTPS-only redirect enforcement and localhost exception | 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 |
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
- config = Safire::ClientConfig.new(
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[:access_token]
146
- session[:refresh_token] = response[:refresh_token] || session[:refresh_token]
147
- session[:token_expires_at] = Time.current + response[:expires_in].to_i.seconds
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[:access_token]
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 = ['openid', 'profile', 'patient/*.read']
182
- merged = (base_scopes + extra_scopes).uniq
183
- client.authorization_url(scope_override: merged)
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
- auth_url = @client.authorization_url
211
- session[:pkce_verifier] = @client.code_verifier
212
- session[:state] = @client.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
- token_response = @client.exchange_code_for_token(
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
- state: params[:state],
227
- pkce_verifier: session.delete(:pkce_verifier)
224
+ code_verifier: session.delete(:code_verifier)
228
225
  )
229
226
 
230
- session[:access_token] = token_response[:access_token]
231
- session[:refresh_token] = token_response[:refresh_token]
232
- session[:token_expires_at] = Time.current + token_response[:expires_in].to_i.seconds
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`. They are independent of each other.
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` | Planned | UDAP Security 1.0 — accepted by the validator, raises `NotImplementedError` until implemented |
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
- For UDAP, `client_type:` is ignored — UDAP clients always authenticate with a JWT signed by their private key.
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 parameters are validated at initialization. Safire raises `Safire::Errors::ConfigurationError` for any violation:
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 by SMART App Launch 2.2.0
120
- - **Exception:** `http` is permitted for `localhost` and `127.0.0.1` (local development only)
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` and `private_key` from leaking in logs or REPL output.
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] # => "[FILTERED]"
145
- config.to_hash[:base_url] # => "https://fhir.example.com"
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
- - [SMART App Launch Workflows]({{ site.baseurl }}/smart-on-fhir/) — step-by-step authorization flow guides
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
@@ -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 (planned)"| G["Protocols::Udap\n(future)"]
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 | Yes | — | OAuth2 client identifier |
45
- | `redirect_uri` | String | Yes | — | Registered callback URL |
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; required for `:confidential_asymmetric` |
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`; auto-detected from key type |
52
- | `jwks_uri` | String | No | — | URL to client's public JWKS, included as `jku` in JWT header |
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 is a Ruby gem implementing SMART App Launch 2.2.0 and UDAP Security protocols for healthcare client applications, with full support for OAuth 2.0 authorization against HL7 FHIR servers."
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
  [![Gem Version](https://badge.fury.io/rb/safire.svg)](https://badge.fury.io/rb/safire)
12
12
  [![CI](https://github.com/vanessuniq/safire/workflows/CI/badge.svg)](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/)** protocols for clients.
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 protocol overview and planned support |
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
- > Planned. See [ROADMAP.md](https://github.com/vanessuniq/safire/blob/main/ROADMAP.md) for details (coming soon).
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 discovery, authorization flows, token management, and backend services token requests.
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
- description: "How to install the Safire Ruby gem and get started with SMART App Launch and UDAP authorization in your healthcare application."
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 ≥ 4.0.2. OpenSSL is bundled with Ruby — no separate install needed.
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 configuration time — HTTP redirect URIs are rejected in non-localhost environments.
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 during development:
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
- redirect_uri: 'http://localhost:3000/auth/callback'
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 (Confidential Asymmetric)
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
- Use strong keys:
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: 6
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 from config and not passed at call time
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: 4
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