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,250 @@
1
+ ---
2
+ layout: default
3
+ title: Token Exchange & Refresh
4
+ parent: Confidential Asymmetric 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
+ Instead of a shared secret, Safire generates a signed JWT assertion and includes it in the request body. Your application code looks identical to other client types.
24
+
25
+ ```ruby
26
+ def callback
27
+ unless params[:state] == session[:oauth_state]
28
+ Rails.logger.error("State mismatch: expected #{session[:oauth_state]}, got #{params[:state]}")
29
+ render plain: 'Invalid state parameter', status: :unauthorized
30
+ return
31
+ end
32
+
33
+ # Safire generates and signs a JWT assertion automatically
34
+ tokens = @client.request_access_token(
35
+ code: params[:code],
36
+ code_verifier: session[:code_verifier]
37
+ )
38
+
39
+ session[:access_token] = tokens['access_token']
40
+ session[:refresh_token] = tokens['refresh_token']
41
+ session[:token_expires_at] = Time.current + tokens['expires_in'].seconds
42
+ session[:patient_id] = tokens['patient'] if tokens['patient']
43
+ session[:encounter_id] = tokens['encounter'] if tokens['encounter']
44
+
45
+ session.delete(:oauth_state)
46
+ session.delete(:code_verifier)
47
+
48
+ redirect_to patient_path(session[:patient_id])
49
+ rescue Safire::Errors::TokenError => e
50
+ Rails.logger.error("Token exchange failed: #{e.message}")
51
+ render plain: 'Authorization failed', status: :unauthorized
52
+ end
53
+ ```
54
+
55
+ Safire sends:
56
+
57
+ ```http
58
+ POST /token HTTP/1.1
59
+ Content-Type: application/x-www-form-urlencoded
60
+
61
+ grant_type=authorization_code&
62
+ code=AUTH_CODE_FROM_CALLBACK&
63
+ redirect_uri=https://myapp.example.com/callback&
64
+ code_verifier=nioBARPNwPA8JvVQdZUPxTk6f...&
65
+ client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&
66
+ client_assertion=eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCIsImtpZCI6Im15LWtleS1pZCJ9...
67
+ ```
68
+
69
+ {: .important }
70
+ > No `Authorization` header is sent. The `client_id` is inside the JWT assertion, not in the request body.
71
+
72
+ **What Safire does automatically** when `client_type: :confidential_asymmetric`:
73
+
74
+ 1. Builds a JWT assertion with the required claims
75
+ 2. Signs the JWT using your private key and the detected or configured algorithm
76
+ 3. Adds `client_assertion_type` and `client_assertion` to the request body
77
+ 4. Generates a fresh assertion per request (unique `jti`, updated `exp`)
78
+
79
+ **JWT assertion structure:**
80
+
81
+ | Field | Value |
82
+ |-------|-------|
83
+ | Header `alg` | `RS384` or `ES384` |
84
+ | Header `kid` | Your registered key ID |
85
+ | Header `jku` | Your JWKS URI (if configured) |
86
+ | Claim `iss` | `client_id` |
87
+ | Claim `sub` | `client_id` |
88
+ | Claim `aud` | Token endpoint URL |
89
+ | Claim `exp` | `now + 300s` (5 minutes max per spec) |
90
+ | Claim `jti` | UUID (replay protection) |
91
+
92
+ ---
93
+
94
+ ## Step 4: Token Refresh
95
+
96
+ Each refresh request generates a fresh JWT assertion automatically.
97
+
98
+ ```ruby
99
+ module SmartAuthentication
100
+ extend ActiveSupport::Concern
101
+
102
+ included do
103
+ before_action :ensure_authenticated
104
+ before_action :ensure_valid_token
105
+ end
106
+
107
+ private
108
+
109
+ def ensure_authenticated
110
+ unless session[:access_token]
111
+ redirect_to launch_path, alert: 'Please sign in to continue.'
112
+ end
113
+ end
114
+
115
+ def ensure_valid_token
116
+ return unless session[:access_token] && session[:token_expires_at]
117
+ refresh_access_token if session[:token_expires_at] < 5.minutes.from_now
118
+ end
119
+
120
+ def refresh_access_token
121
+ return unless session[:refresh_token]
122
+
123
+ # Fresh JWT assertion generated automatically per request
124
+ new_tokens = build_smart_client.refresh_token(
125
+ refresh_token: session[:refresh_token]
126
+ )
127
+
128
+ session[:access_token] = new_tokens['access_token']
129
+ session[:token_expires_at] = Time.current + new_tokens['expires_in'].seconds
130
+ session[:refresh_token] = new_tokens['refresh_token'] if new_tokens['refresh_token']
131
+ rescue Safire::Errors::TokenError => e
132
+ Rails.logger.error("Token refresh failed: #{e.message}")
133
+ clear_auth_session
134
+ redirect_to launch_path, alert: 'Your session has expired. Please sign in again.'
135
+ end
136
+
137
+ def clear_auth_session
138
+ %i[access_token refresh_token token_expires_at patient_id encounter_id].each do |key|
139
+ session.delete(key)
140
+ end
141
+ end
142
+
143
+ def build_smart_client
144
+ config = Safire::ClientConfig.new(
145
+ base_url: ENV['FHIR_BASE_URL'],
146
+ client_id: ENV['SMART_CLIENT_ID'],
147
+ redirect_uri: callback_url,
148
+ scopes: ['openid', 'profile', 'patient/*.read', 'offline_access'],
149
+ private_key: OpenSSL::PKey::RSA.new(File.read(ENV['SMART_PRIVATE_KEY_PATH'])),
150
+ kid: ENV['SMART_KEY_ID'],
151
+ jwks_uri: ENV['SMART_JWKS_URI']
152
+ )
153
+ Safire::Client.new(config, client_type: :confidential_asymmetric)
154
+ end
155
+ end
156
+ ```
157
+
158
+ The refresh request includes a fresh JWT assertion:
159
+
160
+ ```http
161
+ POST /token HTTP/1.1
162
+ Content-Type: application/x-www-form-urlencoded
163
+
164
+ grant_type=refresh_token&
165
+ refresh_token=eyJhbGciOiJub25lIn0...&
166
+ client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&
167
+ client_assertion=eyJhbGciOiJSUzM4NCJ9...
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Error Handling
173
+
174
+ | Error code | Meaning | Suggested action |
175
+ |------------|---------|-----------------|
176
+ | `invalid_client` | JWT assertion rejected (wrong key, bad signature, expired) | Log, check key config, return 500 |
177
+ | `invalid_grant` | Code or refresh token expired | Redirect to launch |
178
+
179
+ ```ruby
180
+ rescue Safire::Errors::TokenError => e
181
+ case e.error_code
182
+ when 'invalid_client'
183
+ Rails.logger.error("JWT assertion rejected: #{e.message}")
184
+ render plain: 'Client authentication failed', status: :unauthorized
185
+ when 'invalid_grant'
186
+ redirect_to launch_path, alert: 'Authorization expired. Please try again.'
187
+ else
188
+ Rails.logger.error("Token exchange failed: #{e.message}")
189
+ render plain: 'Authorization failed', status: :unauthorized
190
+ end
191
+ ```
192
+
193
+ **Missing credentials** — if `private_key` or `kid` is absent, Safire raises before the HTTP call:
194
+
195
+ ```ruby
196
+ rescue Safire::Errors::TokenError => e
197
+ if e.message.include?('Missing required asymmetric credentials')
198
+ Rails.logger.error("Asymmetric auth misconfigured: #{e.message}")
199
+ render plain: 'Server configuration error', status: :internal_server_error
200
+ else
201
+ raise
202
+ end
203
+ ```
204
+
205
+ ---
206
+
207
+ ## Testing Your Integration
208
+
209
+ ```bash
210
+ # .env.development
211
+ FHIR_BASE_URL=https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir
212
+ SMART_CLIENT_ID=your_test_client_id
213
+ SMART_PRIVATE_KEY_PATH=test/fixtures/private_key.pem
214
+ SMART_KEY_ID=test-key-id
215
+ ```
216
+
217
+ {: .note }
218
+ > Register your public key at [https://launch.smarthealthit.org](https://launch.smarthealthit.org). The reference server supports `private_key_jwt`.
219
+
220
+ ```ruby
221
+ RSpec.describe 'SMART Confidential Asymmetric Flow', type: :request do
222
+ it 'uses JWT assertion in body and sends no Authorization header' do
223
+ get '/auth/launch'
224
+ state = session[:oauth_state]
225
+
226
+ stub_request(:post, "#{ENV['FHIR_BASE_URL']}/token")
227
+ .to_return(status: 200, body: {
228
+ access_token: 'token_123', token_type: 'Bearer', expires_in: 3600
229
+ }.to_json)
230
+
231
+ get '/auth/callback', params: { code: 'auth_code', state: state }
232
+
233
+ expect(WebMock).to have_requested(:post, "#{ENV['FHIR_BASE_URL']}/token")
234
+ .with { |req|
235
+ body = URI.decode_www_form(req.body).to_h
236
+ body['client_assertion_type'] == 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' &&
237
+ body['client_assertion'].present? &&
238
+ !body.key?('client_id') &&
239
+ !req.headers.key?('Authorization')
240
+ }
241
+ end
242
+ end
243
+ ```
244
+
245
+ {: .note }
246
+ > A complete Rails controller implementation is available in the [Advanced Examples]({{ site.baseurl }}/advanced/) guide.
247
+
248
+ ---
249
+
250
+ **See also:** [Security Guide]({{ site.baseurl }}/security/) · [Troubleshooting]({% link troubleshooting/index.md %}) · [SMART Discovery]({% link smart-on-fhir/discovery/index.md %})
@@ -0,0 +1,75 @@
1
+ ---
2
+ layout: default
3
+ title: Authorization
4
+ parent: Confidential Symmetric 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`. You can check server capabilities to confirm confidential symmetric support before proceeding.
24
+
25
+ ```ruby
26
+ def check_server_capabilities
27
+ metadata = @client.server_metadata
28
+
29
+ unless metadata.supports_symmetric_auth?
30
+ raise 'Server does not support confidential symmetric clients'
31
+ end
32
+
33
+ auth_methods = metadata.token_endpoint_auth_methods_supported
34
+ unless auth_methods.include?('client_secret_basic')
35
+ raise 'Server does not support client_secret_basic'
36
+ end
37
+
38
+ render json: {
39
+ supports_confidential_symmetric: true,
40
+ auth_methods: auth_methods,
41
+ supports_offline_access: metadata.scopes_supported&.include?('offline_access')
42
+ }
43
+ end
44
+ ```
45
+
46
+ See [SMART Discovery]({% link smart-on-fhir/discovery/metadata.md %}) for the full field reference and validation rules.
47
+
48
+ ---
49
+
50
+ ## Step 2: Authorization Request
51
+
52
+ Authorization URL generation is identical to the public client flow — Safire handles PKCE automatically.
53
+
54
+ ```ruby
55
+ def launch
56
+ auth_data = @client.authorization_url
57
+
58
+ session[:oauth_state] = auth_data[:state]
59
+ session[:code_verifier] = auth_data[:code_verifier]
60
+
61
+ redirect_to auth_data[:auth_url], allow_other_host: true
62
+ end
63
+ ```
64
+
65
+ The generated URL parameters are identical to the public client. The only difference surfaces at token exchange, where Safire adds the `Authorization: Basic` header.
66
+
67
+ {: .note }
68
+ > **Offline Access** — Include `offline_access` in your scopes to obtain a refresh token for long-lived sessions.
69
+
70
+ {: .note }
71
+ > **POST-Based Authorization** — If the server advertises `authorize-post`, pass `method: :post` to `authorization_url`. See [POST-Based Authorization]({% link smart-on-fhir/post-based-authorization.md %}) for details.
72
+
73
+ ---
74
+
75
+ **Next:** [Token Exchange & Refresh]({% link smart-on-fhir/confidential-symmetric/token-exchange.md %})
@@ -0,0 +1,69 @@
1
+ ---
2
+ layout: default
3
+ title: Confidential Symmetric Client Workflow
4
+ parent: SMART on FHIR
5
+ nav_order: 3
6
+ has_children: true
7
+ permalink: /smart-on-fhir/confidential-symmetric/
8
+ ---
9
+
10
+ # Confidential Symmetric Client Workflow
11
+
12
+ {: .no_toc }
13
+
14
+ <div class="code-example" markdown="1">
15
+ This guide demonstrates SMART on FHIR confidential symmetric 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
+ Confidential symmetric clients are server-side applications that can securely store a shared `client_secret`. The secret is sent with every token request using **HTTP Basic Authentication**, providing an additional authentication layer on top of PKCE.
23
+
24
+ Suitable for:
25
+ - Traditional server-side web applications
26
+ - Backend services with secure credential storage
27
+ - Enterprise applications behind firewalls
28
+
29
+ ---
30
+
31
+ ## Key Differences from Public Clients
32
+
33
+ | Aspect | Public Client | Confidential Symmetric |
34
+ |--------|---------------|------------------------|
35
+ | **Credential** | None | Shared `client_secret` |
36
+ | **Token Request Auth** | `client_id` in body | `Authorization: Basic` header |
37
+ | **Security Layer** | PKCE only | PKCE + client secret |
38
+ | **Typical Use Case** | SPAs, mobile apps | Server-side apps |
39
+ | **Offline Access** | Limited | Full support |
40
+
41
+ {: .important }
42
+ > **PKCE is still required.** The client secret provides an additional authentication layer, not a replacement for PKCE.
43
+
44
+ ---
45
+
46
+ ## Client Setup
47
+
48
+ ```ruby
49
+ config = Safire::ClientConfig.new(
50
+ base_url: ENV.fetch('FHIR_BASE_URL'),
51
+ client_id: ENV.fetch('SMART_CLIENT_ID'),
52
+ client_secret: ENV.fetch('SMART_CLIENT_SECRET'),
53
+ redirect_uri: callback_url,
54
+ scopes: ['openid', 'profile', 'patient/*.read', 'offline_access']
55
+ )
56
+
57
+ @client = Safire::Client.new(config, client_type: :confidential_symmetric)
58
+ ```
59
+
60
+ Load `client_secret` from an environment variable, Rails credentials, or a secrets manager — never hard-code it. See the [Security Guide]({{ site.baseurl }}/security/#credential-protection) for loading patterns and rotation.
61
+
62
+ ---
63
+
64
+ ## What's Next
65
+
66
+ - [Authorization]({% link smart-on-fhir/confidential-symmetric/authorization.md %}) — Discovery and generating the authorization URL
67
+ - [Token Exchange & Refresh]({% link smart-on-fhir/confidential-symmetric/token-exchange.md %}) — Basic auth token requests, refresh, and error handling
68
+ - [Security Guide]({{ site.baseurl }}/security/) — Secret management and rotation
69
+ - [Advanced Examples]({{ site.baseurl }}/advanced/) — Complete Rails controller, caching, multi-server, and retry patterns
@@ -0,0 +1,215 @@
1
+ ---
2
+ layout: default
3
+ title: Token Exchange & Refresh
4
+ parent: Confidential Symmetric 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
+ This is where confidential symmetric clients differ from public clients. Safire automatically adds an `Authorization: Basic` header — your application code looks identical.
24
+
25
+ ```ruby
26
+ def callback
27
+ unless params[:state] == session[:oauth_state]
28
+ Rails.logger.error("State mismatch: expected #{session[:oauth_state]}, got #{params[:state]}")
29
+ render plain: 'Invalid state parameter', status: :unauthorized
30
+ return
31
+ end
32
+
33
+ # Safire uses Basic auth automatically for :confidential_symmetric
34
+ tokens = @client.request_access_token(
35
+ code: params[:code],
36
+ code_verifier: session[:code_verifier]
37
+ )
38
+
39
+ session[:access_token] = tokens['access_token']
40
+ session[:refresh_token] = tokens['refresh_token']
41
+ session[:token_expires_at] = Time.current + tokens['expires_in'].seconds
42
+ session[:patient_id] = tokens['patient'] if tokens['patient']
43
+ session[:encounter_id] = tokens['encounter'] if tokens['encounter']
44
+
45
+ session.delete(:oauth_state)
46
+ session.delete(:code_verifier)
47
+
48
+ redirect_to patient_path(session[:patient_id])
49
+ rescue Safire::Errors::TokenError => e
50
+ Rails.logger.error("Token exchange failed: #{e.message}")
51
+ render plain: 'Authorization failed', status: :unauthorized
52
+ end
53
+ ```
54
+
55
+ Safire sends:
56
+
57
+ ```http
58
+ POST /token HTTP/1.1
59
+ Content-Type: application/x-www-form-urlencoded
60
+ Authorization: Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=
61
+
62
+ grant_type=authorization_code&
63
+ code=AUTH_CODE_FROM_CALLBACK&
64
+ redirect_uri=https://myapp.example.com/callback&
65
+ code_verifier=nioBARPNwPA8JvVQdZUPxTk6f...
66
+ ```
67
+
68
+ {: .important }
69
+ > The Basic auth value is `Base64(client_id:client_secret)`. Safire constructs this automatically — `client_id` is **not** included in the request body for confidential symmetric clients.
70
+
71
+ Safire does this automatically when `client_type: :confidential_symmetric`:
72
+ 1. Constructs the `Authorization: Basic` header from `client_id` and `client_secret`
73
+ 2. Excludes `client_id` from the request body
74
+ 3. Applies Basic auth to both token exchange and token refresh
75
+
76
+ ---
77
+
78
+ ## Step 4: Token Refresh
79
+
80
+ ```ruby
81
+ module SmartAuthentication
82
+ extend ActiveSupport::Concern
83
+
84
+ included do
85
+ before_action :ensure_authenticated
86
+ before_action :ensure_valid_token
87
+ end
88
+
89
+ private
90
+
91
+ def ensure_authenticated
92
+ unless session[:access_token]
93
+ redirect_to launch_path, alert: 'Please sign in to continue.'
94
+ end
95
+ end
96
+
97
+ def ensure_valid_token
98
+ return unless session[:access_token] && session[:token_expires_at]
99
+ refresh_access_token if session[:token_expires_at] < 5.minutes.from_now
100
+ end
101
+
102
+ def refresh_access_token
103
+ return unless session[:refresh_token]
104
+
105
+ # Basic auth is applied automatically
106
+ new_tokens = build_smart_client.refresh_token(
107
+ refresh_token: session[:refresh_token]
108
+ )
109
+
110
+ session[:access_token] = new_tokens['access_token']
111
+ session[:token_expires_at] = Time.current + new_tokens['expires_in'].seconds
112
+ session[:refresh_token] = new_tokens['refresh_token'] if new_tokens['refresh_token']
113
+ rescue Safire::Errors::TokenError => e
114
+ Rails.logger.error("Token refresh failed: #{e.message}")
115
+ clear_auth_session
116
+ redirect_to launch_path, alert: 'Your session has expired. Please sign in again.'
117
+ end
118
+
119
+ def clear_auth_session
120
+ %i[access_token refresh_token token_expires_at patient_id encounter_id].each do |key|
121
+ session.delete(key)
122
+ end
123
+ end
124
+
125
+ def build_smart_client
126
+ config = Safire::ClientConfig.new(
127
+ base_url: ENV.fetch('FHIR_BASE_URL'),
128
+ client_id: ENV.fetch('SMART_CLIENT_ID'),
129
+ client_secret: ENV.fetch('SMART_CLIENT_SECRET'),
130
+ redirect_uri: callback_url,
131
+ scopes: ['openid', 'profile', 'patient/*.read', 'offline_access']
132
+ )
133
+ Safire::Client.new(config, client_type: :confidential_symmetric)
134
+ end
135
+ end
136
+ ```
137
+
138
+ The refresh request uses Basic auth in the same way as the exchange:
139
+
140
+ ```http
141
+ POST /token HTTP/1.1
142
+ Content-Type: application/x-www-form-urlencoded
143
+ Authorization: Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=
144
+
145
+ grant_type=refresh_token&
146
+ refresh_token=eyJhbGciOiJub25lIn0...
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Error Handling
152
+
153
+ | Error code | Meaning | Suggested action |
154
+ |------------|---------|-----------------|
155
+ | `invalid_client` | Wrong `client_id` or `client_secret` | Log, alert ops team, return 500 |
156
+ | `invalid_grant` | Code or refresh token expired | Redirect to launch |
157
+
158
+ ```ruby
159
+ rescue Safire::Errors::TokenError => e
160
+ case e.error_code
161
+ when 'invalid_client'
162
+ Rails.logger.error('Invalid client credentials — check client_id and client_secret')
163
+ notify_operations_team('SMART client credentials invalid')
164
+ render plain: 'Configuration error', status: :internal_server_error
165
+ when 'invalid_grant'
166
+ redirect_to launch_path, alert: 'Authorization expired. Please try again.'
167
+ else
168
+ Rails.logger.error("Token exchange failed: #{e.message}")
169
+ render plain: 'Authorization failed', status: :unauthorized
170
+ end
171
+ ```
172
+
173
+ ---
174
+
175
+ ## Testing Your Integration
176
+
177
+ ```bash
178
+ # .env.development
179
+ FHIR_BASE_URL=https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir
180
+ SMART_CLIENT_ID=your_test_client_id
181
+ SMART_CLIENT_SECRET=your_test_secret
182
+ ```
183
+
184
+ {: .note }
185
+ > Register your client at [https://launch.smarthealthit.org](https://launch.smarthealthit.org). The reference server supports `client_secret_basic`.
186
+
187
+ ```ruby
188
+ RSpec.describe 'SMART Confidential Symmetric Flow', type: :request do
189
+ it 'uses Basic auth header and excludes client_id from body' do
190
+ get '/auth/launch'
191
+ state = session[:oauth_state]
192
+
193
+ stub_request(:post, "#{ENV['FHIR_BASE_URL']}/token")
194
+ .to_return(status: 200, body: {
195
+ access_token: 'token_123', token_type: 'Bearer', expires_in: 3600
196
+ }.to_json)
197
+
198
+ get '/auth/callback', params: { code: 'auth_code', state: state }
199
+
200
+ expected_basic = Base64.strict_encode64("#{ENV['SMART_CLIENT_ID']}:#{ENV['SMART_CLIENT_SECRET']}")
201
+ expect(WebMock).to have_requested(:post, "#{ENV['FHIR_BASE_URL']}/token")
202
+ .with { |req|
203
+ req.headers['Authorization'] == "Basic #{expected_basic}" &&
204
+ !req.body.include?('client_id')
205
+ }
206
+ end
207
+ end
208
+ ```
209
+
210
+ {: .note }
211
+ > A complete Rails controller implementation is available in the [Advanced Examples]({{ site.baseurl }}/advanced/) guide.
212
+
213
+ ---
214
+
215
+ **See also:** [Security Guide]({{ site.baseurl }}/security/) · [Troubleshooting]({% link troubleshooting/index.md %}) · [SMART Discovery]({% link smart-on-fhir/discovery/index.md %})