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,158 @@
1
+ ---
2
+ layout: default
3
+ title: Client Setup
4
+ parent: Configuration
5
+ nav_order: 1
6
+ ---
7
+
8
+ # Client Setup
9
+
10
+ {: .no_toc }
11
+
12
+ ## Table of contents
13
+ {: .no_toc .text-delta }
14
+
15
+ 1. TOC
16
+ {:toc}
17
+
18
+ ---
19
+
20
+ ## Creating a Client
21
+
22
+ Pass configuration as a Hash — Safire wraps it in a `ClientConfig` automatically:
23
+
24
+ ```ruby
25
+ client = Safire::Client.new(
26
+ base_url: 'https://fhir.example.com/r4',
27
+ client_id: 'my_client_id',
28
+ redirect_uri: 'https://myapp.com/callback',
29
+ scopes: ['openid', 'profile', 'patient/*.read']
30
+ )
31
+ ```
32
+
33
+ If you need to reuse the same configuration across multiple clients or inspect it before use, create a `ClientConfig` explicitly:
34
+
35
+ ```ruby
36
+ config = Safire::ClientConfig.new(
37
+ base_url: 'https://fhir.example.com/r4',
38
+ client_id: 'my_client_id',
39
+ redirect_uri: 'https://myapp.com/callback',
40
+ scopes: ['openid', 'profile', 'patient/*.read']
41
+ )
42
+
43
+ client = Safire::Client.new(config)
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Protocol and Client Type
49
+
50
+ `protocol:` and `client_type:` are keyword arguments to `Safire::Client.new`. They are independent of each other.
51
+
52
+ ```ruby
53
+ client = Safire::Client.new(config, protocol: :smart, client_type: :confidential_symmetric)
54
+ ```
55
+
56
+ ### Protocol
57
+
58
+ Selects the authorization protocol. Defaults to `:smart`.
59
+
60
+ | Value | Status | Description |
61
+ |-------|--------|-------------|
62
+ | `:smart` | Implemented | SMART App Launch 2.2.0 |
63
+ | `:udap` | Planned | UDAP Security 1.0 — accepted by the validator, raises `NotImplementedError` until implemented |
64
+
65
+ For UDAP, `client_type:` is ignored — UDAP clients always authenticate with a JWT signed by their private key.
66
+
67
+ ### Client Type
68
+
69
+ Selects the SMART authentication method. Applies only when `protocol: :smart`. Defaults to `:public`.
70
+
71
+ | Value | Extra config required | Authentication |
72
+ |-------|-----------------------|----------------|
73
+ | `:public` | None | PKCE; `client_id` in request body |
74
+ | `:confidential_symmetric` | `client_secret` | HTTP Basic auth |
75
+ | `:confidential_asymmetric` | `private_key`, `kid` | JWT assertion (RS384/ES384) |
76
+
77
+ ```ruby
78
+ # Public (default)
79
+ client = Safire::Client.new(config)
80
+
81
+ # Confidential symmetric
82
+ client = Safire::Client.new(
83
+ { **base_config, client_secret: ENV.fetch('SMART_CLIENT_SECRET') },
84
+ client_type: :confidential_symmetric
85
+ )
86
+
87
+ # Confidential asymmetric
88
+ client = Safire::Client.new(
89
+ {
90
+ **base_config,
91
+ private_key: OpenSSL::PKey::RSA.new(File.read(ENV.fetch('SMART_PRIVATE_KEY_PATH'))),
92
+ kid: ENV.fetch('SMART_KEY_ID'),
93
+ jwks_uri: ENV.fetch('SMART_JWKS_URI') # optional
94
+ },
95
+ client_type: :confidential_asymmetric
96
+ )
97
+ ```
98
+
99
+ You can also change `client_type` after initialization — useful when selecting a type based on server capabilities discovered at runtime:
100
+
101
+ ```ruby
102
+ client = Safire::Client.new(config)
103
+ metadata = client.server_metadata
104
+
105
+ client.client_type = :confidential_asymmetric if metadata.supports_asymmetric_auth?
106
+ ```
107
+
108
+ For a decision guide on which client type to use, see [SMART on FHIR — Choosing a Client Type]({{ site.baseurl }}/smart-on-fhir/).
109
+
110
+ ---
111
+
112
+ ## URI Validation
113
+
114
+ All URI parameters are validated at initialization. Safire raises `Safire::Errors::ConfigurationError` for any violation:
115
+
116
+ - URIs must be well-formed (scheme + host required)
117
+ - URIs must use `https` — required by SMART App Launch 2.2.0
118
+ - **Exception:** `http` is permitted for `localhost` and `127.0.0.1` (local development only)
119
+
120
+ The following attributes are validated:
121
+
122
+ | Attribute | Validated when |
123
+ |-----------|----------------|
124
+ | `base_url` | Always |
125
+ | `redirect_uri` | Always |
126
+ | `issuer` | When provided (defaults to `base_url`) |
127
+ | `authorization_endpoint` | When provided |
128
+ | `token_endpoint` | When provided |
129
+ | `jwks_uri` | When provided |
130
+
131
+ If you need to bypass discovery and provide endpoints directly, set `authorization_endpoint` and `token_endpoint` in your config. Safire will use them as-is instead of fetching `/.well-known/smart-configuration`.
132
+
133
+ ---
134
+
135
+ ## Credential Protection
136
+
137
+ `ClientConfig` prevents `client_secret` and `private_key` from leaking in logs or REPL output.
138
+
139
+ `#to_hash` replaces sensitive fields with `'[FILTERED]'`:
140
+
141
+ ```ruby
142
+ config.to_hash[:client_secret] # => "[FILTERED]"
143
+ config.to_hash[:base_url] # => "https://fhir.example.com"
144
+ ```
145
+
146
+ `#inspect` is overridden to mask sensitive fields and omit `nil` attributes, so REPL sessions and error messages never expose credentials:
147
+
148
+ ```ruby
149
+ config.inspect
150
+ # => "#<Safire::ClientConfig base_url: \"https://fhir.example.com\", client_id: \"my_client_id\", client_secret: \"[FILTERED]\", ...>"
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Next Steps
156
+
157
+ - [Logging]({{ site.baseurl }}/configuration/logging/) — configure Safire's logger and HTTP request logging
158
+ - [SMART on FHIR Workflows]({{ site.baseurl }}/smart-on-fhir/) — step-by-step authorization flow guides
@@ -0,0 +1,60 @@
1
+ ---
2
+ layout: default
3
+ title: Configuration
4
+ nav_order: 3
5
+ has_children: true
6
+ permalink: /configuration/
7
+ ---
8
+
9
+ # Configuration
10
+
11
+ Safire is configured in two places:
12
+
13
+ - **Client configuration** — the FHIR server URL, credentials, and OAuth parameters passed to `Safire::Client.new`
14
+ - **Global configuration** — the logger, log level, and HTTP logging behaviour set once via `Safire.configure`
15
+
16
+ ## Architecture Overview
17
+
18
+ `Safire::Client` is the public entry point. It owns a `ClientConfig` (validated at construction) and lazily builds a protocol implementation when first used. See [ADR-002]({% link adr/ADR-002-facade-and-forwardable.md %}) for the facade design rationale, [ADR-003]({% link adr/ADR-003-protocol-vs-client-type.md %}) for the `protocol:` / `client_type:` design, and [ADR-006]({% link adr/ADR-006-lazy-discovery.md %}) for the lazy discovery design.
19
+
20
+ ```mermaid
21
+ flowchart TD
22
+ A["Safire::Client.new(config, protocol: :smart, client_type: :public)"]
23
+ B["Safire::ClientConfig\n— validates URIs\n— masks sensitive attrs"]
24
+ C{protocol:}
25
+ D["Protocols::Smart\n— reads attrs from ClientConfig\n— owns HTTPClient"]
26
+ E["SmartMetadata\n(lazy — fetched on first use)"]
27
+ F["GET /.well-known/\nsmart-configuration"]
28
+
29
+ A -->|"resolves config"| B
30
+ A -->|"validates protocol + client_type"| C
31
+ C -->|":smart (default)"| D
32
+ C -->|":udap (planned)"| G["Protocols::Udap\n(future)"]
33
+ D -->|"lazily fetches"| E
34
+ E -->|"HTTP"| F
35
+ ```
36
+
37
+ ## Quick Reference
38
+
39
+ `protocol:` and `client_type:` are keyword arguments to `Safire::Client.new`. All other parameters are keys in the configuration hash (or `Safire::ClientConfig` attributes).
40
+
41
+ | Parameter | Type | Required | Default | Description |
42
+ |-----------|------|----------|---------|-------------|
43
+ | `base_url` | String | Yes | — | FHIR server base URL |
44
+ | `client_id` | String | Yes | — | OAuth2 client identifier |
45
+ | `redirect_uri` | String | Yes | — | Registered callback URL |
46
+ | `protocol:` | Symbol | No | `:smart` | Authorization protocol — `:smart` or `:udap` |
47
+ | `client_type:` | Symbol | No | `:public` | SMART client type — `:public`, `:confidential_symmetric`, or `:confidential_asymmetric` |
48
+ | `client_secret` | String | No | — | Required for `:confidential_symmetric` |
49
+ | `private_key` | OpenSSL::PKey / String | No | — | RSA/EC private key; required for `:confidential_asymmetric` |
50
+ | `kid` | String | No | — | Key ID matching the public key registered with the server |
51
+ | `jwt_algorithm` | String | No | auto | `RS384` or `ES384`; auto-detected from key type |
52
+ | `jwks_uri` | String | No | — | URL to client's public JWKS, included as `jku` in JWT header |
53
+ | `scopes` | Array | No | — | Default scopes for authorization requests |
54
+ | `authorization_endpoint` | String | No | — | Override the discovered authorization endpoint |
55
+ | `token_endpoint` | String | No | — | Override the discovered token endpoint |
56
+
57
+ ## In This Section
58
+
59
+ - [Client Setup]({{ site.baseurl }}/configuration/client-setup/) — creating a client, protocol and client type selection, URI rules, and credential protection
60
+ - [Logging]({{ site.baseurl }}/configuration/logging/) — global logger setup, HTTP request logging, log levels, and environment variables
@@ -0,0 +1,86 @@
1
+ ---
2
+ layout: default
3
+ title: Logging
4
+ parent: Configuration
5
+ nav_order: 2
6
+ ---
7
+
8
+ # Logging
9
+
10
+ {: .no_toc }
11
+
12
+ ## Table of contents
13
+ {: .no_toc .text-delta }
14
+
15
+ 1. TOC
16
+ {:toc}
17
+
18
+ ---
19
+
20
+ ## Global Logger Setup
21
+
22
+ Configure Safire's logger once at application startup via `Safire.configure`:
23
+
24
+ ```ruby
25
+ # config/initializers/safire.rb
26
+ Safire.configure do |config|
27
+ config.logger = Rails.logger
28
+ config.log_level = Rails.env.development? ? Logger::DEBUG : Logger::INFO
29
+ config.log_http = true # default
30
+ end
31
+ ```
32
+
33
+ By default, Safire logs to `$stdout` at `Logger::INFO`.
34
+
35
+ ---
36
+
37
+ ## Log Levels
38
+
39
+ | Level | Behaviour |
40
+ |-------|-----------|
41
+ | `Logger::DEBUG` | Verbose — all Safire internal operations |
42
+ | `Logger::INFO` | Standard — normal operation events (default) |
43
+ | `Logger::WARN` | Compliance warnings and non-critical issues only |
44
+ | `Logger::ERROR` | Errors only |
45
+
46
+ ---
47
+
48
+ ## HTTP Request Logging
49
+
50
+ When `log_http` is `true` (the default), Safire logs each outbound HTTP request and response. Sensitive data is automatically filtered:
51
+
52
+ - The `Authorization` header is replaced with `[FILTERED]`
53
+ - Request and response **bodies are never logged** — tokens and credentials are never captured
54
+
55
+ ```ruby
56
+ Safire.configure do |config|
57
+ config.log_http = false # disable if not needed in production
58
+ end
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Environment Variables
64
+
65
+ ### `SAFIRE_LOGGER`
66
+
67
+ By default Safire logs to `$stdout`. Set `SAFIRE_LOGGER` to a file path to redirect output:
68
+
69
+ ```bash
70
+ SAFIRE_LOGGER=/var/log/safire.log
71
+ ```
72
+
73
+ This only affects the **default logger**. If you set `config.logger` in `Safire.configure`, `SAFIRE_LOGGER` is ignored entirely.
74
+
75
+ | `SAFIRE_LOGGER` set? | `config.logger` set? | Log destination |
76
+ |----------------------|----------------------|-----------------|
77
+ | No | No | `$stdout` |
78
+ | Yes | No | File at that path |
79
+ | Either | Yes | Your custom logger |
80
+
81
+ ---
82
+
83
+ ## Next Steps
84
+
85
+ - [Client Setup]({{ site.baseurl }}/configuration/client-setup/) — client parameters, protocol, and credential protection
86
+ - [Troubleshooting]({{ site.baseurl }}/troubleshooting/) — common issues and solutions
data/docs/index.md ADDED
@@ -0,0 +1,64 @@
1
+ ---
2
+ layout: default
3
+ title: Home
4
+ nav_order: 1
5
+ permalink: /
6
+ ---
7
+
8
+ # Safire Documentation
9
+
10
+ [![Gem Version](https://badge.fury.io/rb/safire.svg)](https://badge.fury.io/rb/safire)
11
+ [![CI](https://github.com/vanessuniq/safire/workflows/CI/badge.svg)](https://github.com/vanessuniq/safire/actions)
12
+
13
+ A lean Ruby gem implementing **[SMART on FHIR](https://hl7.org/fhir/smart-app-launch/)** and **[UDAP](https://hl7.org/fhir/us/udap-security/)** protocols for clients.
14
+
15
+ ## Quick Navigation
16
+
17
+ | Section | Description |
18
+ |---------|-------------|
19
+ | [Getting Started]({{ site.baseurl }}/installation/) | Install Safire and quick start guide |
20
+ | [Configuration]({{ site.baseurl }}/configuration/) | All configuration options and parameters |
21
+ | [SMART on FHIR]({{ site.baseurl }}/smart-on-fhir/) | Discovery, Public clients, Confidential clients |
22
+ | [UDAP]({{ site.baseurl }}/udap/) | UDAP protocol overview and planned support |
23
+ | [Security Guide]({{ site.baseurl }}/security/) | HTTPS, credential protection, token storage, key rotation |
24
+ | [Advanced Examples]({{ site.baseurl }}/advanced/) | Caching, multi-server, token management, complete Rails integration |
25
+ | [Troubleshooting]({{ site.baseurl }}/troubleshooting/) | Common issues and solutions |
26
+ | [Safire API Docs]({{ site.baseurl }}/api/){:target="_blank"} | Complete YARD documentation |
27
+
28
+ ## Features
29
+
30
+ ### SMART on FHIR App Launch
31
+
32
+ - Discovery (`/.well-known/smart-configuration`)
33
+ - Public Client (PKCE)
34
+ - Confidential Symmetric Client (client_secret + Basic Auth)
35
+ - Confidential Asymmetric Client (private_key_jwt with RS384/ES384)
36
+ - POST-Based Authorization
37
+
38
+ ### UDAP
39
+
40
+ > Planned. See [ROADMAP.md](https://github.com/vanessuniq/safire/blob/main/ROADMAP.md) for details (coming soon).
41
+
42
+ ## Demo Application
43
+
44
+ A Sinatra-based demo app is included to help you explore Safire's features:
45
+
46
+ ```bash
47
+ bin/demo
48
+ ```
49
+
50
+ Visit http://localhost:4567 to test SMART discovery, authorization flows, and token management.
51
+
52
+ See [`examples/sinatra_app/README.md`](https://github.com/vanessuniq/safire/tree/main/examples/sinatra_app) for details.
53
+
54
+ ## Community
55
+
56
+ - [GitHub Repository](https://github.com/vanessuniq/safire)
57
+ - [Issue Tracker](https://github.com/vanessuniq/safire/issues)
58
+ - [Architecture Decision Records]({{ site.baseurl }}/adr/) — design decisions and rationale
59
+
60
+ ---
61
+
62
+ *Last updated: {{ site.time | date: '%B %d, %Y' }}*
63
+
64
+ *Parts of this project were developed with AI assistance (Claude Code) and reviewed by maintainers.*
@@ -0,0 +1,96 @@
1
+ ---
2
+ layout: default
3
+ title: Installation
4
+ nav_order: 2
5
+ ---
6
+
7
+ # Installation
8
+
9
+ {: .no_toc }
10
+
11
+ ## Table of contents
12
+ {: .no_toc .text-delta }
13
+
14
+ 1. TOC
15
+ {:toc}
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ **Requirements:** Ruby ≥ 4.0.2. OpenSSL is bundled with Ruby — no separate install needed.
22
+
23
+ Add to your Gemfile:
24
+
25
+ ```ruby
26
+ gem 'safire'
27
+ ```
28
+
29
+ Then run:
30
+
31
+ ```bash
32
+ bundle install
33
+ ```
34
+
35
+ Or install directly:
36
+
37
+ ```bash
38
+ gem install safire
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Development Setup
44
+
45
+ Clone the repo and set up the development environment:
46
+
47
+ ```bash
48
+ git clone https://github.com/vanessuniq/safire.git
49
+ cd safire
50
+ bin/setup # Install dependencies
51
+ bundle exec rspec # Run tests to verify
52
+ bin/console # Interactive prompt
53
+ ```
54
+
55
+ To serve the docs site locally:
56
+
57
+ ```bash
58
+ bin/docs # Generate YARD API docs
59
+ cd docs && bundle install && bundle exec jekyll serve
60
+ ```
61
+
62
+ Then visit `http://localhost:4000/safire/`.
63
+
64
+ ---
65
+
66
+ ## Verify
67
+
68
+ A quick smoke test to confirm the gem is installed and SMART discovery is working:
69
+
70
+ ```ruby
71
+ require 'safire'
72
+
73
+ client = Safire::Client.new(
74
+ base_url: 'https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir',
75
+ client_id: 'test',
76
+ redirect_uri: 'https://example.com/callback',
77
+ scopes: ['openid', 'profile', 'patient/*.read']
78
+ )
79
+
80
+ metadata = client.server_metadata
81
+ puts metadata.authorization_endpoint
82
+ # => https://launch.smarthealthit.org/.../auth/authorize
83
+ ```
84
+
85
+ If you see an authorization endpoint URL, the gem is working. For troubleshooting, see the [Troubleshooting Guide]({{ site.baseurl }}/troubleshooting/).
86
+
87
+ ---
88
+
89
+ ## Next Steps
90
+
91
+ | | |
92
+ |-|-|
93
+ | [Configuration]({{ site.baseurl }}/configuration/) | Client credentials, logging, and protocol selection |
94
+ | [SMART on FHIR]({{ site.baseurl }}/smart-on-fhir/) | Authorization flows for public and confidential clients |
95
+ | [Security Guide]({{ site.baseurl }}/security/) | HTTPS requirements, credential protection, token storage |
96
+ | [API Reference]({{ site.baseurl }}/api/){:target="_blank"} | Complete YARD documentation |