safire 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +62 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +35 -0
  6. data/CODE_OF_CONDUCT.md +17 -0
  7. data/CONTRIBUTION.md +283 -0
  8. data/Gemfile +26 -0
  9. data/Gemfile.lock +186 -0
  10. data/LICENSE +201 -0
  11. data/README.md +159 -0
  12. data/ROADMAP.md +54 -0
  13. data/Rakefile +26 -0
  14. data/docs/.gitignore +5 -0
  15. data/docs/404.html +25 -0
  16. data/docs/Gemfile +37 -0
  17. data/docs/Gemfile.lock +195 -0
  18. data/docs/_config.yml +103 -0
  19. data/docs/_includes/footer_custom.html +6 -0
  20. data/docs/_includes/head_custom.html +14 -0
  21. data/docs/_sass/custom/custom.scss +108 -0
  22. data/docs/adr/ADR-001-activesupport-dependency.md +50 -0
  23. data/docs/adr/ADR-002-facade-and-forwardable.md +79 -0
  24. data/docs/adr/ADR-003-protocol-vs-client-type.md +67 -0
  25. data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +59 -0
  26. data/docs/adr/ADR-005-per-client-http-ownership.md +58 -0
  27. data/docs/adr/ADR-006-lazy-discovery.md +83 -0
  28. data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +59 -0
  29. data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +74 -0
  30. data/docs/adr/index.md +22 -0
  31. data/docs/advanced.md +284 -0
  32. data/docs/configuration/client-setup.md +158 -0
  33. data/docs/configuration/index.md +60 -0
  34. data/docs/configuration/logging.md +86 -0
  35. data/docs/index.md +64 -0
  36. data/docs/installation.md +96 -0
  37. data/docs/security.md +256 -0
  38. data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +72 -0
  39. data/docs/smart-on-fhir/confidential-asymmetric/index.md +162 -0
  40. data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +250 -0
  41. data/docs/smart-on-fhir/confidential-symmetric/authorization.md +75 -0
  42. data/docs/smart-on-fhir/confidential-symmetric/index.md +69 -0
  43. data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +215 -0
  44. data/docs/smart-on-fhir/discovery/capability-checks.md +142 -0
  45. data/docs/smart-on-fhir/discovery/index.md +96 -0
  46. data/docs/smart-on-fhir/discovery/metadata.md +147 -0
  47. data/docs/smart-on-fhir/index.md +72 -0
  48. data/docs/smart-on-fhir/post-based-authorization.md +190 -0
  49. data/docs/smart-on-fhir/public-client/authorization.md +112 -0
  50. data/docs/smart-on-fhir/public-client/index.md +80 -0
  51. data/docs/smart-on-fhir/public-client/token-exchange.md +249 -0
  52. data/docs/troubleshooting/auth-errors.md +124 -0
  53. data/docs/troubleshooting/client-errors.md +130 -0
  54. data/docs/troubleshooting/index.md +99 -0
  55. data/docs/troubleshooting/token-errors.md +99 -0
  56. data/docs/udap.md +78 -0
  57. data/lib/safire/client.rb +195 -0
  58. data/lib/safire/client_config.rb +169 -0
  59. data/lib/safire/client_config_builder.rb +72 -0
  60. data/lib/safire/entity.rb +26 -0
  61. data/lib/safire/errors.rb +247 -0
  62. data/lib/safire/http_client.rb +87 -0
  63. data/lib/safire/jwt_assertion.rb +237 -0
  64. data/lib/safire/middleware/https_only_redirects.rb +39 -0
  65. data/lib/safire/pkce.rb +39 -0
  66. data/lib/safire/protocols/behaviours.rb +54 -0
  67. data/lib/safire/protocols/smart.rb +378 -0
  68. data/lib/safire/protocols/smart_metadata.rb +231 -0
  69. data/lib/safire/version.rb +4 -0
  70. data/lib/safire.rb +54 -0
  71. data/safire.gemspec +36 -0
  72. metadata +184 -0
@@ -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.