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
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