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,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Capability Checks and Client Selection
|
|
4
|
+
parent: SMART Discovery
|
|
5
|
+
grand_parent: SMART on FHIR
|
|
6
|
+
nav_order: 2
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Capability Checks and Client Selection
|
|
10
|
+
|
|
11
|
+
{: .no_toc }
|
|
12
|
+
|
|
13
|
+
## Table of contents
|
|
14
|
+
{: .no_toc .text-delta }
|
|
15
|
+
|
|
16
|
+
1. TOC
|
|
17
|
+
{:toc}
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Full Checks (`supports_*?`)
|
|
22
|
+
|
|
23
|
+
These methods verify both the capability flag **and** any required associated fields. Use them to confirm the server is fully ready for a given mode.
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# Launch modes — requires capability flag AND authorization_endpoint present
|
|
27
|
+
metadata.supports_ehr_launch? # launch-ehr + authorization_endpoint
|
|
28
|
+
metadata.supports_standalone_launch? # launch-standalone + authorization_endpoint
|
|
29
|
+
|
|
30
|
+
# Public clients
|
|
31
|
+
metadata.supports_public_auth?
|
|
32
|
+
# => true if capabilities include "client-public"
|
|
33
|
+
|
|
34
|
+
# Confidential symmetric — checks capability AND auth method compatibility
|
|
35
|
+
metadata.supports_symmetric_auth?
|
|
36
|
+
# => true if:
|
|
37
|
+
# capabilities include "client-confidential-symmetric"
|
|
38
|
+
# AND (token_endpoint_auth_methods_supported is blank
|
|
39
|
+
# OR includes "client_secret_basic")
|
|
40
|
+
|
|
41
|
+
# Confidential asymmetric — checks capability, auth method, AND algorithm support
|
|
42
|
+
metadata.supports_asymmetric_auth?
|
|
43
|
+
# => true if:
|
|
44
|
+
# capabilities include "client-confidential-asymmetric"
|
|
45
|
+
# AND (token_endpoint_auth_methods_supported is blank
|
|
46
|
+
# OR includes "private_key_jwt")
|
|
47
|
+
# AND asymmetric_signing_algorithms_supported.any?
|
|
48
|
+
|
|
49
|
+
# OpenID Connect — requires capability flag, issuer, AND jwks_uri present
|
|
50
|
+
metadata.supports_openid_connect?
|
|
51
|
+
|
|
52
|
+
# POST-based authorization
|
|
53
|
+
metadata.supports_post_based_authorization?
|
|
54
|
+
# => true if capabilities include "authorize-post"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Flag-Only Checks (`*_capability?`)
|
|
60
|
+
|
|
61
|
+
These check only the capability string — they do **not** verify that required fields are present. Use them for lightweight checks or when you plan to validate fields separately.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
metadata.ehr_launch_capability? # capabilities include "launch-ehr"
|
|
65
|
+
metadata.standalone_launch_capability? # capabilities include "launch-standalone"
|
|
66
|
+
metadata.openid_connect_capability? # capabilities include "sso-openid-connect"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
{: .important }
|
|
70
|
+
> **`supports_*?` vs `*_capability?`** — `supports_ehr_launch?` returns `false` if `authorization_endpoint` is missing even when the capability flag is set. `ehr_launch_capability?` returns `true` based on the flag alone. Prefer `supports_*?` unless you have a specific reason to check the flag independently.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Asymmetric Signing Algorithms
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
metadata.asymmetric_signing_algorithms_supported
|
|
78
|
+
# Returns the intersection of server-advertised signing algorithms and
|
|
79
|
+
# Safire's supported set [RS384, ES384].
|
|
80
|
+
# If the server does not advertise algorithms, both RS384 and ES384 are assumed.
|
|
81
|
+
# => ["RS384", "ES384"]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
This method powers `supports_asymmetric_auth?` internally and is also useful when selecting a signing algorithm explicitly for a confidential asymmetric client.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Client Selection Based on Discovery
|
|
89
|
+
|
|
90
|
+
**Manual client type switch** — discover first, then update the client type. Already-fetched metadata is preserved; no re-discovery occurs.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
client = Safire::Client.new(config) # defaults to :public
|
|
94
|
+
metadata = client.server_metadata
|
|
95
|
+
|
|
96
|
+
if metadata.supports_symmetric_auth?
|
|
97
|
+
client.client_type = :confidential_symmetric
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
tokens = client.request_access_token(code: code, code_verifier: verifier)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Automatic selection** based on server capabilities:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
def configure_client_type(client)
|
|
107
|
+
metadata = client.server_metadata
|
|
108
|
+
|
|
109
|
+
if metadata.supports_asymmetric_auth?
|
|
110
|
+
client.client_type = :confidential_asymmetric
|
|
111
|
+
elsif metadata.supports_symmetric_auth?
|
|
112
|
+
client.client_type = :confidential_symmetric
|
|
113
|
+
elsif metadata.supports_public_auth?
|
|
114
|
+
client.client_type = :public
|
|
115
|
+
else
|
|
116
|
+
raise 'Server does not support any known client types'
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## SMART Capabilities Reference
|
|
124
|
+
|
|
125
|
+
| Capability | Description |
|
|
126
|
+
|------------|-------------|
|
|
127
|
+
| `launch-ehr` | Supports EHR-initiated launch |
|
|
128
|
+
| `launch-standalone` | Supports standalone launch |
|
|
129
|
+
| `authorize-post` | Supports POST-based authorization |
|
|
130
|
+
| `client-public` | Supports public clients |
|
|
131
|
+
| `client-confidential-symmetric` | Supports `client_secret_basic` |
|
|
132
|
+
| `client-confidential-asymmetric` | Supports `private_key_jwt` |
|
|
133
|
+
| `sso-openid-connect` | Supports OpenID Connect |
|
|
134
|
+
| `context-ehr-patient` | EHR launch provides patient context |
|
|
135
|
+
| `context-ehr-encounter` | EHR launch provides encounter context |
|
|
136
|
+
| `context-standalone-patient` | Standalone launch can request patient context |
|
|
137
|
+
| `context-standalone-encounter` | Standalone launch can request encounter context |
|
|
138
|
+
| `permission-offline` | Supports `offline_access` scope |
|
|
139
|
+
| `permission-patient` | Supports patient-level scopes |
|
|
140
|
+
| `permission-user` | Supports user-level scopes |
|
|
141
|
+
| `permission-v1` | Supports SMART v1 scopes |
|
|
142
|
+
| `permission-v2` | Supports SMART v2 scopes |
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: SMART Discovery
|
|
4
|
+
parent: SMART on FHIR
|
|
5
|
+
nav_order: 1
|
|
6
|
+
has_children: true
|
|
7
|
+
permalink: /smart-on-fhir/discovery/
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# SMART Discovery
|
|
11
|
+
|
|
12
|
+
{: .no_toc }
|
|
13
|
+
|
|
14
|
+
<div class="code-example" markdown="1">
|
|
15
|
+
SMART on FHIR discovery allows clients to dynamically learn about a FHIR server's authorization capabilities before initiating the OAuth flow.
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Overview
|
|
21
|
+
|
|
22
|
+
Safire fetches server metadata from `/.well-known/smart-configuration`, appended to your `base_url`:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
base_url = 'https://fhir.example.com/r4'
|
|
26
|
+
# Fetches: https://fhir.example.com/r4/.well-known/smart-configuration
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Trailing slashes are handled automatically. The metadata is fetched lazily on first use and cached within the client instance.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
config = Safire::ClientConfig.new(
|
|
33
|
+
base_url: 'https://fhir.example.com',
|
|
34
|
+
client_id: 'my_client',
|
|
35
|
+
redirect_uri: 'https://myapp.com/callback',
|
|
36
|
+
scopes: ['openid', 'profile']
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# :public is the default client_type — appropriate for discovery
|
|
40
|
+
client = Safire::Client.new(config)
|
|
41
|
+
metadata = client.server_metadata
|
|
42
|
+
# => #<Safire::Protocols::SmartMetadata ...>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`server_metadata` returns a `Safire::Protocols::SmartMetadata` object with typed accessors for all fields. See [Metadata Fields and Validation]({% link smart-on-fhir/discovery/metadata.md %}) for the full field reference and validation rules.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Error Handling
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
begin
|
|
53
|
+
metadata = client.server_metadata
|
|
54
|
+
rescue Safire::Errors::DiscoveryError => e
|
|
55
|
+
case e.message
|
|
56
|
+
when /404/
|
|
57
|
+
puts 'FHIR server does not support SMART on FHIR'
|
|
58
|
+
when /timeout/i
|
|
59
|
+
puts 'Discovery request timed out'
|
|
60
|
+
when /expected JSON object/
|
|
61
|
+
puts 'Server returned invalid SMART configuration'
|
|
62
|
+
else
|
|
63
|
+
puts "Discovery failed: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Graceful fallback** — fall back to known endpoints when discovery is unavailable:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
def discover_with_fallback(client)
|
|
72
|
+
metadata = client.server_metadata
|
|
73
|
+
{
|
|
74
|
+
authorization_endpoint: metadata.authorization_endpoint,
|
|
75
|
+
token_endpoint: metadata.token_endpoint,
|
|
76
|
+
source: :discovery
|
|
77
|
+
}
|
|
78
|
+
rescue Safire::Errors::DiscoveryError => e
|
|
79
|
+
Rails.logger.warn("Discovery failed, using fallback: #{e.message}")
|
|
80
|
+
{
|
|
81
|
+
authorization_endpoint: ENV['FALLBACK_AUTH_ENDPOINT'],
|
|
82
|
+
token_endpoint: ENV['FALLBACK_TOKEN_ENDPOINT'],
|
|
83
|
+
source: :fallback
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
{: .note }
|
|
89
|
+
> Application-level metadata caching (e.g. `Rails.cache`) and multi-server registry patterns are covered in the [Advanced Examples]({{ site.baseurl }}/advanced/) guide.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## What's Next
|
|
94
|
+
|
|
95
|
+
- [Metadata Fields and Validation]({% link smart-on-fhir/discovery/metadata.md %}) — field reference, validation rules, PKCE checks
|
|
96
|
+
- [Capability Checks and Client Selection]({% link smart-on-fhir/discovery/capability-checks.md %}) — all capability methods and dynamic client type selection
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Metadata Fields and Validation
|
|
4
|
+
parent: SMART Discovery
|
|
5
|
+
grand_parent: SMART on FHIR
|
|
6
|
+
nav_order: 1
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Metadata Fields and Validation
|
|
10
|
+
|
|
11
|
+
{: .no_toc }
|
|
12
|
+
|
|
13
|
+
## Table of contents
|
|
14
|
+
{: .no_toc .text-delta }
|
|
15
|
+
|
|
16
|
+
1. TOC
|
|
17
|
+
{:toc}
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Field Reference
|
|
22
|
+
|
|
23
|
+
`server_metadata` returns a `Safire::Protocols::SmartMetadata` object. All fields are accessible as typed readers.
|
|
24
|
+
|
|
25
|
+
### Always Required
|
|
26
|
+
|
|
27
|
+
| Field | Type | Description |
|
|
28
|
+
|-------|------|-------------|
|
|
29
|
+
| `token_endpoint` | String | OAuth2 token endpoint URL |
|
|
30
|
+
| `grant_types_supported` | Array | Supported OAuth2 grant types |
|
|
31
|
+
| `capabilities` | Array | SMART capabilities list |
|
|
32
|
+
| `code_challenge_methods_supported` | Array | Must include `"S256"`, must not include `"plain"` |
|
|
33
|
+
|
|
34
|
+
### Conditionally Required
|
|
35
|
+
|
|
36
|
+
| Field | Type | Required when |
|
|
37
|
+
|-------|------|--------------|
|
|
38
|
+
| `authorization_endpoint` | String | capabilities include `launch-ehr` or `launch-standalone` |
|
|
39
|
+
| `issuer` | String | capabilities include `sso-openid-connect` |
|
|
40
|
+
| `jwks_uri` | String | capabilities include `sso-openid-connect` |
|
|
41
|
+
|
|
42
|
+
### Optional
|
|
43
|
+
|
|
44
|
+
| Field | Type | Description |
|
|
45
|
+
|-------|------|-------------|
|
|
46
|
+
| `registration_endpoint` | String | Dynamic client registration URL |
|
|
47
|
+
| `scopes_supported` | Array | Available OAuth2 scopes |
|
|
48
|
+
| `response_types_supported` | Array | Supported response types |
|
|
49
|
+
| `token_endpoint_auth_methods_supported` | Array | Token endpoint auth methods (e.g. `client_secret_basic`, `private_key_jwt`) |
|
|
50
|
+
| `token_endpoint_auth_signing_alg_values_supported` | Array | JWT signing algorithms (e.g. `RS384`, `ES384`) — used by `asymmetric_signing_algorithms_supported` |
|
|
51
|
+
| `introspection_endpoint` | String | Token introspection URL |
|
|
52
|
+
| `revocation_endpoint` | String | Token revocation URL |
|
|
53
|
+
| `management_endpoint` | String | User access management URL |
|
|
54
|
+
| `associated_endpoints` | Array | Endpoints sharing this auth server |
|
|
55
|
+
| `user_access_brand_bundle` | String | Brand bundle URL for user-facing apps |
|
|
56
|
+
| `user_access_brand_identifier` | String | Primary brand identifier |
|
|
57
|
+
|
|
58
|
+
### Example Usage
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
metadata.token_endpoint # => "https://fhir.example.com/token"
|
|
62
|
+
metadata.grant_types_supported # => ["authorization_code", "refresh_token"]
|
|
63
|
+
metadata.capabilities # => ["launch-ehr", "client-public", ...]
|
|
64
|
+
metadata.code_challenge_methods_supported # => ["S256"]
|
|
65
|
+
|
|
66
|
+
# Conditionally present
|
|
67
|
+
metadata.authorization_endpoint # => "https://fhir.example.com/authorize"
|
|
68
|
+
metadata.issuer # => "https://fhir.example.com"
|
|
69
|
+
metadata.jwks_uri # => "https://fhir.example.com/.well-known/jwks.json"
|
|
70
|
+
|
|
71
|
+
# Optional
|
|
72
|
+
metadata.registration_endpoint # => "https://fhir.example.com/register"
|
|
73
|
+
metadata.scopes_supported # => ["openid", "profile", "patient/*.read", ...]
|
|
74
|
+
metadata.token_endpoint_auth_methods_supported # => ["client_secret_basic", "private_key_jwt"]
|
|
75
|
+
metadata.token_endpoint_auth_signing_alg_values_supported # => ["RS384", "ES384"]
|
|
76
|
+
metadata.introspection_endpoint # => "https://fhir.example.com/introspect"
|
|
77
|
+
metadata.revocation_endpoint # => "https://fhir.example.com/revoke"
|
|
78
|
+
metadata.management_endpoint # => "https://fhir.example.com/manage"
|
|
79
|
+
metadata.associated_endpoints # => [{"url" => "...", "capabilities" => [...]}]
|
|
80
|
+
metadata.user_access_brand_bundle # => "https://fhir.example.com/brands"
|
|
81
|
+
metadata.user_access_brand_identifier # => "example-brand"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Validation
|
|
87
|
+
|
|
88
|
+
`valid?` checks conformance with SMART App Launch 2.2.0 and logs a warning for each violation. It never raises — deciding whether to block on non-compliant metadata is the caller's responsibility.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
if metadata.valid?
|
|
92
|
+
# All required fields present, PKCE compliant
|
|
93
|
+
else
|
|
94
|
+
# Safire has already logged warnings for each violation
|
|
95
|
+
raise 'Server metadata does not meet SMART App Launch 2.2.0 requirements'
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**What `valid?` checks:**
|
|
100
|
+
- All always-required fields are present
|
|
101
|
+
- Conditional fields present when their capability is advertised
|
|
102
|
+
- `code_challenge_methods_supported` includes `'S256'` (SHALL per SMART 2.2.0)
|
|
103
|
+
- `code_challenge_methods_supported` does not include `'plain'` (SHALL NOT per SMART 2.2.0)
|
|
104
|
+
|
|
105
|
+
Example warning output:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
WARN: SMART metadata non-compliance: required field 'authorization_endpoint' is missing
|
|
109
|
+
WARN: SMART metadata non-compliance: 'S256' is missing from code_challenge_methods_supported (SMART App Launch 2.2.0 requires S256)
|
|
110
|
+
WARN: SMART metadata non-compliance: 'plain' is present in code_challenge_methods_supported (SMART App Launch 2.2.0 prohibits plain)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Example Server Response (SMART App Launch 2.2.0)
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"issuer": "https://fhir.example.com",
|
|
120
|
+
"authorization_endpoint": "https://fhir.example.com/authorize",
|
|
121
|
+
"token_endpoint": "https://fhir.example.com/token",
|
|
122
|
+
"jwks_uri": "https://fhir.example.com/.well-known/jwks.json",
|
|
123
|
+
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
124
|
+
"scopes_supported": ["openid", "profile", "launch", "patient/*.read", "offline_access"],
|
|
125
|
+
"response_types_supported": ["code"],
|
|
126
|
+
"token_endpoint_auth_methods_supported": [
|
|
127
|
+
"client_secret_basic",
|
|
128
|
+
"private_key_jwt"
|
|
129
|
+
],
|
|
130
|
+
"token_endpoint_auth_signing_alg_values_supported": ["RS384", "ES384"],
|
|
131
|
+
"code_challenge_methods_supported": ["S256"],
|
|
132
|
+
"capabilities": [
|
|
133
|
+
"launch-ehr",
|
|
134
|
+
"launch-standalone",
|
|
135
|
+
"client-public",
|
|
136
|
+
"client-confidential-symmetric",
|
|
137
|
+
"client-confidential-asymmetric",
|
|
138
|
+
"sso-openid-connect",
|
|
139
|
+
"context-ehr-patient",
|
|
140
|
+
"context-ehr-encounter",
|
|
141
|
+
"context-standalone-patient",
|
|
142
|
+
"permission-offline",
|
|
143
|
+
"permission-patient",
|
|
144
|
+
"permission-user"
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
```
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: SMART on FHIR
|
|
4
|
+
nav_order: 4
|
|
5
|
+
has_children: true
|
|
6
|
+
permalink: /smart-on-fhir/
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# SMART on FHIR
|
|
10
|
+
|
|
11
|
+
This section provides step-by-step guides for implementing SMART on FHIR authorization flows with Safire.
|
|
12
|
+
|
|
13
|
+
## Available Workflows
|
|
14
|
+
|
|
15
|
+
| Workflow | Description |
|
|
16
|
+
|----------|-------------|
|
|
17
|
+
| [SMART Discovery]({% link smart-on-fhir/discovery/index.md %}) | Fetching and using SMART configuration metadata |
|
|
18
|
+
| [Public Client]({% link smart-on-fhir/public-client/index.md %}) | Authorization flow for browser-based and mobile applications |
|
|
19
|
+
| [Confidential Symmetric Client]({% link smart-on-fhir/confidential-symmetric/index.md %}) | Authorization flow for server-side applications with client secrets |
|
|
20
|
+
| [Confidential Asymmetric Client]({% link smart-on-fhir/confidential-asymmetric/index.md %}) | Authorization flow using private_key_jwt (RSA/EC key pair) |
|
|
21
|
+
| [POST-Based Authorization]({% link smart-on-fhir/post-based-authorization.md %}) | Sending the authorization request as a form POST (`authorize-post` capability) |
|
|
22
|
+
|
|
23
|
+
## Choosing a Client Type
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Is your application a server-side web application
|
|
27
|
+
that can securely store credentials?
|
|
28
|
+
│
|
|
29
|
+
├── YES → Can you use asymmetric key pairs (RSA/EC)?
|
|
30
|
+
│ │
|
|
31
|
+
│ ├── YES → Confidential Asymmetric Client
|
|
32
|
+
│ │ (Uses private_key_jwt with signed JWT assertions)
|
|
33
|
+
│ │
|
|
34
|
+
│ └── NO → Confidential Symmetric Client
|
|
35
|
+
│ (Uses client_secret with HTTP Basic auth)
|
|
36
|
+
│
|
|
37
|
+
└── NO → Public Client
|
|
38
|
+
(Uses PKCE only, no client secret)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Common Flow
|
|
42
|
+
|
|
43
|
+
All SMART authorization flows follow this general pattern. The key differences between client types are in how they authenticate during token exchange and refresh.
|
|
44
|
+
|
|
45
|
+
```mermaid
|
|
46
|
+
sequenceDiagram
|
|
47
|
+
participant App
|
|
48
|
+
participant Safire
|
|
49
|
+
participant FHIR as FHIR Server
|
|
50
|
+
|
|
51
|
+
App->>Safire: Client.new(config)
|
|
52
|
+
Note over Safire: No network call yet
|
|
53
|
+
|
|
54
|
+
App->>Safire: authorization_url()
|
|
55
|
+
Safire->>FHIR: GET /.well-known/smart-configuration
|
|
56
|
+
FHIR-->>Safire: SmartMetadata (endpoints, capabilities)
|
|
57
|
+
Safire-->>App: { auth_url, state, code_verifier }
|
|
58
|
+
|
|
59
|
+
App->>FHIR: Redirect user to auth_url
|
|
60
|
+
FHIR-->>App: Callback with ?code=...&state=...
|
|
61
|
+
|
|
62
|
+
App->>Safire: request_access_token(code:, code_verifier:)
|
|
63
|
+
Note over Safire: Auth method varies by client_type:<br/>public → client_id in body<br/>confidential_symmetric → Basic auth header<br/>confidential_asymmetric → JWT assertion in body
|
|
64
|
+
Safire->>FHIR: POST /token
|
|
65
|
+
FHIR-->>Safire: { access_token, refresh_token, expires_in, ... }
|
|
66
|
+
Safire-->>App: token response Hash
|
|
67
|
+
|
|
68
|
+
App->>Safire: refresh_token(refresh_token:)
|
|
69
|
+
Safire->>FHIR: POST /token (grant_type=refresh_token)
|
|
70
|
+
FHIR-->>Safire: { access_token, ... }
|
|
71
|
+
Safire-->>App: new token response Hash
|
|
72
|
+
```
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: POST-Based Authorization
|
|
4
|
+
parent: SMART on FHIR
|
|
5
|
+
nav_order: 5
|
|
6
|
+
has_toc: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# POST-Based Authorization
|
|
10
|
+
|
|
11
|
+
{: .no_toc }
|
|
12
|
+
|
|
13
|
+
<div class="code-example" markdown="1">
|
|
14
|
+
SMART App Launch 2.2.0 introduces the `authorize-post` capability, allowing servers to accept the authorization request as an HTTP form POST instead of a GET redirect. This page explains when and how to use it with Safire.
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
## Table of contents
|
|
18
|
+
{: .no_toc .text-delta }
|
|
19
|
+
|
|
20
|
+
1. TOC
|
|
21
|
+
{:toc}
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Overview
|
|
26
|
+
|
|
27
|
+
By default, SMART authorization uses a GET redirect — the user's browser is sent to the authorization server with all parameters in the query string. Servers that advertise the `authorize-post` capability also accept the authorization request as an HTTP POST with parameters in the request body.
|
|
28
|
+
|
|
29
|
+
POST-based authorization can be useful when:
|
|
30
|
+
- Authorization parameters are too large for a URL (e.g. rich `state` or `launch` values)
|
|
31
|
+
- The client prefers to avoid sensitive parameters appearing in browser history or server logs
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Detecting Server Support
|
|
36
|
+
|
|
37
|
+
Check whether the authorization server advertises the `authorize-post` capability before using POST mode:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
client = Safire::Client.new(config, client_type: :public)
|
|
41
|
+
metadata = client.server_metadata
|
|
42
|
+
|
|
43
|
+
if metadata.supports_post_based_authorization?
|
|
44
|
+
puts "Server supports POST-based authorization"
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Generating a POST Authorization Request
|
|
51
|
+
|
|
52
|
+
Pass `method: :post` (or `method: 'post'`) to `authorization_url`:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
auth_data = client.authorization_url(method: :post)
|
|
56
|
+
|
|
57
|
+
auth_data[:auth_url] # The bare authorization endpoint URL
|
|
58
|
+
auth_data[:params] # Hash of parameters to POST as the request body
|
|
59
|
+
auth_data[:state] # Store in session for CSRF verification
|
|
60
|
+
auth_data[:code_verifier] # Store in session for token exchange
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The `:get` method returns `auth_url` as a fully-formed URL with query parameters. The `:post` method returns the endpoint separately from the parameters so your application can submit a form POST.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Rails Example
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# app/controllers/smart_auth_controller.rb
|
|
71
|
+
def launch
|
|
72
|
+
# Check server support (optional but recommended)
|
|
73
|
+
metadata = @client.server_metadata
|
|
74
|
+
use_post = metadata.supports_post_based_authorization?
|
|
75
|
+
|
|
76
|
+
auth_data = @client.authorization_url(method: use_post ? :post : :get)
|
|
77
|
+
|
|
78
|
+
session[:oauth_state] = auth_data[:state]
|
|
79
|
+
session[:code_verifier] = auth_data[:code_verifier]
|
|
80
|
+
|
|
81
|
+
if use_post
|
|
82
|
+
# Render a form that auto-submits to the authorization endpoint
|
|
83
|
+
@auth_url = auth_data[:auth_url]
|
|
84
|
+
@auth_params = auth_data[:params]
|
|
85
|
+
render :authorize_post
|
|
86
|
+
else
|
|
87
|
+
redirect_to auth_data[:auth_url], allow_other_host: true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```erb
|
|
93
|
+
<%# app/views/smart_auth/authorize_post.html.erb %>
|
|
94
|
+
<form id="auth-form" method="POST" action="<%= @auth_url %>">
|
|
95
|
+
<% @auth_params.each do |key, value| %>
|
|
96
|
+
<input type="hidden" name="<%= key %>" value="<%= value %>">
|
|
97
|
+
<% end %>
|
|
98
|
+
</form>
|
|
99
|
+
|
|
100
|
+
<script>
|
|
101
|
+
// Auto-submit after the page loads
|
|
102
|
+
document.getElementById('auth-form').submit();
|
|
103
|
+
</script>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Response Hash Comparison
|
|
107
|
+
|
|
108
|
+
| Key | GET (`method: :get`) | POST (`method: :post`) |
|
|
109
|
+
|---------------|-----------------------------------------------|--------------------------------|
|
|
110
|
+
| `:auth_url` | Full URL with query string parameters | Bare authorization endpoint URL |
|
|
111
|
+
| `:state` | State value (also embedded in query string) | State value |
|
|
112
|
+
| `:code_verifier` | PKCE code verifier | PKCE code verifier |
|
|
113
|
+
| `:params` | Not present | Hash of all authorization parameters |
|
|
114
|
+
|
|
115
|
+
The callback handling (token exchange) is identical for both methods — the authorization server always redirects back to your `redirect_uri` with an authorization code.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Sinatra Example
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
get '/launch' do
|
|
123
|
+
auth_data = @client.authorization_url(method: :post)
|
|
124
|
+
|
|
125
|
+
session[:state] = auth_data[:state]
|
|
126
|
+
session[:code_verifier] = auth_data[:code_verifier]
|
|
127
|
+
|
|
128
|
+
@auth_url = auth_data[:auth_url]
|
|
129
|
+
@auth_params = auth_data[:params]
|
|
130
|
+
erb :authorize_post
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```erb
|
|
135
|
+
<%# views/authorize_post.erb %>
|
|
136
|
+
<form id="auth-form" method="POST" action="<%= @auth_url %>">
|
|
137
|
+
<% @auth_params.each do |key, value| %>
|
|
138
|
+
<input type="hidden" name="<%= key %>" value="<%= value %>">
|
|
139
|
+
<% end %>
|
|
140
|
+
</form>
|
|
141
|
+
<script>document.getElementById('auth-form').submit();</script>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## String and Symbol Forms
|
|
147
|
+
|
|
148
|
+
Both string and symbol values are accepted:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
client.authorization_url(method: :post) # symbol
|
|
152
|
+
client.authorization_url(method: 'post') # string — also accepted
|
|
153
|
+
client.authorization_url(method: :get) # default
|
|
154
|
+
client.authorization_url(method: 'get') # also accepted
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Passing any other value raises a `Safire::Errors::ConfigurationError`:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
client.authorization_url(method: :put)
|
|
161
|
+
# => Safire::Errors::ConfigurationError:
|
|
162
|
+
# Invalid authorization method: :put. Supported methods are :get and :post
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Authorization Parameters
|
|
168
|
+
|
|
169
|
+
The parameters included in `:params` (POST) are identical to those embedded in the query string for GET:
|
|
170
|
+
|
|
171
|
+
| Parameter | Description |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `response_type` | `"code"` — OAuth 2.0 authorization code flow |
|
|
174
|
+
| `client_id` | Your registered client identifier |
|
|
175
|
+
| `redirect_uri` | Callback URL for your application |
|
|
176
|
+
| `scope` | Requested permissions (space-separated) |
|
|
177
|
+
| `state` | CSRF protection token (32 hex chars, randomly generated) |
|
|
178
|
+
| `aud` | FHIR server being accessed |
|
|
179
|
+
| `code_challenge_method` | `"S256"` — PKCE using SHA-256 |
|
|
180
|
+
| `code_challenge` | SHA-256 hash of the code verifier |
|
|
181
|
+
| `launch` | EHR launch token (if provided via `launch:` argument) |
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Next Steps
|
|
186
|
+
|
|
187
|
+
- [Public Client Workflow]({% link smart-on-fhir/public-client/index.md %})
|
|
188
|
+
- [Confidential Symmetric Client Workflow]({% link smart-on-fhir/confidential-symmetric/index.md %})
|
|
189
|
+
- [Confidential Asymmetric Client Workflow]({% link smart-on-fhir/confidential-asymmetric/index.md %})
|
|
190
|
+
- [SMART Discovery Details]({% link smart-on-fhir/discovery/index.md %})
|