safire 0.1.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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +62 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +35 -0
  6. data/CODE_OF_CONDUCT.md +17 -0
  7. data/CONTRIBUTION.md +283 -0
  8. data/Gemfile +26 -0
  9. data/Gemfile.lock +186 -0
  10. data/LICENSE +201 -0
  11. data/README.md +159 -0
  12. data/ROADMAP.md +54 -0
  13. data/Rakefile +26 -0
  14. data/docs/.gitignore +5 -0
  15. data/docs/404.html +25 -0
  16. data/docs/Gemfile +37 -0
  17. data/docs/Gemfile.lock +195 -0
  18. data/docs/_config.yml +103 -0
  19. data/docs/_includes/footer_custom.html +6 -0
  20. data/docs/_includes/head_custom.html +14 -0
  21. data/docs/_sass/custom/custom.scss +108 -0
  22. data/docs/adr/ADR-001-activesupport-dependency.md +50 -0
  23. data/docs/adr/ADR-002-facade-and-forwardable.md +79 -0
  24. data/docs/adr/ADR-003-protocol-vs-client-type.md +67 -0
  25. data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +59 -0
  26. data/docs/adr/ADR-005-per-client-http-ownership.md +58 -0
  27. data/docs/adr/ADR-006-lazy-discovery.md +83 -0
  28. data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +59 -0
  29. data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +74 -0
  30. data/docs/adr/index.md +22 -0
  31. data/docs/advanced.md +284 -0
  32. data/docs/configuration/client-setup.md +158 -0
  33. data/docs/configuration/index.md +60 -0
  34. data/docs/configuration/logging.md +86 -0
  35. data/docs/index.md +64 -0
  36. data/docs/installation.md +96 -0
  37. data/docs/security.md +256 -0
  38. data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +72 -0
  39. data/docs/smart-on-fhir/confidential-asymmetric/index.md +162 -0
  40. data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +250 -0
  41. data/docs/smart-on-fhir/confidential-symmetric/authorization.md +75 -0
  42. data/docs/smart-on-fhir/confidential-symmetric/index.md +69 -0
  43. data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +215 -0
  44. data/docs/smart-on-fhir/discovery/capability-checks.md +142 -0
  45. data/docs/smart-on-fhir/discovery/index.md +96 -0
  46. data/docs/smart-on-fhir/discovery/metadata.md +147 -0
  47. data/docs/smart-on-fhir/index.md +72 -0
  48. data/docs/smart-on-fhir/post-based-authorization.md +190 -0
  49. data/docs/smart-on-fhir/public-client/authorization.md +112 -0
  50. data/docs/smart-on-fhir/public-client/index.md +80 -0
  51. data/docs/smart-on-fhir/public-client/token-exchange.md +249 -0
  52. data/docs/troubleshooting/auth-errors.md +124 -0
  53. data/docs/troubleshooting/client-errors.md +130 -0
  54. data/docs/troubleshooting/index.md +99 -0
  55. data/docs/troubleshooting/token-errors.md +99 -0
  56. data/docs/udap.md +78 -0
  57. data/lib/safire/client.rb +195 -0
  58. data/lib/safire/client_config.rb +169 -0
  59. data/lib/safire/client_config_builder.rb +72 -0
  60. data/lib/safire/entity.rb +26 -0
  61. data/lib/safire/errors.rb +247 -0
  62. data/lib/safire/http_client.rb +87 -0
  63. data/lib/safire/jwt_assertion.rb +237 -0
  64. data/lib/safire/middleware/https_only_redirects.rb +39 -0
  65. data/lib/safire/pkce.rb +39 -0
  66. data/lib/safire/protocols/behaviours.rb +54 -0
  67. data/lib/safire/protocols/smart.rb +378 -0
  68. data/lib/safire/protocols/smart_metadata.rb +231 -0
  69. data/lib/safire/version.rb +4 -0
  70. data/lib/safire.rb +54 -0
  71. data/safire.gemspec +36 -0
  72. metadata +184 -0
