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,99 @@
1
+ ---
2
+ layout: default
3
+ title: Troubleshooting
4
+ nav_order: 8
5
+ has_children: true
6
+ permalink: /troubleshooting/
7
+ ---
8
+
9
+ # Troubleshooting
10
+
11
+ {: .no_toc }
12
+
13
+ <div class="code-example" markdown="1">
14
+ Common issues and solutions when integrating SMART on FHIR with Safire.
15
+ </div>
16
+
17
+ ## Table of contents
18
+ {: .no_toc .text-delta }
19
+
20
+ 1. TOC
21
+ {:toc}
22
+
23
+ ---
24
+
25
+ ## Error Types
26
+
27
+ Safire raises typed errors so you can handle each failure category separately:
28
+
29
+ | Error class | When raised |
30
+ |-------------|-------------|
31
+ | `Safire::Errors::ConfigurationError` | Missing or invalid client configuration — caught at construction time |
32
+ | `Safire::Errors::DiscoveryError` | SMART metadata fetch failed (HTTP error, invalid JSON) |
33
+ | `Safire::Errors::TokenError` | Token exchange or refresh failed (OAuth error, missing fields) |
34
+ | `Safire::Errors::NetworkError` | Transport-level failure (connection refused, timeout, blocked redirect) |
35
+
36
+ All Safire errors inherit from `Safire::Errors::Error`, so you can catch everything with a single rescue if needed.
37
+
38
+ ```ruby
39
+ begin
40
+ tokens = client.request_access_token(code: code, code_verifier: verifier)
41
+ rescue Safire::Errors::ConfigurationError => e
42
+ # Client misconfiguration — fix before retrying
43
+ Rails.logger.error("Configuration error: #{e.message}")
44
+ render plain: 'Server configuration error', status: :internal_server_error
45
+ rescue Safire::Errors::TokenError => e
46
+ # OAuth error — e.status, e.error_code, e.error_description are all available
47
+ Rails.logger.error("Token error: #{e.message}")
48
+ redirect_to launch_path, alert: 'Authorization failed. Please try again.'
49
+ rescue Safire::Errors::NetworkError => e
50
+ Rails.logger.error("Network error: #{e.message}")
51
+ render plain: 'Server temporarily unavailable', status: :service_unavailable
52
+ end
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Debugging
58
+
59
+ ### Enable detailed logging
60
+
61
+ ```ruby
62
+ Safire.configure do |config|
63
+ config.logger = Rails.logger
64
+ config.log_level = Logger::DEBUG
65
+ end
66
+ ```
67
+
68
+ HTTP request logging is on by default. Sensitive headers (`Authorization`) are always filtered. Request and response bodies are never logged.
69
+
70
+ ```
71
+ INFO: request: POST https://fhir.example.com/token
72
+ INFO: request: Authorization: [FILTERED]
73
+ INFO: response: Status 200
74
+ ```
75
+
76
+ To disable HTTP logging:
77
+
78
+ ```ruby
79
+ Safire.configure { |c| c.log_http = false }
80
+ ```
81
+
82
+ ### Test against the SMART reference server
83
+
84
+ ```ruby
85
+ # .env.development
86
+ FHIR_BASE_URL=https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir
87
+ ```
88
+
89
+ Visit [launch.smarthealthit.org](https://launch.smarthealthit.org) to configure simulated patients and launch contexts.
90
+
91
+ ---
92
+
93
+ ## Getting Help
94
+
95
+ - **Check the logs first** — Safire logs one line per error with all relevant context
96
+ - **Test endpoints manually** — `curl https://fhir.example.com/.well-known/smart-configuration`
97
+ - **Open an issue** — [github.com/vanessuniq/safire/issues](https://github.com/vanessuniq/safire/issues)
98
+
99
+ When reporting an issue, include: Safire version (`Safire::VERSION`), Ruby version, the error message and backtrace, and the server type if known. Never include credentials or tokens.
@@ -0,0 +1,99 @@
1
+ ---
2
+ layout: default
3
+ title: Token and PKCE Errors
4
+ parent: Troubleshooting
5
+ nav_order: 2
6
+ ---
7
+
8
+ # Token and PKCE Errors
9
+
10
+ {: .no_toc }
11
+
12
+ ## Table of contents
13
+ {: .no_toc .text-delta }
14
+
15
+ 1. TOC
16
+ {:toc}
17
+
18
+ ---
19
+
20
+ ## Token Exchange Errors
21
+
22
+ ### `TokenError`: Token request failed
23
+
24
+ ```
25
+ Safire::Errors::TokenError: Token request failed — HTTP 400 — invalid_grant — Authorization code has expired
26
+ ```
27
+
28
+ Common OAuth error codes and their meaning:
29
+
30
+ | `error_code` | Cause | Action |
31
+ |--------------|-------|--------|
32
+ | `invalid_grant` | Code expired or already used | Codes are single-use; the user must re-authorize |
33
+ | `invalid_client` | Client ID or credentials not recognized | Verify registration and credentials |
34
+ | `invalid_request` | Missing required parameter | Check `redirect_uri` matches exactly what was registered |
35
+ | `unauthorized_client` | Client not authorized for this grant type | Verify server-side client configuration |
36
+
37
+ The `redirect_uri` in the token request must exactly match the one used in the authorization request and the one registered with the server — including trailing slashes.
38
+
39
+ ### `TokenError`: Missing access token
40
+
41
+ ```
42
+ Safire::Errors::TokenError: Missing access token in response; received fields: token_type, expires_in
43
+ ```
44
+
45
+ The server returned a 200 response without an `access_token`. This usually means the server returned an OAuth error body with a 200 status (non-standard behaviour). Inspect the received field names in the error message to diagnose what the server actually returned.
46
+
47
+ ---
48
+
49
+ ## Refresh Token Errors
50
+
51
+ ### `TokenError`: Refresh token invalid or expired
52
+
53
+ ```
54
+ Safire::Errors::TokenError: Token request failed — HTTP 400 — invalid_grant — Refresh token expired
55
+ ```
56
+
57
+ Refresh tokens expire, get revoked, or may be single-use on some servers. When a refresh fails with `invalid_grant`, re-authenticate rather than retrying:
58
+
59
+ ```ruby
60
+ def refresh_access_token
61
+ new_tokens = client.refresh_token(refresh_token: session[:refresh_token])
62
+ session[:access_token] = new_tokens['access_token']
63
+ session[:refresh_token] = new_tokens['refresh_token'] if new_tokens['refresh_token']
64
+ rescue Safire::Errors::TokenError => e
65
+ raise unless e.error_code == 'invalid_grant'
66
+
67
+ clear_session
68
+ redirect_to launch_path, alert: 'Session expired. Please sign in again.'
69
+ end
70
+ ```
71
+
72
+ Some servers issue a new refresh token on each refresh (rotating tokens). Always update both `access_token` and `refresh_token` from the response.
73
+
74
+ ---
75
+
76
+ ## PKCE Errors
77
+
78
+ ### Invalid `code_challenge` at the server
79
+
80
+ **Symptom:** authorization fails at the server with a PKCE-related error.
81
+
82
+ **Cause:** the `code_verifier` used in the token exchange does not match the `code_challenge` sent in the authorization request. This almost always means the verifier was regenerated rather than stored and retrieved.
83
+
84
+ Store the verifier from `authorization_url` and use it unchanged in the token exchange:
85
+
86
+ ```ruby
87
+ # On launch — store the verifier
88
+ auth_data = client.authorization_url
89
+ session[:code_verifier] = auth_data[:code_verifier]
90
+
91
+ # On callback — use exactly what was stored
92
+ tokens = client.request_access_token(
93
+ code: params[:code],
94
+ code_verifier: session[:code_verifier]
95
+ )
96
+ session.delete(:code_verifier) # discard after use
97
+ ```
98
+
99
+ Never call `Safire::PKCE.generate_code_verifier` in the callback — a new verifier will not match the challenge already sent to the server.
data/docs/udap.md ADDED
@@ -0,0 +1,78 @@
1
+ ---
2
+ layout: default
3
+ title: UDAP
4
+ nav_order: 5
5
+ ---
6
+
7
+ # UDAP
8
+
9
+ {: .no_toc }
10
+
11
+ <div class="code-example" markdown="1">
12
+ **Status:** Planned — see [ROADMAP.md](https://github.com/vanessuniq/safire/blob/main/ROADMAP.md) for timeline and progress.
13
+ </div>
14
+
15
+ ## Table of contents
16
+ {: .no_toc .text-delta }
17
+
18
+ 1. TOC
19
+ {:toc}
20
+
21
+ ---
22
+
23
+ ## Overview
24
+
25
+ UDAP (Unified Data Access Profiles) is a security framework for healthcare data exchange defined by the [UDAP Security Implementation Guide](https://hl7.org/fhir/us/udap-security/). It extends standard OAuth 2.0 with X.509 certificate-based identity, dynamic client registration, and trust community models — designed primarily for backend system-to-system integration and cross-organizational data access.
26
+
27
+ UDAP is a separate protocol from SMART on FHIR. In Safire, it is selected via `protocol: :udap` rather than a `client_type:`. Watch the [GitHub repository](https://github.com/vanessuniq/safire) for release announcements.
28
+
29
+ ---
30
+
31
+ ## Planned Features
32
+
33
+ ### Discovery
34
+
35
+ - **UDAP Discovery** (`/.well-known/udap`) — fetch server metadata and trust anchors
36
+
37
+ ### Client Flows
38
+
39
+ - **Dynamic Client Registration (DCR)** — one-time registration using a signed software statement to obtain a `client_id`; required only when the client has not previously registered with the server and if the server supports DCR
40
+ - **JWT Client Authentication** — authenticate on every request using a signed JWT assertion (Authentication Token, AnT) with an X.509 certificate chain in the `x5c` header; the registered `client_id` is reused as `iss` and `sub` in each assertion
41
+ - **Tiered OAuth** — delegated authorization for multi-system access per the UDAP Security IG
42
+ - **Pushed Authorization Requests (RFC 9126)** — PAR support for pre-registering authorization requests
43
+
44
+ ### Trust Framework
45
+
46
+ - **Certificate Validation** — verify server and client certificates against trust anchors
47
+ - **Trust Community Support** — integration with UDAP trust communities (e.g. Carequality, CommonWell)
48
+
49
+ ---
50
+
51
+ ## When to Use UDAP
52
+
53
+ | Scenario | Why UDAP |
54
+ |----------|----------|
55
+ | **Backend / B2B Integration** | Server-to-server flows without user interaction; certificate-based identity replaces pre-shared secrets |
56
+ | **Dynamic Client Registration** | Clients can register programmatically without manual server-side approval |
57
+ | **Cross-Organization Access** | Trust communities allow clients to be recognized across participant organizations without per-server registration |
58
+ | **High-Assurance Identity** | X.509 certificates provide stronger identity guarantees than client secrets |
59
+
60
+ ---
61
+
62
+ ## Comparison with SMART on FHIR
63
+
64
+ | Feature | SMART on FHIR | UDAP |
65
+ |---------|---------------|------|
66
+ | Primary use case | User-facing apps, EHR launch | B2B, backend services, cross-org access |
67
+ | Client registration | Pre-registered per server, optional DCR (recommended) | Dynamic (DCR) or pre-registered |
68
+ | Authentication | Client secrets or `private_key_jwt` | Signed JWT assertions (AnT) with X.509 `x5c` chain |
69
+ | Trust model | Per-server registration | Certificate-based trust communities |
70
+ | Safire selection | `client_type: :public / :confidential_symmetric / :confidential_asymmetric` | `protocol: :udap` (planned) |
71
+
72
+ ### Resources
73
+
74
+ - [UDAP Security IG](https://hl7.org/fhir/us/udap-security/) — HL7 Implementation Guide
75
+ - [UDAP JWT Client Auth](https://www.udap.org/udap-jwt-client-auth.html) — JWT assertion specification
76
+ - [UDAP Dynamic Client Registration](https://www.udap.org/udap-dynamic-client-registration.html) — DCR specification
77
+ - [RFC 9126 — Pushed Authorization Requests](https://datatracker.ietf.org/doc/html/rfc9126)
78
+ - [UDAP Tiered OAuth](https://hl7.org/fhir/us/udap-security/b2b.html) — Delegated authorization
@@ -0,0 +1,195 @@
1
+ module Safire
2
+ # Unified facade client for SMART on FHIR and (future) UDAP authorization flows.
3
+ #
4
+ # This class is the main entry point for integrating SMART on FHIR authorization via Safire.
5
+ # It supports discovery of server metadata and provides a unified interface for building
6
+ # authorization URLs, exchanging authorization codes, and refreshing tokens.
7
+ #
8
+ # Configuration is provided via {Safire::ClientConfig} or a Hash. At minimum:
9
+ #
10
+ # * :base_url [String] FHIR base URL used for SMART discovery
11
+ # * :client_id [String] OAuth2 client identifier
12
+ # * :redirect_uri [String] redirect URI registered with the authorization server
13
+ # * :scopes [Array<String>] default scopes requested during authorization
14
+ # * :client_secret [String, optional] required for confidential_symmetric clients
15
+ # * :private_key [OpenSSL::PKey, String, optional] private key for confidential_asymmetric clients
16
+ # * :kid [String, optional] key ID matching the registered public key for asymmetric clients
17
+ # * :jwt_algorithm [String, optional] JWT signing algorithm (RS384 or ES384). Auto-detected if not provided
18
+ # * :jwks_uri [String, optional] URL to client's JWKS for jku header in JWT assertions
19
+ #
20
+ # The +protocol:+ keyword selects the authorization protocol:
21
+ #
22
+ # * :smart (default) — SMART App Launch 2.2.0
23
+ # * :udap — UDAP Security (future; not yet implemented)
24
+ #
25
+ # The +client_type:+ keyword controls how the SMART client authenticates at the token endpoint:
26
+ #
27
+ # * :public (default) — no client authentication; client_id sent in request body
28
+ # * :confidential_symmetric — HTTP Basic auth using client_secret
29
+ # * :confidential_asymmetric — private_key_jwt assertion (JWT signed with private key)
30
+ #
31
+ # client_type is validated for :smart and ignored for :udap. UDAP clients authenticate via
32
+ # signed JWT assertions (Authentication Token / AnT) with an X.509 certificate chain in the
33
+ # x5c JOSE header; the authentication method is not user-configurable for UDAP. DCR is
34
+ # typically performed once to obtain a client_id, which is then reused as iss/sub in every
35
+ # subsequent AnT. The unregistered client flow (§8.1) allows client_credentials grant without
36
+ # prior DCR when identity can be fully determined from certificate attributes alone.
37
+ #
38
+ # @note Future kwargs (not yet implemented):
39
+ #
40
+ # flow: [Symbol] the authorization flow for this client.
41
+ # SMART values:
42
+ # nil / absent — :app_launch (default): SMART App Launch, authorization_code grant
43
+ # :backend_services — SMART Backend Services, client_credentials grant;
44
+ # private_key_jwt is implied; client_type validation is skipped
45
+ # UDAP values (protocol: :udap):
46
+ # :b2b — client_credentials grant, server-to-server
47
+ # :b2c — authorization_code grant, user-facing
48
+ # :tiered_oauth — authorization_code + IdP identity delegation
49
+ #
50
+ # Contract methods will be extended per flow in a future PR.
51
+ # When protocol: :udap is fully implemented, client_type: will default to nil
52
+ # (not applicable) and the flow: kwarg will drive B2B vs B2C selection.
53
+ #
54
+ # @!attribute [r] config
55
+ # @return [Safire::ClientConfig] the resolved client configuration
56
+ #
57
+ # @!attribute [r] protocol
58
+ # @return [Symbol] the selected protocol (:smart or :udap)
59
+ #
60
+ # @!attribute [r] client_type
61
+ # @return [Symbol] the client authentication method
62
+ # (:public, :confidential_symmetric, or :confidential_asymmetric)
63
+ #
64
+ # @see Safire::ClientConfig
65
+ # @see Safire::Protocols::Smart
66
+ # @see Safire::Protocols::Behaviours
67
+ #
68
+ # @example Step 0 – Initialize configuration
69
+ # config = Safire::ClientConfig.new(
70
+ # base_url: 'https://fhir.example.com',
71
+ # client_id: 'my_client_id',
72
+ # redirect_uri: 'https://myapp.example.com/callback',
73
+ # scopes: ['openid', 'profile', 'patient/*.read']
74
+ # )
75
+ #
76
+ # @example Step 1 – /launch route (authorization request)
77
+ # client = Safire::Client.new(config) # defaults to protocol: :smart, client_type: :public
78
+ # auth_data = client.authorization_url
79
+ #
80
+ # session[:state] = auth_data[:state]
81
+ # session[:code_verifier] = auth_data[:code_verifier]
82
+ #
83
+ # redirect_to auth_data[:auth_url]
84
+ #
85
+ # @example Step 2 – /callback route (token exchange)
86
+ # return head :unauthorized unless params[:state] == session[:state]
87
+ #
88
+ # client = Safire::Client.new(config)
89
+ # token_data = client.request_access_token(
90
+ # code: params[:code],
91
+ # code_verifier: session[:code_verifier]
92
+ # )
93
+ #
94
+ # @example Step 3 – Refreshing an access token
95
+ # client = Safire::Client.new(config)
96
+ # new_tokens = client.refresh_token(refresh_token: stored_refresh_token)
97
+ class Client
98
+ extend Forwardable
99
+
100
+ VALID_PROTOCOLS = %i[smart udap].freeze
101
+
102
+ PROTOCOL_CLASSES = {
103
+ smart: Protocols::Smart
104
+ # udap: Protocols::Udap # future
105
+ }.freeze
106
+
107
+ # Valid client_type values per protocol.
108
+ # nil means the protocol does not use client_type (e.g. UDAP authenticates via signed
109
+ # JWT assertions with an X.509 certificate chain; the authentication method is not
110
+ # user-configurable for UDAP).
111
+ PROTOCOL_CLIENT_TYPES = {
112
+ smart: %i[public confidential_symmetric confidential_asymmetric],
113
+ udap: nil # UDAP authenticates via signed JWT assertions (AnT) with X.509 certificate chain
114
+ }.freeze
115
+
116
+ def_delegators :protocol_client,
117
+ :server_metadata, :authorization_url,
118
+ :request_access_token, :refresh_token,
119
+ :token_response_valid?, :register_client
120
+
121
+ attr_reader :config, :protocol, :client_type
122
+
123
+ def initialize(config, protocol: :smart, client_type: :public)
124
+ @protocol = protocol.to_sym
125
+ @client_type = client_type.to_sym
126
+ @config = build_config(config)
127
+
128
+ validate_protocol!
129
+ validate_client_type!
130
+ end
131
+
132
+ # Changes the client type for this client.
133
+ #
134
+ # Updates the underlying protocol client in place — server metadata already
135
+ # fetched is preserved and no re-discovery occurs.
136
+ #
137
+ # @param new_client_type [Symbol, String] the new client type
138
+ # @return [Symbol] the new client type
139
+ # @raise [Safire::Errors::ConfigurationError] if the client type is not valid for this protocol
140
+ #
141
+ # @example Discover then switch client type
142
+ # client = Safire::Client.new(config) # defaults to :public
143
+ # metadata = client.server_metadata
144
+ #
145
+ # if metadata.supports_symmetric_auth?
146
+ # client.client_type = :confidential_symmetric
147
+ # end
148
+ def client_type=(new_client_type)
149
+ if PROTOCOL_CLIENT_TYPES[@protocol].nil?
150
+ Safire.logger.warn(
151
+ "client_type is not configurable for protocol: :#{@protocol}; " \
152
+ 'UDAP clients authenticate via signed JWT assertions — ignoring'
153
+ )
154
+ return
155
+ end
156
+
157
+ @client_type = new_client_type.to_sym
158
+ validate_client_type!
159
+ @protocol_client&.client_type = @client_type
160
+ end
161
+
162
+ private
163
+
164
+ def protocol_client
165
+ @protocol_client ||= PROTOCOL_CLASSES.fetch(@protocol).new(config, client_type:)
166
+ end
167
+
168
+ def build_config(config)
169
+ return config if config.is_a?(Safire::ClientConfig)
170
+
171
+ Safire::ClientConfig.new(config)
172
+ end
173
+
174
+ def validate_protocol!
175
+ return if VALID_PROTOCOLS.include?(@protocol)
176
+
177
+ raise Errors::ConfigurationError.new(
178
+ invalid_attribute: :protocol,
179
+ invalid_value: @protocol,
180
+ valid_values: VALID_PROTOCOLS
181
+ )
182
+ end
183
+
184
+ def validate_client_type!
185
+ valid_types = PROTOCOL_CLIENT_TYPES[@protocol]
186
+ return if valid_types.nil? || valid_types.include?(@client_type)
187
+
188
+ raise Errors::ConfigurationError.new(
189
+ invalid_attribute: :client_type,
190
+ invalid_value: @client_type,
191
+ valid_values: valid_types
192
+ )
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,169 @@
1
+ module Safire
2
+ # Client configuration entity providing necessary attributes to perform different
3
+ # auth flows such as SMART on FHIR puclic, confidential symmetric, confidential asymmetric
4
+ # clients, and backend services.
5
+ # The ClientConfig instance is passed to Safire::Client upon initialization.
6
+ #
7
+ # @!attribute [r] base_url
8
+ # @return [String] the base URL of the FHIR service
9
+ # @!attribute [r] issuer
10
+ # @return [String] the URL of the FHIR service from which the app wishes to retrieve FHIR data.
11
+ # Optionally provided. Will default to `base_url` if not provided.
12
+ # @!attribute [r] client_id
13
+ # @return [String] the client identifier issued to the app by the authorization server
14
+ # @!attribute [r] redirect_uri
15
+ # @return [String] the redirect URI registered by the app with the authorization server
16
+ # @!attribute [r] scopes
17
+ # @return [Array<String>] list of OAuth2 scopes describing the app's desired access.
18
+ # Optionally provided.
19
+ # @!attribute [r] authorization_endpoint
20
+ # @return [String] URL of the server’s OAuth2 Authorization Endpoint.
21
+ # => Optional, will be retrieved from the well-known smart-configuration if not provided
22
+ # @!attribute [r] token_endpoint
23
+ # @return [String] URL of the server's OAuth2 Token Endpoint.
24
+ # => Optional, will be retrieved from the well-known smart-configuration if not provided
25
+ # @!attribute [r] private_key
26
+ # @return [OpenSSL::PKey::RSA, OpenSSL::PKey::EC, String, nil] the private key for signing
27
+ # JWT assertions in confidential asymmetric auth. Can be an OpenSSL key object or PEM string.
28
+ # @!attribute [r] kid
29
+ # @return [String, nil] the key ID matching the public key registered with the authorization server.
30
+ # Required for confidential asymmetric authentication.
31
+ # @!attribute [r] jwt_algorithm
32
+ # @return [String, nil] the JWT signing algorithm (RS384 or ES384).
33
+ # Optional, auto-detected from key type if not provided.
34
+ # @!attribute [r] jwks_uri
35
+ # @return [String, nil] URL to the client's JWKS containing the public key.
36
+ # Optional, included as jku header in JWT assertions when provided.
37
+ #
38
+ # @example Initializing a ClientConfig
39
+ # config = Safire::ClientConfig.new(
40
+ # base_url: 'https://fhir.example.com',
41
+ # client_id: 'my_client_id',
42
+ # redirect_uri: 'https://myapp.example.com/callback',
43
+ # scopes: ['openid', 'profile', 'patient/*.read']
44
+ # )
45
+ # client = Safire::Client.new(config)
46
+ #
47
+ # @example Initializing a ClientConfig using the Builder
48
+ # config = Safire::ClientConfig.builder
49
+ # .base_url('https://fhir.example.com')
50
+ # .client_id('my_client_id')
51
+ # .redirect_uri('https://myapp.example.com/callback')
52
+ # .scopes(['openid', 'profile', 'patient/*.read'])
53
+ # .build
54
+ # client = Safire::Client.new(config)
55
+ #
56
+ # @see Safire::ClientConfigBuilder
57
+ class ClientConfig < Entity
58
+ ATTRIBUTES = %i[
59
+ base_url issuer client_id client_secret redirect_uri
60
+ scopes authorization_endpoint token_endpoint
61
+ private_key kid jwt_algorithm jwks_uri
62
+ ].freeze
63
+
64
+ attr_reader(*ATTRIBUTES)
65
+
66
+ def initialize(config)
67
+ super(config, ATTRIBUTES)
68
+
69
+ @issuer ||= base_url
70
+ validate!
71
+ end
72
+
73
+ class << self
74
+ def builder
75
+ ClientConfigBuilder.new
76
+ end
77
+ end
78
+
79
+ SENSITIVE_ATTRIBUTES = %i[client_secret private_key].freeze
80
+ URI_ATTRS = %i[base_url redirect_uri issuer authorization_endpoint token_endpoint jwks_uri].freeze
81
+ OPTIONAL_URI_ATTRS = %i[authorization_endpoint token_endpoint jwks_uri].freeze
82
+ private_constant :SENSITIVE_ATTRIBUTES, :URI_ATTRS, :OPTIONAL_URI_ATTRS
83
+
84
+ # @api private
85
+ def inspect
86
+ attrs = ATTRIBUTES.map do |attr|
87
+ value = send(attr)
88
+ next if value.nil?
89
+
90
+ masked = SENSITIVE_ATTRIBUTES.include?(attr) ? '[FILTERED]' : value.inspect
91
+ "#{attr}: #{masked}"
92
+ end.compact.join(', ')
93
+ "#<#{self.class} #{attrs}>"
94
+ end
95
+
96
+ protected
97
+
98
+ # @return [Array<Symbol>] attributes masked as '[FILTERED]' in #to_hash
99
+ def sensitive_attributes
100
+ SENSITIVE_ATTRIBUTES
101
+ end
102
+
103
+ private
104
+
105
+ # Validates all URI attributes for structure and HTTPS requirement.
106
+ #
107
+ # Per SMART App Launch 2.2.0 (§App Protection, §Confidential Asymmetric),
108
+ # all exchanges involving sensitive data SHALL use TLS. All endpoint URIs
109
+ # must therefore use the `https` scheme.
110
+ #
111
+ # Exception: `http` is permitted when the host is `localhost` or `127.0.0.1`
112
+ # to support local development without a TLS termination proxy.
113
+ #
114
+ # @raise [Errors::ConfigurationError] if any URI is malformed or uses HTTP on a non-localhost host
115
+ def validate_uris!
116
+ invalid_uris, non_https_uris = collect_uri_violations
117
+ return if invalid_uris.empty? && non_https_uris.empty?
118
+
119
+ raise Errors::ConfigurationError.new(
120
+ invalid_uri_attributes: invalid_uris,
121
+ non_https_uri_attributes: non_https_uris
122
+ )
123
+ end
124
+
125
+ def collect_uri_violations
126
+ invalid_uris = []
127
+ non_https_uris = []
128
+
129
+ URI_ATTRS.each do |attr|
130
+ value = send(attr)
131
+ next if value.nil? && OPTIONAL_URI_ATTRS.include?(attr)
132
+
133
+ case classify_uri(value)
134
+ when :invalid then invalid_uris << attr
135
+ when :non_https then non_https_uris << attr
136
+ end
137
+ end
138
+
139
+ [invalid_uris, non_https_uris]
140
+ end
141
+
142
+ def classify_uri(value)
143
+ uri = Addressable::URI.parse(value)
144
+ return :invalid unless uri.scheme && uri.host
145
+
146
+ :non_https if uri.scheme != 'https' && !localhost_host?(uri.host)
147
+ rescue Addressable::URI::InvalidURIError
148
+ :invalid
149
+ end
150
+
151
+ # Returns true when the host is a local loopback address.
152
+ # HTTP is permitted for localhost to support development environments.
153
+ def localhost_host?(host)
154
+ %w[localhost 127.0.0.1].include?(host)
155
+ end
156
+
157
+ def validate!
158
+ required_attrs = %i[base_url client_id redirect_uri]
159
+ nil_vars = required_attrs.select { |attr| send(attr).nil? }
160
+
161
+ if nil_vars.empty?
162
+ validate_uris!
163
+ return
164
+ end
165
+
166
+ raise Errors::ConfigurationError.new(missing_attributes: nil_vars)
167
+ end
168
+ end
169
+ end