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