@@ -0,0 +1,112 @@
1
+ ---
2
+ layout: default
3
+ title: Authorization
4
+ parent: Public Client Workflow
5
+ grand_parent: SMART on FHIR
6
+ nav_order: 1
7
+ ---
8
+
9
+ # Authorization
10
+
11
+ {: .no_toc }
12
+
13
+ ## Table of contents
14
+ {: .no_toc .text-delta }
15
+
16
+ 1. TOC
17
+ {:toc}
18
+
19
+ ---
20
+
21
+ ## Step 1: SMART Discovery
22
+
23
+ Before generating an authorization URL, Safire fetches the server's SMART configuration from `/.well-known/smart-configuration`. This happens lazily on first use.
24
+
25
+ ```ruby
26
+ def show_capabilities
27
+ metadata = @client.server_metadata
28
+
29
+ render json: {
30
+ authorization_endpoint: metadata.authorization_endpoint,
31
+ token_endpoint: metadata.token_endpoint,
32
+ capabilities: metadata.capabilities,
33
+ supports_public_clients: metadata.supports_public_auth?,
34
+ supports_pkce: metadata.code_challenge_methods_supported.include?('S256')
35
+ }
36
+ end
37
+ ```
38
+
39
+ Safire parses and validates the response and caches the metadata in the client instance. See [Metadata Fields and Validation]({% link smart-on-fhir/discovery/metadata.md %}) for the full field reference and validation rules.
40
+
41
+ ---
42
+
43
+ ## Step 2: Authorization Request
44
+
45
+ Generate the authorization URL and redirect the user to the SMART authorization server.
46
+
47
+ ```ruby
48
+ def launch
49
+ auth_data = @client.authorization_url
50
+
51
+ # Store state and code_verifier server-side (never expose to client)
52
+ session[:oauth_state] = auth_data[:state]
53
+ session[:code_verifier] = auth_data[:code_verifier]
54
+
55
+ redirect_to auth_data[:auth_url], allow_other_host: true
56
+ end
57
+ ```
58
+
59
+ `authorization_url` returns:
60
+
61
+ ```ruby
62
+ auth_data
63
+ # => {
64
+ # auth_url: "https://fhir.example.com/authorize?response_type=code&client_id=...",
65
+ # state: "5b03ee70c3ff6b00e7fcd78227fb4bff", # 32 hex chars (128 bits)
66
+ # code_verifier: "nioBARPNwPA8JvVQdZUPxTk6f..." # 128 characters
67
+ # }
68
+ ```
69
+
70
+ {: .important }
71
+ > Each call to `authorization_url` generates a fresh `state` and `code_verifier`. Never reuse values across authorization attempts.
72
+
73
+ The generated URL includes these parameters:
74
+
75
+ | Parameter | Value |
76
+ |-----------|-------|
77
+ | `response_type` | `code` |
78
+ | `client_id` | Your registered client identifier |
79
+ | `redirect_uri` | Your callback URL |
80
+ | `scope` | Requested permissions (space-separated) |
81
+ | `state` | CSRF protection token (32 hex chars) |
82
+ | `aud` | FHIR server being accessed |
83
+ | `code_challenge_method` | `S256` |
84
+ | `code_challenge` | `Base64URL(SHA256(code_verifier))` |
85
+
86
+ {: .note }
87
+ > **POST-Based Authorization** — If the server advertises the `authorize-post` capability, pass `method: :post` to submit the request as a form POST instead of a GET redirect. See [POST-Based Authorization]({% link smart-on-fhir/post-based-authorization.md %}) for details.
88
+
89
+ ---
90
+
91
+ ## EHR-Initiated Launch
92
+
93
+ When a launch is initiated from within an EHR (rather than standalone), the EHR provides a `launch` token as a query parameter. Pass it to `authorization_url`:
94
+
95
+ ```ruby
96
+ def ehr_launch
97
+ launch_token = params[:launch]
98
+
99
+ auth_data = @client.authorization_url(launch: launch_token)
100
+
101
+ session[:oauth_state] = auth_data[:state]
102
+ session[:code_verifier] = auth_data[:code_verifier]
103
+
104
+ redirect_to auth_data[:auth_url], allow_other_host: true
105
+ end
106
+ ```
107
+
108
+ The `launch` parameter is included in the authorization URL. The EHR uses it to convey context (patient, encounter) that the authorization server will include in the token response.
109
+
110
+ ---
111
+
112
+ **Next:** [Token Exchange & Refresh]({% link smart-on-fhir/public-client/token-exchange.md %})
@@ -0,0 +1,80 @@
1
+ ---
2
+ layout: default
3
+ title: Public Client Workflow
4
+ parent: SMART on FHIR
5
+ nav_order: 2
6
+ has_children: true
7
+ permalink: /smart-on-fhir/public-client/
8
+ ---
9
+
10
+ # Public Client Workflow
11
+
12
+ {: .no_toc }
13
+
14
+ <div class="code-example" markdown="1">
15
+ This guide demonstrates SMART on FHIR public client integration in a **Rails application**. The patterns shown here can be adapted for Sinatra or other Ruby web frameworks.
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## Overview
21
+
22
+ Public clients are applications that cannot securely store a client secret, such as:
23
+ - Browser-based single-page applications (SPAs)
24
+ - Native mobile applications
25
+ - Desktop applications distributed to end users
26
+
27
+ Because there is no shared secret, public clients use **PKCE (Proof Key for Code Exchange)** to prove that the party exchanging an authorization code is the same party that initiated the request. This protects against authorization code interception attacks.
28
+
29
+ ---
30
+
31
+ ## PKCE at a Glance
32
+
33
+ PKCE is built into Safire — you do not implement it yourself. Here is how it works:
34
+
35
+ 1. **Code Verifier** — Safire generates a cryptographically random 128-character string on each launch
36
+ 2. **Code Challenge** — Safire computes `Base64URL(SHA256(verifier))` and sends it with the authorization request
37
+ 3. **Verification** — When you exchange the authorization code for tokens, you send the original verifier; the server re-computes the hash and confirms it matches
38
+
39
+ Store the code verifier server-side only and delete it immediately after the token exchange. See the [Security Guide]({{ site.baseurl }}/security/#pkce-code-verifier) for handling rules.
40
+
41
+ ---
42
+
43
+ ## Client Setup
44
+
45
+ ```ruby
46
+ # config/routes.rb
47
+ Rails.application.routes.draw do
48
+ get '/auth/launch', to: 'smart_auth#launch'
49
+ get '/auth/callback', to: 'smart_auth#callback'
50
+ end
51
+
52
+ # app/controllers/smart_auth_controller.rb
53
+ class SmartAuthController < ApplicationController
54
+ before_action :initialize_client
55
+
56
+ private
57
+
58
+ def initialize_client
59
+ config = Safire::ClientConfig.new(
60
+ base_url: ENV['FHIR_BASE_URL'],
61
+ client_id: ENV['SMART_CLIENT_ID'],
62
+ redirect_uri: callback_url,
63
+ scopes: ['openid', 'profile', 'patient/*.read']
64
+ )
65
+
66
+ @client = Safire::Client.new(config, client_type: :public) # :public is the default client_type, so can omit to pass this.
67
+ end
68
+ end
69
+ ```
70
+
71
+ No `client_secret` is configured — public clients authenticate using PKCE only.
72
+
73
+ ---
74
+
75
+ ## What's Next
76
+
77
+ - [Authorization]({% link smart-on-fhir/public-client/authorization.md %}) — Discovery and generating the authorization URL
78
+ - [Token Exchange & Refresh]({% link smart-on-fhir/public-client/token-exchange.md %}) — Exchanging the code, refreshing tokens, and error handling
79
+ - [Security Guide]({{ site.baseurl }}/security/) — Token storage, CSRF protection, scope minimization
80
+ - [Advanced Examples]({{ site.baseurl }}/advanced/) — Complete Rails controller, caching, multi-server, and retry patterns
@@ -0,0 +1,249 @@
1
+ ---
2
+ layout: default
3
+ title: Token Exchange & Refresh
4
+ parent: Public Client Workflow
5
+ grand_parent: SMART on FHIR
6
+ nav_order: 2
7
+ ---
8
+
9
+ # Token Exchange & Refresh
10
+
11
+ {: .no_toc }
12
+
13
+ ## Table of contents
14
+ {: .no_toc .text-delta }
15
+
16
+ 1. TOC
17
+ {:toc}
18
+
19
+ ---
20
+
21
+ ## Step 3: Token Exchange
22
+
23
+ After the user authorizes, the server redirects to your callback with an authorization code. Exchange it for tokens.
24
+
25
+ ```ruby
26
+ def callback
27
+ # Verify state parameter (CSRF protection)
28
+ unless params[:state] == session[:oauth_state]
29
+ Rails.logger.error("State mismatch: expected #{session[:oauth_state]}, got #{params[:state]}")
30
+ render plain: 'Invalid state parameter', status: :unauthorized
31
+ return
32
+ end
33
+
34
+ tokens = @client.request_access_token(
35
+ code: params[:code],
36
+ code_verifier: session[:code_verifier]
37
+ )
38
+
39
+ # Store tokens server-side only
40
+ session[:access_token] = tokens['access_token']
41
+ session[:refresh_token] = tokens['refresh_token']
42
+ session[:token_expires_at] = Time.current + tokens['expires_in'].seconds
43
+
44
+ # SMART context parameters
45
+ session[:patient_id] = tokens['patient'] if tokens['patient']
46
+ session[:encounter_id] = tokens['encounter'] if tokens['encounter']
47
+
48
+ # Clean up authorization state immediately
49
+ session.delete(:oauth_state)
50
+ session.delete(:code_verifier)
51
+
52
+ redirect_to patient_path(session[:patient_id])
53
+ rescue Safire::Errors::TokenError => e
54
+ Rails.logger.error("Token exchange failed: #{e.message}")
55
+ render plain: 'Authorization failed', status: :unauthorized
56
+ end
57
+ ```
58
+
59
+ Safire sends:
60
+
61
+ ```http
62
+ POST /token HTTP/1.1
63
+ Content-Type: application/x-www-form-urlencoded
64
+
65
+ grant_type=authorization_code&
66
+ code=AUTH_CODE_FROM_CALLBACK&
67
+ redirect_uri=https://myapp.example.com/callback&
68
+ code_verifier=nioBARPNwPA8JvVQdZUPxTk6f...&
69
+ client_id=my_public_client
70
+ ```
71
+
72
+ {: .note }
73
+ > Public clients include `client_id` in the request body. No `Authorization` header or `client_secret` is sent.
74
+
75
+ The token response includes:
76
+
77
+ ```ruby
78
+ tokens
79
+ # => {
80
+ # "access_token" => "eyJhbGci...",
81
+ # "token_type" => "Bearer",
82
+ # "expires_in" => 3600,
83
+ # "scope" => "openid profile patient/*.read",
84
+ # "refresh_token" => "eyJhbGci...",
85
+ # "patient" => "123", # SMART context (if present)
86
+ # "encounter" => "456", # SMART context (if present)
87
+ # "id_token" => "eyJ..." # OpenID Connect (if requested)
88
+ # }
89
+ ```
90
+
91
+ See the [Security Guide]({{ site.baseurl }}/security/#token-and-session-security) for token storage rules.
92
+
93
+ ---
94
+
95
+ ## Step 4: Token Refresh
96
+
97
+ Use a controller concern to automatically refresh tokens before they expire.
98
+
99
+ ```ruby
100
+ # app/controllers/concerns/smart_authentication.rb
101
+ module SmartAuthentication
102
+ extend ActiveSupport::Concern
103
+
104
+ included do
105
+ before_action :ensure_authenticated
106
+ before_action :ensure_valid_token
107
+ end
108
+
109
+ private
110
+
111
+ def ensure_authenticated
112
+ unless session[:access_token]
113
+ redirect_to launch_path, alert: 'Please sign in to continue.'
114
+ end
115
+ end
116
+
117
+ def ensure_valid_token
118
+ return unless session[:access_token] && session[:token_expires_at]
119
+ refresh_access_token if session[:token_expires_at] < 5.minutes.from_now
120
+ end
121
+
122
+ def refresh_access_token
123
+ return unless session[:refresh_token]
124
+
125
+ new_tokens = build_smart_client.refresh_token(
126
+ refresh_token: session[:refresh_token]
127
+ )
128
+
129
+ session[:access_token] = new_tokens['access_token']
130
+ session[:token_expires_at] = Time.current + new_tokens['expires_in'].seconds
131
+ session[:refresh_token] = new_tokens['refresh_token'] if new_tokens['refresh_token']
132
+ rescue Safire::Errors::TokenError => e
133
+ Rails.logger.error("Token refresh failed: #{e.message}")
134
+ clear_auth_session
135
+ redirect_to launch_path, alert: 'Your session has expired. Please sign in again.'
136
+ end
137
+
138
+ def clear_auth_session
139
+ session.delete(:access_token)
140
+ session.delete(:refresh_token)
141
+ session.delete(:token_expires_at)
142
+ session.delete(:patient_id)
143
+ session.delete(:encounter_id)
144
+ end
145
+
146
+ def build_smart_client
147
+ config = Safire::ClientConfig.new(
148
+ base_url: ENV['FHIR_BASE_URL'],
149
+ client_id: ENV['SMART_CLIENT_ID'],
150
+ redirect_uri: callback_url,
151
+ scopes: ['openid', 'profile', 'patient/*.read']
152
+ )
153
+ Safire::Client.new(config, client_type: :public)
154
+ end
155
+ end
156
+ ```
157
+
158
+ **Reduced scopes on refresh** — request a subset of the original grant:
159
+
160
+ ```ruby
161
+ client.refresh_token(
162
+ refresh_token: session[:refresh_token],
163
+ scopes: ['patient/Patient.read'] # Must be a subset of the original
164
+ )
165
+ ```
166
+
167
+ ---
168
+
169
+ ## Error Handling
170
+
171
+ | Error code | Meaning | Suggested action |
172
+ |------------|---------|-----------------|
173
+ | `invalid_grant` | Code expired or already used | Redirect to launch |
174
+ | `invalid_client` | Client ID not recognised | Log and return 500 — configuration issue |
175
+
176
+ ```ruby
177
+ rescue Safire::Errors::TokenError => e
178
+ case e.error_code
179
+ when 'invalid_grant'
180
+ redirect_to launch_path, alert: 'Authorization code expired. Please try again.'
181
+ when 'invalid_client'
182
+ Rails.logger.error("Invalid client configuration: #{e.message}")
183
+ render plain: 'Configuration error', status: :internal_server_error
184
+ else
185
+ Rails.logger.error("Token exchange failed: #{e.message}")
186
+ render plain: 'Authorization failed', status: :unauthorized
187
+ end
188
+ ```
189
+
190
+ **Discovery errors** — catch early to surface server availability issues:
191
+
192
+ ```ruby
193
+ def initialize_client
194
+ # ...
195
+ @client.server_metadata # Trigger discovery eagerly if desired
196
+ rescue Safire::Errors::DiscoveryError => e
197
+ Rails.logger.error("SMART discovery failed: #{e.message}")
198
+ render plain: 'FHIR server not available', status: :service_unavailable
199
+ end
200
+ ```
201
+
202
+ ---
203
+
204
+ ## Testing Your Integration
205
+
206
+ Set up against the [SMART Health IT reference server](https://launch.smarthealthit.org):
207
+
208
+ ```bash
209
+ # .env.development
210
+ FHIR_BASE_URL=https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir
211
+ SMART_CLIENT_ID=your_test_client_id
212
+ ```
213
+
214
+ Steps:
215
+ 1. Register your client with redirect URI `http://localhost:3000/auth/callback`
216
+ 2. Start your Rails server: `rails s`
217
+ 3. Visit `http://localhost:3000/auth/launch`
218
+ 4. Complete the flow on the reference server
219
+ 5. Verify the callback receives tokens with SMART context
220
+
221
+ ```ruby
222
+ # spec/requests/smart_auth_spec.rb
223
+ RSpec.describe 'SMART Public Client Flow', type: :request do
224
+ before do
225
+ stub_request(:get, "#{ENV['FHIR_BASE_URL']}/.well-known/smart-configuration")
226
+ .to_return(status: 200, body: {
227
+ authorization_endpoint: "#{ENV['FHIR_BASE_URL']}/authorize",
228
+ token_endpoint: "#{ENV['FHIR_BASE_URL']}/token",
229
+ capabilities: ['launch-standalone', 'client-public'],
230
+ code_challenge_methods_supported: ['S256']
231
+ }.to_json)
232
+ end
233
+
234
+ it 'generates authorization URL and stores state' do
235
+ get '/auth/launch'
236
+
237
+ expect(response).to redirect_to(/authorize/)
238
+ expect(session[:oauth_state].length).to eq(32)
239
+ expect(session[:code_verifier].length).to eq(128)
240
+ end
241
+ end
242
+ ```
243
+
244
+ {: .note }
245
+ > A complete Rails controller implementation is available in the [Advanced Examples]({{ site.baseurl }}/advanced/) guide.
246
+
247
+ ---
248
+
249
+ **See also:** [Security Guide]({{ site.baseurl }}/security/) · [Troubleshooting]({% link troubleshooting/index.md %}) · [SMART Discovery]({% link smart-on-fhir/discovery/index.md %})
@@ -0,0 +1,124 @@
1
+ ---
2
+ layout: default
3
+ title: Discovery and Authorization Errors
4
+ parent: Troubleshooting
5
+ nav_order: 1
6
+ ---
7
+
8
+ # Discovery and Authorization Errors
9
+
10
+ {: .no_toc }
11
+
12
+ ## Table of contents
13
+ {: .no_toc .text-delta }
14
+
15
+ 1. TOC
16
+ {:toc}
17
+
18
+ ---
19
+
20
+ ## Discovery Errors
21
+
22
+ ### `DiscoveryError`: Failed to discover SMART configuration
23
+
24
+ ```
25
+ Safire::Errors::DiscoveryError: Failed to discover SMART configuration from
26
+ https://fhir.example.com/.well-known/smart-configuration (HTTP 404)
27
+ ```
28
+
29
+ **Causes:** the server does not support SMART on FHIR, `base_url` includes an extra path segment, or the server uses a non-standard discovery path.
30
+
31
+ Verify the endpoint manually:
32
+
33
+ ```bash
34
+ curl -I https://fhir.example.com/.well-known/smart-configuration
35
+ ```
36
+
37
+ Ensure `base_url` points to the FHIR root, not a resource path:
38
+
39
+ ```ruby
40
+ # ✅ Correct
41
+ base_url: 'https://fhir.example.com/r4'
42
+
43
+ # ❌ Too specific — strip the resource type
44
+ base_url: 'https://fhir.example.com/r4/Patient'
45
+ ```
46
+
47
+ If the server does not support discovery, provide endpoints manually in `ClientConfig`:
48
+
49
+ ```ruby
50
+ config = Safire::ClientConfig.new(
51
+ base_url: 'https://fhir.example.com',
52
+ client_id: 'my_client',
53
+ redirect_uri: 'https://myapp.com/callback',
54
+ scopes: ['openid', 'profile'],
55
+ authorization_endpoint: 'https://fhir.example.com/authorize',
56
+ token_endpoint: 'https://fhir.example.com/token'
57
+ )
58
+ ```
59
+
60
+ ### `DiscoveryError`: Invalid SMART configuration format
61
+
62
+ ```
63
+ Safire::Errors::DiscoveryError: ... response is not a JSON object
64
+ ```
65
+
66
+ The server returned an HTML error page, a JSON array, or malformed JSON. Inspect the raw response:
67
+
68
+ ```bash
69
+ curl https://fhir.example.com/.well-known/smart-configuration
70
+ ```
71
+
72
+ The response must be a JSON object (`{...}`) with at least `authorization_endpoint` and `token_endpoint`.
73
+
74
+ ---
75
+
76
+ ## Authorization Errors
77
+
78
+ ### `ConfigurationError`: Missing scopes
79
+
80
+ ```
81
+ Safire::Errors::ConfigurationError: Configuration missing: scopes
82
+ ```
83
+
84
+ Scopes must be provided either in `ClientConfig` or when calling `authorization_url`:
85
+
86
+ ```ruby
87
+ # Option 1 — in config
88
+ config = Safire::ClientConfig.new(
89
+ scopes: ['openid', 'profile', 'patient/*.read'],
90
+ # ...
91
+ )
92
+
93
+ # Option 2 — per request
94
+ auth_data = client.authorization_url(
95
+ custom_scopes: ['openid', 'profile', 'patient/Patient.read']
96
+ )
97
+ ```
98
+
99
+ ### State mismatch on callback
100
+
101
+ **Symptom:** authorization callback fails or state validation raises an error.
102
+
103
+ **Causes:** state not stored in session before redirect, session expired, or multiple tabs in flight.
104
+
105
+ Always store both `state` and `code_verifier` before redirecting, and validate `state` immediately on callback:
106
+
107
+ ```ruby
108
+ # On launch
109
+ auth_data = client.authorization_url
110
+ session[:oauth_state] = auth_data[:state]
111
+ session[:code_verifier] = auth_data[:code_verifier]
112
+ redirect_to auth_data[:auth_url], allow_other_host: true
113
+
114
+ # On callback
115
+ unless params[:state] == session[:oauth_state]
116
+ render plain: 'Invalid state', status: :unauthorized
117
+ return
118
+ end
119
+
120
+ # Delete immediately after use
121
+ session.delete(:oauth_state)
122
+ ```
123
+
124
+ If the session has expired by the time the user returns, redirect them back to the launch endpoint with a user-friendly message rather than showing a raw error.
@@ -0,0 +1,130 @@
1
+ ---
2
+ layout: default
3
+ title: Confidential Client and Network Errors
4
+ parent: Troubleshooting
5
+ nav_order: 3
6
+ ---
7
+
8
+ # Confidential Client and Network Errors
9
+
10
+ {: .no_toc }
11
+
12
+ ## Table of contents
13
+ {: .no_toc .text-delta }
14
+
15
+ 1. TOC
16
+ {:toc}
17
+
18
+ ---
19
+
20
+ ## Confidential Symmetric Client Errors
21
+
22
+ ### `ConfigurationError`: Missing `client_secret`
23
+
24
+ ```
25
+ Safire::Errors::ConfigurationError: Configuration missing: client_secret
26
+ ```
27
+
28
+ `client_secret` must be present when using `:confidential_symmetric`:
29
+
30
+ ```ruby
31
+ config = Safire::ClientConfig.new(
32
+ client_secret: ENV.fetch('SMART_CLIENT_SECRET'),
33
+ # ...
34
+ )
35
+ client = Safire::Client.new(config, client_type: :confidential_symmetric)
36
+ ```
37
+
38
+ You can also pass it as an override directly to the token call — useful when rotating secrets:
39
+
40
+ ```ruby
41
+ tokens = client.request_access_token(
42
+ code: code, code_verifier: verifier,
43
+ client_secret: ENV.fetch('SMART_CLIENT_SECRET')
44
+ )
45
+ ```
46
+
47
+ ### `401 Unauthorized` with Basic Auth
48
+
49
+ **Causes:** incorrect credentials, or the server does not support `client_secret_basic`.
50
+
51
+ Verify the server supports Basic Auth before debugging credentials:
52
+
53
+ ```ruby
54
+ metadata = client.server_metadata
55
+ unless metadata.token_endpoint_auth_methods_supported.include?('client_secret_basic')
56
+ raise 'Server does not support client_secret_basic'
57
+ end
58
+ ```
59
+
60
+ Safire encodes credentials with `Base64.strict_encode64` — special characters in secrets are handled automatically.
61
+
62
+ ---
63
+
64
+ ## Confidential Asymmetric Client Errors
65
+
66
+ ### `ConfigurationError`: Missing `private_key` or `kid`
67
+
68
+ ```
69
+ Safire::Errors::ConfigurationError: Configuration missing: private_key, kid
70
+ ```
71
+
72
+ Both are required for `:confidential_asymmetric`:
73
+
74
+ ```ruby
75
+ config = Safire::ClientConfig.new(
76
+ private_key: OpenSSL::PKey::RSA.new(File.read(ENV['SMART_PRIVATE_KEY_PATH'])),
77
+ kid: ENV.fetch('SMART_KEY_ID'),
78
+ # ...
79
+ )
80
+ client = Safire::Client.new(config, client_type: :confidential_asymmetric)
81
+ ```
82
+
83
+ ### `401 Unauthorized` with JWT assertion
84
+
85
+ **Causes:** key mismatch, wrong `kid`, clock skew, or server does not support `private_key_jwt`.
86
+
87
+ Verify `private_key_jwt` is supported:
88
+
89
+ ```ruby
90
+ metadata = client.server_metadata
91
+ unless metadata.token_endpoint_auth_methods_supported.include?('private_key_jwt')
92
+ raise 'Server does not support private_key_jwt'
93
+ end
94
+ ```
95
+
96
+ Verify the public key registered with the server matches the private key you are using, and that the `kid` value matches the key ID the server expects. Safire sets JWT `exp` to 5 minutes from `iat` — if your system clock is significantly skewed from the server, assertions will be rejected.
97
+
98
+ ---
99
+
100
+ ## Network Errors
101
+
102
+ ### `NetworkError`: Connection refused or timeout
103
+
104
+ ```
105
+ Safire::Errors::NetworkError: HTTP request failed: Connection refused
106
+ ```
107
+
108
+ Verify server connectivity before debugging Safire configuration:
109
+
110
+ ```bash
111
+ curl -v https://fhir.example.com/.well-known/smart-configuration
112
+ ```
113
+
114
+ For transient network failures, implement retry with exponential backoff in your application — see [Advanced Examples]({{ site.baseurl }}/advanced/#token-management) for a reusable pattern.
115
+
116
+ ### `NetworkError`: Blocked redirect to non-HTTPS URL
117
+
118
+ ```
119
+ Safire::Errors::NetworkError: Blocked redirect to non-HTTPS URL: http://fhir.example.com/...
120
+ ```
121
+
122
+ Safire blocks redirects to non-HTTPS URLs (except `localhost`). Configure `base_url` with the final HTTPS URL directly, bypassing any HTTP-to-HTTPS redirect the server may use:
123
+
124
+ ```ruby
125
+ # ✅ Use the final HTTPS URL directly
126
+ base_url: 'https://fhir.example.com/r4'
127
+
128
+ # ❌ Will fail if the server redirects HTTP → HTTPS
129
+ base_url: 'http://fhir.example.com/r4'
130
+ ```