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,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: "ADR-005: Per-client HTTPClient ownership — no shared connection pool"
|
|
4
|
+
parent: Architecture Decision Records
|
|
5
|
+
nav_order: 5
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ADR-005: Per-client `HTTPClient` ownership — no shared connection pool
|
|
9
|
+
|
|
10
|
+
**Status:** Accepted
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Context
|
|
15
|
+
|
|
16
|
+
Safire needs to make HTTP requests for SMART discovery and token operations. The question is: at what scope should the HTTP client live?
|
|
17
|
+
|
|
18
|
+
**Option A — Module-level singleton:** one `HTTPClient` shared across all `Safire::Client` instances.
|
|
19
|
+
|
|
20
|
+
**Option B — Per-`Client` ownership:** each `Safire::Client` constructs and owns its `Protocols::Smart` instance, which in turn owns its own `HTTPClient`.
|
|
21
|
+
|
|
22
|
+
A shared HTTP client creates several problems:
|
|
23
|
+
|
|
24
|
+
1. **Thread safety:** Faraday connection objects are not documented as thread-safe. A shared connection used concurrently by multiple clients in a web application could produce race conditions in connection state.
|
|
25
|
+
|
|
26
|
+
2. **Configuration isolation:** if different clients need different SSL configurations, timeouts, or user-agent strings, a shared client cannot serve them all without complex multiplexing logic.
|
|
27
|
+
|
|
28
|
+
3. **Discovery cache isolation:** SMART discovery metadata is cached inside `Protocols::Smart`. If two clients point at different FHIR servers, their metadata must not bleed across — and the HTTP client that fetched the metadata is tightly coupled to the `Smart` instance that owns the cache. Sharing the HTTP client would require separating it from the cache, which defeats the clean ownership model.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Decision
|
|
33
|
+
|
|
34
|
+
Each `Protocols::Smart` instance creates and owns its own `Safire::HTTPClient`:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
def initialize(config, client_type: :public)
|
|
38
|
+
# ...
|
|
39
|
+
@http_client = Safire::HTTPClient.new
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The `HTTPClient` is not shared, not exposed publicly, and not accessible from `Safire::Client`. Its lifetime is tied to the `Protocols::Smart` instance, which is itself tied to a single `Safire::Client`.
|
|
44
|
+
|
|
45
|
+
For callers managing multiple FHIR servers, the recommended pattern is a per-server client registry (see [Advanced Examples]({{ site.baseurl }}/advanced/#multi-server-management)).
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Consequences
|
|
50
|
+
|
|
51
|
+
**Benefits:**
|
|
52
|
+
- Each client is fully isolated — different SSL configs, timeouts, or FHIR servers do not interact
|
|
53
|
+
- Thread-safe by design — no shared mutable state in the HTTP layer across clients
|
|
54
|
+
- Discovery cache and HTTP client have the same lifetime and owner — no partial invalidation
|
|
55
|
+
|
|
56
|
+
**Trade-offs:**
|
|
57
|
+
- No connection pooling across clients — applications with many client instances make independent TCP connections per client; for most healthcare FHIR use cases (one or a few servers) this is not a significant concern
|
|
58
|
+
- Each `Safire::Client.new` allocates a new Faraday connection object, even before any network call; this is a minor allocation cost mitigated by lazy protocol client construction (see [ADR-006]({% link adr/ADR-006-lazy-discovery.md %}))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: "ADR-006: Lazy SMART discovery — no HTTP in constructors"
|
|
4
|
+
parent: Architecture Decision Records
|
|
5
|
+
nav_order: 6
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ADR-006: Lazy SMART discovery — no HTTP in constructors
|
|
9
|
+
|
|
10
|
+
**Status:** Accepted
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Context
|
|
15
|
+
|
|
16
|
+
SMART on FHIR clients need the authorization server's endpoints (`authorization_endpoint`, `token_endpoint`) to build authorization URLs and request tokens. These are obtained by fetching `/.well-known/smart-configuration`. There are two approaches:
|
|
17
|
+
|
|
18
|
+
**Option A — eager discovery:** fetch metadata in `Smart#initialize`.
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
def initialize(config, client_type: :public)
|
|
22
|
+
# ...
|
|
23
|
+
@server_metadata = fetch_metadata # HTTP call here
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Option B — lazy discovery:** defer the fetch until a method actually needs an endpoint.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
def server_metadata
|
|
31
|
+
@server_metadata ||= fetch_metadata # HTTP call deferred
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def authorization_endpoint
|
|
35
|
+
@authorization_endpoint ||= server_metadata.authorization_endpoint
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Eager discovery has a significant problem: it makes `Safire::Client.new` a network operation. Construction can fail with a network error, configuration validation occurs after a potentially slow HTTP round-trip, and there is no way to instantiate a client to inspect its configuration without triggering discovery. It also makes testing harder — every `Client.new` call requires a stub.
|
|
40
|
+
|
|
41
|
+
A second concern is `client_type=` mutation. After discovery, a caller may want to switch client type based on what the server supports:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
client = Safire::Client.new(config)
|
|
45
|
+
metadata = client.server_metadata
|
|
46
|
+
|
|
47
|
+
client.client_type = :confidential_symmetric if metadata.supports_symmetric_auth?
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
With eager discovery, changing `client_type` must not trigger re-discovery — the metadata is already fetched. This means decoupling the discovery result from construction is necessary regardless.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Decision
|
|
55
|
+
|
|
56
|
+
Discovery is lazy and memoised at the `Protocols::Smart` instance level:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
def server_metadata
|
|
60
|
+
return @server_metadata if @server_metadata
|
|
61
|
+
|
|
62
|
+
response = @http_client.get(well_known_endpoint)
|
|
63
|
+
@server_metadata = SmartMetadata.new(parse_discovery_body(response.body))
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`authorization_endpoint` and `token_endpoint` are also lazy — they fall back to `server_metadata` only when not manually configured in `ClientConfig`, avoiding a discovery call for clients with pre-known endpoints.
|
|
68
|
+
|
|
69
|
+
`Safire::Client` memoises the protocol client itself (`@protocol_client ||= ...`), so changing `client_type=` reuses the existing `Protocols::Smart` instance — and thus its already-fetched `@server_metadata` — rather than constructing a new one. This is the mechanism that prevents double-discovery on `client_type=` changes.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Consequences
|
|
74
|
+
|
|
75
|
+
**Benefits:**
|
|
76
|
+
- `Safire::Client.new` is instantaneous — no network calls, no stubs required at construction time
|
|
77
|
+
- Configuration errors are raised before any HTTP call
|
|
78
|
+
- Callers control when discovery happens — supports application-level caching patterns (see [Advanced Examples]({{ site.baseurl }}/advanced/#metadata-caching))
|
|
79
|
+
- `client_type=` mutation preserves cached metadata — no re-discovery
|
|
80
|
+
|
|
81
|
+
**Trade-offs:**
|
|
82
|
+
- Discovery errors surface at first use (e.g. `authorization_url`), not at construction — callers must handle `Errors::DiscoveryError` in their flow logic rather than at the `new` call site
|
|
83
|
+
- In-process metadata caching is per-instance only — across requests in a web app, callers must implement application-level caching (e.g. `Rails.cache`) to avoid repeated discovery HTTP calls
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: "ADR-007: HTTPS-only redirect enforcement and localhost exception"
|
|
4
|
+
parent: Architecture Decision Records
|
|
5
|
+
nav_order: 7
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ADR-007: HTTPS-only redirect enforcement and localhost exception
|
|
9
|
+
|
|
10
|
+
**Status:** Accepted
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Context
|
|
15
|
+
|
|
16
|
+
SMART App Launch 2.2.0 requires TLS for all exchanges involving sensitive data. However, enforcing HTTPS at the URI validation layer (`ClientConfig`) is not sufficient on its own — an authorization server could respond to a legitimate HTTPS request with a redirect to an HTTP endpoint. Without enforcement at the HTTP layer, Safire would silently follow that redirect, potentially exposing tokens or authorization codes over an unencrypted connection.
|
|
17
|
+
|
|
18
|
+
This is a known attack surface: a compromised or misconfigured server can use open redirects to redirect a client to an attacker-controlled HTTP endpoint.
|
|
19
|
+
|
|
20
|
+
There is also a practical concern: developers running local FHIR servers (e.g. HAPI FHIR, Inferno test environments) use `http://localhost` or `http://127.0.0.1`. Blocking these in a development environment would make Safire unusable without a TLS termination proxy.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Decision
|
|
25
|
+
|
|
26
|
+
HTTPS is enforced at **two layers**, both with the same localhost exception:
|
|
27
|
+
|
|
28
|
+
**Layer 1 — `ClientConfig` URI validation:** all URI attributes (`base_url`, `redirect_uri`, `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`) must use `https://`, except when the host is `localhost` or `127.0.0.1`.
|
|
29
|
+
|
|
30
|
+
**Layer 2 — `HttpsOnlyRedirects` Faraday middleware:** intercepts every 3xx response before `faraday-follow_redirects` follows it. If the redirect target is not HTTPS (and not localhost), a `Safire::Errors::NetworkError` is raised immediately rather than following the redirect.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# middleware/https_only_redirects.rb
|
|
34
|
+
def on_complete(env)
|
|
35
|
+
return unless redirect?(env)
|
|
36
|
+
|
|
37
|
+
location = env.response_headers['location']
|
|
38
|
+
uri = URI.parse(location)
|
|
39
|
+
return if uri.scheme == 'https' || localhost?(uri.host)
|
|
40
|
+
|
|
41
|
+
raise Safire::Errors::NetworkError,
|
|
42
|
+
"Blocked redirect to non-HTTPS URL: #{location}"
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Both layers use the same localhost exception (`localhost` and `127.0.0.1`) and must stay consistent. The middleware raises `NetworkError` (transport layer) rather than `ConfigurationError` (construction time) because redirect enforcement is a runtime concern.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Consequences
|
|
51
|
+
|
|
52
|
+
**Benefits:**
|
|
53
|
+
- Defence-in-depth: HTTPS is enforced at both config time and at every HTTP redirect, closing the redirect-based attack vector
|
|
54
|
+
- Consistent localhost exception across both enforcement points — `http://localhost` works in both URI validation and redirect following
|
|
55
|
+
- Clear error message when a non-HTTPS redirect is blocked, pointing directly at the offending URL
|
|
56
|
+
|
|
57
|
+
**Trade-offs:**
|
|
58
|
+
- The localhost exception must be maintained in two places — `ClientConfig#localhost_host?` and `HttpsOnlyRedirects` — any change to the exception policy must be applied to both; this duplication is intentional (the two layers are independent defences) but must be kept in sync
|
|
59
|
+
- Blocking non-HTTPS redirects can cause unexpected failures if a FHIR server uses HTTP-to-HTTPS redirect chains (e.g. `http://fhir.example.com` → `https://fhir.example.com`); callers should configure `base_url` with the final HTTPS URL directly
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: "ADR-008: Warn and return false for compliance validation — raise only for configuration errors"
|
|
4
|
+
parent: Architecture Decision Records
|
|
5
|
+
nav_order: 8
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# ADR-008: Warn and return false for compliance validation — raise only for configuration errors
|
|
9
|
+
|
|
10
|
+
**Status:** Accepted
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Context
|
|
15
|
+
|
|
16
|
+
Safire performs two different kinds of checks:
|
|
17
|
+
|
|
18
|
+
1. **Configuration checks** — validating that the caller has provided a usable configuration (required attributes present, URIs well-formed and HTTPS). These run at construction time and represent programming errors if they fail.
|
|
19
|
+
|
|
20
|
+
2. **Compliance checks** — validating that a remote server's response conforms to the SMART App Launch 2.2.0 specification. These run at runtime and represent server behaviour, not caller behaviour.
|
|
21
|
+
|
|
22
|
+
The question is: what should compliance checks do when they find a violation?
|
|
23
|
+
|
|
24
|
+
**Option A — Raise an exception:** `token_response_valid?` raises `TokenError`; `SmartMetadata#valid?` raises `DiscoveryError`. The caller must rescue.
|
|
25
|
+
|
|
26
|
+
**Option B — Warn and return false:** log a warning via `Safire.logger` for each violation found, then return `false`. Never raise.
|
|
27
|
+
|
|
28
|
+
Option A treats a non-compliant server as an unrecoverable error. In practice, some production FHIR servers have minor token response non-compliance (e.g. `token_type: "bearer"` in lowercase rather than `"Bearer"`) but are otherwise functional. Raising an exception would prevent Safire from working with those servers entirely, with no way for callers to override the decision.
|
|
29
|
+
|
|
30
|
+
Option B lets the caller decide what to do: they can check the return value, observe the warnings in their logs, and choose to proceed or abort. This is consistent with how Ruby standard library methods (e.g. `URI.parse`, `JSON.parse` with `rescue nil`) handle validation — surface the issue, let the caller decide.
|
|
31
|
+
|
|
32
|
+
There is also a clear boundary: **the caller controls the config** (configuration errors should raise — the caller can fix them); **the server controls the response** (compliance violations should warn — the caller cannot fix a remote server).
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Decision
|
|
37
|
+
|
|
38
|
+
Compliance validation methods use the **warn + return false** pattern:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
def token_response_valid?(response)
|
|
42
|
+
# ...
|
|
43
|
+
Safire.logger.warn("SMART token response non-compliance: token_type is #{...}; expected 'Bearer'")
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def valid? # SmartMetadata
|
|
48
|
+
# ...
|
|
49
|
+
Safire.logger.warn("SMART metadata non-compliance: 'S256' not in code_challenge_methods_supported")
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
These methods:
|
|
55
|
+
- Never raise an exception
|
|
56
|
+
- Log one warning per violation (not a single combined message) so each issue is individually observable
|
|
57
|
+
- Return `true` only when fully compliant; `false` as soon as any violation is found
|
|
58
|
+
- Are **user-callable** — they are not invoked automatically by `authorization_url` or `server_metadata`; callers opt in to compliance checking
|
|
59
|
+
|
|
60
|
+
Configuration validation (`ClientConfig#validate!`, `Smart#validate!`) raises `ConfigurationError` — these are programming errors that must be fixed before the gem can function.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Consequences
|
|
65
|
+
|
|
66
|
+
**Benefits:**
|
|
67
|
+
- Safire can interoperate with non-compliant but functional FHIR servers; callers choose whether to enforce strict compliance
|
|
68
|
+
- Each violation produces a separate, actionable log line rather than a single combined error
|
|
69
|
+
- Callers can implement their own compliance gate: `raise unless client.token_response_valid?(response)`
|
|
70
|
+
- Consistent with the principle of least surprise — a compliance check method that raises on failure is not useful as a boolean check
|
|
71
|
+
|
|
72
|
+
**Trade-offs:**
|
|
73
|
+
- Callers who do not call `token_response_valid?` get no compliance signal at all — non-compliant responses are silently accepted; this is intentional (opt-in, not opt-out)
|
|
74
|
+
- The distinction between "warn + return false" and "raise" must be maintained consistently — new validation methods should follow the same rule: server behaviour → warn; caller configuration → raise
|
data/docs/adr/index.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Architecture Decision Records
|
|
4
|
+
nav_order: 8
|
|
5
|
+
has_children: true
|
|
6
|
+
permalink: /adr/
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Architecture Decision Records
|
|
10
|
+
|
|
11
|
+
Architecture Decision Records (ADRs) document significant design decisions made in Safire — what was decided, why, and what trade-offs were accepted.
|
|
12
|
+
|
|
13
|
+
| ADR | Title | Status |
|
|
14
|
+
|-----|-------|--------|
|
|
15
|
+
| [ADR-001]({% link adr/ADR-001-activesupport-dependency.md %}) | `ActiveSupport` as a runtime dependency | Accepted |
|
|
16
|
+
| [ADR-002]({% link adr/ADR-002-facade-and-forwardable.md %}) | Facade pattern — `Client` delegates to protocol implementations via `Forwardable` | Accepted |
|
|
17
|
+
| [ADR-003]({% link adr/ADR-003-protocol-vs-client-type.md %}) | `protocol:` and `client_type:` as orthogonal dimensions | Accepted |
|
|
18
|
+
| [ADR-004]({% link adr/ADR-004-clientconfig-immutability-and-entity-masking.md %}) | `ClientConfig` immutability and `Entity` sensitive attribute masking | Accepted |
|
|
19
|
+
| [ADR-005]({% link adr/ADR-005-per-client-http-ownership.md %}) | Per-client `HTTPClient` ownership — no shared connection pool | Accepted |
|
|
20
|
+
| [ADR-006]({% link adr/ADR-006-lazy-discovery.md %}) | Lazy SMART discovery — no HTTP in constructors | Accepted |
|
|
21
|
+
| [ADR-007]({% link adr/ADR-007-https-only-redirects-and-localhost-exception.md %}) | HTTPS-only redirect enforcement and localhost exception | Accepted |
|
|
22
|
+
| [ADR-008]({% link adr/ADR-008-warn-return-false-for-compliance-validation.md %}) | Warn and return false for compliance validation — raise only for configuration errors | Accepted |
|
data/docs/advanced.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Advanced Examples
|
|
4
|
+
nav_order: 7
|
|
5
|
+
permalink: /advanced/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Advanced Examples
|
|
9
|
+
|
|
10
|
+
{: .no_toc }
|
|
11
|
+
|
|
12
|
+
<div class="code-example" markdown="1">
|
|
13
|
+
Patterns for caching, multi-server management, token lifecycle, and complete Rails integration.
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
## Table of contents
|
|
17
|
+
{: .no_toc .text-delta }
|
|
18
|
+
|
|
19
|
+
1. TOC
|
|
20
|
+
{:toc}
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Metadata Caching
|
|
25
|
+
|
|
26
|
+
Safire caches SMART metadata within the client instance. In high-traffic applications you may want to share that cache across requests or processes using Rails.cache to avoid repeated HTTP calls to the FHIR server.
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# app/services/smart_metadata_service.rb
|
|
30
|
+
class SmartMetadataService
|
|
31
|
+
CACHE_TTL = 1.hour
|
|
32
|
+
|
|
33
|
+
def self.fetch(base_url)
|
|
34
|
+
Rails.cache.fetch("smart_metadata:#{base_url}", expires_in: CACHE_TTL) do
|
|
35
|
+
config = Safire::ClientConfig.new(
|
|
36
|
+
base_url: base_url,
|
|
37
|
+
client_id: 'discovery_only',
|
|
38
|
+
redirect_uri: 'https://example.com',
|
|
39
|
+
scopes: []
|
|
40
|
+
)
|
|
41
|
+
client = Safire::Client.new(config)
|
|
42
|
+
client.server_metadata.to_hash
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.invalidate(base_url)
|
|
47
|
+
Rails.cache.delete("smart_metadata:#{base_url}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Usage
|
|
54
|
+
metadata = SmartMetadataService.fetch('https://fhir.example.com/r4')
|
|
55
|
+
auth_endpoint = metadata[:authorization_endpoint]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
{: .note }
|
|
59
|
+
> Cache the serialised hash (`to_hash`), not the `SmartMetadata` object itself — the object holds an HTTPClient reference that does not serialise cleanly.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Multi-Server Management
|
|
64
|
+
|
|
65
|
+
Applications that connect to multiple FHIR servers can use a registry to manage one client per server, keeping each client's metadata cache isolated.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# app/services/fhir_server_registry.rb
|
|
69
|
+
class FhirServerRegistry
|
|
70
|
+
def initialize
|
|
71
|
+
@clients = {}
|
|
72
|
+
@mutex = Mutex.new
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def client_for(server_key)
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
@clients[server_key] ||= build_client(server_key)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def invalidate(server_key)
|
|
82
|
+
@mutex.synchronize { @clients.delete(server_key) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
SERVERS = {
|
|
88
|
+
epic: { base_url: ENV['EPIC_BASE_URL'], client_id: ENV['EPIC_CLIENT_ID'] },
|
|
89
|
+
cerner: { base_url: ENV['CERNER_BASE_URL'], client_id: ENV['CERNER_CLIENT_ID'] }
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
def build_client(server_key)
|
|
93
|
+
cfg = SERVERS.fetch(server_key) { raise ArgumentError, "Unknown server: #{server_key}" }
|
|
94
|
+
config = Safire::ClientConfig.new(
|
|
95
|
+
base_url: cfg[:base_url],
|
|
96
|
+
client_id: cfg[:client_id],
|
|
97
|
+
redirect_uri: ENV['REDIRECT_URI'],
|
|
98
|
+
scopes: ['openid', 'profile', 'patient/*.read']
|
|
99
|
+
)
|
|
100
|
+
Safire::Client.new(config)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Shared registry — initialise once at application boot
|
|
105
|
+
FHIR_REGISTRY = FhirServerRegistry.new
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# In a controller
|
|
110
|
+
client = FHIR_REGISTRY.client_for(:epic)
|
|
111
|
+
metadata = client.server_metadata
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Token Management
|
|
117
|
+
|
|
118
|
+
### Proactive Refresh
|
|
119
|
+
|
|
120
|
+
Check token expiry before making API calls rather than waiting for a 401:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# app/services/token_manager.rb
|
|
124
|
+
class TokenManager
|
|
125
|
+
EXPIRY_BUFFER = 5.minutes
|
|
126
|
+
|
|
127
|
+
def self.valid_token(session)
|
|
128
|
+
return refresh_token(session) if expiring_soon?(session)
|
|
129
|
+
|
|
130
|
+
session[:access_token]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.expiring_soon?(session)
|
|
134
|
+
expires_at = session[:token_expires_at]
|
|
135
|
+
return true if expires_at.nil?
|
|
136
|
+
|
|
137
|
+
Time.current >= (expires_at - EXPIRY_BUFFER)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.refresh_token(session)
|
|
141
|
+
client = build_client(session)
|
|
142
|
+
token_params = { refresh_token: session[:refresh_token] }
|
|
143
|
+
response = client.refresh_token(token_params)
|
|
144
|
+
|
|
145
|
+
session[:access_token] = response[:access_token]
|
|
146
|
+
session[:refresh_token] = response[:refresh_token] || session[:refresh_token]
|
|
147
|
+
session[:token_expires_at] = Time.current + response[:expires_in].to_i.seconds
|
|
148
|
+
|
|
149
|
+
response[:access_token]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Retry with Exponential Backoff
|
|
155
|
+
|
|
156
|
+
For transient failures during token exchange:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
def exchange_with_retry(client, params, max_attempts: 3)
|
|
160
|
+
attempts = 0
|
|
161
|
+
|
|
162
|
+
begin
|
|
163
|
+
attempts += 1
|
|
164
|
+
client.exchange_code_for_token(params)
|
|
165
|
+
rescue Safire::Errors::TokenError => e
|
|
166
|
+
raise if attempts >= max_attempts
|
|
167
|
+
raise unless e.message.match?(/timeout|503|429/i)
|
|
168
|
+
|
|
169
|
+
sleep(2**attempts)
|
|
170
|
+
retry
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Custom Scopes Per Request
|
|
176
|
+
|
|
177
|
+
Override the default scopes for specific actions without reconfiguring the client:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
def launch_with_scopes(client, extra_scopes: [])
|
|
181
|
+
base_scopes = ['openid', 'profile', 'patient/*.read']
|
|
182
|
+
merged = (base_scopes + extra_scopes).uniq
|
|
183
|
+
client.authorization_url(scope_override: merged)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Requesting additional write access for a specific workflow
|
|
187
|
+
url = launch_with_scopes(client, extra_scopes: ['patient/*.write', 'user/*.read'])
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Complete Rails Example
|
|
193
|
+
|
|
194
|
+
A single controller covers the full SMART authorization cycle. Only the client setup differs between client types — the controller logic is identical.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# config/routes.rb
|
|
198
|
+
Rails.application.routes.draw do
|
|
199
|
+
get '/auth/launch', to: 'smart_auth#launch'
|
|
200
|
+
get '/auth/callback', to: 'smart_auth#callback'
|
|
201
|
+
post '/auth/logout', to: 'smart_auth#logout'
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# app/controllers/smart_auth_controller.rb
|
|
205
|
+
class SmartAuthController < ApplicationController
|
|
206
|
+
before_action :initialize_client
|
|
207
|
+
|
|
208
|
+
# Step 1 — Redirect user to the authorization server
|
|
209
|
+
def launch
|
|
210
|
+
auth_url = @client.authorization_url
|
|
211
|
+
session[:pkce_verifier] = @client.code_verifier
|
|
212
|
+
session[:state] = @client.state
|
|
213
|
+
|
|
214
|
+
redirect_to auth_url, allow_other_host: true
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Step 2 — Handle the authorization server callback
|
|
218
|
+
def callback
|
|
219
|
+
if params[:error]
|
|
220
|
+
redirect_to root_path, alert: "Authorization failed: #{params[:error_description]}"
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
token_response = @client.exchange_code_for_token(
|
|
225
|
+
code: params[:code],
|
|
226
|
+
state: params[:state],
|
|
227
|
+
pkce_verifier: session.delete(:pkce_verifier)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
session[:access_token] = token_response[:access_token]
|
|
231
|
+
session[:refresh_token] = token_response[:refresh_token]
|
|
232
|
+
session[:token_expires_at] = Time.current + token_response[:expires_in].to_i.seconds
|
|
233
|
+
|
|
234
|
+
redirect_to dashboard_path
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def logout
|
|
238
|
+
reset_session
|
|
239
|
+
redirect_to root_path
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def initialize_client
|
|
245
|
+
config = Safire::ClientConfig.new(
|
|
246
|
+
base_url: ENV['FHIR_BASE_URL'],
|
|
247
|
+
client_id: ENV['SMART_CLIENT_ID'],
|
|
248
|
+
redirect_uri: callback_url,
|
|
249
|
+
scopes: ['openid', 'profile', 'patient/*.read', 'offline_access']
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@client = Safire::Client.new(config) # :public is the default client_type
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Switching Client Types
|
|
258
|
+
|
|
259
|
+
Only `initialize_client` changes. The rest of the controller is untouched.
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
# Confidential Symmetric — add client_secret
|
|
263
|
+
config = Safire::ClientConfig.new(
|
|
264
|
+
base_url: ENV['FHIR_BASE_URL'],
|
|
265
|
+
client_id: ENV['SMART_CLIENT_ID'],
|
|
266
|
+
client_secret: ENV['SMART_CLIENT_SECRET'], # from ENV, credentials, or secrets manager
|
|
267
|
+
redirect_uri: callback_url,
|
|
268
|
+
scopes: ['openid', 'profile', 'patient/*.read', 'offline_access']
|
|
269
|
+
)
|
|
270
|
+
@client = Safire::Client.new(config, client_type: :confidential_symmetric)
|
|
271
|
+
|
|
272
|
+
# Confidential Asymmetric — add private_key and kid
|
|
273
|
+
config = Safire::ClientConfig.new(
|
|
274
|
+
base_url: ENV['FHIR_BASE_URL'],
|
|
275
|
+
client_id: ENV['SMART_CLIENT_ID'],
|
|
276
|
+
private_key: OpenSSL::PKey::RSA.new(File.read(ENV['SMART_PRIVATE_KEY_PATH'])),
|
|
277
|
+
kid: ENV['SMART_KEY_ID'],
|
|
278
|
+
redirect_uri: callback_url,
|
|
279
|
+
scopes: ['openid', 'profile', 'patient/*.read', 'offline_access']
|
|
280
|
+
)
|
|
281
|
+
@client = Safire::Client.new(config, client_type: :confidential_asymmetric)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
See the [Security Guide]({{ site.baseurl }}/security/) for credential loading patterns and key rotation.
|