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.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +62 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +35 -0
- data/CODE_OF_CONDUCT.md +17 -0
- data/CONTRIBUTION.md +283 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +186 -0
- data/LICENSE +201 -0
- data/README.md +159 -0
- data/ROADMAP.md +54 -0
- data/Rakefile +26 -0
- data/docs/.gitignore +5 -0
- data/docs/404.html +25 -0
- data/docs/Gemfile +37 -0
- data/docs/Gemfile.lock +195 -0
- data/docs/_config.yml +103 -0
- data/docs/_includes/footer_custom.html +6 -0
- data/docs/_includes/head_custom.html +14 -0
- data/docs/_sass/custom/custom.scss +108 -0
- data/docs/adr/ADR-001-activesupport-dependency.md +50 -0
- data/docs/adr/ADR-002-facade-and-forwardable.md +79 -0
- data/docs/adr/ADR-003-protocol-vs-client-type.md +67 -0
- data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +59 -0
- data/docs/adr/ADR-005-per-client-http-ownership.md +58 -0
- data/docs/adr/ADR-006-lazy-discovery.md +83 -0
- data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +59 -0
- data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +74 -0
- data/docs/adr/index.md +22 -0
- data/docs/advanced.md +284 -0
- data/docs/configuration/client-setup.md +158 -0
- data/docs/configuration/index.md +60 -0
- data/docs/configuration/logging.md +86 -0
- data/docs/index.md +64 -0
- data/docs/installation.md +96 -0
- data/docs/security.md +256 -0
- data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +72 -0
- data/docs/smart-on-fhir/confidential-asymmetric/index.md +162 -0
- data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +250 -0
- data/docs/smart-on-fhir/confidential-symmetric/authorization.md +75 -0
- data/docs/smart-on-fhir/confidential-symmetric/index.md +69 -0
- data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +215 -0
- data/docs/smart-on-fhir/discovery/capability-checks.md +142 -0
- data/docs/smart-on-fhir/discovery/index.md +96 -0
- data/docs/smart-on-fhir/discovery/metadata.md +147 -0
- data/docs/smart-on-fhir/index.md +72 -0
- data/docs/smart-on-fhir/post-based-authorization.md +190 -0
- data/docs/smart-on-fhir/public-client/authorization.md +112 -0
- data/docs/smart-on-fhir/public-client/index.md +80 -0
- data/docs/smart-on-fhir/public-client/token-exchange.md +249 -0
- data/docs/troubleshooting/auth-errors.md +124 -0
- data/docs/troubleshooting/client-errors.md +130 -0
- data/docs/troubleshooting/index.md +99 -0
- data/docs/troubleshooting/token-errors.md +99 -0
- data/docs/udap.md +78 -0
- data/lib/safire/client.rb +195 -0
- data/lib/safire/client_config.rb +169 -0
- data/lib/safire/client_config_builder.rb +72 -0
- data/lib/safire/entity.rb +26 -0
- data/lib/safire/errors.rb +247 -0
- data/lib/safire/http_client.rb +87 -0
- data/lib/safire/jwt_assertion.rb +237 -0
- data/lib/safire/middleware/https_only_redirects.rb +39 -0
- data/lib/safire/pkce.rb +39 -0
- data/lib/safire/protocols/behaviours.rb +54 -0
- data/lib/safire/protocols/smart.rb +378 -0
- data/lib/safire/protocols/smart_metadata.rb +231 -0
- data/lib/safire/version.rb +4 -0
- data/lib/safire.rb +54 -0
- data/safire.gemspec +36 -0
- 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 %})
|