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
data/docs/security.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Security Guide
|
|
4
|
+
nav_order: 6
|
|
5
|
+
permalink: /security/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Security Guide
|
|
9
|
+
|
|
10
|
+
{: .no_toc }
|
|
11
|
+
|
|
12
|
+
This guide covers security requirements and best practices for every Safire integration, regardless of client type. Apply these rules in all production deployments.
|
|
13
|
+
|
|
14
|
+
## Table of contents
|
|
15
|
+
{: .no_toc .text-delta }
|
|
16
|
+
|
|
17
|
+
1. TOC
|
|
18
|
+
{:toc}
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## HTTPS and Redirect URI Rules
|
|
23
|
+
|
|
24
|
+
All production FHIR integrations must use HTTPS. Safire enforces this at configuration time — HTTP redirect URIs are rejected in non-localhost environments.
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# config/environments/production.rb
|
|
28
|
+
config.force_ssl = true
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# ✅ Always use HTTPS in production
|
|
33
|
+
config = Safire::ClientConfig.new(
|
|
34
|
+
redirect_uri: 'https://myapp.example.com/auth/callback',
|
|
35
|
+
# ...
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# ❌ Raises Safire::Errors::ConfigurationError
|
|
39
|
+
config = Safire::ClientConfig.new(
|
|
40
|
+
redirect_uri: 'http://myapp.example.com/auth/callback',
|
|
41
|
+
# ...
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Localhost is permitted during development:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# ✅ Allowed for local development only
|
|
49
|
+
redirect_uri: 'http://localhost:3000/auth/callback'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Credential Protection
|
|
55
|
+
|
|
56
|
+
Never expose client secrets or private keys in logs, responses, or version control.
|
|
57
|
+
|
|
58
|
+
### Client Secrets (Confidential Symmetric)
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# ❌ NEVER: log the secret
|
|
62
|
+
Rails.logger.info("Using secret: #{client_secret}")
|
|
63
|
+
|
|
64
|
+
# ❌ NEVER: render in a response
|
|
65
|
+
render json: { client_secret: ENV['SMART_CLIENT_SECRET'] }
|
|
66
|
+
|
|
67
|
+
# ❌ NEVER: commit .env to version control
|
|
68
|
+
# Add to .gitignore: .env
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Load secrets from a secure source:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# Environment variable
|
|
75
|
+
config = Safire::ClientConfig.new(
|
|
76
|
+
client_secret: ENV.fetch('SMART_CLIENT_SECRET'),
|
|
77
|
+
# ...
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Rails credentials
|
|
81
|
+
config = Safire::ClientConfig.new(
|
|
82
|
+
client_secret: Rails.application.credentials.smart[:client_secret],
|
|
83
|
+
# ...
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# AWS Secrets Manager
|
|
87
|
+
require 'aws-sdk-secretsmanager'
|
|
88
|
+
|
|
89
|
+
def fetch_client_secret
|
|
90
|
+
client = Aws::SecretsManager::Client.new
|
|
91
|
+
secret = client.get_secret_value(secret_id: 'smart/credentials')
|
|
92
|
+
JSON.parse(secret.secret_string)['client_secret']
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Private Keys (Confidential Asymmetric)
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# ❌ NEVER: render or log the key
|
|
100
|
+
render json: { private_key: @private_key.to_pem }
|
|
101
|
+
|
|
102
|
+
# Add to .gitignore: *.pem, *.key
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Load private keys securely:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# From a file path
|
|
109
|
+
private_key = OpenSSL::PKey::RSA.new(File.read(ENV['SMART_PRIVATE_KEY_PATH']))
|
|
110
|
+
|
|
111
|
+
# From a PEM string in an env var
|
|
112
|
+
private_key = OpenSSL::PKey::RSA.new(ENV['SMART_PRIVATE_KEY_PEM'])
|
|
113
|
+
|
|
114
|
+
# From Rails credentials
|
|
115
|
+
private_key = OpenSSL::PKey::RSA.new(
|
|
116
|
+
Rails.application.credentials.smart[:private_key_pem]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# From AWS Secrets Manager
|
|
120
|
+
def fetch_private_key
|
|
121
|
+
client = Aws::SecretsManager::Client.new
|
|
122
|
+
secret = client.get_secret_value(secret_id: 'smart/private-key')
|
|
123
|
+
OpenSSL::PKey::RSA.new(secret.secret_string)
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Use strong keys:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
# RSA: minimum 2048-bit, 4096-bit recommended
|
|
131
|
+
key = OpenSSL::PKey::RSA.generate(4096)
|
|
132
|
+
|
|
133
|
+
# EC: must use P-384 curve (required by SMART spec for ES384)
|
|
134
|
+
key = OpenSSL::PKey::EC.generate('secp384r1')
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
{: .note }
|
|
138
|
+
> 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
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Token and Session Security
|
|
143
|
+
|
|
144
|
+
### Token Storage
|
|
145
|
+
|
|
146
|
+
Always store tokens server-side. Never expose them to client-side code.
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# ✅ DO: Server-side session
|
|
150
|
+
session[:access_token] = tokens['access_token']
|
|
151
|
+
|
|
152
|
+
# ✅ DO: Encrypted database column
|
|
153
|
+
user.update(encrypted_access_token: cipher.encrypt(tokens['access_token']))
|
|
154
|
+
|
|
155
|
+
# ❌ DON'T: Plain cookie
|
|
156
|
+
cookies[:access_token] = tokens['access_token']
|
|
157
|
+
|
|
158
|
+
# ❌ DON'T: JSON response to the browser
|
|
159
|
+
render json: { access_token: tokens['access_token'] }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### CSRF State Parameter
|
|
163
|
+
|
|
164
|
+
Safire generates a 32-character hex state value (128 bits of entropy) automatically. Always verify it on callback and delete it immediately after:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
def callback
|
|
168
|
+
unless params[:state] == session[:oauth_state]
|
|
169
|
+
render plain: 'Invalid state', status: :unauthorized
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ... exchange code for tokens ...
|
|
174
|
+
|
|
175
|
+
session.delete(:oauth_state) # ✅ Delete after validation
|
|
176
|
+
session.delete(:code_verifier) # ✅ Delete after token exchange
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### PKCE Code Verifier
|
|
181
|
+
|
|
182
|
+
Safire generates the code verifier automatically. Store it server-side only and discard it immediately after the token exchange — never send it to the client or include it in a URL.
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# Store on launch
|
|
186
|
+
session[:code_verifier] = auth_data[:code_verifier]
|
|
187
|
+
|
|
188
|
+
# Delete immediately after exchange
|
|
189
|
+
session.delete(:code_verifier)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Key Rotation and Scope Minimization
|
|
195
|
+
|
|
196
|
+
### Symmetric Secret Rotation
|
|
197
|
+
|
|
198
|
+
Support two secrets during rotation to allow a zero-downtime rollover:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
module SmartSecretRotation
|
|
202
|
+
def build_smart_client
|
|
203
|
+
create_client(primary_secret)
|
|
204
|
+
rescue Safire::Errors::TokenError => e
|
|
205
|
+
raise unless e.error_code == 'invalid_client'
|
|
206
|
+
create_client(secondary_secret) # Fall back during rotation
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def primary_secret = ENV['SMART_CLIENT_SECRET']
|
|
210
|
+
def secondary_secret = ENV['SMART_CLIENT_SECRET_PREVIOUS']
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Asymmetric Key Rotation
|
|
215
|
+
|
|
216
|
+
Publish both old and new public keys simultaneously in your JWKS endpoint during rotation:
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{
|
|
220
|
+
"keys": [
|
|
221
|
+
{ "kid": "key-v1", "kty": "RSA", "use": "sig", ... },
|
|
222
|
+
{ "kid": "key-v2", "kty": "RSA", "use": "sig", ... }
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Rotation steps:
|
|
228
|
+
1. Generate a new key pair with a new `kid`
|
|
229
|
+
2. Add the new public key to your JWKS endpoint
|
|
230
|
+
3. Update your application to use the new private key
|
|
231
|
+
4. Remove the old public key from JWKS after a grace period (allow in-flight tokens to expire)
|
|
232
|
+
|
|
233
|
+
### Scope Minimization
|
|
234
|
+
|
|
235
|
+
Request only the scopes your application needs. Broad wildcard scopes increase the impact of a compromised token.
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# ✅ Request specific resource types
|
|
239
|
+
scopes: ['patient/Patient.read', 'patient/Observation.read']
|
|
240
|
+
|
|
241
|
+
# ❌ Avoid unless truly necessary
|
|
242
|
+
scopes: ['patient/*.*']
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
You can also reduce scopes at refresh time:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
client.refresh_token(
|
|
249
|
+
refresh_token: session[:refresh_token],
|
|
250
|
+
scopes: ['patient/Patient.read'] # Must be a subset of the original grant
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
*See also: [Configuration Guide]({{ site.baseurl }}/configuration/) for `ssl_options` and `log_http` settings that affect security behaviour.*
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Authorization
|
|
4
|
+
parent: Confidential Asymmetric 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. Check that the server supports `private_key_jwt` and your preferred signing algorithm.
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
def check_server_capabilities
|
|
27
|
+
metadata = @client.server_metadata
|
|
28
|
+
|
|
29
|
+
unless metadata.supports_asymmetric_auth?
|
|
30
|
+
raise 'Server does not support confidential asymmetric clients'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
auth_methods = metadata.token_endpoint_auth_methods_supported
|
|
34
|
+
algorithms = metadata.asymmetric_signing_algorithms_supported
|
|
35
|
+
|
|
36
|
+
render json: {
|
|
37
|
+
supports_asymmetric: true,
|
|
38
|
+
auth_methods: auth_methods,
|
|
39
|
+
signing_algorithms: algorithms,
|
|
40
|
+
supports_private_key_jwt: auth_methods&.include?('private_key_jwt'),
|
|
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, including `asymmetric_signing_algorithms_supported`.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Step 2: Authorization Request
|
|
51
|
+
|
|
52
|
+
Authorization URL generation is identical to other client types — 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 other client types. The difference surfaces at token exchange, where Safire replaces the authorization header with a signed JWT assertion in the request body.
|
|
66
|
+
|
|
67
|
+
{: .note }
|
|
68
|
+
> **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.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
**Next:** [Token Exchange & Refresh]({% link smart-on-fhir/confidential-asymmetric/token-exchange.md %})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Confidential Asymmetric Client Workflow
|
|
4
|
+
parent: SMART on FHIR
|
|
5
|
+
nav_order: 4
|
|
6
|
+
has_children: true
|
|
7
|
+
permalink: /smart-on-fhir/confidential-asymmetric/
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Confidential Asymmetric Client Workflow
|
|
11
|
+
|
|
12
|
+
{: .no_toc }
|
|
13
|
+
|
|
14
|
+
<div class="code-example" markdown="1">
|
|
15
|
+
This guide demonstrates SMART on FHIR confidential asymmetric 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 asymmetric clients authenticate using **`private_key_jwt`** — a signed JWT assertion built from your RSA or EC private key — instead of a shared client secret. This is the most secure SMART client authentication method and is recommended for many production healthcare deployments.
|
|
23
|
+
|
|
24
|
+
Safire implements `private_key_jwt` per [SMART App Launch STU 2.2.0](https://hl7.org/fhir/smart-app-launch/client-confidential-asymmetric.html).
|
|
25
|
+
|
|
26
|
+
Suitable for:
|
|
27
|
+
- Backend services where sharing a secret out-of-band is impractical
|
|
28
|
+
- Multi-tenant platforms where independent key rotation per tenant is valuable
|
|
29
|
+
- Deployments requiring the highest level of client authentication assurance
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Key Differences from Other Client Types
|
|
34
|
+
|
|
35
|
+
| Aspect | Public | Confidential Symmetric | Confidential Asymmetric |
|
|
36
|
+
|--------|--------|------------------------|-------------------------|
|
|
37
|
+
| **Credential** | None | Shared `client_secret` | RSA or EC private key |
|
|
38
|
+
| **Token Request Auth** | `client_id` in body | `Authorization: Basic` header | JWT assertion in body |
|
|
39
|
+
| **Key Rotation** | N/A | Coordinated secret change | Publish new public key, rotate independently |
|
|
40
|
+
| **Algorithms** | N/A | N/A | RS384 or ES384 |
|
|
41
|
+
| **PKCE** | Required | Required | Required |
|
|
42
|
+
|
|
43
|
+
{: .important }
|
|
44
|
+
> **PKCE is still required.** The JWT assertion authenticates the *client*; PKCE protects the *authorization code exchange*. They serve different purposes.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Prerequisites: Keys, JWKS, and Algorithm
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
### Generating a Key Pair
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# RSA key (2048-bit minimum, 4096-bit recommended)
|
|
56
|
+
openssl genrsa -out private_key.pem 4096
|
|
57
|
+
openssl rsa -in private_key.pem -pubout -out public_key.pem
|
|
58
|
+
|
|
59
|
+
# --- OR ---
|
|
60
|
+
|
|
61
|
+
# EC key (P-384 curve, required for ES384)
|
|
62
|
+
openssl ecparam -name secp384r1 -genkey -noout -out private_key_ec.pem
|
|
63
|
+
openssl ec -in private_key_ec.pem -pubout -out public_key_ec.pem
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Add `*.pem` and `*.key` to `.gitignore`. See the [Security Guide]({{ site.baseurl }}/security/#private-keys-confidential-asymmetric) for loading keys securely in production.
|
|
67
|
+
|
|
68
|
+
### Publishing Your Public Key (JWKS)
|
|
69
|
+
|
|
70
|
+
The authorization server must know your public key. Host a JWKS endpoint:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"keys": [
|
|
75
|
+
{
|
|
76
|
+
"kty": "RSA",
|
|
77
|
+
"kid": "my-key-id-123",
|
|
78
|
+
"use": "sig",
|
|
79
|
+
"alg": "RS384",
|
|
80
|
+
"n": "<base64url-encoded modulus>",
|
|
81
|
+
"e": "AQAB"
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If you provide a `jwks_uri` in your Safire config, it is included as the `jku` header in JWT assertions so the server can locate your public key automatically.
|
|
88
|
+
|
|
89
|
+
### JWT Assertion Structure
|
|
90
|
+
|
|
91
|
+
Safire builds the following JWT assertion (sent as `client_assertion` in every token request):
|
|
92
|
+
|
|
93
|
+
```mermaid
|
|
94
|
+
flowchart LR
|
|
95
|
+
subgraph Header["JOSE Header"]
|
|
96
|
+
H1["alg: RS384 or ES384"]
|
|
97
|
+
H2["kid: key ID"]
|
|
98
|
+
H3["jku: JWKS URI (optional)"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
subgraph Claims["JWT Claims"]
|
|
102
|
+
C1["iss: client_id"]
|
|
103
|
+
C2["sub: client_id"]
|
|
104
|
+
C3["aud: token_endpoint"]
|
|
105
|
+
C4["jti: unique random ID"]
|
|
106
|
+
C5["iat: issued at"]
|
|
107
|
+
C6["exp: iat + 5 minutes"]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
PK["Private Key\n(RSA or EC)"]
|
|
111
|
+
|
|
112
|
+
Header --> JWT
|
|
113
|
+
Claims --> JWT
|
|
114
|
+
PK -->|signs| JWT["Signed JWT Assertion\n(client_assertion)"]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Algorithm Selection
|
|
118
|
+
|
|
119
|
+
Safire supports the two algorithms required by the SMART specification:
|
|
120
|
+
|
|
121
|
+
| Algorithm | Key Type | Use Case |
|
|
122
|
+
|-----------|----------|----------|
|
|
123
|
+
| **RS384** | RSA | Most common, widest server support |
|
|
124
|
+
| **ES384** | EC, P-384 curve | Smaller keys, faster signing |
|
|
125
|
+
|
|
126
|
+
Safire auto-detects the algorithm from the key type — no explicit configuration needed:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# RSA key → RS384 automatically
|
|
130
|
+
config = Safire::ClientConfig.new(private_key: OpenSSL::PKey::RSA.new(...), kid: 'my-rsa-key', ...)
|
|
131
|
+
|
|
132
|
+
# EC key → ES384 automatically
|
|
133
|
+
config = Safire::ClientConfig.new(private_key: OpenSSL::PKey::EC.generate('secp384r1'), kid: 'my-ec-key', ...)
|
|
134
|
+
|
|
135
|
+
# Override explicitly if needed
|
|
136
|
+
config = Safire::ClientConfig.new(private_key: rsa_key, kid: 'my-key', jwt_algorithm: 'RS384', ...)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Client Setup
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
config = Safire::ClientConfig.new(
|
|
143
|
+
base_url: ENV['FHIR_BASE_URL'],
|
|
144
|
+
client_id: ENV['SMART_CLIENT_ID'],
|
|
145
|
+
redirect_uri: callback_url,
|
|
146
|
+
scopes: ['openid', 'profile', 'patient/*.read', 'offline_access'],
|
|
147
|
+
private_key: OpenSSL::PKey::RSA.new(File.read(ENV['SMART_PRIVATE_KEY_PATH'])),
|
|
148
|
+
kid: ENV['SMART_KEY_ID'],
|
|
149
|
+
jwks_uri: ENV['SMART_JWKS_URI'] # Optional
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@client = Safire::Client.new(config, client_type: :confidential_asymmetric)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## What's Next
|
|
158
|
+
|
|
159
|
+
- [Authorization]({% link smart-on-fhir/confidential-asymmetric/authorization.md %}) — Discovery and generating the authorization URL
|
|
160
|
+
- [Token Exchange & Refresh]({% link smart-on-fhir/confidential-asymmetric/token-exchange.md %}) — JWT assertion token requests, refresh, and error handling
|
|
161
|
+
- [Security Guide]({{ site.baseurl }}/security/) — Private key management and rotation
|
|
162
|
+
- [Advanced Examples]({{ site.baseurl }}/advanced/) — Complete Rails controller, caching, multi-server, and retry patterns
|